Skip to main content

mq_edit/document/
line_analyzer.rs

1/// Simple line-based Markdown analyzer
2/// This analyzes individual lines to determine their Markdown element type
3/// without relying on full AST parsing, avoiding sync issues
4/// Table column alignment derived from separator row
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum TableAlignment {
7    Left,   // :---
8    Center, // :---:
9    Right,  // ---:
10    None,   // ---
11}
12
13#[derive(Debug, Clone, PartialEq)]
14pub enum LineType {
15    Heading(usize), // Level 1-6
16    ListItem,
17    OrderedListItem,
18    TaskListItem(bool), // checked
19    Blockquote,
20    CodeFence(Option<String>), // language
21    InCode,                    // Inside code block
22    HorizontalRule,
23    Image(String, String),               // (alt_text, path)
24    TableHeader(Vec<String>),            // Table header row with cell contents
25    TableSeparator(Vec<TableAlignment>), // Table separator row with alignments
26    TableRow(Vec<String>),               // Table data row with cell contents
27    FrontMatterDelimiter,                // --- or +++ at start/end of front matter
28    FrontMatterContent,                  // YAML/TOML content inside front matter
29    Text,
30}
31
32pub struct LineAnalyzer;
33
34impl LineAnalyzer {
35    /// Analyze a line and determine its Markdown type
36    pub fn analyze_line(line: &str) -> LineType {
37        let trimmed = line.trim_start();
38
39        // Front matter delimiter (--- or +++)
40        if (trimmed == "---" || trimmed == "+++") && line == trimmed {
41            return LineType::FrontMatterDelimiter;
42        }
43
44        // Heading
45        if let Some(rest) = trimmed.strip_prefix('#') {
46            let mut level = 1;
47            let mut chars = rest.chars();
48            while let Some('#') = chars.next() {
49                level += 1;
50                if level > 6 {
51                    break;
52                }
53            }
54            if level <= 6
55                && rest
56                    .chars()
57                    .nth(level - 1)
58                    .is_some_and(|c| c.is_whitespace())
59            {
60                return LineType::Heading(level);
61            }
62        }
63
64        // Horizontal rule
65        if trimmed.starts_with("---")
66            || trimmed.starts_with("***")
67            || trimmed.starts_with("___")
68                && trimmed
69                    .chars()
70                    .all(|c| c == '-' || c == '*' || c == '_' || c.is_whitespace())
71        {
72            return LineType::HorizontalRule;
73        }
74
75        // Code fence
76        if trimmed.starts_with("```") {
77            let lang = trimmed
78                .strip_prefix("```")
79                .map(|s| s.trim())
80                .filter(|s| !s.is_empty())
81                .map(|s| s.to_string());
82            return LineType::CodeFence(lang);
83        }
84
85        // Task list item
86        if trimmed.starts_with("- [") {
87            if trimmed.contains("- [x]") || trimmed.contains("- [X]") {
88                return LineType::TaskListItem(true);
89            } else if trimmed.contains("- [ ]") {
90                return LineType::TaskListItem(false);
91            }
92        }
93
94        // Unordered list
95        if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
96            return LineType::ListItem;
97        }
98
99        // Ordered list
100        if let Some(ch) = trimmed.chars().next()
101            && ch.is_ascii_digit()
102        {
103            let rest = &trimmed[1..];
104            if rest.starts_with(". ") || rest.starts_with(") ") {
105                return LineType::OrderedListItem;
106            }
107        }
108
109        // Blockquote
110        if trimmed.starts_with("> ") {
111            return LineType::Blockquote;
112        }
113
114        // Image: ![alt text](path)
115        if trimmed.starts_with("![")
116            && let Some(alt_end) = trimmed.find("](")
117        {
118            let alt_text = &trimmed[2..alt_end];
119            let rest = &trimmed[alt_end + 2..];
120            if let Some(path_end) = rest.find(')') {
121                let path = &rest[..path_end];
122                return LineType::Image(alt_text.to_string(), path.to_string());
123            }
124        }
125
126        LineType::Text
127    }
128
129    /// Check if a line contains bold text
130    pub fn contains_bold(line: &str) -> bool {
131        line.contains("**") || line.contains("__")
132    }
133
134    /// Check if a line contains italic text
135    pub fn contains_italic(line: &str) -> bool {
136        line.contains('*') || line.contains('_')
137    }
138
139    /// Check if a line contains strikethrough
140    pub fn contains_strikethrough(line: &str) -> bool {
141        line.contains("~~")
142    }
143
144    /// Check if a line contains inline code
145    pub fn contains_inline_code(line: &str) -> bool {
146        line.contains('`') && !line.trim().starts_with("```")
147    }
148
149    /// Check if a line contains a link
150    pub fn contains_link(line: &str) -> bool {
151        line.contains('[') && line.contains("](")
152    }
153
154    /// Check if a line looks like a table row (contains pipe delimiters)
155    pub fn is_table_row(line: &str) -> bool {
156        let trimmed = line.trim();
157        trimmed.contains('|') && !trimmed.starts_with("```")
158    }
159
160    /// Check if a line is a table separator row (e.g., |---|:---:|---:|)
161    pub fn is_table_separator(line: &str) -> bool {
162        let trimmed = line.trim();
163        if !trimmed.contains('|') {
164            return false;
165        }
166        // Remove pipes and check if remaining content is only dashes, colons, spaces
167        let content: String = trimmed
168            .chars()
169            .filter(|c| *c != '|' && !c.is_whitespace())
170            .collect();
171        !content.is_empty() && content.chars().all(|c| c == '-' || c == ':')
172    }
173
174    /// Parse table cell contents from a row
175    pub fn parse_table_cells(line: &str) -> Vec<String> {
176        let trimmed = line.trim();
177        let stripped = trimmed.trim_matches('|');
178        stripped
179            .split('|')
180            .map(|cell| cell.trim().to_string())
181            .collect()
182    }
183
184    /// Parse alignment from separator row
185    pub fn parse_table_alignment(line: &str) -> Vec<TableAlignment> {
186        Self::parse_table_cells(line)
187            .iter()
188            .map(|cell| {
189                let cell = cell.trim();
190                let starts_colon = cell.starts_with(':');
191                let ends_colon = cell.ends_with(':');
192                match (starts_colon, ends_colon) {
193                    (true, true) => TableAlignment::Center,
194                    (true, false) => TableAlignment::Left,
195                    (false, true) => TableAlignment::Right,
196                    (false, false) => TableAlignment::None,
197                }
198            })
199            .collect()
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_heading_detection() {
209        assert_eq!(
210            LineAnalyzer::analyze_line("# Heading 1"),
211            LineType::Heading(1)
212        );
213        assert_eq!(
214            LineAnalyzer::analyze_line("## Heading 2"),
215            LineType::Heading(2)
216        );
217        assert_eq!(
218            LineAnalyzer::analyze_line("### Heading 3"),
219            LineType::Heading(3)
220        );
221    }
222
223    #[test]
224    fn test_list_detection() {
225        assert_eq!(LineAnalyzer::analyze_line("- Item"), LineType::ListItem);
226        assert_eq!(LineAnalyzer::analyze_line("* Item"), LineType::ListItem);
227        assert_eq!(
228            LineAnalyzer::analyze_line("1. Item"),
229            LineType::OrderedListItem
230        );
231    }
232
233    #[test]
234    fn test_task_list() {
235        assert_eq!(
236            LineAnalyzer::analyze_line("- [ ] Todo"),
237            LineType::TaskListItem(false)
238        );
239        assert_eq!(
240            LineAnalyzer::analyze_line("- [x] Done"),
241            LineType::TaskListItem(true)
242        );
243    }
244
245    #[test]
246    fn test_blockquote() {
247        assert_eq!(LineAnalyzer::analyze_line("> Quote"), LineType::Blockquote);
248    }
249
250    #[test]
251    fn test_code_fence() {
252        assert_eq!(
253            LineAnalyzer::analyze_line("```rust"),
254            LineType::CodeFence(Some("rust".to_string()))
255        );
256        assert_eq!(LineAnalyzer::analyze_line("```"), LineType::CodeFence(None));
257    }
258
259    #[test]
260    fn test_is_table_row() {
261        assert!(LineAnalyzer::is_table_row("| Name | Age |"));
262        assert!(LineAnalyzer::is_table_row("|Name|Age|"));
263        assert!(LineAnalyzer::is_table_row("| Name | Age | City |"));
264        assert!(!LineAnalyzer::is_table_row("Normal text"));
265        // Note: "Text with | pipe" is detected as table row, but context validation
266        // (requiring separator row) ensures it's not treated as a valid table
267        assert!(!LineAnalyzer::is_table_row("```|code|```"));
268    }
269
270    #[test]
271    fn test_is_table_separator() {
272        assert!(LineAnalyzer::is_table_separator("|---|---|"));
273        assert!(LineAnalyzer::is_table_separator("| --- | --- |"));
274        assert!(LineAnalyzer::is_table_separator("| :--- | ---: |"));
275        assert!(LineAnalyzer::is_table_separator("|:---:|:---:|"));
276        assert!(LineAnalyzer::is_table_separator("| :--- | :---: | ---: |"));
277        assert!(!LineAnalyzer::is_table_separator("| Name | Age |"));
278        assert!(!LineAnalyzer::is_table_separator("Normal text"));
279    }
280
281    #[test]
282    fn test_parse_table_cells() {
283        let cells = LineAnalyzer::parse_table_cells("| Name | Age |");
284        assert_eq!(cells, vec!["Name", "Age"]);
285
286        let cells = LineAnalyzer::parse_table_cells("|Name|Age|");
287        assert_eq!(cells, vec!["Name", "Age"]);
288
289        let cells = LineAnalyzer::parse_table_cells("| Name | Age | City |");
290        assert_eq!(cells, vec!["Name", "Age", "City"]);
291
292        let cells = LineAnalyzer::parse_table_cells("|  Spaced  |  Content  |");
293        assert_eq!(cells, vec!["Spaced", "Content"]);
294    }
295
296    #[test]
297    fn test_parse_table_alignment() {
298        let alignments = LineAnalyzer::parse_table_alignment("| :--- | ---: | :---: | --- |");
299        assert_eq!(
300            alignments,
301            vec![
302                TableAlignment::Left,
303                TableAlignment::Right,
304                TableAlignment::Center,
305                TableAlignment::None,
306            ]
307        );
308
309        let alignments = LineAnalyzer::parse_table_alignment("|:---|---:|:---:|---|");
310        assert_eq!(
311            alignments,
312            vec![
313                TableAlignment::Left,
314                TableAlignment::Right,
315                TableAlignment::Center,
316                TableAlignment::None,
317            ]
318        );
319    }
320
321    #[test]
322    fn test_front_matter_delimiter() {
323        assert_eq!(
324            LineAnalyzer::analyze_line("---"),
325            LineType::FrontMatterDelimiter
326        );
327        assert_eq!(
328            LineAnalyzer::analyze_line("+++"),
329            LineType::FrontMatterDelimiter
330        );
331        // With spaces should not be treated as front matter delimiter
332        assert_eq!(LineAnalyzer::analyze_line("--- "), LineType::HorizontalRule);
333        // Not at start should be horizontal rule
334        assert_eq!(LineAnalyzer::analyze_line(" ---"), LineType::HorizontalRule);
335    }
336}