Skip to content

Commit fc220c2

Browse files
laststylebender14autofix-ci[bot]tusharmath
authored
feat(markdown): render task list checkboxes with special chars (#2292)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Tushar Mathur <tusharmath@gmail.com>
1 parent c96a632 commit fc220c2

3 files changed

Lines changed: 196 additions & 3 deletions

File tree

crates/forge_markdown_stream/src/list.rs

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@ const BULLETS_ASTERISK: [&str; 4] = ["∗", "⁎", "✱", "✳"];
1616
/// Bullet characters for plus lists at different nesting levels.
1717
const BULLETS_PLUS: [&str; 4] = ["⊕", "⊙", "⊛", "⊜"];
1818

19+
/// Checkbox characters for task list items.
20+
const CHECKBOX_UNCHECKED: &str = "";
21+
const CHECKBOX_CHECKED: &str = "";
22+
23+
/// Strips checkbox prefix from content and returns (checkbox_char,
24+
/// remaining_content). Returns None if no checkbox is found at the start.
25+
fn strip_checkbox_prefix(content: &str) -> Option<(&'static str, &str)> {
26+
if let Some(rest) = content.strip_prefix("[ ] ") {
27+
Some((CHECKBOX_UNCHECKED, rest))
28+
} else if let Some(rest) = content
29+
.strip_prefix("[x] ")
30+
.or_else(|| content.strip_prefix("[X] "))
31+
{
32+
Some((CHECKBOX_CHECKED, rest))
33+
} else if content == "[ ]" {
34+
Some((CHECKBOX_UNCHECKED, ""))
35+
} else if content == "[x]" || content == "[X]" {
36+
Some((CHECKBOX_CHECKED, ""))
37+
} else {
38+
None
39+
}
40+
}
41+
1942
/// List rendering state for tracking nesting and numbering.
2043
#[derive(Default)]
2144
pub struct ListState {
@@ -105,6 +128,19 @@ pub fn render_list_item<S: InlineStyler + ListStyler>(
105128

106129
let level = list_state.level().saturating_sub(1);
107130

131+
// Check for checkbox at start of content
132+
let (checkbox_prefix, actual_content) = match strip_checkbox_prefix(content) {
133+
Some((checkbox, rest)) => {
134+
let styled = if checkbox == CHECKBOX_CHECKED {
135+
styler.checkbox_checked(checkbox)
136+
} else {
137+
styler.checkbox_unchecked(checkbox)
138+
};
139+
(format!("{} ", styled), rest)
140+
}
141+
None => (String::new(), content),
142+
};
143+
108144
// Calculate marker - use our own counter for ordered lists to work around
109145
// the parser bug that normalizes all numbers to 1
110146
let marker = match bullet {
@@ -121,7 +157,8 @@ pub fn render_list_item<S: InlineStyler + ListStyler>(
121157
// Calculate indentation
122158
let indent_spaces = indent * 2;
123159
let marker_width = visible_length(&marker);
124-
let content_indent = indent_spaces + marker_width + 1;
160+
let checkbox_width = if checkbox_prefix.is_empty() { 0 } else { 2 }; // checkbox + space
161+
let content_indent = indent_spaces + marker_width + 1 + checkbox_width;
125162

126163
// Color the marker based on bullet type
127164
let colored_marker = match bullet {
@@ -133,10 +170,16 @@ pub fn render_list_item<S: InlineStyler + ListStyler>(
133170
};
134171

135172
// Parse and render inline content
136-
let rendered_content = render_inline_content(content, styler);
173+
let rendered_content = render_inline_content(actual_content, styler);
137174

138175
// Build prefixes
139-
let first_prefix = format!("{}{}{} ", margin, " ".repeat(indent_spaces), colored_marker);
176+
let first_prefix = format!(
177+
"{}{}{} {}",
178+
margin,
179+
" ".repeat(indent_spaces),
180+
colored_marker,
181+
checkbox_prefix
182+
);
140183
let next_prefix = format!("{}{}", margin, " ".repeat(content_indent));
141184

142185
// Wrap the content
@@ -344,4 +387,136 @@ mod tests {
344387
state.pop();
345388
assert_eq!(state.level(), 1);
346389
}
390+
391+
mod checkbox {
392+
use super::*;
393+
394+
mod strip_checkbox_prefix_tests {
395+
use super::*;
396+
397+
#[test]
398+
fn valid_patterns() {
399+
// (input, expected_checkbox, expected_remaining)
400+
let cases = [
401+
("[ ] Task", Some((CHECKBOX_UNCHECKED, "Task"))),
402+
("[x] Done", Some((CHECKBOX_CHECKED, "Done"))),
403+
("[X] Done", Some((CHECKBOX_CHECKED, "Done"))),
404+
("[ ]", Some((CHECKBOX_UNCHECKED, ""))),
405+
("[x]", Some((CHECKBOX_CHECKED, ""))),
406+
("[X]", Some((CHECKBOX_CHECKED, ""))),
407+
];
408+
409+
for (input, expected) in cases {
410+
let actual = strip_checkbox_prefix(input);
411+
assert_eq!(actual, expected, "input: {input:?}");
412+
}
413+
}
414+
415+
#[test]
416+
fn invalid_patterns() {
417+
let cases = [
418+
"[] text", // no space inside brackets
419+
"[y] text", // wrong character
420+
"prefix [ ] suffix", // not at start
421+
"array[x]", // array index syntax
422+
"Just plain text", // no brackets
423+
" [ ] Task", // leading whitespace
424+
"[X]Task", // no space after checkbox
425+
];
426+
427+
for input in cases {
428+
let actual = strip_checkbox_prefix(input);
429+
assert_eq!(actual, None, "input: {input:?} should not match");
430+
}
431+
}
432+
}
433+
434+
mod render_tests {
435+
use super::*;
436+
437+
#[test]
438+
fn checkbox_unchecked() {
439+
insta::assert_snapshot!(
440+
render(0, ListBullet::Dash, "[ ] Task to do"),
441+
@" <dash>•</dash> <unchecked></unchecked> Task to do"
442+
);
443+
}
444+
445+
#[test]
446+
fn checkbox_checked_lowercase() {
447+
insta::assert_snapshot!(
448+
render(0, ListBullet::Dash, "[x] Completed task"),
449+
@" <dash>•</dash> <checked></checked> Completed task"
450+
);
451+
}
452+
453+
#[test]
454+
fn checkbox_checked_uppercase() {
455+
insta::assert_snapshot!(
456+
render(0, ListBullet::Dash, "[X] Another completed task"),
457+
@" <dash>•</dash> <checked></checked> Another completed task"
458+
);
459+
}
460+
461+
#[test]
462+
fn checkbox_unchecked_empty_content() {
463+
insta::assert_snapshot!(
464+
render(0, ListBullet::Dash, "[ ]"),
465+
@" <dash>•</dash> <unchecked></unchecked>"
466+
);
467+
}
468+
469+
#[test]
470+
fn checkbox_checked_empty_content() {
471+
insta::assert_snapshot!(
472+
render(0, ListBullet::Dash, "[x]"),
473+
@" <dash>•</dash> <checked></checked>"
474+
);
475+
}
476+
477+
#[test]
478+
fn checkbox_with_ordered_list() {
479+
insta::assert_snapshot!(
480+
render(0, ListBullet::Ordered(1), "[ ] Ordered task"),
481+
@" <num>1.</num> <unchecked></unchecked> Ordered task"
482+
);
483+
}
484+
}
485+
486+
mod no_false_positives {
487+
use super::*;
488+
489+
#[test]
490+
fn empty_brackets() {
491+
insta::assert_snapshot!(
492+
render(0, ListBullet::Dash, "[] Not a checkbox"),
493+
@" <dash>•</dash> [] Not a checkbox"
494+
);
495+
}
496+
497+
#[test]
498+
fn checkbox_pattern_mid_content() {
499+
insta::assert_snapshot!(
500+
render(0, ListBullet::Dash, "Item with [ ] in middle"),
501+
@" <dash>•</dash> Item with [ ] in middle"
502+
);
503+
}
504+
505+
#[test]
506+
fn array_index_syntax() {
507+
insta::assert_snapshot!(
508+
render(0, ListBullet::Dash, "array[x] access"),
509+
@" <dash>•</dash> array[x] access"
510+
);
511+
}
512+
513+
#[test]
514+
fn map_access_syntax() {
515+
insta::assert_snapshot!(
516+
render(0, ListBullet::Dash, "map[ ] access"),
517+
@" <dash>•</dash> map[ ] access"
518+
);
519+
}
520+
}
521+
}
347522
}

crates/forge_markdown_stream/src/style.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub trait ListStyler {
3030
fn bullet_plus(&self, text: &str) -> String;
3131
fn bullet_plus_expand(&self, text: &str) -> String;
3232
fn number(&self, text: &str) -> String;
33+
fn checkbox_checked(&self, text: &str) -> String;
34+
fn checkbox_unchecked(&self, text: &str) -> String;
3335
}
3436

3537
/// Trait for styling table elements.

crates/forge_markdown_stream/src/theme.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@ impl ListStyler for Theme {
247247
fn number(&self, text: &str) -> String {
248248
self.list_number.apply(text).to_string()
249249
}
250+
251+
fn checkbox_checked(&self, text: &str) -> String {
252+
self.checkbox_checked.apply(text).to_string()
253+
}
254+
255+
fn checkbox_unchecked(&self, text: &str) -> String {
256+
self.checkbox_unchecked.apply(text).to_string()
257+
}
250258
}
251259

252260
impl TableStyler for Theme {
@@ -465,6 +473,14 @@ impl ListStyler for TagStyler {
465473
fn number(&self, text: &str) -> String {
466474
format!("<num>{}</num>", text)
467475
}
476+
477+
fn checkbox_checked(&self, text: &str) -> String {
478+
format!("<checked>{}</checked>", text)
479+
}
480+
481+
fn checkbox_unchecked(&self, text: &str) -> String {
482+
format!("<unchecked>{}</unchecked>", text)
483+
}
468484
}
469485

470486
#[cfg(test)]

0 commit comments

Comments
 (0)