Skip to main content

agent_doc_core/
syntax.rs

1use serde::Serialize;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4#[serde(rename_all = "snake_case")]
5pub enum VisualTokenKind {
6    ComponentOpen,
7    ComponentClose,
8    ComponentBody,
9    PatchOpen,
10    PatchClose,
11    Boundary,
12    ScratchComment,
13    ScratchCommentBody,
14    Prompt,
15    ResponseHeading,
16    TrackedId,
17    LabelTag,
18    /// Markdown **strong** emphasis span (`**text**` or `__text__`), rendered
19    /// bold in the editor (`#editor-bold-markdown-rendering`).
20    Bold,
21    /// Markdown *emphasis* span (`*text*` or `_text_`), rendered italic.
22    Italic,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct VisualToken {
27    pub kind: VisualTokenKind,
28    pub start: usize,
29    pub end: usize,
30}
31
32pub fn collect_visual_tokens(doc: &str) -> Vec<VisualToken> {
33    let code_ranges = crate::component::find_code_ranges(doc);
34    let mut tokens = Vec::new();
35
36    collect_comment_tokens(doc, &code_ranges, &mut tokens);
37    collect_component_body_tokens(doc, &code_ranges, &mut tokens);
38    collect_scratch_comment_body_tokens(doc, &code_ranges, &mut tokens);
39    collect_line_tokens(doc, &code_ranges, &mut tokens);
40    collect_label_tags(doc, &code_ranges, &mut tokens);
41    collect_tracked_ids(doc, &code_ranges, &mut tokens);
42    collect_emphasis_tokens(doc, &code_ranges, &mut tokens);
43
44    tokens.sort_by_key(|token| (token.start, token.end));
45    tokens
46}
47
48/// Collect markdown emphasis spans (`#editor-bold-markdown-rendering`): `**…**`
49/// / `__…__` → [`VisualTokenKind::Bold`], `*…*` / `_…_` → [`VisualTokenKind::Italic`].
50/// Scans per line (no cross-line matches), skips code ranges. Bold and italic use
51/// **independent** consumed maps, so italic nests inside bold — `**_x_**` emits
52/// both a Bold span and an inner Italic span and the editor renders the inner
53/// text bold+italic (`#editor-nested-bold-italic-rendering`) — while two bolds
54/// (or two italics) never overlap. Markers are ASCII so byte-index scanning stays
55/// on char boundaries; emitted offsets are byte offsets (FFI converts to UTF-16).
56fn collect_emphasis_tokens(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
57    let mut line_start = 0usize;
58    for line in doc.split_inclusive('\n') {
59        let base = line_start;
60        line_start += line.len();
61        let bytes = line.as_bytes();
62        let mut consumed_bold = vec![false; bytes.len()];
63        let mut consumed_italic = vec![false; bytes.len()];
64        // Strong (double markers) first, then italic (single). The two levels keep
65        // separate consumed maps so an inner italic inside a bold run is still found.
66        scan_emphasis_runs(
67            bytes,
68            base,
69            b'*',
70            2,
71            VisualTokenKind::Bold,
72            &mut consumed_bold,
73            code_ranges,
74            out,
75        );
76        scan_emphasis_runs(
77            bytes,
78            base,
79            b'_',
80            2,
81            VisualTokenKind::Bold,
82            &mut consumed_bold,
83            code_ranges,
84            out,
85        );
86        scan_emphasis_runs(
87            bytes,
88            base,
89            b'*',
90            1,
91            VisualTokenKind::Italic,
92            &mut consumed_italic,
93            code_ranges,
94            out,
95        );
96        scan_emphasis_runs(
97            bytes,
98            base,
99            b'_',
100            1,
101            VisualTokenKind::Italic,
102            &mut consumed_italic,
103            code_ranges,
104            out,
105        );
106    }
107}
108
109/// Scan one line for emphasis runs of a single `marker`/`mlen` level, emitting a
110/// token per matched span and marking its byte range in `consumed` so the same
111/// level does not re-match it. See [`collect_emphasis_tokens`].
112#[allow(clippy::too_many_arguments)]
113fn scan_emphasis_runs(
114    bytes: &[u8],
115    base: usize,
116    marker: u8,
117    mlen: usize,
118    kind: VisualTokenKind,
119    consumed: &mut [bool],
120    code_ranges: &[(usize, usize)],
121    out: &mut Vec<VisualToken>,
122) {
123    // Marker-char test (does not read `consumed`, so it never conflicts with the
124    // mutable borrow when marking a span consumed below).
125    let is_marker_chars = |i: usize| -> bool {
126        if i + mlen > bytes.len() {
127            return false;
128        }
129        (0..mlen).all(|k| bytes[i + k] == marker)
130            // A single-marker italic must not be part of a double marker.
131            && (mlen == 2
132                || ((i == 0 || bytes[i - 1] != marker)
133                    && (i + 1 >= bytes.len() || bytes[i + 1] != marker)))
134    };
135    let is_marker = |i: usize, consumed: &[bool]| -> bool {
136        is_marker_chars(i) && (0..mlen).all(|k| !consumed[i + k])
137    };
138    let mut i = 0usize;
139    while i + mlen <= bytes.len() {
140        if is_marker(i, consumed) {
141            let mut j = i + mlen;
142            let mut close = None;
143            while j + mlen <= bytes.len() {
144                if is_marker(j, consumed) {
145                    close = Some(j);
146                    break;
147                }
148                j += 1;
149            }
150            if let Some(close) = close
151                && close > i + mlen
152            {
153                let tok_start = base + i;
154                let tok_end = base + close + mlen;
155                if !overlaps_code(tok_start, tok_end, code_ranges) {
156                    out.push(VisualToken {
157                        kind,
158                        start: tok_start,
159                        end: tok_end,
160                    });
161                    for slot in consumed.iter_mut().take(close + mlen).skip(i) {
162                        *slot = true;
163                    }
164                }
165                i = close + mlen;
166                continue;
167            }
168        }
169        i += 1;
170    }
171}
172
173enum CommentKind<'a> {
174    ComponentOpen(&'a str),
175    ComponentClose(&'a str),
176    PatchOpen,
177    PatchClose,
178    Boundary,
179    ScratchComment,
180}
181
182fn classify_comment_inner(inner: &str) -> CommentKind<'_> {
183    if inner.starts_with("agent:boundary:") {
184        CommentKind::Boundary
185    } else if let Some(rest) = inner.strip_prefix("agent:") {
186        let name = rest.split_whitespace().next().unwrap_or_default();
187        if name.is_empty() {
188            CommentKind::ScratchComment
189        } else {
190            CommentKind::ComponentOpen(name)
191        }
192    } else if let Some(rest) = inner.strip_prefix("/agent:") {
193        let name = rest.split_whitespace().next().unwrap_or_default();
194        if name.is_empty() {
195            CommentKind::ScratchComment
196        } else {
197            CommentKind::ComponentClose(name)
198        }
199    } else if inner.starts_with("patch:") {
200        CommentKind::PatchOpen
201    } else if inner.starts_with("/patch:") {
202        CommentKind::PatchClose
203    } else {
204        CommentKind::ScratchComment
205    }
206}
207
208fn collect_comment_tokens(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
209    let mut search_from = 0usize;
210    while let Some(rel) = doc[search_from..].find("<!--") {
211        let start = search_from + rel;
212        let Some(close_rel) = doc[start + 4..].find("-->") else {
213            break;
214        };
215        let end = start + 4 + close_rel + 3;
216        search_from = end;
217
218        if overlaps_code(start, end, code_ranges) {
219            continue;
220        }
221
222        let inner = doc[start + 4..end - 3].trim();
223        let kind = match classify_comment_inner(inner) {
224            CommentKind::ComponentOpen(_) => VisualTokenKind::ComponentOpen,
225            CommentKind::ComponentClose(_) => VisualTokenKind::ComponentClose,
226            CommentKind::PatchOpen => VisualTokenKind::PatchOpen,
227            CommentKind::PatchClose => VisualTokenKind::PatchClose,
228            CommentKind::Boundary => VisualTokenKind::Boundary,
229            CommentKind::ScratchComment => VisualTokenKind::ScratchComment,
230        };
231        out.push(VisualToken { kind, start, end });
232    }
233}
234
235fn collect_component_body_tokens(
236    doc: &str,
237    code_ranges: &[(usize, usize)],
238    out: &mut Vec<VisualToken>,
239) {
240    let mut stack: Vec<(String, usize)> = Vec::new();
241    let mut search_from = 0usize;
242    while let Some(rel) = doc[search_from..].find("<!--") {
243        let start = search_from + rel;
244        let Some(close_rel) = doc[start + 4..].find("-->") else {
245            break;
246        };
247        let end = start + 4 + close_rel + 3;
248        search_from = end;
249
250        if overlaps_code(start, end, code_ranges) {
251            continue;
252        }
253
254        let inner = doc[start + 4..end - 3].trim();
255        match classify_comment_inner(inner) {
256            CommentKind::ComponentOpen(name) => {
257                stack.push((name.to_string(), end));
258            }
259            CommentKind::ComponentClose(name) => {
260                if let Some(idx) = stack.iter().rposition(|(open_name, _)| open_name == name) {
261                    let (_, body_start) = stack.remove(idx);
262                    if body_start < start && !doc[body_start..start].trim().is_empty() {
263                        out.push(VisualToken {
264                            kind: VisualTokenKind::ComponentBody,
265                            start: body_start,
266                            end: start,
267                        });
268                    }
269                }
270            }
271            CommentKind::PatchOpen
272            | CommentKind::PatchClose
273            | CommentKind::Boundary
274            | CommentKind::ScratchComment => {}
275        }
276    }
277}
278
279fn collect_scratch_comment_body_tokens(
280    doc: &str,
281    code_ranges: &[(usize, usize)],
282    out: &mut Vec<VisualToken>,
283) {
284    let mut search_from = 0usize;
285    while let Some(rel) = doc[search_from..].find("<!--") {
286        let start = search_from + rel;
287        let Some(close_rel) = doc[start + 4..].find("-->") else {
288            break;
289        };
290        let end = start + 4 + close_rel + 3;
291        search_from = end;
292
293        if overlaps_code(start, end, code_ranges) {
294            continue;
295        }
296
297        let inner = doc[start + 4..end - 3].trim();
298        if !matches!(classify_comment_inner(inner), CommentKind::ScratchComment) {
299            continue;
300        }
301
302        let body_start = start + 4;
303        let body_end = end - 3;
304        if body_start < body_end && !doc[body_start..body_end].trim().is_empty() {
305            out.push(VisualToken {
306                kind: VisualTokenKind::ScratchCommentBody,
307                start: body_start,
308                end: body_end,
309            });
310        }
311    }
312}
313
314fn collect_line_tokens(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
315    let mut offset = 0usize;
316    for chunk in doc.split_inclusive('\n') {
317        let line = chunk.strip_suffix('\n').unwrap_or(chunk);
318        let line_end = offset + line.len();
319        if !overlaps_code(offset, line_end, code_ranges) {
320            let leading_ws = line.len() - line.trim_start_matches([' ', '\t']).len();
321            let trimmed = &line[leading_ws..];
322            if trimmed.starts_with("❯ ") {
323                out.push(VisualToken {
324                    kind: VisualTokenKind::Prompt,
325                    start: offset + leading_ws,
326                    end: line_end,
327                });
328            }
329            if trimmed.starts_with("### Re:") {
330                out.push(VisualToken {
331                    kind: VisualTokenKind::ResponseHeading,
332                    start: offset + leading_ws,
333                    end: line_end,
334                });
335            }
336        }
337        offset += chunk.len();
338    }
339}
340
341fn collect_tracked_ids(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
342    let mut search_from = 0usize;
343    while let Some(rel) = doc[search_from..].find("[#") {
344        let start = search_from + rel;
345        let rest = &doc[start + 2..];
346        let Some(close_rel) = rest.find(']') else {
347            break;
348        };
349        let end = start + 2 + close_rel + 1;
350        search_from = end;
351
352        if overlaps_code(start, end, code_ranges) {
353            continue;
354        }
355
356        let inner = &rest[..close_rel];
357        if !inner.is_empty()
358            && inner
359                .chars()
360                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
361        {
362            out.push(VisualToken {
363                kind: VisualTokenKind::TrackedId,
364                start,
365                end,
366            });
367        }
368    }
369}
370
371fn collect_label_tags(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
372    let mut search_from = 0usize;
373    while let Some(rel) = doc[search_from..].find('[') {
374        let start = search_from + rel;
375        let rest = &doc[start + 1..];
376        let Some(close_rel) = rest.find(']') else {
377            break;
378        };
379        let end = start + 1 + close_rel + 1;
380        search_from = end;
381
382        if overlaps_code(start, end, code_ranges) {
383            continue;
384        }
385        if start > 0 && doc.as_bytes()[start - 1] == b'!' {
386            continue;
387        }
388        if doc[end..].starts_with('(') {
389            continue;
390        }
391
392        let inner = &rest[..close_rel];
393        if inner.starts_with('#') || inner == "x" {
394            continue;
395        }
396        if inner.len() <= 1 {
397            continue;
398        }
399        if !inner
400            .chars()
401            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
402        {
403            continue;
404        }
405        if !inner.chars().any(|ch| ch.is_ascii_alphabetic()) {
406            continue;
407        }
408
409        out.push(VisualToken {
410            kind: VisualTokenKind::LabelTag,
411            start,
412            end,
413        });
414    }
415}
416
417fn overlaps_code(start: usize, end: usize, code_ranges: &[(usize, usize)]) -> bool {
418    code_ranges
419        .iter()
420        .any(|&(code_start, code_end)| start < code_end && end > code_start)
421}
422
423#[cfg(test)]
424mod tests {
425    use super::{VisualTokenKind, collect_visual_tokens};
426
427    fn tokens_of(doc: &str, kind: VisualTokenKind) -> Vec<String> {
428        collect_visual_tokens(doc)
429            .into_iter()
430            .filter(|t| t.kind == kind)
431            .map(|t| doc[t.start..t.end].to_string())
432            .collect()
433    }
434
435    #[test]
436    fn collects_bold_emphasis_spans() {
437        // #editor-bold-markdown-rendering: ** ** and __ __ → Bold.
438        let doc = "this is **bold** and __also bold__ here\n";
439        assert_eq!(
440            tokens_of(doc, VisualTokenKind::Bold),
441            vec!["**bold**".to_string(), "__also bold__".to_string()]
442        );
443    }
444
445    #[test]
446    fn collects_italic_emphasis_spans_not_overlapping_bold() {
447        let doc = "**strong** then *soft* and _under_\n";
448        assert_eq!(tokens_of(doc, VisualTokenKind::Bold), vec!["**strong**"]);
449        assert_eq!(
450            tokens_of(doc, VisualTokenKind::Italic),
451            vec!["*soft*".to_string(), "_under_".to_string()]
452        );
453    }
454
455    #[test]
456    fn bold_pin_marker_renders_bold() {
457        // The operator pin marker `**prioritized**` / `**pin**` is bold emphasis.
458        let doc = "- **prioritized** do [#x]\n- **pin** do [#y]\n";
459        assert_eq!(
460            tokens_of(doc, VisualTokenKind::Bold),
461            vec!["**prioritized**".to_string(), "**pin**".to_string()]
462        );
463    }
464
465    #[test]
466    fn nested_bold_italic_emits_both_spans() {
467        // #editor-nested-bold-italic-rendering: `**_x_**` emits a Bold span over
468        // the whole run AND an Italic span over the inner text, so the editor
469        // renders the inner text bold+italic.
470        let doc = "a **_bold italic_** b\n";
471        let bold = tokens_of(doc, VisualTokenKind::Bold);
472        let italic = tokens_of(doc, VisualTokenKind::Italic);
473        assert_eq!(bold, vec!["**_bold italic_**".to_string()]);
474        assert_eq!(italic, vec!["_bold italic_".to_string()]);
475    }
476
477    #[test]
478    fn emphasis_inside_code_is_ignored() {
479        let doc = "before\n```\n**not bold**\n```\nafter **yes**\n";
480        assert_eq!(tokens_of(doc, VisualTokenKind::Bold), vec!["**yes**"]);
481    }
482
483    #[test]
484    fn unclosed_emphasis_marker_is_not_a_span() {
485        let doc = "a ** dangling and b * lonely\n";
486        assert!(tokens_of(doc, VisualTokenKind::Bold).is_empty());
487        assert!(tokens_of(doc, VisualTokenKind::Italic).is_empty());
488    }
489
490    #[test]
491    fn collects_agent_and_scratch_comments() {
492        let doc = "\
493<!-- agent:exchange patch=append -->
494hello
495<!-- /agent:exchange -->
496<!-- patch:exchange -->
497<!-- /patch:exchange -->
498<!-- agent:boundary:abc12345 -->
499<!-- scratch note -->
500";
501        let tokens = collect_visual_tokens(doc);
502        let kinds: Vec<VisualTokenKind> = tokens.iter().map(|token| token.kind).collect();
503        assert_eq!(
504            kinds,
505            vec![
506                VisualTokenKind::ComponentOpen,
507                VisualTokenKind::ComponentBody,
508                VisualTokenKind::ComponentClose,
509                VisualTokenKind::PatchOpen,
510                VisualTokenKind::PatchClose,
511                VisualTokenKind::Boundary,
512                VisualTokenKind::ScratchComment,
513                VisualTokenKind::ScratchCommentBody,
514            ]
515        );
516    }
517
518    #[test]
519    fn ignores_visual_markers_inside_fenced_code() {
520        let doc = "\
521```md
522<!-- agent:exchange -->
523❯ hidden prompt
524### Re: hidden
525[#hide]
526```
527";
528        assert!(collect_visual_tokens(doc).is_empty());
529    }
530
531    #[test]
532    fn collects_prompts_response_headings_and_tracked_ids() {
533        let doc = "\
534❯ do #qey0. spec-test-build-install-commit-push
535### Re: #qey0 editor highlighting — gpt-5
536- [ ] [#qey0] Track this work item
537- [ ] [#multi-user] Alias-style id should still highlight
538- [ ] [recommended] Treat labels as metadata, not broken markdown refs
539";
540        let tokens = collect_visual_tokens(doc);
541        assert!(
542            tokens
543                .iter()
544                .any(|token| token.kind == VisualTokenKind::Prompt)
545        );
546        assert!(
547            tokens
548                .iter()
549                .any(|token| token.kind == VisualTokenKind::ResponseHeading)
550        );
551        assert_eq!(
552            tokens
553                .iter()
554                .filter(|token| token.kind == VisualTokenKind::TrackedId)
555                .count(),
556            2
557        );
558        assert!(
559            tokens
560                .iter()
561                .any(|token| token.kind == VisualTokenKind::LabelTag)
562        );
563    }
564
565    #[test]
566    fn ignores_image_alt_text_and_checklists_for_label_tags() {
567        let doc = "\
568- [ ] keep checklist
569![img_1.png](img_1.png)
570- [recommended] keep label
571";
572        let tokens = collect_visual_tokens(doc);
573        let label_tags: Vec<&super::VisualToken> = tokens
574            .iter()
575            .filter(|token| token.kind == VisualTokenKind::LabelTag)
576            .collect();
577        assert_eq!(label_tags.len(), 1);
578        assert_eq!(
579            &doc[label_tags[0].start..label_tags[0].end],
580            "[recommended]"
581        );
582    }
583
584    #[test]
585    fn collects_multiline_scratch_comment_body() {
586        let doc = "\
587<!--
588scratch note
589```md
590![img_3.png](img_3.png)
591```
592-->
593";
594        let tokens = collect_visual_tokens(doc);
595        let body = tokens
596            .iter()
597            .find(|token| token.kind == VisualTokenKind::ScratchCommentBody)
598            .expect("expected scratch comment body token");
599        let body_text = &doc[body.start..body.end];
600        assert!(body_text.contains("scratch note"));
601        assert!(body_text.contains("![img_3.png](img_3.png)"));
602    }
603}