Skip to main content

marco_core/intelligence/editor/
highlight.rs

1// Syntax highlighting: map AST nodes to SourceView5 text tags
2
3use crate::parser::{Document, Node, NodeKind, Position, Span};
4
5#[derive(Debug, Clone, PartialEq)]
6pub struct Highlight {
7    pub span: Span,
8    pub tag: HighlightTag,
9}
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum HighlightTag {
13    Heading1,
14    Heading2,
15    Heading3,
16    Heading4,
17    Heading5,
18    Heading6,
19    Emphasis,
20    Strong,
21    Strikethrough,
22    Mark,
23    Superscript,
24    Subscript,
25    Link,
26    Image,
27    CodeSpan,
28    CodeBlock,
29    InlineHtml,
30    HardBreak,
31    SoftBreak,
32    ThematicBreak,
33    Blockquote,
34    Admonition,
35    HtmlBlock,
36    List,
37    ListItem,
38    TaskCheckboxChecked,
39    TaskCheckboxUnchecked,
40    Table,
41    TableRow,
42    TableRowHeader,
43    TableCell,
44    TableCellHeader,
45    LinkReference,
46    DefinitionList,
47    DefinitionTerm,
48    DefinitionDescription,
49    TabBlockContainer,
50    TabBlockHeader,
51    SliderDeckMarker,
52    SliderSeparatorHorizontal,
53    SliderSeparatorVertical,
54}
55
56pub fn compute_highlights(document: &Document) -> Vec<Highlight> {
57    let mut highlights = Vec::new();
58
59    for node in &document.children {
60        collect_highlights(node, &mut highlights);
61    }
62
63    finalize_highlights(highlights)
64}
65
66pub fn compute_highlights_with_source(document: &Document, source: &str) -> Vec<Highlight> {
67    let mut highlights = compute_highlights(document);
68    highlights.extend(compute_tab_block_marker_highlights(source));
69    highlights.extend(compute_slider_marker_highlights(source));
70    finalize_highlights(highlights)
71}
72
73fn finalize_highlights(mut highlights: Vec<Highlight>) -> Vec<Highlight> {
74    highlights.retain(|h| is_non_empty_span(&h.span));
75    highlights.sort_by(|a, b| {
76        let start_cmp =
77            (a.span.start.line, a.span.start.column).cmp(&(b.span.start.line, b.span.start.column));
78        if start_cmp != std::cmp::Ordering::Equal {
79            return start_cmp;
80        }
81
82        let end_cmp =
83            (b.span.end.line, b.span.end.column).cmp(&(a.span.end.line, a.span.end.column));
84        if end_cmp != std::cmp::Ordering::Equal {
85            return end_cmp;
86        }
87
88        tag_rank(&a.tag).cmp(&tag_rank(&b.tag))
89    });
90    highlights.dedup_by(|a, b| a.tag == b.tag && a.span == b.span);
91
92    highlights
93}
94
95fn is_non_empty_span(span: &Span) -> bool {
96    let start = (span.start.line, span.start.column);
97    let end = (span.end.line, span.end.column);
98    start < end
99}
100
101fn tag_rank(tag: &HighlightTag) -> u8 {
102    match tag {
103        HighlightTag::Heading1 => 1,
104        HighlightTag::Heading2 => 2,
105        HighlightTag::Heading3 => 3,
106        HighlightTag::Heading4 => 4,
107        HighlightTag::Heading5 => 5,
108        HighlightTag::Heading6 => 6,
109        HighlightTag::Emphasis => 10,
110        HighlightTag::Strong => 11,
111        HighlightTag::Strikethrough => 12,
112        HighlightTag::Mark => 13,
113        HighlightTag::Superscript => 14,
114        HighlightTag::Subscript => 15,
115        HighlightTag::Link => 16,
116        HighlightTag::Image => 17,
117        HighlightTag::CodeSpan => 20,
118        HighlightTag::CodeBlock => 21,
119        HighlightTag::InlineHtml => 30,
120        HighlightTag::HardBreak => 40,
121        HighlightTag::SoftBreak => 41,
122        HighlightTag::ThematicBreak => 42,
123        HighlightTag::Blockquote => 50,
124        HighlightTag::Admonition => 51,
125        HighlightTag::HtmlBlock => 52,
126        HighlightTag::List => 60,
127        HighlightTag::ListItem => 61,
128        HighlightTag::TaskCheckboxUnchecked => 62,
129        HighlightTag::TaskCheckboxChecked => 63,
130        HighlightTag::Table => 70,
131        HighlightTag::TableRowHeader => 71,
132        HighlightTag::TableRow => 72,
133        HighlightTag::TableCellHeader => 73,
134        HighlightTag::TableCell => 74,
135        HighlightTag::LinkReference => 80,
136        HighlightTag::DefinitionList => 90,
137        HighlightTag::DefinitionTerm => 91,
138        HighlightTag::DefinitionDescription => 92,
139        HighlightTag::TabBlockContainer => 100,
140        HighlightTag::TabBlockHeader => 101,
141        HighlightTag::SliderDeckMarker => 110,
142        HighlightTag::SliderSeparatorHorizontal => 111,
143        HighlightTag::SliderSeparatorVertical => 112,
144    }
145}
146
147fn compute_tab_block_marker_highlights(source: &str) -> Vec<Highlight> {
148    fn trim_upto_3_spaces(s: &str) -> (&str, usize) {
149        let bytes = s.as_bytes();
150        let mut i = 0usize;
151        for _ in 0..3 {
152            if bytes.get(i) == Some(&b' ') {
153                i += 1;
154            } else {
155                break;
156            }
157        }
158        (&s[i..], i)
159    }
160
161    fn fence_prefix(rest: &str) -> Option<(char, usize, &str)> {
162        let mut chars = rest.chars();
163        let ch = chars.next()?;
164        if ch != '`' && ch != '~' {
165            return None;
166        }
167        let mut count = 1usize;
168        for c in chars.clone() {
169            if c == ch {
170                count += 1;
171            } else {
172                break;
173            }
174        }
175        if count >= 3 {
176            Some((ch, count, &rest[count..]))
177        } else {
178            None
179        }
180    }
181
182    let mut highlights: Vec<Highlight> = Vec::new();
183    let mut in_tab_block = false;
184    let mut in_fence: Option<(char, usize)> = None;
185    let mut line_start_offset: usize = 0;
186    let mut line_no: usize = 1;
187
188    for seg in source.split_inclusive('\n') {
189        let seg_len = seg.len();
190
191        let line = seg
192            .strip_suffix('\n')
193            .unwrap_or(seg)
194            .strip_suffix('\r')
195            .unwrap_or(seg.strip_suffix('\n').unwrap_or(seg));
196
197        let (rest, _indent_len) = trim_upto_3_spaces(line);
198
199        if let Some((fch, fcount, after_fence)) = fence_prefix(rest) {
200            match in_fence {
201                None => in_fence = Some((fch, fcount)),
202                Some((open_ch, open_count)) => {
203                    if fch == open_ch && fcount >= open_count && after_fence.trim().is_empty() {
204                        in_fence = None;
205                    }
206                }
207            }
208        }
209
210        if in_fence.is_none() {
211            if !in_tab_block {
212                if let Some(after) = rest.strip_prefix(":::tab") {
213                    if after.is_empty()
214                        || after
215                            .chars()
216                            .next()
217                            .is_some_and(|ch| ch == ' ' || ch == '\t')
218                    {
219                        highlights.push(line_highlight(
220                            line_no,
221                            line_start_offset,
222                            line.len(),
223                            HighlightTag::TabBlockContainer,
224                        ));
225                        in_tab_block = true;
226                    }
227                }
228            } else {
229                if let Some(after) = rest.strip_prefix("@tab") {
230                    let after = after.strip_prefix(' ').or_else(|| after.strip_prefix('\t'));
231                    if let Some(after_ws) = after {
232                        if !after_ws.trim().is_empty() {
233                            highlights.push(line_highlight(
234                                line_no,
235                                line_start_offset,
236                                line.len(),
237                                HighlightTag::TabBlockHeader,
238                            ));
239                        }
240                    }
241                }
242
243                if let Some(after) = rest.strip_prefix(":::") {
244                    if after.trim().is_empty() {
245                        highlights.push(line_highlight(
246                            line_no,
247                            line_start_offset,
248                            line.len(),
249                            HighlightTag::TabBlockContainer,
250                        ));
251                        in_tab_block = false;
252                    }
253                }
254            }
255        }
256
257        line_start_offset = line_start_offset.saturating_add(seg_len);
258        line_no = line_no.saturating_add(1);
259    }
260
261    highlights
262}
263
264fn compute_slider_marker_highlights(source: &str) -> Vec<Highlight> {
265    fn trim_upto_3_spaces(s: &str) -> (&str, usize) {
266        let bytes = s.as_bytes();
267        let mut i = 0usize;
268        for _ in 0..3 {
269            if bytes.get(i) == Some(&b' ') {
270                i += 1;
271            } else {
272                break;
273            }
274        }
275        (&s[i..], i)
276    }
277
278    fn fence_prefix(rest: &str) -> Option<(char, usize, &str)> {
279        let mut chars = rest.chars();
280        let ch = chars.next()?;
281        if ch != '`' && ch != '~' {
282            return None;
283        }
284        let mut count = 1usize;
285        for c in chars.clone() {
286            if c == ch {
287                count += 1;
288            } else {
289                break;
290            }
291        }
292        if count >= 3 {
293            Some((ch, count, &rest[count..]))
294        } else {
295            None
296        }
297    }
298
299    let mut highlights: Vec<Highlight> = Vec::new();
300    let mut in_slider_deck = false;
301    let mut in_fence: Option<(char, usize)> = None;
302    let mut line_start_offset: usize = 0;
303    let mut line_no: usize = 1;
304
305    for seg in source.split_inclusive('\n') {
306        let seg_len = seg.len();
307
308        let line = seg
309            .strip_suffix('\n')
310            .unwrap_or(seg)
311            .strip_suffix('\r')
312            .unwrap_or(seg.strip_suffix('\n').unwrap_or(seg));
313
314        let (rest, _indent_len) = trim_upto_3_spaces(line);
315
316        if let Some((fch, fcount, after_fence)) = fence_prefix(rest) {
317            match in_fence {
318                None => in_fence = Some((fch, fcount)),
319                Some((open_ch, open_count)) => {
320                    if fch == open_ch && fcount >= open_count && after_fence.trim().is_empty() {
321                        in_fence = None;
322                    }
323                }
324            }
325        }
326
327        if in_fence.is_none() {
328            if !in_slider_deck {
329                if let Some(after) = rest.strip_prefix("@slidestart") {
330                    let ok = after.is_empty()
331                        || after
332                            .chars()
333                            .next()
334                            .is_some_and(|ch| ch == ' ' || ch == '\t' || ch == ':');
335                    if ok {
336                        highlights.push(line_highlight(
337                            line_no,
338                            line_start_offset,
339                            line.len(),
340                            HighlightTag::SliderDeckMarker,
341                        ));
342                        in_slider_deck = true;
343                    }
344                }
345            } else {
346                if let Some(after) = rest.strip_prefix("@slideend") {
347                    if after.is_empty()
348                        || after
349                            .chars()
350                            .next()
351                            .is_some_and(|ch| ch == ' ' || ch == '\t')
352                    {
353                        highlights.push(line_highlight(
354                            line_no,
355                            line_start_offset,
356                            line.len(),
357                            HighlightTag::SliderDeckMarker,
358                        ));
359                        in_slider_deck = false;
360                    }
361                }
362
363                if rest.trim() == "---" {
364                    highlights.push(line_highlight(
365                        line_no,
366                        line_start_offset,
367                        line.len(),
368                        HighlightTag::SliderSeparatorHorizontal,
369                    ));
370                } else if rest.trim() == "--" {
371                    highlights.push(line_highlight(
372                        line_no,
373                        line_start_offset,
374                        line.len(),
375                        HighlightTag::SliderSeparatorVertical,
376                    ));
377                }
378            }
379        }
380
381        line_start_offset = line_start_offset.saturating_add(seg_len);
382        line_no = line_no.saturating_add(1);
383    }
384
385    highlights
386}
387
388fn line_highlight(
389    line: usize,
390    line_start_offset: usize,
391    line_len_bytes: usize,
392    tag: HighlightTag,
393) -> Highlight {
394    let start = Position::new(line, 1, line_start_offset);
395    let end = Position::new(line, line_len_bytes + 1, line_start_offset + line_len_bytes);
396    Highlight {
397        span: Span::new(start, end),
398        tag,
399    }
400}
401
402fn collect_highlights(node: &Node, highlights: &mut Vec<Highlight>) {
403    if let Some(span) = &node.span {
404        match &node.kind {
405            NodeKind::Heading { level, .. } => {
406                let tag = match level {
407                    1 => HighlightTag::Heading1,
408                    2 => HighlightTag::Heading2,
409                    3 => HighlightTag::Heading3,
410                    4 => HighlightTag::Heading4,
411                    5 => HighlightTag::Heading5,
412                    6 => HighlightTag::Heading6,
413                    _ => HighlightTag::Heading1,
414                };
415
416                let full_line_span = Span::new(
417                    Position::new(span.start.line, 1, span.start_line_offset()),
418                    span.end,
419                );
420
421                highlights.push(Highlight {
422                    span: full_line_span,
423                    tag,
424                });
425            }
426            NodeKind::Emphasis => highlights.push(Highlight {
427                span: *span,
428                tag: HighlightTag::Emphasis,
429            }),
430            NodeKind::Strong | NodeKind::StrongEmphasis => highlights.push(Highlight {
431                span: *span,
432                tag: HighlightTag::Strong,
433            }),
434            NodeKind::Strikethrough => highlights.push(Highlight {
435                span: *span,
436                tag: HighlightTag::Strikethrough,
437            }),
438            NodeKind::Mark => highlights.push(Highlight {
439                span: *span,
440                tag: HighlightTag::Mark,
441            }),
442            NodeKind::Superscript => highlights.push(Highlight {
443                span: *span,
444                tag: HighlightTag::Superscript,
445            }),
446            NodeKind::Subscript => highlights.push(Highlight {
447                span: *span,
448                tag: HighlightTag::Subscript,
449            }),
450            NodeKind::Link { .. }
451            | NodeKind::PlatformMention { .. }
452            | NodeKind::FootnoteReference { .. } => highlights.push(Highlight {
453                span: *span,
454                tag: HighlightTag::Link,
455            }),
456            NodeKind::Image { .. } => highlights.push(Highlight {
457                span: *span,
458                tag: HighlightTag::Image,
459            }),
460            NodeKind::CodeSpan(_) | NodeKind::InlineMath { .. } => highlights.push(Highlight {
461                span: *span,
462                tag: HighlightTag::CodeSpan,
463            }),
464            NodeKind::CodeBlock { .. }
465            | NodeKind::DisplayMath { .. }
466            | NodeKind::MermaidDiagram { .. } => highlights.push(Highlight {
467                span: *span,
468                tag: HighlightTag::CodeBlock,
469            }),
470            NodeKind::InlineHtml(_) => highlights.push(Highlight {
471                span: *span,
472                tag: HighlightTag::InlineHtml,
473            }),
474            NodeKind::ThematicBreak => highlights.push(Highlight {
475                span: *span,
476                tag: HighlightTag::ThematicBreak,
477            }),
478            NodeKind::HtmlBlock { .. } => highlights.push(Highlight {
479                span: *span,
480                tag: HighlightTag::HtmlBlock,
481            }),
482            NodeKind::Blockquote => highlights.push(Highlight {
483                span: *span,
484                tag: HighlightTag::Blockquote,
485            }),
486            NodeKind::Admonition { .. } => highlights.push(Highlight {
487                span: *span,
488                tag: HighlightTag::Admonition,
489            }),
490            NodeKind::List { .. } => highlights.push(Highlight {
491                span: *span,
492                tag: HighlightTag::List,
493            }),
494            NodeKind::ListItem => highlights.push(Highlight {
495                span: *span,
496                tag: HighlightTag::ListItem,
497            }),
498            NodeKind::TaskCheckbox { checked } | NodeKind::TaskCheckboxInline { checked } => {
499                highlights.push(Highlight {
500                    span: *span,
501                    tag: if *checked {
502                        HighlightTag::TaskCheckboxChecked
503                    } else {
504                        HighlightTag::TaskCheckboxUnchecked
505                    },
506                })
507            }
508            NodeKind::Table { .. } => highlights.push(Highlight {
509                span: *span,
510                tag: HighlightTag::Table,
511            }),
512            NodeKind::TableRow { header } => highlights.push(Highlight {
513                span: *span,
514                tag: if *header {
515                    HighlightTag::TableRowHeader
516                } else {
517                    HighlightTag::TableRow
518                },
519            }),
520            NodeKind::TableCell { header, .. } => highlights.push(Highlight {
521                span: *span,
522                tag: if *header {
523                    HighlightTag::TableCellHeader
524                } else {
525                    HighlightTag::TableCell
526                },
527            }),
528            NodeKind::LinkReference { .. } => highlights.push(Highlight {
529                span: *span,
530                tag: HighlightTag::LinkReference,
531            }),
532            NodeKind::DefinitionList => highlights.push(Highlight {
533                span: *span,
534                tag: HighlightTag::DefinitionList,
535            }),
536            NodeKind::DefinitionTerm => highlights.push(Highlight {
537                span: *span,
538                tag: HighlightTag::DefinitionTerm,
539            }),
540            NodeKind::DefinitionDescription => highlights.push(Highlight {
541                span: *span,
542                tag: HighlightTag::DefinitionDescription,
543            }),
544            NodeKind::Paragraph
545            | NodeKind::Text(_)
546            | NodeKind::HardBreak
547            | NodeKind::SoftBreak
548            | NodeKind::TabGroup
549            | NodeKind::TabItem { .. }
550            | NodeKind::SliderDeck { .. }
551            | NodeKind::Slide { .. }
552            | NodeKind::FootnoteDefinition { .. } => {}
553        }
554    }
555
556    for child in &node.children {
557        collect_highlights(child, highlights);
558    }
559}