at main 168 lines 5.9 kB view raw
1//! Syntax span tracking for conditional visibility. 2//! 3//! Tracks markdown syntax characters (like `**`, `#`, `>`) so they can be 4//! shown/hidden based on cursor position (Obsidian-style editing). 5 6use std::ops::Range; 7 8use smol_str::SmolStr; 9 10/// Classification of markdown syntax characters. 11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 12pub enum SyntaxType { 13 /// Inline formatting: **, *, ~~, `, $, [, ], (, ) 14 Inline, 15 /// Block formatting: #, >, -, *, 1., ```, --- 16 Block, 17} 18 19/// Information about a syntax span for conditional visibility. 20#[derive(Debug, Clone, PartialEq, Eq)] 21pub struct SyntaxSpanInfo { 22 /// Unique identifier for this syntax span (e.g., "s0", "s1") 23 pub syn_id: SmolStr, 24 /// Source char range this syntax covers (just this marker) 25 pub char_range: Range<usize>, 26 /// Whether this is inline or block-level syntax 27 pub syntax_type: SyntaxType, 28 /// For paired inline syntax (**, *, etc), the full formatted region 29 /// from opening marker through content to closing marker. 30 /// When cursor is anywhere in this range, the syntax is visible. 31 pub formatted_range: Option<Range<usize>>, 32} 33 34impl SyntaxSpanInfo { 35 /// Adjust all position fields by a character delta. 36 /// 37 /// This adjusts both `char_range` and `formatted_range` (if present) together, 38 /// ensuring they stay in sync. Use this instead of manually adjusting fields 39 /// to avoid forgetting one. 40 pub fn adjust_positions(&mut self, char_delta: isize) { 41 self.char_range.start = (self.char_range.start as isize + char_delta) as usize; 42 self.char_range.end = (self.char_range.end as isize + char_delta) as usize; 43 if let Some(ref mut fr) = self.formatted_range { 44 fr.start = (fr.start as isize + char_delta) as usize; 45 fr.end = (fr.end as isize + char_delta) as usize; 46 } 47 } 48 49 /// Check if cursor is within the visibility range for this syntax. 50 /// 51 /// Returns true if the cursor should cause this syntax to be visible. 52 /// Uses `formatted_range` if present (for paired syntax like **bold**), 53 /// otherwise uses `char_range` (for standalone syntax like # heading). 54 pub fn cursor_in_range(&self, cursor_pos: usize) -> bool { 55 let range = self.formatted_range.as_ref().unwrap_or(&self.char_range); 56 cursor_pos >= range.start && cursor_pos <= range.end 57 } 58} 59 60/// Classify syntax text as inline or block level. 61pub fn classify_syntax(text: &str) -> SyntaxType { 62 let trimmed = text.trim_start(); 63 64 // Check for block-level markers 65 if trimmed.starts_with('#') 66 || trimmed.starts_with('>') 67 || trimmed.starts_with("```") 68 || trimmed.starts_with("---") 69 || (trimmed.starts_with('-') 70 && trimmed 71 .chars() 72 .nth(1) 73 .map(|c| c.is_whitespace()) 74 .unwrap_or(false)) 75 || (trimmed.starts_with('*') 76 && trimmed 77 .chars() 78 .nth(1) 79 .map(|c| c.is_whitespace()) 80 .unwrap_or(false)) 81 || trimmed 82 .chars() 83 .next() 84 .map(|c| c.is_ascii_digit()) 85 .unwrap_or(false) 86 && trimmed.contains('.') 87 { 88 SyntaxType::Block 89 } else { 90 SyntaxType::Inline 91 } 92} 93 94#[cfg(test)] 95mod tests { 96 use smol_str::ToSmolStr; 97 98 use super::*; 99 100 #[test] 101 fn test_classify_block_syntax() { 102 assert_eq!(classify_syntax("# "), SyntaxType::Block); 103 assert_eq!(classify_syntax("## "), SyntaxType::Block); 104 assert_eq!(classify_syntax("> "), SyntaxType::Block); 105 assert_eq!(classify_syntax("- "), SyntaxType::Block); 106 assert_eq!(classify_syntax("* "), SyntaxType::Block); 107 assert_eq!(classify_syntax("1. "), SyntaxType::Block); 108 assert_eq!(classify_syntax("```"), SyntaxType::Block); 109 assert_eq!(classify_syntax("---"), SyntaxType::Block); 110 } 111 112 #[test] 113 fn test_classify_inline_syntax() { 114 assert_eq!(classify_syntax("**"), SyntaxType::Inline); 115 assert_eq!(classify_syntax("*"), SyntaxType::Inline); 116 assert_eq!(classify_syntax("`"), SyntaxType::Inline); 117 assert_eq!(classify_syntax("~~"), SyntaxType::Inline); 118 assert_eq!(classify_syntax("["), SyntaxType::Inline); 119 assert_eq!(classify_syntax("]("), SyntaxType::Inline); 120 } 121 122 #[test] 123 fn test_adjust_positions() { 124 let mut span = SyntaxSpanInfo { 125 syn_id: "s0".to_smolstr(), 126 char_range: 10..15, 127 syntax_type: SyntaxType::Inline, 128 formatted_range: Some(10..25), 129 }; 130 131 span.adjust_positions(5); 132 assert_eq!(span.char_range, 15..20); 133 assert_eq!(span.formatted_range, Some(15..30)); 134 135 span.adjust_positions(-3); 136 assert_eq!(span.char_range, 12..17); 137 assert_eq!(span.formatted_range, Some(12..27)); 138 } 139 140 #[test] 141 fn test_cursor_in_range() { 142 // Paired syntax with formatted_range 143 let span = SyntaxSpanInfo { 144 syn_id: "s0".to_smolstr(), 145 char_range: 0..2, // ** 146 syntax_type: SyntaxType::Inline, 147 formatted_range: Some(0..10), // **content** 148 }; 149 150 assert!(span.cursor_in_range(0)); 151 assert!(span.cursor_in_range(5)); 152 assert!(span.cursor_in_range(10)); 153 assert!(!span.cursor_in_range(11)); 154 155 // Unpaired syntax without formatted_range 156 let span = SyntaxSpanInfo { 157 syn_id: "s1".to_smolstr(), 158 char_range: 0..2, // ## 159 syntax_type: SyntaxType::Block, 160 formatted_range: None, 161 }; 162 163 assert!(span.cursor_in_range(0)); 164 assert!(span.cursor_in_range(1)); 165 assert!(span.cursor_in_range(2)); 166 assert!(!span.cursor_in_range(3)); 167 } 168}