1use std::{num::NonZeroUsize, ops::Range, sync::LazyLock};
2
3use phf::phf_set;
4use regex::Regex;
5use regex_automata::{Input, PatternID, meta::Regex as MetaRegex};
6use serde::Deserialize;
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct StyleSpan {
11 pub start: usize,
13 pub end: usize,
15 pub token_type: TokenType,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum TokenType {
22 Keyword,
23 Identifier,
24 String,
25 Number,
26 Regex,
27 Comment,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub enum Language {
37 #[default]
39 JavaScript,
40 Css,
42}
43
44impl Language {
45 pub fn is_keyword(self, ident: &str) -> bool {
47 match self {
48 Language::JavaScript => JS_KEYWORDS.contains(ident),
49 Language::Css => false,
50 }
51 }
52}
53
54static JS_KEYWORDS: phf::Set<&'static str> = phf_set! {
56 "as",
57 "async",
58 "await",
59 "break",
60 "case",
61 "catch",
62 "class",
63 "const",
64 "continue",
65 "debugger",
66 "default",
67 "delete",
68 "do",
69 "else",
70 "enum",
71 "export",
72 "extends",
73 "false",
74 "finally",
75 "for",
76 "from",
77 "function",
78 "if",
79 "implements",
80 "import",
81 "in",
82 "instanceof",
83 "interface",
84 "let",
85 "new",
86 "null",
87 "of",
88 "package",
89 "private",
90 "protected",
91 "public",
92 "return",
93 "static",
94 "super",
95 "switch",
96 "this",
97 "throw",
98 "true",
99 "try",
100 "type",
101 "typeof",
102 "undefined",
103 "var",
104 "void",
105 "while",
106 "with",
107 "yield",
108};
109
110pub(crate) const ANSI_CODE_RESET: &str = "\x1b[0m";
111pub(crate) const ANSI_CODE_CYAN: &str = "\x1b[36m";
112pub(crate) const ANSI_CODE_YELLOW: &str = "\x1b[33m";
113pub(crate) const ANSI_CODE_GREEN: &str = "\x1b[32m";
114pub(crate) const ANSI_CODE_MAGENTA: &str = "\x1b[35m";
115pub(crate) const ANSI_CODE_GRAY: &str = "\x1b[90m";
116pub(crate) const ANSI_CODE_RED_BOLD: &str = "\x1b[31m\x1b[1m";
117pub(crate) const ANSI_CODE_YELLOW_BOLD: &str = "\x1b[33m\x1b[1m";
118pub(crate) const ANSI_CODE_CYAN_BOLD: &str = "\x1b[36m\x1b[1m";
119
120#[derive(Debug, Clone, Copy)]
122pub struct ColorScheme {
123 pub reset: &'static str,
124 pub keyword: &'static str,
125 pub identifier: &'static str,
126 pub string: &'static str,
127 pub number: &'static str,
128 pub regex: &'static str,
129 pub comment: &'static str,
130 pub gutter: &'static str,
131 pub marker: &'static str,
132 pub message: &'static str,
133}
134
135impl ColorScheme {
136 pub const fn colored(marker_color: &'static str) -> Self {
138 Self {
139 reset: ANSI_CODE_RESET,
140 keyword: ANSI_CODE_CYAN,
141 identifier: ANSI_CODE_YELLOW,
142 string: ANSI_CODE_GREEN,
143 number: ANSI_CODE_MAGENTA,
144 regex: ANSI_CODE_MAGENTA,
145 comment: ANSI_CODE_GRAY,
146 gutter: ANSI_CODE_GRAY,
147 marker: marker_color,
148 message: marker_color,
149 }
150 }
151
152 pub const fn plain() -> Self {
154 Self {
155 reset: "",
156 keyword: "",
157 identifier: "",
158 string: "",
159 number: "",
160 regex: "",
161 comment: "",
162 gutter: "",
163 marker: "",
164 message: "",
165 }
166 }
167
168 pub fn color_for_token(&self, token_type: TokenType) -> &'static str {
170 match token_type {
171 TokenType::Keyword => self.keyword,
172 TokenType::Identifier => self.identifier,
173 TokenType::String => self.string,
174 TokenType::Number => self.number,
175 TokenType::Regex => self.regex,
176 TokenType::Comment => self.comment,
177 }
178 }
179}
180
181pub(crate) struct Lines<'a> {
194 source: &'a str,
195 line_starts: Vec<usize>,
198 first_line: usize,
200 total_lines: usize,
202}
203
204impl<'a> Lines<'a> {
205 #[cfg(test)]
207 pub fn new(source: &'a str) -> Self {
208 Self::windowed(source, 0, usize::MAX)
209 }
210
211 pub fn windowed(source: &'a str, window_start: usize, window_end: usize) -> Self {
219 let bytes = source.as_bytes();
220
221 let store_start = window_start.saturating_sub(MAX_BACKSCAN_LINES);
224 let store_end = window_end.saturating_add(1);
226
227 let mut line_starts = Vec::new();
228 let mut line_num: usize = 0;
229 if store_start == 0 {
231 line_starts.push(0);
232 }
233 line_num += 1;
234
235 for found in memchr::Memchr3::new(b'\n', b'\r', b'\xE2', bytes) {
236 let b = bytes[found];
237 let line_start = if b == b'\n' {
238 found + 1
239 } else if b == b'\r' {
240 if found + 1 < bytes.len() && bytes[found + 1] == b'\n' {
242 continue;
243 }
244 found + 1
246 } else {
247 if found + 2 < bytes.len()
253 && bytes[found + 1] == 0x80
254 && (bytes[found + 2] == 0xA8 || bytes[found + 2] == 0xA9)
255 {
256 found + 3
257 } else {
258 continue;
261 }
262 };
263
264 if line_num >= store_end {
265 return Self {
267 source,
268 line_starts,
269 first_line: store_start,
270 total_lines: line_num + 1,
271 };
272 }
273 if line_num >= store_start {
274 line_starts.push(line_start);
275 }
276 line_num += 1;
277 }
278
279 Self {
281 source,
282 line_starts,
283 first_line: store_start.min(line_num.saturating_sub(1)),
284 total_lines: line_num,
285 }
286 }
287
288 pub fn len(&self) -> NonZeroUsize {
290 NonZeroUsize::new(self.total_lines).unwrap()
292 }
293
294 pub fn source(&self) -> &'a str {
296 self.source
297 }
298
299 pub fn starts(&self) -> &[usize] {
302 &self.line_starts
303 }
304
305 pub fn first_line(&self) -> usize {
307 self.first_line
308 }
309
310 pub fn content(&self, idx: usize) -> &'a str {
317 let (start, end) = self.byte_bounds(idx);
318 let line = &self.source[start..end];
319 line.strip_suffix("\r\n")
320 .or_else(|| line.strip_suffix('\n'))
321 .or_else(|| line.strip_suffix('\r'))
322 .or_else(|| line.strip_suffix('\u{2028}'))
323 .or_else(|| line.strip_suffix('\u{2029}'))
324 .unwrap_or(line)
325 }
326
327 pub fn byte_bounds(&self, idx: usize) -> (usize, usize) {
330 let local = idx - self.first_line;
331 let start = self
332 .line_starts
333 .get(local)
334 .copied()
335 .unwrap_or(self.source.len());
336 let end = self
337 .line_starts
338 .get(local + 1)
339 .copied()
340 .unwrap_or(self.source.len());
341 (start, end)
342 }
343}
344
345fn lookup_line(line_starts: &[usize], byte_offset: usize) -> usize {
347 match line_starts.binary_search(&byte_offset) {
348 Ok(idx) => idx,
349 Err(idx) => idx.saturating_sub(1),
350 }
351}
352
353fn line_bounds(line_starts: &[usize], source_len: usize, line_idx: usize) -> (usize, usize) {
355 let start = line_starts.get(line_idx).copied().unwrap_or(source_len);
356 let end = line_starts.get(line_idx + 1).copied().unwrap_or(source_len);
357 (start, end)
358}
359
360struct Scanner<'a> {
368 markers: Vec<StyleSpan>,
369 line_starts: &'a [usize],
370 source: &'a str,
371 output_ranges: Vec<(usize, usize)>,
374 language: Language,
375}
376
377impl<'a> Scanner<'a> {
378 fn new(
379 line_starts: &'a [usize],
380 source: &'a str,
381 output_ranges: Vec<(usize, usize)>,
382 language: Language,
383 ) -> Self {
384 Self {
385 markers: Vec::new(),
386 line_starts,
387 source,
388 output_ranges,
389 language,
390 }
391 }
392
393 fn output_end(&self) -> usize {
395 self.output_ranges.last().map_or(0, |r| r.1)
396 }
397
398 #[inline]
400 fn overlaps_output(&self, start: usize, end: usize) -> bool {
401 for &(rs, re) in &self.output_ranges {
404 if rs >= end {
405 return false;
406 }
407 if re > start {
408 return true;
409 }
410 }
411 false
412 }
413
414 fn add_span(&mut self, start: usize, end: usize, token_type: TokenType) {
420 if start >= end {
421 return;
422 }
423
424 if !self.overlaps_output(start, end) {
425 return;
426 }
427
428 let source_len = self.source.len();
429 let start_line = lookup_line(self.line_starts, start);
430 let end_line = lookup_line(self.line_starts, end.saturating_sub(1));
431
432 if start_line != end_line {
433 for line_idx in start_line..=end_line {
435 let (line_start, line_end) = line_bounds(self.line_starts, source_len, line_idx);
436 let span_start = start.max(line_start);
437 let span_end = end.min(line_end);
438 if span_start < span_end && self.overlaps_output(span_start, span_end) {
439 self.markers.push(StyleSpan {
440 start: span_start,
441 end: span_end,
442 token_type,
443 });
444 }
445 }
446 return;
447 }
448
449 self.markers.push(StyleSpan {
450 start,
451 end,
452 token_type,
453 });
454 }
455}
456
457const MAX_BACKSCAN_LINES: usize = 200;
464
465fn find_scan_start(lines: &Lines<'_>, target_line: usize, visible_start: usize) -> usize {
483 let mut result = 0;
484
485 if target_line > 0 {
487 let first = lines.first_line();
488 let search_start = target_line.saturating_sub(MAX_BACKSCAN_LINES).max(first);
489
490 result = 'line: {
491 for line_idx in (search_start..target_line).rev() {
492 if lines.content(line_idx).trim().is_empty() {
493 let (start, _) = lines.byte_bounds(line_idx);
494 break 'line start;
495 }
496 }
497 if search_start > first {
498 0
499 } else {
500 let (start, _) = lines.byte_bounds(search_start);
501 start
502 }
503 };
504 }
505
506 const MIN_SKIP_DISTANCE: usize = 200;
510 if visible_start > result + MIN_SKIP_DISTANCE {
511 let search_from = result;
512 let window = &lines.source().as_bytes()[search_from..visible_start];
513 if let Some(pos) = window.iter().rposition(|&b| b == b';') {
514 result = search_from + pos + 1;
515 }
516 }
517
518 result
519}
520
521pub fn extract_highlights(
542 lines: &Lines<'_>,
543 line_range: Range<usize>,
544 language: Language,
545 visible_window: Option<(usize, usize)>,
546) -> Vec<Vec<StyleSpan>> {
547 let line_starts = lines.starts();
548 let first_line = lines.first_line();
549 let source = lines.source();
550 let local_count = line_starts.len();
551
552 let local_start = line_range.start - first_line;
553 let local_end = line_range.end - first_line;
554
555 let output_ranges: Vec<(usize, usize)> = (local_start..local_end.min(local_count))
559 .filter_map(|local_idx| {
560 let ls = line_starts[local_idx];
561 let line_end = line_starts
562 .get(local_idx + 1)
563 .copied()
564 .unwrap_or(source.len());
565 let (rs, re) = if let Some((trunc_offset, avail_width)) = visible_window {
566 (
567 (ls + trunc_offset).min(line_end),
568 (ls + trunc_offset + avail_width).min(line_end),
569 )
570 } else {
571 (ls, line_end)
572 };
573 if rs < re { Some((rs, re)) } else { None }
574 })
575 .collect();
576
577 let visible_start = output_ranges.first().map_or(0, |r| r.0);
580 let scan_start = find_scan_start(lines, line_range.start, visible_start);
581
582 let scan_end = output_ranges.last().map_or(source.len(), |r| r.1);
583 let mut scanner = Scanner::new(line_starts, source, output_ranges, language);
584 scanner.scan(scan_start, scan_end, None);
585 let all_spans = scanner.markers;
586
587 debug_assert!(
588 all_spans.windows(2).all(|w| w[0].start <= w[1].start),
589 "spans should already be sorted by the left-to-right scan"
590 );
591 debug_assert!(
592 all_spans.windows(2).all(|w| w[0].end <= w[1].start),
593 "spans should be non-overlapping"
594 );
595 group_spans_by_line(&all_spans, line_starts, first_line, source, line_range)
596}
597
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
604enum TokenKind {
605 String,
606 Template,
607 LineComment,
608 BlockComment,
609 Number,
610 Ident,
611 Close,
612 Brace,
613 Postfix,
614 Slash,
615 Op,
616}
617
618const TOKEN_RULES: &[(TokenKind, &str)] = &[
623 (
624 TokenKind::String,
625 r#""(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?"#,
626 ),
627 (TokenKind::Template, r"`"),
634 (TokenKind::LineComment, r"//[^\n]*"),
635 (TokenKind::BlockComment, r"(?s)/\*.*?\*/"),
636 (
637 TokenKind::Number,
638 r"0[xX][\da-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?",
639 ),
640 (TokenKind::Ident, r"[A-Za-z_$\x80-\xff][\w$\x80-\xff]*"),
641 (TokenKind::Close, r"[)\]]"),
642 (TokenKind::Brace, r"[(\[{}]"),
643 (TokenKind::Postfix, r"\+\+|--"),
644 (TokenKind::Slash, r"/"),
645 (TokenKind::Op, r"[=+\-*%<>&|^!~?:;,.]"),
647];
648
649impl TokenKind {
650 fn from_pattern_id(id: PatternID) -> Self {
651 TOKEN_RULES[id.as_usize()].0
652 }
653}
654
655static TOKEN_RE: LazyLock<MetaRegex> = LazyLock::new(|| {
660 let patterns: Vec<&str> = TOKEN_RULES.iter().map(|(_, p)| *p).collect();
661 MetaRegex::new_many(&patterns).expect("token patterns must compile")
662});
663
664static REGEX_LITERAL_RE: LazyLock<Regex> = LazyLock::new(|| {
673 Regex::new(r#"/(?:[^\\/\[\n\r]|\\.|\[(?:[^\]\\\n\r]|\\.)*\])+/[A-Za-z]*"#)
674 .expect("regex literal regex must compile")
675});
676
677impl Scanner<'_> {
678 fn scan_template(&mut self, tpl_start: usize, scan_end: usize) -> usize {
691 let bytes = self.source.as_bytes();
692 let search_start = tpl_start + 1;
693
694 let mut seg_start = tpl_start;
697
698 let mut i = search_start;
700
701 let iter = memchr::Memchr2::new(b'`', b'$', &bytes[search_start..scan_end]);
709 for found in iter {
710 let pos = search_start + found;
711 if pos < i {
713 continue;
714 }
715
716 let mut backslashes = 0;
719 while pos > search_start + backslashes && bytes[pos - 1 - backslashes] == b'\\' {
720 backslashes += 1;
721 }
722 if backslashes % 2 != 0 {
723 i = pos + 1;
724 continue;
725 }
726
727 let b = bytes[pos];
728 if b == b'`' {
729 self.add_span(seg_start, pos + 1, TokenType::String);
731 return pos + 1;
732 }
733 debug_assert_eq!(b, b'$');
735 if pos + 1 < scan_end && bytes[pos + 1] == b'{' {
736 if pos > seg_start {
738 self.add_span(seg_start, pos, TokenType::String);
739 }
740
741 let expr_start = pos + 2;
747 let expr_end = self.scan(expr_start, scan_end, Some(1));
748
749 if expr_end > expr_start && bytes.get(expr_end - 1) == Some(&b'}') {
751 seg_start = expr_end - 1;
752 } else {
753 seg_start = expr_end;
755 }
756 i = expr_end;
757 continue;
758 }
759 i = pos + 1;
761 }
762
763 if scan_end > seg_start {
765 self.add_span(seg_start, scan_end, TokenType::String);
766 }
767 scan_end
768 }
769
770 fn scan(&mut self, start_pos: usize, scan_end: usize, mut brace_depth: Option<u32>) -> usize {
778 let mut pos = start_pos;
779
780 let mut last_token = LastToken::None;
784
785 while let Some(m) = TOKEN_RE.search(&Input::new(self.source).range(pos..scan_end)) {
786 let start = m.start();
787 let raw_end = m.end();
788
789 if start >= self.output_end() {
791 break;
792 }
793
794 let end = raw_end.min(scan_end);
796
797 match TokenKind::from_pattern_id(m.pattern()) {
798 TokenKind::String => {
799 self.add_span(start, end, TokenType::String);
800 last_token = LastToken::Value;
801 }
802 TokenKind::Template => {
803 let tpl_end = self.scan_template(start, scan_end);
807 last_token = LastToken::Value;
808 pos = tpl_end;
809 continue;
811 }
812 TokenKind::LineComment | TokenKind::BlockComment => {
813 self.add_span(start, end, TokenType::Comment);
814 }
816 TokenKind::Postfix => {
817 last_token = LastToken::PostfixOp;
818 }
819 TokenKind::Slash => {
820 if last_token.slash_means_regex()
821 && let Some(re_match) = REGEX_LITERAL_RE.find_at(self.source, start)
822 && re_match.start() == start
823 {
824 let re_end = re_match.end().min(scan_end);
825 self.add_span(start, re_end, TokenType::Regex);
826 last_token = LastToken::Value;
827 pos = re_end;
828 continue;
829 }
830 last_token = LastToken::Operator;
831 }
832 TokenKind::Close => {
833 last_token = LastToken::CloseBracket;
834 }
835 TokenKind::Brace => {
836 let ch = self.source.as_bytes()[start];
837 if ch == b'{' {
838 if let Some(ref mut depth) = brace_depth {
839 *depth += 1;
840 }
841 } else if ch == b'}'
842 && let Some(ref mut depth) = brace_depth
843 {
844 if *depth <= 1 {
846 return end;
847 }
848 *depth -= 1;
849 }
850 last_token = LastToken::Operator;
851 }
852 TokenKind::Op => {
853 last_token = LastToken::Operator;
854 }
855 TokenKind::Number => {
856 self.add_span(start, end, TokenType::Number);
857 last_token = LastToken::Value;
858 }
859 TokenKind::Ident => {
860 let ident = &self.source[start..end];
861 let token_type = if self.language.is_keyword(ident) {
862 Some(TokenType::Keyword)
863 } else if ident.as_bytes()[0].is_ascii_uppercase() {
864 Some(TokenType::Identifier)
866 } else {
867 None
868 };
869 if let Some(tt) = token_type {
870 self.add_span(start, end, tt);
871 }
872 last_token = LastToken::Value;
873 }
874 }
875
876 assert!(
877 raw_end > pos,
878 "TOKEN_RE produced a zero-width match at byte {pos}"
879 );
880 pos = raw_end;
881 }
882
883 scan_end
884 }
885}
886
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890enum LastToken {
891 None,
893 Value,
895 CloseBracket,
897 PostfixOp,
899 Operator,
901}
902
903impl LastToken {
904 fn slash_means_regex(self) -> bool {
906 match self {
907 LastToken::None | LastToken::Operator => true,
908 LastToken::Value | LastToken::CloseBracket | LastToken::PostfixOp => false,
909 }
910 }
911}
912
913fn group_spans_by_line(
919 spans: &[StyleSpan],
920 line_starts: &[usize],
921 first_line: usize,
922 source: &str,
923 line_range: Range<usize>,
924) -> Vec<Vec<StyleSpan>> {
925 if source.is_empty() {
926 return Vec::new();
927 }
928
929 let line_count = first_line + line_starts.len();
930
931 let start_line_idx = line_range.start.min(line_count);
932 let end_line_idx = line_range.end.min(line_count);
933
934 let output_line_count = end_line_idx.saturating_sub(start_line_idx);
935 let mut line_highlights = Vec::with_capacity(output_line_count);
936
937 let mut span_idx = 0;
938
939 for line_idx in start_line_idx..end_line_idx {
940 let local_idx = line_idx - first_line;
941 let (line_start, line_end) = line_bounds(line_starts, source.len(), local_idx);
942
943 let mut line_spans = Vec::new();
944
945 while span_idx < spans.len() {
946 let span = &spans[span_idx];
947
948 if span.start >= line_end {
949 break;
950 }
951 debug_assert!(
952 span.start >= line_start,
953 "span at {} precedes line start {line_start}",
954 span.start
955 );
956
957 line_spans.push(StyleSpan {
958 start: span.start - line_start,
959 end: span.end - line_start,
960 token_type: span.token_type,
961 });
962
963 span_idx += 1;
964 }
965
966 line_highlights.push(line_spans);
967 }
968
969 line_highlights
970}
971
972pub fn apply_line_highlights(
984 visible_content: &str,
985 spans: &[StyleSpan],
986 color_scheme: &ColorScheme,
987 truncation_offset: usize,
988 prefix_len: usize,
989) -> String {
990 if spans.is_empty() {
991 return visible_content.to_string();
992 }
993
994 let visible_end = truncation_offset + visible_content.len().saturating_sub(prefix_len);
996
997 let mut result = String::with_capacity(visible_content.len() + spans.len() * 10);
998 let mut last_offset = 0;
999
1000 let start_idx = spans.partition_point(|s| s.end <= truncation_offset);
1002
1003 for span in &spans[start_idx..] {
1004 if span.start >= visible_end {
1005 break;
1006 }
1007
1008 let display_start = (span.start.max(truncation_offset) - truncation_offset + prefix_len)
1010 .min(visible_content.len());
1011 let display_end =
1012 (span.end.min(visible_end) - truncation_offset + prefix_len).min(visible_content.len());
1013
1014 if display_start < display_end {
1015 if display_start > last_offset {
1017 result.push_str(&visible_content[last_offset..display_start]);
1018 }
1019 result.push_str(color_scheme.color_for_token(span.token_type));
1021 result.push_str(&visible_content[display_start..display_end]);
1022 result.push_str(color_scheme.reset);
1023 last_offset = display_end;
1024 }
1025 }
1026
1027 if last_offset < visible_content.len() {
1029 result.push_str(&visible_content[last_offset..]);
1030 }
1031
1032 result
1033}
1034
1035#[cfg(test)]
1036pub mod tests {
1037 use super::*;
1038
1039 const JS: Language = Language::JavaScript;
1041
1042 pub fn strip_ansi_codes(s: &str) -> String {
1044 let mut result = String::with_capacity(s.len());
1045 let mut chars = s.chars();
1046
1047 while let Some(ch) = chars.next() {
1048 if ch == '\x1b' {
1049 if chars.next() == Some('[') {
1050 for ch in chars.by_ref() {
1051 if ch.is_alphabetic() {
1052 break;
1053 }
1054 }
1055 }
1056 } else {
1057 result.push(ch);
1058 }
1059 }
1060
1061 result
1062 }
1063
1064 #[test]
1069 fn test_apply_line_highlights_basic() {
1070 let source = "const Foo = 123";
1071 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1072 let color_scheme = ColorScheme::colored(ANSI_CODE_RED_BOLD);
1073
1074 let result = apply_line_highlights(source, &highlights[0], &color_scheme, 0, 0);
1075
1076 assert!(result.contains("\x1b["), "Result should contain ANSI codes");
1077 assert!(result.contains("const"), "Result should contain 'const'");
1078 assert!(result.contains("Foo"), "Result should contain 'Foo'");
1079 assert!(result.contains("123"), "Result should contain '123'");
1080 }
1081
1082 #[test]
1083 fn test_apply_line_highlights_plain() {
1084 let source = "const foo = 123";
1085 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1086 let color_scheme = ColorScheme::plain();
1087
1088 let result = apply_line_highlights(source, &highlights[0], &color_scheme, 0, 0);
1089 assert_eq!(result, source);
1090 }
1091
1092 #[test]
1093 fn test_only_capitalized_identifiers_highlighted() {
1094 let source = "const foo = Bar";
1095 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1096
1097 let has_identifier = highlights[0]
1098 .iter()
1099 .any(|s| s.token_type == TokenType::Identifier);
1100 assert!(has_identifier, "Capitalized 'Bar' should be highlighted");
1101
1102 let ident_starts: Vec<usize> = highlights[0]
1103 .iter()
1104 .filter(|s| s.token_type == TokenType::Identifier)
1105 .map(|s| s.start)
1106 .collect();
1107 assert_eq!(
1108 ident_starts,
1109 vec![12],
1110 "Only 'Bar' at offset 12 should be highlighted"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_strip_ansi_codes() {
1116 let input = "\x1b[36mconst\x1b[0m foo = \x1b[35m123\x1b[0m";
1117 let result = strip_ansi_codes(input);
1118 assert_eq!(result, "const foo = 123");
1119 }
1120
1121 #[test]
1122 fn test_apply_line_highlights_with_truncation() {
1123 let source = "const Foo = 123";
1124 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1125 let color_scheme = ColorScheme::colored(ANSI_CODE_RED_BOLD);
1126
1127 let visible = &source[6..];
1129 let result = apply_line_highlights(visible, &highlights[0], &color_scheme, 6, 0);
1130
1131 let stripped = strip_ansi_codes(&result);
1132 assert_eq!(stripped, "Foo = 123");
1133 assert!(
1134 result.contains("\x1b["),
1135 "Should contain ANSI codes for Foo/123"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_apply_line_highlights_overlapping_truncation() {
1141 let source = r#"const x = "hello world";"#;
1144 let truncation_offset = 15;
1145 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1146 let color_scheme = ColorScheme::colored(ANSI_CODE_RED_BOLD);
1147
1148 let visible = &source[truncation_offset..];
1149 let result =
1150 apply_line_highlights(visible, &highlights[0], &color_scheme, truncation_offset, 0);
1151
1152 let stripped = strip_ansi_codes(&result);
1153 assert_eq!(stripped, visible);
1154 assert!(
1157 result.starts_with("\x1b["),
1158 "Should start with ANSI code for the overlapping string: {result:?}"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_comments_and_numbers() {
1164 let source = "const x = 42; // comment\nobj.foo = 10;";
1165 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1166
1167 assert_eq!(highlights.len(), 2);
1168
1169 let line1_has_comment = highlights[0]
1170 .iter()
1171 .any(|m| m.token_type == TokenType::Comment);
1172 assert!(line1_has_comment, "First line should have comment markers");
1173
1174 let line1_has_number = highlights[0]
1175 .iter()
1176 .any(|m| m.token_type == TokenType::Number);
1177 let line2_has_number = highlights[1]
1178 .iter()
1179 .any(|m| m.token_type == TokenType::Number);
1180 assert!(line1_has_number);
1181 assert!(line2_has_number);
1182 }
1183
1184 #[test]
1185 fn test_multiline_comment() {
1186 let source = "const x = 1;\n/* multi\n line */\nconst y = 2;";
1187 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1188
1189 assert_eq!(highlights.len(), 4);
1190
1191 let line2_has_comment = highlights[1]
1192 .iter()
1193 .any(|m| m.token_type == TokenType::Comment);
1194 let line3_has_comment = highlights[2]
1195 .iter()
1196 .any(|m| m.token_type == TokenType::Comment);
1197
1198 assert!(line2_has_comment, "Line 2 should have comment marker");
1199 assert!(line3_has_comment, "Line 3 should have comment marker");
1200 }
1201
1202 #[test]
1203 fn test_multiline_template_literal() {
1204 let source = "const x = `line1\nline2\nline3`;";
1205 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1206
1207 assert_eq!(highlights.len(), 3);
1208
1209 for (i, highlight) in highlights.iter().enumerate() {
1210 let has_string = highlight.iter().any(|m| m.token_type == TokenType::String);
1211 assert!(
1212 has_string,
1213 "Line {} should have string markers for the template literal",
1214 i + 1
1215 );
1216 }
1217 }
1218
1219 #[test]
1220 fn test_template_literal_with_expression() {
1221 let source = "const x = `hello ${name}!`;";
1224 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1225
1226 let string_spans: Vec<(usize, usize)> = highlights[0]
1227 .iter()
1228 .filter(|s| s.token_type == TokenType::String)
1229 .map(|s| (s.start, s.end))
1230 .collect();
1231
1232 assert!(
1235 string_spans.len() >= 2,
1236 "Should have at least 2 string segments: got {:?}",
1237 string_spans
1238 );
1239
1240 let name_offset = source.find("name").unwrap();
1242 let name_in_string = highlights[0].iter().any(|s| {
1243 s.token_type == TokenType::String && s.start <= name_offset && s.end > name_offset
1244 });
1245 assert!(
1246 !name_in_string,
1247 "'name' should not be marked as part of a string"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_template_literal_nested() {
1253 let source = r#"const x = `a ${`b ${c}`} d`;"#;
1255 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1256
1257 assert!(!highlights.is_empty());
1259 let has_string = highlights[0]
1260 .iter()
1261 .any(|m| m.token_type == TokenType::String);
1262 assert!(has_string, "Should have string markers");
1263 }
1264
1265 #[test]
1270 fn test_template_unclosed_expression() {
1271 let source = "const x = `hello ${name";
1274 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1275 assert!(!highlights.is_empty(), "Should produce highlights");
1276
1277 let has_string = highlights[0]
1279 .iter()
1280 .any(|m| m.token_type == TokenType::String);
1281 assert!(has_string, "Should still mark the string part before ${{");
1282
1283 let name_offset = source.find("name").unwrap();
1285 let name_in_string = highlights[0].iter().any(|s| {
1286 s.token_type == TokenType::String && s.start <= name_offset && s.end > name_offset
1287 });
1288 assert!(
1289 !name_in_string,
1290 "'name' inside unclosed expression should not be a string"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_template_brace_in_string_inside_expression() {
1296 let source = r#"const x = `${ "}" } end`;"#;
1298 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1299 assert!(!highlights.is_empty());
1300
1301 let end_offset = source.find(" end").unwrap();
1303 let has_end_string = highlights[0].iter().any(|s| {
1304 s.token_type == TokenType::String && s.start <= end_offset && s.end > end_offset
1305 });
1306 assert!(
1307 has_end_string,
1308 "String part after expression should be marked"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_template_empty_expression() {
1314 let source = "const x = `hello ${}world`;";
1316 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1317 assert!(!highlights.is_empty());
1318
1319 let string_spans: Vec<usize> = highlights[0]
1321 .iter()
1322 .filter(|s| s.token_type == TokenType::String)
1323 .map(|s| s.start)
1324 .collect();
1325 assert!(
1326 string_spans.len() >= 2,
1327 "Empty expression should still split into two string segments, got {:?}",
1328 string_spans
1329 );
1330 }
1331
1332 #[test]
1333 fn test_template_nested_backtick_in_expression() {
1334 let source = r#"const x = `some${`template`}literal`;"#;
1336 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1337 assert!(!highlights.is_empty());
1338
1339 let literal_offset = source.rfind("literal").unwrap();
1341 let literal_is_string = highlights[0].iter().any(|s| {
1342 s.token_type == TokenType::String && s.start <= literal_offset && s.end > literal_offset
1343 });
1344 assert!(
1345 literal_is_string,
1346 "'literal' should be marked as string (outer template quasi), spans: {:?}",
1347 highlights[0]
1348 );
1349
1350 let template_offset = source.find("template").unwrap();
1352 let template_is_string = highlights[0].iter().any(|s| {
1353 s.token_type == TokenType::String
1354 && s.start <= template_offset
1355 && s.end > template_offset
1356 });
1357 assert!(
1358 template_is_string,
1359 "'template' should be marked as string (inner template), spans: {:?}",
1360 highlights[0]
1361 );
1362 }
1363
1364 #[test]
1365 fn test_template_block_comment_with_backtick_in_expression() {
1366 let source = r#"const x = `some${ /* ` */ ""}literal`;"#;
1368 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1369 assert!(!highlights.is_empty());
1370
1371 let comment_offset = source.find("/* ` */").unwrap();
1373 let comment_is_comment = highlights[0].iter().any(|s| {
1374 s.token_type == TokenType::Comment
1375 && s.start <= comment_offset
1376 && s.end > comment_offset
1377 });
1378 assert!(
1379 comment_is_comment,
1380 "'/* ` */' should be marked as comment, spans: {:?}",
1381 highlights[0]
1382 );
1383
1384 let literal_offset = source.rfind("literal").unwrap();
1386 let literal_is_string = highlights[0].iter().any(|s| {
1387 s.token_type == TokenType::String && s.start <= literal_offset && s.end > literal_offset
1388 });
1389 assert!(
1390 literal_is_string,
1391 "'literal' should be marked as string, spans: {:?}",
1392 highlights[0]
1393 );
1394 }
1395
1396 #[test]
1397 fn test_template_line_comment_with_backtick_in_expression() {
1398 let source = "const x = `some${ // `\n}literal`;";
1402 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1403 assert!(highlights.len() >= 2, "Should have at least 2 lines");
1404
1405 let line1 = "const x = `some${ // `";
1407 let comment_offset = line1.find("// `").unwrap();
1408 let comment_is_comment = highlights[0].iter().any(|s| {
1409 s.token_type == TokenType::Comment
1410 && s.start <= comment_offset
1411 && s.end > comment_offset
1412 });
1413 assert!(
1414 comment_is_comment,
1415 "'// `' should be marked as comment, spans: {:?}",
1416 highlights[0]
1417 );
1418
1419 let line2 = "}literal`;";
1422 let literal_offset = line2.find("literal").unwrap();
1423 let literal_is_string = highlights[1].iter().any(|s| {
1424 s.token_type == TokenType::String && s.start <= literal_offset && s.end > literal_offset
1425 });
1426 assert!(
1427 literal_is_string,
1428 "'literal' should be marked as string, spans: {:?}",
1429 highlights[1]
1430 );
1431 }
1432
1433 #[test]
1434 fn test_template_string_with_backtick_in_expression() {
1435 let source = r#"const x = `some${"`"}literal`;"#;
1437 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1438 assert!(!highlights.is_empty());
1439
1440 let inner_str_offset = source.find(r#""`""#).unwrap();
1442 let inner_is_string = highlights[0].iter().any(|s| {
1443 s.token_type == TokenType::String
1444 && s.start <= inner_str_offset
1445 && s.end > inner_str_offset
1446 });
1447 assert!(
1448 inner_is_string,
1449 r#"'"`"' should be marked as string, spans: {:?}"#,
1450 highlights[0]
1451 );
1452
1453 let literal_offset = source.rfind("literal").unwrap();
1455 let literal_is_string = highlights[0].iter().any(|s| {
1456 s.token_type == TokenType::String && s.start <= literal_offset && s.end > literal_offset
1457 });
1458 assert!(
1459 literal_is_string,
1460 "'literal' should be marked as string, spans: {:?}",
1461 highlights[0]
1462 );
1463 }
1464
1465 #[test]
1466 fn test_line_range_filtering() {
1467 let source = "const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nconst e = 5;";
1468
1469 let highlights = extract_highlights(&Lines::new(source), 1..4, JS, None);
1470
1471 assert_eq!(highlights.len(), 3);
1472 assert!(highlights.iter().all(|h| !h.is_empty()));
1473 }
1474
1475 #[test]
1480 fn test_regex_after_equals() {
1481 let source = "const re = /foo/gi;";
1482 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1483
1484 let has_regex = highlights[0]
1485 .iter()
1486 .any(|m| m.token_type == TokenType::Regex);
1487 assert!(has_regex, "/foo/gi should be highlighted as regex");
1488 }
1489
1490 #[test]
1491 fn test_division_not_regex() {
1492 let source = "const x = a / b / c;";
1494 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1495
1496 let has_regex = highlights[0]
1497 .iter()
1498 .any(|m| m.token_type == TokenType::Regex);
1499 assert!(!has_regex, "a / b / c should not have regex markers");
1500 }
1501
1502 #[test]
1507 fn test_js_keywords_highlighted() {
1508 let source = "const foo = function() { return true; }";
1509 let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1510
1511 let keyword_starts: Vec<usize> = highlights[0]
1512 .iter()
1513 .filter(|s| s.token_type == TokenType::Keyword)
1514 .map(|s| s.start)
1515 .collect();
1516
1517 assert!(
1519 keyword_starts.contains(&0),
1520 "'const' should start at offset 0"
1521 );
1522 assert!(
1523 keyword_starts.contains(&12),
1524 "'function' should start at offset 12"
1525 );
1526 assert!(
1527 keyword_starts.contains(&25),
1528 "'return' should start at offset 25"
1529 );
1530 assert!(
1531 keyword_starts.contains(&32),
1532 "'true' should start at offset 32"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_css_no_keywords() {
1538 let source = "const foo = function() { return true; }";
1539 let highlights =
1540 extract_highlights(&Lines::new(source), 0..usize::MAX, Language::Css, None);
1541
1542 let has_keyword = highlights[0]
1543 .iter()
1544 .any(|m| m.token_type == TokenType::Keyword);
1545 assert!(
1546 !has_keyword,
1547 "CSS language should not produce keyword markers"
1548 );
1549 }
1550
1551 #[test]
1556 fn test_block_comment_with_blank_line_known_limitation() {
1557 let mut source = String::new();
1567 for i in 0..20 {
1570 source.push_str(&format!("const x{i} = {i};\n"));
1571 }
1572 source.push_str("/** sneaky\n");
1573 source.push('\n'); source.push_str("*/\n");
1575 source.push_str("const after = 1;\n");
1576
1577 let lines = Lines::new(&source);
1578 let closer_line_idx = lines.len().get() - 3;
1580
1581 let highlights = extract_highlights(&lines, closer_line_idx..closer_line_idx + 1, JS, None);
1582 assert_eq!(highlights.len(), 1);
1583
1584 let has_comment = highlights[0]
1588 .iter()
1589 .any(|m| m.token_type == TokenType::Comment);
1590 assert!(
1591 !has_comment,
1592 "Known limitation: `*/` loses comment highlighting when the skip-scan heuristic \
1593 starts after the `/*` opener"
1594 );
1595 }
1596}