Skip to main content

next_code_frame/
highlight.rs

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/// A styled byte range within a line (non-overlapping, sorted by start)
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct StyleSpan {
11    /// Start byte offset relative to line start (0-indexed, inclusive)
12    pub start: usize,
13    /// End byte offset relative to line start (0-indexed, exclusive)
14    pub end: usize,
15    /// The token type being styled
16    pub token_type: TokenType,
17}
18
19/// Token types for syntax highlighting
20#[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/// Language hint for keyword highlighting.
31///
32/// Determines which set of keywords are recognized as `TokenType::Keyword`.
33/// Non-keyword tokens (strings, comments, numbers, etc.) are language-agnostic.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub enum Language {
37    /// JavaScript/TypeScript keywords
38    #[default]
39    JavaScript,
40    /// CSS keywords (currently empty — CSS has no keyword highlighting)
41    Css,
42}
43
44impl Language {
45    /// Returns true if the given identifier is a keyword in this language.
46    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
54/// JavaScript/TypeScript keywords (compile-time perfect hash set)
55static 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/// ANSI color codes for token types
121#[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    /// Get a color scheme with ANSI colors (matching babel-code-frame)
137    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    /// Get a plain color scheme with no ANSI codes (all empty strings)
153    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    /// Get the color for a token type
169    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
181// ---------------------------------------------------------------------------
182// Shared line-boundary helpers
183// ---------------------------------------------------------------------------
184
185/// Precomputed line index over a source string.
186///
187/// Scans for line terminators once on construction, then provides O(1)
188/// access to line content and byte ranges without allocating a `Vec<&str>`.
189///
190/// Recognized line terminators (per ECMA-262 §12.3):
191/// - LF (`\n`), CRLF (`\r\n`), standalone CR (`\r`)
192/// - U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR
193pub(crate) struct Lines<'a> {
194    source: &'a str,
195    /// Byte offset of the start of each line. `line_starts[0]` corresponds
196    /// to the line at absolute index `first_line`.
197    line_starts: Vec<usize>,
198    /// The 0-indexed absolute line number of `line_starts[0]`.
199    first_line: usize,
200    /// Total number of lines in the source (always ≥ 1).
201    total_lines: usize,
202}
203
204impl<'a> Lines<'a> {
205    /// Build the full line index by scanning for all line terminators.
206    #[cfg(test)]
207    pub fn new(source: &'a str) -> Self {
208        Self::windowed(source, 0, usize::MAX)
209    }
210
211    /// Build a windowed line index. Only stores line-start offsets for
212    /// approximately `window_start..window_end` (0-indexed), plus a margin
213    /// for the skip-scan heuristic. Stops scanning once the window is
214    /// covered — never reads past the end of the window.
215    ///
216    /// This is much faster than `new()` for large files because it avoids
217    /// allocating a Vec entry for every line in the file.
218    pub fn windowed(source: &'a str, window_start: usize, window_end: usize) -> Self {
219        let bytes = source.as_bytes();
220
221        // Add margin before the window for the skip-scan backscan
222        // heuristic (which walks up to MAX_BACKSCAN_LINES backwards).
223        let store_start = window_start.saturating_sub(MAX_BACKSCAN_LINES);
224        // +1 so byte_bounds works for the last visible line.
225        let store_end = window_end.saturating_add(1);
226
227        let mut line_starts = Vec::new();
228        let mut line_num: usize = 0;
229        // Line 0 always starts at byte 0.
230        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                // CRLF: skip the \r and let the \n branch handle it.
241                if found + 1 < bytes.len() && bytes[found + 1] == b'\n' {
242                    continue;
243                }
244                // Standalone \r (classic Mac line ending).
245                found + 1
246            } else {
247                // 0xE2 is the leading byte of the 3-byte UTF-8 encoding of
248                // U+2028 LINE SEPARATOR (E2 80 A8) and U+2029 PARAGRAPH
249                // SEPARATOR (E2 80 A9). UTF-8 forbids overlong encodings,
250                // so this exact sequence is the only way these codepoints
251                // appear.
252                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                    // Not a line separator — just a 0xE2 byte in some
259                    // other multi-byte character. Skip it.
260                    continue;
261                }
262            };
263
264            if line_num >= store_end {
265                // Past the window — we have enough data.
266                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        // File ended before or within the window — total is exact.
280        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    /// Number of lines (always at least 1).
289    pub fn len(&self) -> NonZeroUsize {
290        // SAFETY: total_lines is always at least 1.
291        NonZeroUsize::new(self.total_lines).unwrap()
292    }
293
294    /// The full source string.
295    pub fn source(&self) -> &'a str {
296        self.source
297    }
298
299    /// The raw line-start offsets (for passing to highlight internals).
300    /// Index 0 corresponds to absolute line `first_line()`.
301    pub fn starts(&self) -> &[usize] {
302        &self.line_starts
303    }
304
305    /// The absolute 0-indexed line number of `starts()[0]`.
306    pub fn first_line(&self) -> usize {
307        self.first_line
308    }
309
310    /// Get the content of line `idx` (0-indexed absolute), stripping the
311    /// trailing line terminator (LF, CRLF, CR, U+2028, or U+2029).
312    ///
313    /// # Panics
314    ///
315    /// Panics if `idx` is outside the stored window.
316    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    /// Byte range `[start, end)` for line `idx` (0-indexed absolute,
328    /// including the newline terminator).
329    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
345/// Look up which line (0-indexed) a byte offset falls on via binary search.
346fn 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
353/// Get the byte range [start, end) for a given line index (0-indexed).
354fn 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
360/// Tokenizer state that scans source code and collects syntax-highlight spans.
361///
362/// The scanner always tokenizes from a given `start_pos` to `scan_end` within
363/// the full `source`, but only *emits* spans that overlap with `output_ranges`.
364/// This lets callers scan from byte 0 (to maintain correct tokenizer state
365/// across multiline comments/strings) while only producing output for the
366/// visible window of lines.
367struct Scanner<'a> {
368    markers: Vec<StyleSpan>,
369    line_starts: &'a [usize],
370    source: &'a str,
371    /// Sorted, non-overlapping byte ranges we're producing highlights for.
372    /// Spans outside these ranges are skipped.
373    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    /// Returns the end of the last output range, or 0 if empty.
394    fn output_end(&self) -> usize {
395        self.output_ranges.last().map_or(0, |r| r.1)
396    }
397
398    /// Check whether a byte range `[start, end)` overlaps any output range.
399    #[inline]
400    fn overlaps_output(&self, start: usize, end: usize) -> bool {
401        // Ranges are sorted and there are typically ≤6, so linear scan
402        // is faster than binary search for the common case.
403        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    /// Push a style span for a byte range.
415    ///
416    /// When a token spans multiple lines, it is split into one span per line
417    /// so that each line's spans are self-contained. Spans outside
418    /// `output_ranges` are skipped.
419    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            // If the token spans lines, split it so each line's spans are self-contained.
434            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
457// ---------------------------------------------------------------------------
458// Scan-start heuristic
459// ---------------------------------------------------------------------------
460
461/// Maximum number of lines to walk back looking for a safe restart point.
462/// If we don't find one within this limit, fall back to byte 0.
463const MAX_BACKSCAN_LINES: usize = 200;
464
465/// Find a safe byte offset to start the tokenizer scan from, close to
466/// `target_line` (0-indexed) and ideally near `visible_start` (the
467/// absolute byte offset where the visible window begins). This avoids
468/// scanning the entire file from byte 0 when the visible window is in
469/// the middle of a large file.
470///
471/// Two-phase heuristic:
472/// 1. **Line-level**: Walk backwards from `target_line` looking for a blank line — a reliable
473///    restart point outside strings/comments.
474/// 2. **Byte-level**: If `visible_start` is far (>200 bytes) from the line-level result (common for
475///    minified files with one huge line), scan backwards from `visible_start` for a `;` statement
476///    boundary. This can technically land inside a string containing `;`, but in practice minified
477///    code has frequent semicolons between statements and the consequence is at most slightly wrong
478///    highlighting.
479///
480/// Phase 1 is always safe. Phase 2 trades perfect accuracy for
481/// dramatically better performance on minified files (~100x).
482fn find_scan_start(lines: &Lines<'_>, target_line: usize, visible_start: usize) -> usize {
483    let mut result = 0;
484
485    // Phase 1: line-level backscan for a blank line
486    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    // Phase 2: if the visible window starts far into the line, scan
507    // backwards for a `;` which typically marks a statement boundary
508    // in minified code.
509    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
521// ---------------------------------------------------------------------------
522// Public entry point
523// ---------------------------------------------------------------------------
524
525/// Extract syntax highlighting markers for source code.
526///
527/// Uses a language-agnostic byte-scanning tokenizer inspired by the `js-tokens`
528/// regex approach. It never fails and produces best-effort highlighting for any
529/// input — recognizing quoted strings, comments, numbers, regex literals, and
530/// capitalized identifiers.
531///
532/// # Parameters
533/// - `source`: The source code to highlight
534/// - `line_range`: Range of line indices (0-indexed, start inclusive, end exclusive). Style markers
535///   are only produced for lines within this range. Pass `0..usize::MAX` to produce markers for all
536///   lines.
537/// - `visible_window`: Optional `(truncation_offset, available_width)` hint. When provided, the
538///   scanner's output range is narrowed to only the visible byte window within each line, avoiding
539///   tokenization of content that will be truncated away. This dramatically improves performance on
540///   minified files with very long lines.
541pub 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    // Build per-line visible byte ranges. When a visible_window is
556    // provided, each range covers only the truncated portion of the
557    // line; otherwise it covers the full line.
558    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    // Find a safe byte offset to start the tokenizer scan from, close to
578    // the visible window. Uses line-level and byte-level heuristics.
579    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// ---------------------------------------------------------------------------
599// Tokenizer (language-agnostic, js-tokens style)
600// ---------------------------------------------------------------------------
601
602/// Token kinds recognized by the scanner, used for match dispatch.
603#[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
618/// Each entry pairs a `TokenKind` with its regex pattern. Order matters:
619/// earlier patterns take priority when multiple can match at the same
620/// position (e.g. `//` before `/`). The `PatternID` returned by the
621/// multi-pattern regex indexes directly into this array.
622const TOKEN_RULES: &[(TokenKind, &str)] = &[
623    (
624        TokenKind::String,
625        r#""(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?"#,
626    ),
627    // Match only the opening backtick of a template literal. The rest
628    // of the template (quasis, expressions, closing backtick) is handled
629    // by `scan_template` which manually walks the content, recursing into
630    // `scan()` for `${...}` expressions. This avoids the regex trying to
631    // match across expression boundaries where backticks in nested
632    // templates, comments, or strings would confuse it.
633    (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    // Operators / punctuation catch-all for `last_token` tracking
646    (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
655/// A multi-pattern regex where each pattern corresponds to a `TokenKind`.
656/// `regex_automata::meta::Regex::new_many()` returns the `PatternID` directly
657/// from a match, avoiding capture-group overhead and linear scanning.
658/// Pattern ordering determines match priority (leftmost-first semantics).
659static 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
664/// Regex that matches a regex literal starting at the opening `/`.
665/// Handles character classes `[...]` (where `/` is literal), escape sequences,
666/// and flags. Does not match across newlines (regex literals are single-line).
667///
668/// Structure: `/` then body then `/` then optional flags:
669/// - `[^\\/\[\n\r]` — normal chars (not `\`, `/`, `[`, newline)
670/// - `\\.`          — escape sequences
671/// - `\[(?:[^\]\\\n\r]|\\.)*\]` — character classes with their own escapes
672static 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    /// Scan a template literal starting at the opening backtick.
679    ///
680    /// Walks the source byte-by-byte from `tpl_start` (the `` ` ``), emitting
681    /// `String` spans for quasi segments and recursively calling `scan()` for
682    /// `${...}` expression holes. This correctly handles backticks that appear
683    /// inside expressions (in nested templates, strings, or comments) because
684    /// the recursive `scan()` call tokenizes the expression content — including
685    /// any inner template literals — before we resume scanning the outer
686    /// template.
687    ///
688    /// Returns the byte position just past the closing backtick (or `scan_end`
689    /// if the template is unterminated).
690    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        // Track start of current string segment (includes the backtick or
695        // closing `}` of the previous expression)
696        let mut seg_start = tpl_start;
697
698        // Current position — may jump forward past `${...}` expressions.
699        let mut i = search_start;
700
701        // Use a persistent Memchr2 iterator for `` ` `` and `$` over the full
702        // template range. This avoids reinitializing the SIMD searcher on each
703        // call. When `i` jumps forward (after a `${...}` expression), we skip
704        // any stale positions the iterator yields before `i`.
705        //
706        // Escapes (`\`) are handled by advancing `i` past the escaped byte
707        // when a match at `pos` is preceded by an odd number of backslashes.
708        let iter = memchr::Memchr2::new(b'`', b'$', &bytes[search_start..scan_end]);
709        for found in iter {
710            let pos = search_start + found;
711            // Skip positions we've already moved past (after expression scan)
712            if pos < i {
713                continue;
714            }
715
716            // Count consecutive preceding backslashes to detect escapes.
717            // An odd count means this byte is escaped.
718            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                // Closing backtick — emit the final quasi (including the backtick)
730                self.add_span(seg_start, pos + 1, TokenType::String);
731                return pos + 1;
732            }
733            // b == b'$'
734            debug_assert_eq!(b, b'$');
735            if pos + 1 < scan_end && bytes[pos + 1] == b'{' {
736                // End the current quasi segment just before the `${`
737                if pos > seg_start {
738                    self.add_span(seg_start, pos, TokenType::String);
739                }
740
741                // Tokenize the expression with brace_depth=1. The recursive
742                // scan handles all tokens inside the expression — including
743                // nested template literals, strings with backticks, comments
744                // with backticks, etc. It returns the byte position just past
745                // the matching `}`.
746                let expr_start = pos + 2;
747                let expr_end = self.scan(expr_start, scan_end, Some(1));
748
749                // The next quasi segment starts at the closing `}`
750                if expr_end > expr_start && bytes.get(expr_end - 1) == Some(&b'}') {
751                    seg_start = expr_end - 1;
752                } else {
753                    // Unclosed expression — no more quasi segments
754                    seg_start = expr_end;
755                }
756                i = expr_end;
757                continue;
758            }
759            // Lone `$` not followed by `{` — skip it
760            i = pos + 1;
761        }
762
763        // Unterminated template — emit whatever quasi content we have
764        if scan_end > seg_start {
765            self.add_span(seg_start, scan_end, TokenType::String);
766        }
767        scan_end
768    }
769
770    /// Core tokenizer loop. Scans `source[start_pos..scan_end]` and appends
771    /// style markers.
772    ///
773    /// When `brace_depth` is `Some(n)` we are inside a template expression
774    /// `${...}`. The scanner tracks `{` / `}` tokens and returns as soon as
775    /// the matching `}` brings the depth back to 0, returning the byte
776    /// position just past the `}`. Pass `None` for top-level scanning.
777    fn scan(&mut self, start_pos: usize, scan_end: usize, mut brace_depth: Option<u32>) -> usize {
778        let mut pos = start_pos;
779
780        // Track the last non-whitespace, non-comment token kind for regex
781        // disambiguation. A `/` following a value or close bracket is division;
782        // following an operator or at start of input it's a regex.
783        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            // Once we're past the last output range, no future tokens can be visible.
790            if start >= self.output_end() {
791                break;
792            }
793
794            // Clamp the match end to scan_end
795            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                    // The regex only matched the opening backtick. Walk the
804                    // full template literal (quasis + expression holes)
805                    // manually, recursing into scan() for each ${...}.
806                    let tpl_end = self.scan_template(start, scan_end);
807                    last_token = LastToken::Value;
808                    pos = tpl_end;
809                    // we already updated pos so just continue
810                    continue;
811                }
812                TokenKind::LineComment | TokenKind::BlockComment => {
813                    self.add_span(start, end, TokenType::Comment);
814                    // Comments don't update last_token
815                }
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                        // test first to avoid underflow
845                        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                        // Highlight capitalized identifiers (matching Babel behavior)
865                        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/// Tracks the kind of the last non-whitespace, non-comment token for regex
888/// disambiguation.
889#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890enum LastToken {
891    /// Start of input
892    None,
893    /// Identifier, number, string, regex — values that end expressions
894    Value,
895    /// `)` or `]` — could end an expression
896    CloseBracket,
897    /// `++` or `--` — postfix operators end expressions
898    PostfixOp,
899    /// Operators, open brackets, commas, semicolons, `{`, `}` — regex follows
900    Operator,
901}
902
903impl LastToken {
904    /// Returns true if a `/` at this position should be treated as starting a regex literal.
905    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
913// ---------------------------------------------------------------------------
914// Span → per-line grouping
915// ---------------------------------------------------------------------------
916
917/// Group spans by line. O(spans) single pass.
918fn 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
972// ---------------------------------------------------------------------------
973// Line rendering with truncation-aware highlighting
974// ---------------------------------------------------------------------------
975
976/// Apply syntax highlighting to a (possibly truncated) line of text.
977///
978/// Iterates the line's `StyleSpan`s, converting from line-relative offsets to
979/// display offsets accounting for truncation, and inserts ANSI color codes.
980///
981/// - `truncation_offset`: byte offset in the original line where visible source content starts
982/// - `prefix_len`: byte length of any prefix prepended before source content (e.g., `"..."` = 3)
983pub 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    // The visible source region in original-line coordinates
995    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    // Skip spans that end before the visible window
1001    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        // Clamp span to the visible window and convert to display coordinates
1009        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            // Emit unstyled text before this span
1016            if display_start > last_offset {
1017                result.push_str(&visible_content[last_offset..display_start]);
1018            }
1019            // Emit styled span content
1020            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    // Emit any remaining unstyled text
1028    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    /// Default language for tests
1040    const JS: Language = Language::JavaScript;
1041
1042    /// Strip ANSI escape codes from a string
1043    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    // -----------------------------------------------------------------------
1065    // Basic highlighting tests
1066    // -----------------------------------------------------------------------
1067
1068    #[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        // Truncate to show "Foo = 123" (offset 6, length 9, no prefix)
1128        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        // "hello world" is a string starting at offset 10
1142        // Truncating at offset 15 lands inside the string ("o world";)
1143        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        // The visible portion starts inside the string, so it should
1155        // begin with an ANSI code for the overlapping string style
1156        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        // `hello ${name}!` should mark `hello ` and `!` as string,
1222        // but NOT mark `name` as string.
1223        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        // Should have two string segments: `hello ${ and }!`
1233        // The `name` between ${ and } should NOT be in any string range
1234        assert!(
1235            string_spans.len() >= 2,
1236            "Should have at least 2 string segments: got {:?}",
1237            string_spans
1238        );
1239
1240        // Verify "name" is NOT inside any string span
1241        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        // Nested template literal: `a ${`b ${c}`} d`
1254        let source = r#"const x = `a ${`b ${c}`} d`;"#;
1255        let highlights = extract_highlights(&Lines::new(source), 0..usize::MAX, JS, None);
1256
1257        // Should not panic and should produce some markers
1258        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    // -----------------------------------------------------------------------
1266    // Unbalanced template literal tests
1267    // -----------------------------------------------------------------------
1268
1269    #[test]
1270    fn test_template_unclosed_expression() {
1271        // `hello ${name` — the `${` is never closed with `}`
1272        // Should not panic; the string part before `${` should still be marked.
1273        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        // Should have at least one string marker for the "`hello " part
1278        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        // "name" should NOT be marked as string since it's inside an expression hole
1284        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        // `${ "}" }` — the `}` inside the string should not close the expression
1297        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        // The " end" part after the real closing } should be marked as string
1302        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        // `hello ${}world` — empty expression hole
1315        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        // Both "hello " and "world" parts should be string-marked
1320        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        // `some${`template`}literal` — nested template inside expression
1335        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        // "literal" should be part of a string span (the outer template quasi)
1340        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        // "template" should also be string (inner template literal)
1351        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        // `some${ /* ` */ ""}literal` — block comment containing backtick inside expression
1367        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        // The /* ` */ should be a comment, not end the template
1372        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        // "literal" should be string (outer template quasi after expression closes)
1385        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        // `some${ // `
1399        // }literal`
1400        // Line comment containing backtick inside expression
1401        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        // The // ` should be a comment on line 1
1406        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        // "literal" on line 2 should be string (outer template quasi)
1420        // Line 2 is "}literal`;" — "literal" starts at byte 1 (line-relative)
1421        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        // `some${"`"}literal` — string containing backtick inside expression
1436        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        // The "`" should be a string span
1441        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        // "literal" should be string (outer template quasi)
1454        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    // -----------------------------------------------------------------------
1476    // Regex literal tests
1477    // -----------------------------------------------------------------------
1478
1479    #[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        // After an identifier, `/` is division not regex
1493        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    // -----------------------------------------------------------------------
1503    // Keyword highlighting tests
1504    // -----------------------------------------------------------------------
1505
1506    #[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        // "const" at 0..5, "function" at 12..20, "return" at 25..31, "true" at 32..36
1518        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    // -----------------------------------------------------------------------
1552    // Scan-start heuristic tests
1553    // -----------------------------------------------------------------------
1554
1555    #[test]
1556    fn test_block_comment_with_blank_line_known_limitation() {
1557        // Known limitation: when a block comment contains a blank line, the
1558        // skip-scan heuristic restarts scanning from that blank line, losing
1559        // track of the opening `/*`. The `*/` closer loses its comment
1560        // highlighting because the scanner never saw the opener.
1561        //
1562        // This is a deliberate tradeoff: blank lines inside block comments
1563        // that span the visible window boundary are vanishingly rare in
1564        // practice, and the only consequence is slightly wrong colors —
1565        // never a crash or missing output.
1566        let mut source = String::new();
1567        // Push enough lines so the blank line inside the comment is chosen
1568        // as the scan start rather than scanning from byte 0.
1569        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'); // blank line inside block comment
1574        source.push_str("*/\n");
1575        source.push_str("const after = 1;\n");
1576
1577        let lines = Lines::new(&source);
1578        // Target the `*/` line — should be Comment but won't be.
1579        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        // With correct full-file scanning, `*/` would be highlighted as a
1585        // comment. But the skip-scan heuristic restarts at the blank line
1586        // inside the comment, so the scanner sees `*/` as stray punctuation.
1587        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}