@@ -16,6 +16,29 @@ const BULLETS_ASTERISK: [&str; 4] = ["∗", "⁎", "✱", "✳"];
1616/// Bullet characters for plus lists at different nesting levels.
1717const 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 ) ]
2144pub 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}
0 commit comments