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