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 Bold,
21 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
48fn 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 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#[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 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 && (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 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 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 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
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
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(""));
628 }
629}