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 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 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 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 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
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
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(""));
602 }
603}