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 is_open_marker = |i: usize, consumed: &[bool]| -> bool {
139        is_marker(i, consumed)
140            && (marker != b'_'
141                || i == 0
142                || !is_ascii_identifier_continue(bytes[i.saturating_sub(1)]))
143    };
144    let is_close_marker = |i: usize, consumed: &[bool]| -> bool {
145        is_marker(i, consumed)
146            && (marker != b'_'
147                || i + mlen >= bytes.len()
148                || !is_ascii_identifier_continue(bytes[i + mlen]))
149    };
150    let mut i = 0usize;
151    while i + mlen <= bytes.len() {
152        if is_open_marker(i, consumed) {
153            let mut j = i + mlen;
154            let mut close = None;
155            while j + mlen <= bytes.len() {
156                if is_close_marker(j, consumed) {
157                    close = Some(j);
158                    break;
159                }
160                j += 1;
161            }
162            if let Some(close) = close
163                && close > i + mlen
164            {
165                let tok_start = base + i;
166                let tok_end = base + close + mlen;
167                if !overlaps_code(tok_start, tok_end, code_ranges) {
168                    out.push(VisualToken {
169                        kind,
170                        start: tok_start,
171                        end: tok_end,
172                    });
173                    for slot in consumed.iter_mut().take(close + mlen).skip(i) {
174                        *slot = true;
175                    }
176                }
177                i = close + mlen;
178                continue;
179            }
180        }
181        i += 1;
182    }
183}
184
185fn is_ascii_identifier_continue(byte: u8) -> bool {
186    byte == b'_' || byte.is_ascii_alphanumeric()
187}
188
189enum CommentKind<'a> {
190    ComponentOpen(&'a str),
191    ComponentClose(&'a str),
192    PatchOpen,
193    PatchClose,
194    Boundary,
195    ScratchComment,
196}
197
198fn classify_comment_inner(inner: &str) -> CommentKind<'_> {
199    if inner.starts_with("agent:boundary:") {
200        CommentKind::Boundary
201    } else if let Some(rest) = inner.strip_prefix("agent:") {
202        let name = rest.split_whitespace().next().unwrap_or_default();
203        if name.is_empty() {
204            CommentKind::ScratchComment
205        } else {
206            CommentKind::ComponentOpen(name)
207        }
208    } else if let Some(rest) = inner.strip_prefix("/agent:") {
209        let name = rest.split_whitespace().next().unwrap_or_default();
210        if name.is_empty() {
211            CommentKind::ScratchComment
212        } else {
213            CommentKind::ComponentClose(name)
214        }
215    } else if inner.starts_with("patch:") {
216        CommentKind::PatchOpen
217    } else if inner.starts_with("/patch:") {
218        CommentKind::PatchClose
219    } else {
220        CommentKind::ScratchComment
221    }
222}
223
224fn collect_comment_tokens(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
225    let mut search_from = 0usize;
226    while let Some(rel) = doc[search_from..].find("<!--") {
227        let start = search_from + rel;
228        let Some(close_rel) = doc[start + 4..].find("-->") else {
229            break;
230        };
231        let end = start + 4 + close_rel + 3;
232        search_from = end;
233
234        if overlaps_code(start, end, code_ranges) {
235            continue;
236        }
237
238        let inner = doc[start + 4..end - 3].trim();
239        let kind = match classify_comment_inner(inner) {
240            CommentKind::ComponentOpen(_) => VisualTokenKind::ComponentOpen,
241            CommentKind::ComponentClose(_) => VisualTokenKind::ComponentClose,
242            CommentKind::PatchOpen => VisualTokenKind::PatchOpen,
243            CommentKind::PatchClose => VisualTokenKind::PatchClose,
244            CommentKind::Boundary => VisualTokenKind::Boundary,
245            CommentKind::ScratchComment => VisualTokenKind::ScratchComment,
246        };
247        out.push(VisualToken { kind, start, end });
248    }
249}
250
251fn collect_component_body_tokens(
252    doc: &str,
253    code_ranges: &[(usize, usize)],
254    out: &mut Vec<VisualToken>,
255) {
256    let mut stack: Vec<(String, usize)> = Vec::new();
257    let mut search_from = 0usize;
258    while let Some(rel) = doc[search_from..].find("<!--") {
259        let start = search_from + rel;
260        let Some(close_rel) = doc[start + 4..].find("-->") else {
261            break;
262        };
263        let end = start + 4 + close_rel + 3;
264        search_from = end;
265
266        if overlaps_code(start, end, code_ranges) {
267            continue;
268        }
269
270        let inner = doc[start + 4..end - 3].trim();
271        match classify_comment_inner(inner) {
272            CommentKind::ComponentOpen(name) => {
273                stack.push((name.to_string(), end));
274            }
275            CommentKind::ComponentClose(name) => {
276                if let Some(idx) = stack.iter().rposition(|(open_name, _)| open_name == name) {
277                    let (_, body_start) = stack.remove(idx);
278                    if body_start < start && !doc[body_start..start].trim().is_empty() {
279                        out.push(VisualToken {
280                            kind: VisualTokenKind::ComponentBody,
281                            start: body_start,
282                            end: start,
283                        });
284                    }
285                }
286            }
287            CommentKind::PatchOpen
288            | CommentKind::PatchClose
289            | CommentKind::Boundary
290            | CommentKind::ScratchComment => {}
291        }
292    }
293}
294
295fn collect_scratch_comment_body_tokens(
296    doc: &str,
297    code_ranges: &[(usize, usize)],
298    out: &mut Vec<VisualToken>,
299) {
300    let mut search_from = 0usize;
301    while let Some(rel) = doc[search_from..].find("<!--") {
302        let start = search_from + rel;
303        let Some(close_rel) = doc[start + 4..].find("-->") else {
304            break;
305        };
306        let end = start + 4 + close_rel + 3;
307        search_from = end;
308
309        if overlaps_code(start, end, code_ranges) {
310            continue;
311        }
312
313        let inner = doc[start + 4..end - 3].trim();
314        if !matches!(classify_comment_inner(inner), CommentKind::ScratchComment) {
315            continue;
316        }
317
318        let body_start = start + 4;
319        let body_end = end - 3;
320        if body_start < body_end && !doc[body_start..body_end].trim().is_empty() {
321            out.push(VisualToken {
322                kind: VisualTokenKind::ScratchCommentBody,
323                start: body_start,
324                end: body_end,
325            });
326        }
327    }
328}
329
330fn collect_line_tokens(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
331    let mut offset = 0usize;
332    for chunk in doc.split_inclusive('\n') {
333        let line = chunk.strip_suffix('\n').unwrap_or(chunk);
334        let line_end = offset + line.len();
335        if !overlaps_code(offset, line_end, code_ranges) {
336            let leading_ws = line.len() - line.trim_start_matches([' ', '\t']).len();
337            let trimmed = &line[leading_ws..];
338            if trimmed.starts_with("❯ ") {
339                out.push(VisualToken {
340                    kind: VisualTokenKind::Prompt,
341                    start: offset + leading_ws,
342                    end: line_end,
343                });
344            }
345            if trimmed.starts_with("### Re:") {
346                out.push(VisualToken {
347                    kind: VisualTokenKind::ResponseHeading,
348                    start: offset + leading_ws,
349                    end: line_end,
350                });
351            }
352        }
353        offset += chunk.len();
354    }
355}
356
357fn collect_tracked_ids(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
358    let mut search_from = 0usize;
359    while let Some(rel) = doc[search_from..].find("[#") {
360        let start = search_from + rel;
361        let rest = &doc[start + 2..];
362        let Some(close_rel) = rest.find(']') else {
363            break;
364        };
365        let end = start + 2 + close_rel + 1;
366        search_from = end;
367
368        if overlaps_code(start, end, code_ranges) {
369            continue;
370        }
371
372        let inner = &rest[..close_rel];
373        if !inner.is_empty()
374            && inner
375                .chars()
376                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
377        {
378            out.push(VisualToken {
379                kind: VisualTokenKind::TrackedId,
380                start,
381                end,
382            });
383        }
384    }
385}
386
387fn collect_label_tags(doc: &str, code_ranges: &[(usize, usize)], out: &mut Vec<VisualToken>) {
388    let mut search_from = 0usize;
389    while let Some(rel) = doc[search_from..].find('[') {
390        let start = search_from + rel;
391        let rest = &doc[start + 1..];
392        let Some(close_rel) = rest.find(']') else {
393            break;
394        };
395        let end = start + 1 + close_rel + 1;
396        search_from = end;
397
398        if overlaps_code(start, end, code_ranges) {
399            continue;
400        }
401        if start > 0 && doc.as_bytes()[start - 1] == b'!' {
402            continue;
403        }
404        if doc[end..].starts_with('(') {
405            continue;
406        }
407
408        let inner = &rest[..close_rel];
409        if inner.starts_with('#') || inner == "x" {
410            continue;
411        }
412        if inner.len() <= 1 {
413            continue;
414        }
415        if !inner
416            .chars()
417            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
418        {
419            continue;
420        }
421        if !inner.chars().any(|ch| ch.is_ascii_alphabetic()) {
422            continue;
423        }
424
425        out.push(VisualToken {
426            kind: VisualTokenKind::LabelTag,
427            start,
428            end,
429        });
430    }
431}
432
433fn overlaps_code(start: usize, end: usize, code_ranges: &[(usize, usize)]) -> bool {
434    code_ranges
435        .iter()
436        .any(|&(code_start, code_end)| start < code_end && end > code_start)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::{VisualTokenKind, collect_visual_tokens};
442
443    fn tokens_of(doc: &str, kind: VisualTokenKind) -> Vec<String> {
444        collect_visual_tokens(doc)
445            .into_iter()
446            .filter(|t| t.kind == kind)
447            .map(|t| doc[t.start..t.end].to_string())
448            .collect()
449    }
450
451    #[test]
452    fn collects_bold_emphasis_spans() {
453        // #editor-bold-markdown-rendering: ** ** and __ __ → Bold.
454        let doc = "this is **bold** and __also bold__ here\n";
455        assert_eq!(
456            tokens_of(doc, VisualTokenKind::Bold),
457            vec!["**bold**".to_string(), "__also bold__".to_string()]
458        );
459    }
460
461    #[test]
462    fn collects_italic_emphasis_spans_not_overlapping_bold() {
463        let doc = "**strong** then *soft* and _under_\n";
464        assert_eq!(tokens_of(doc, VisualTokenKind::Bold), vec!["**strong**"]);
465        assert_eq!(
466            tokens_of(doc, VisualTokenKind::Italic),
467            vec!["*soft*".to_string(), "_under_".to_string()]
468        );
469    }
470
471    #[test]
472    fn underscore_emphasis_does_not_match_inside_identifiers() {
473        let doc = "foo_bar_baz foo__bar__baz _foo_ __foo__ word _bar_ next\n";
474        assert_eq!(tokens_of(doc, VisualTokenKind::Bold), vec!["__foo__"]);
475        assert_eq!(
476            tokens_of(doc, VisualTokenKind::Italic),
477            vec!["_foo_".to_string(), "_bar_".to_string()]
478        );
479    }
480
481    #[test]
482    fn bold_pin_marker_renders_bold() {
483        // The operator pin marker `**prioritized**` / `**pin**` is bold emphasis.
484        let doc = "- **prioritized** do [#x]\n- **pin** do [#y]\n";
485        assert_eq!(
486            tokens_of(doc, VisualTokenKind::Bold),
487            vec!["**prioritized**".to_string(), "**pin**".to_string()]
488        );
489    }
490
491    #[test]
492    fn nested_bold_italic_emits_both_spans() {
493        // #editor-nested-bold-italic-rendering: `**_x_**` emits a Bold span over
494        // the whole run AND an Italic span over the inner text, so the editor
495        // renders the inner text bold+italic.
496        let doc = "a **_bold italic_** b\n";
497        let bold = tokens_of(doc, VisualTokenKind::Bold);
498        let italic = tokens_of(doc, VisualTokenKind::Italic);
499        assert_eq!(bold, vec!["**_bold italic_**".to_string()]);
500        assert_eq!(italic, vec!["_bold italic_".to_string()]);
501    }
502
503    #[test]
504    fn emphasis_inside_code_is_ignored() {
505        let doc = "before\n```\n**not bold**\n```\nafter **yes**\n";
506        assert_eq!(tokens_of(doc, VisualTokenKind::Bold), vec!["**yes**"]);
507    }
508
509    #[test]
510    fn unclosed_emphasis_marker_is_not_a_span() {
511        let doc = "a ** dangling and b * lonely\n";
512        assert!(tokens_of(doc, VisualTokenKind::Bold).is_empty());
513        assert!(tokens_of(doc, VisualTokenKind::Italic).is_empty());
514    }
515
516    #[test]
517    fn collects_agent_and_scratch_comments() {
518        let doc = "\
519<!-- agent:exchange patch=append -->
520hello
521<!-- /agent:exchange -->
522<!-- patch:exchange -->
523<!-- /patch:exchange -->
524<!-- agent:boundary:abc12345 -->
525<!-- scratch note -->
526";
527        let tokens = collect_visual_tokens(doc);
528        let kinds: Vec<VisualTokenKind> = tokens.iter().map(|token| token.kind).collect();
529        assert_eq!(
530            kinds,
531            vec![
532                VisualTokenKind::ComponentOpen,
533                VisualTokenKind::ComponentBody,
534                VisualTokenKind::ComponentClose,
535                VisualTokenKind::PatchOpen,
536                VisualTokenKind::PatchClose,
537                VisualTokenKind::Boundary,
538                VisualTokenKind::ScratchComment,
539                VisualTokenKind::ScratchCommentBody,
540            ]
541        );
542    }
543
544    #[test]
545    fn ignores_visual_markers_inside_fenced_code() {
546        let doc = "\
547```md
548<!-- agent:exchange -->
549❯ hidden prompt
550### Re: hidden
551[#hide]
552```
553";
554        assert!(collect_visual_tokens(doc).is_empty());
555    }
556
557    #[test]
558    fn collects_prompts_response_headings_and_tracked_ids() {
559        let doc = "\
560❯ do #qey0. spec-test-build-install-commit-push
561### Re: #qey0 editor highlighting — gpt-5
562- [ ] [#qey0] Track this work item
563- [ ] [#multi-user] Alias-style id should still highlight
564- [ ] [recommended] Treat labels as metadata, not broken markdown refs
565";
566        let tokens = collect_visual_tokens(doc);
567        assert!(
568            tokens
569                .iter()
570                .any(|token| token.kind == VisualTokenKind::Prompt)
571        );
572        assert!(
573            tokens
574                .iter()
575                .any(|token| token.kind == VisualTokenKind::ResponseHeading)
576        );
577        assert_eq!(
578            tokens
579                .iter()
580                .filter(|token| token.kind == VisualTokenKind::TrackedId)
581                .count(),
582            2
583        );
584        assert!(
585            tokens
586                .iter()
587                .any(|token| token.kind == VisualTokenKind::LabelTag)
588        );
589    }
590
591    #[test]
592    fn ignores_image_alt_text_and_checklists_for_label_tags() {
593        let doc = "\
594- [ ] keep checklist
595![img_1.png](img_1.png)
596- [recommended] keep label
597";
598        let tokens = collect_visual_tokens(doc);
599        let label_tags: Vec<&super::VisualToken> = tokens
600            .iter()
601            .filter(|token| token.kind == VisualTokenKind::LabelTag)
602            .collect();
603        assert_eq!(label_tags.len(), 1);
604        assert_eq!(
605            &doc[label_tags[0].start..label_tags[0].end],
606            "[recommended]"
607        );
608    }
609
610    #[test]
611    fn collects_multiline_scratch_comment_body() {
612        let doc = "\
613<!--
614scratch note
615```md
616![img_3.png](img_3.png)
617```
618-->
619";
620        let tokens = collect_visual_tokens(doc);
621        let body = tokens
622            .iter()
623            .find(|token| token.kind == VisualTokenKind::ScratchCommentBody)
624            .expect("expected scratch comment body token");
625        let body_text = &doc[body.start..body.end];
626        assert!(body_text.contains("scratch note"));
627        assert!(body_text.contains("![img_3.png](img_3.png)"));
628    }
629}