Skip to content

Commit 040b132

Browse files
authored
Merge pull request #622 from kivikakk/push-vsvsrpumnymm
tasklist_in_table: parse a tasklist item if it's the only content of a table cell.
2 parents c2478b8 + 4809952 commit 040b132

File tree

7 files changed

+251
-50
lines changed

7 files changed

+251
-50
lines changed

fuzz/fuzz_targets/all_options.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ fuzz_target!(|s: &str| {
5050
relaxed_tasklist_matching: true,
5151
relaxed_autolinks: true,
5252
broken_link_callback: Some(Arc::new(cb)),
53+
ignore_setext: true,
54+
tasklist_in_table: true,
5355
};
5456

5557
let render = RenderOptions {
@@ -62,7 +64,6 @@ fuzz_target!(|s: &str| {
6264
list_style: ListStyleType::Star,
6365
sourcepos: true,
6466
escaped_char_spans: true,
65-
ignore_setext: true,
6667
ignore_empty_links: true,
6768
gfm_quirks: true,
6869
prefer_fenced: true,

src/cm.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
use crate::ctype::{isalpha, isdigit, ispunct, isspace};
22
use crate::nodes::{
33
AstNode, ListDelimType, ListType, NodeAlert, NodeCodeBlock, NodeHeading, NodeHtmlBlock,
4-
NodeLink, NodeMath, NodeTable, NodeValue, NodeWikiLink,
4+
NodeLink, NodeList, NodeMath, NodeTable, NodeValue, NodeWikiLink, TableAlignment,
55
};
6-
use crate::nodes::{NodeList, TableAlignment};
76
#[cfg(feature = "shortcodes")]
87
use crate::parser::shortcodes::NodeShortCode;
98
use crate::parser::{Options, WikiLinksMode};
109
use crate::scanners;
1110
use crate::strings::trim_start_match;
12-
use crate::{nodes, Plugins};
11+
use crate::{node_matches, nodes, Plugins};
1312
pub use typed_arena::Arena;
1413

1514
use std::cmp::max;
@@ -725,7 +724,13 @@ impl<'a, 'o, 'c> CommonMarkFormatter<'a, 'o, 'c> {
725724
}
726725

727726
fn format_task_item(&mut self, symbol: Option<char>, node: &'a AstNode<'a>, entering: bool) {
728-
self.format_item(node, entering);
727+
if node
728+
.parent()
729+
.map(|p| node_matches!(p, NodeValue::List(_)))
730+
.unwrap_or_default()
731+
{
732+
self.format_item(node, entering);
733+
}
729734
if entering {
730735
write!(self, "[{}] ", symbol.unwrap_or(' ')).unwrap();
731736
}

src/html.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::nodes::{
1515
TableAlignment,
1616
};
1717
use crate::parser::{Options, Plugins};
18-
use crate::scanners;
18+
use crate::{node_matches, scanners};
1919
use std::collections::HashMap;
2020
use std::fmt::{self, Write};
2121
use std::str;
@@ -1208,23 +1208,33 @@ fn render_task_item<'a, T>(
12081208
unreachable!()
12091209
};
12101210

1211+
let write_li = node
1212+
.parent()
1213+
.map(|p| node_matches!(p, NodeValue::List(_)))
1214+
.unwrap_or_default();
1215+
12111216
if entering {
12121217
context.cr()?;
1213-
context.write_str("<li")?;
1214-
if context.options.render.tasklist_classes {
1215-
context.write_str(" class=\"task-list-item\"")?;
1218+
if write_li {
1219+
context.write_str("<li")?;
1220+
if context.options.render.tasklist_classes {
1221+
context.write_str(" class=\"task-list-item\"")?;
1222+
}
1223+
render_sourcepos(context, node)?;
1224+
context.write_str(">")?;
12161225
}
1217-
render_sourcepos(context, node)?;
1218-
context.write_str(">")?;
12191226
context.write_str("<input type=\"checkbox\"")?;
1227+
if !write_li {
1228+
render_sourcepos(context, node)?;
1229+
}
12201230
if context.options.render.tasklist_classes {
12211231
context.write_str(" class=\"task-list-item-checkbox\"")?;
12221232
}
12231233
if symbol.is_some() {
12241234
context.write_str(" checked=\"\"")?;
12251235
}
12261236
context.write_str(" disabled=\"\" /> ")?;
1227-
} else {
1237+
} else if write_li {
12281238
context.write_str("</li>\n")?;
12291239
}
12301240

@@ -1404,7 +1414,7 @@ pub fn render_math<'a, T>(
14041414
pub fn render_math_code_block<'a, T>(
14051415
context: &mut Context<T>,
14061416
node: &'a AstNode<'a>,
1407-
literal: &String,
1417+
literal: &str,
14081418
) -> Result<ChildRendering, fmt::Error> {
14091419
context.cr()?;
14101420

src/nodes.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,10 @@ impl LineColumn {
630630
}
631631

632632
impl Ast {
633-
/// Create a new AST node with the given value.
633+
/// Create a new AST node with the given value and starting sourcepos. The
634+
/// end column is set to zero; it is expected this will be set manually
635+
/// or later in the parse. Use [`new_with_sourcepos`] if you have full
636+
/// sourcepos.
634637
pub fn new(value: NodeValue, start: LineColumn) -> Self {
635638
Ast {
636639
value,
@@ -643,6 +646,20 @@ impl Ast {
643646
line_offsets: Vec::with_capacity(0),
644647
}
645648
}
649+
650+
/// Create a new AST node with the given value and sourcepos.
651+
pub fn new_with_sourcepos(value: NodeValue, sourcepos: Sourcepos) -> Self {
652+
Ast {
653+
value,
654+
content: String::new(),
655+
sourcepos,
656+
internal_offset: 0,
657+
open: true,
658+
last_line_blank: false,
659+
table_visited: false,
660+
line_offsets: Vec::with_capacity(0),
661+
}
662+
}
646663
}
647664

648665
/// The type of a node within the document.
@@ -820,6 +837,7 @@ pub fn can_contain_type<'a>(node: &'a AstNode<'a>, child: &NodeValue) -> bool {
820837
| NodeValue::SpoileredText
821838
| NodeValue::Underline
822839
| NodeValue::Subscript
840+
| NodeValue::TaskItem(_)
823841
),
824842

825843
#[cfg(feature = "shortcodes")]
@@ -841,6 +859,7 @@ pub fn can_contain_type<'a>(node: &'a AstNode<'a>, child: &NodeValue) -> bool {
841859
| NodeValue::Underline
842860
| NodeValue::Subscript
843861
| NodeValue::ShortCode(..)
862+
| NodeValue::TaskItem(_)
844863
),
845864

846865
NodeValue::MultilineBlockQuote(_) => {

src/parser/mod.rs

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ const CODE_INDENT: usize = 4;
4747
// be nested this deeply.
4848
const MAX_LIST_DEPTH: usize = 100;
4949

50+
/// Shorthand for checking if a node's value matches the given expression.
51+
///
52+
/// Note this will `borrow()` the provided node's data attribute while doing the
53+
/// check, which will fail if the node is already mutably borrowed.
54+
#[macro_export]
5055
macro_rules! node_matches {
5156
($node:expr, $( $pat:pat )|+) => {{
5257
matches!(
@@ -731,6 +736,25 @@ pub struct ParseOptions<'c> {
731736
#[cfg_attr(feature = "bon", builder(default))]
732737
pub relaxed_tasklist_matching: bool,
733738

739+
/// Whether tasklist items can be parsed in table cells. At present, the
740+
/// tasklist item must be the only content in the cell. Both tables and
741+
/// tasklists much be enabled for this to work.
742+
///
743+
/// ```
744+
/// # use comrak::{markdown_to_html, Options};
745+
/// let mut options = Options::default();
746+
/// options.extension.table = true;
747+
/// options.extension.tasklist = true;
748+
/// assert_eq!(markdown_to_html("| val |\n| - |\n| [ ] |\n", &options),
749+
/// "<table>\n<thead>\n<tr>\n<th>val</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>[ ]</td>\n</tr>\n</tbody>\n</table>\n");
750+
///
751+
/// options.parse.tasklist_in_table = true;
752+
/// assert_eq!(markdown_to_html("| val |\n| - |\n| [ ] |\n", &options),
753+
/// "<table>\n<thead>\n<tr>\n<th>val</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>\n<input type=\"checkbox\" disabled=\"\" /> </td>\n</tr>\n</tbody>\n</table>\n");
754+
/// ```
755+
#[cfg_attr(feature = "bon", builder(default))]
756+
pub tasklist_in_table: bool,
757+
734758
/// Relax parsing of autolinks, allow links to be detected inside brackets
735759
/// and allow all url schemes. It is intended to allow a very specific type of autolink
736760
/// detection, such as `[this http://and.com that]` or `{http://foo.com}`, on a best can basis.
@@ -3053,6 +3077,13 @@ where
30533077
}
30543078
}
30553079

3080+
// Processes tasklist items in a text node. This function
3081+
// must not detach `node`, as we iterate through siblings in
3082+
// `postprocess_text_nodes_with_context` and may end up relying on it
3083+
// remaining in place.
3084+
//
3085+
// `text` is the mutably borrowed textual content of `node`. If it is empty
3086+
// after the call to `process_tasklist`, it will be properly cleaned up.
30563087
fn process_tasklist(
30573088
&mut self,
30583089
node: &'a AstNode<'a>,
@@ -3072,49 +3103,73 @@ where
30723103
}
30733104

30743105
let parent = node.parent().unwrap();
3075-
if node.previous_sibling().is_some() || parent.previous_sibling().is_some() {
3076-
return;
3077-
}
30783106

3079-
if !node_matches!(parent, NodeValue::Paragraph) {
3080-
return;
3081-
}
3107+
if node_matches!(parent, NodeValue::TableCell) {
3108+
if !self.options.parse.tasklist_in_table {
3109+
return;
3110+
}
30823111

3083-
let grandparent = parent.parent().unwrap();
3084-
if !node_matches!(grandparent, NodeValue::Item(..)) {
3085-
return;
3086-
}
3112+
if node.previous_sibling().is_some() || node.next_sibling().is_some() {
3113+
return;
3114+
}
30873115

3088-
let great_grandparent = grandparent.parent().unwrap();
3089-
if !node_matches!(great_grandparent, NodeValue::List(..)) {
3090-
return;
3091-
}
3116+
// For now, require the task item is the only content of the table cell.
3117+
// If we want to relax this later, we can.
3118+
if end != text.len() {
3119+
return;
3120+
}
30923121

3093-
// These are sound only because the exact text that we've matched and
3094-
// the count thereof (i.e. "end") will precisely map to characters in
3095-
// the source document.
3096-
text.drain(..end);
3122+
text.drain(..end);
3123+
parent.prepend(
3124+
self.arena.alloc(
3125+
Ast::new_with_sourcepos(
3126+
NodeValue::TaskItem(if symbol == ' ' { None } else { Some(symbol) }),
3127+
*sourcepos,
3128+
)
3129+
.into(),
3130+
),
3131+
);
3132+
} else if node_matches!(parent, NodeValue::Paragraph) {
3133+
if node.previous_sibling().is_some() || parent.previous_sibling().is_some() {
3134+
return;
3135+
}
30973136

3098-
let adjust = spx.consume(end) + 1;
3099-
assert_eq!(
3100-
sourcepos.start.column,
3101-
parent.data.borrow().sourcepos.start.column
3102-
);
3137+
let grandparent = parent.parent().unwrap();
3138+
if !node_matches!(grandparent, NodeValue::Item(..)) {
3139+
return;
3140+
}
31033141

3104-
// See tests::fuzz::echaw9. The paragraph doesn't exist in the source,
3105-
// so we remove it.
3106-
if sourcepos.end.column < adjust && node.next_sibling().is_none() {
3107-
parent.detach();
3108-
} else {
3109-
sourcepos.start.column = adjust;
3110-
parent.data.borrow_mut().sourcepos.start.column = adjust;
3111-
}
3142+
let great_grandparent = grandparent.parent().unwrap();
3143+
if !node_matches!(great_grandparent, NodeValue::List(..)) {
3144+
return;
3145+
}
31123146

3113-
grandparent.data.borrow_mut().value =
3114-
NodeValue::TaskItem(if symbol == ' ' { None } else { Some(symbol) });
3147+
// These are sound only because the exact text that we've matched and
3148+
// the count thereof (i.e. "end") will precisely map to characters in
3149+
// the source document.
3150+
text.drain(..end);
31153151

3116-
if let NodeValue::List(ref mut list) = &mut great_grandparent.data.borrow_mut().value {
3117-
list.is_task_list = true;
3152+
let adjust = spx.consume(end) + 1;
3153+
assert_eq!(
3154+
sourcepos.start.column,
3155+
parent.data.borrow().sourcepos.start.column
3156+
);
3157+
3158+
// See tests::fuzz::echaw9. The paragraph doesn't exist in the source,
3159+
// so we remove it.
3160+
if sourcepos.end.column < adjust && node.next_sibling().is_none() {
3161+
parent.detach();
3162+
} else {
3163+
sourcepos.start.column = adjust;
3164+
parent.data.borrow_mut().sourcepos.start.column = adjust;
3165+
}
3166+
3167+
grandparent.data.borrow_mut().value =
3168+
NodeValue::TaskItem(if symbol == ' ' { None } else { Some(symbol) });
3169+
3170+
if let NodeValue::List(ref mut list) = &mut great_grandparent.data.borrow_mut().value {
3171+
list.is_task_list = true;
3172+
}
31183173
}
31193174
}
31203175

0 commit comments

Comments
 (0)