//! Unified highlighting engine over syntect (TextMate grammars) and
//! tree-sitter. Syntect is the default; tree-sitter `Language` is still
//! detected for non-highlighting features (indentation, semantic highlighting).
//!
//! # TextMate cache design
//!
//! Syntect's parser is a sequential state machine — it must process bytes
//! in order from a known parse state to track multi-line constructs and
//! embedded language transitions. To make scrolling cheap, the engine keeps
//! a span cache, a composite `ParseSnapshot` (host parse state plus any
//! embedded-language child state, see `EMBEDDING_SPECS`) at the cache tail,
//! and periodic checkpoint anchors to support resume-from-anywhere.
//!
//! Three render-time paths, gated by what the cache covers:
//!
//! - **Cache hit** — cache fully covers the parse range and there's no
//!   pending edit; filter cached spans for the viewport. Zero parse work.
//! - **Forward extension** — cache covers the start of the parse range but
//!   not its end; resume from `tail_state` and parse only the uncovered
//!   tail bytes. Steady-state scroll path.
//! - **Partial update** — there's a pending edit; resume from the nearest
//!   checkpoint before the dirty point and parse forward looking for
//!   convergence (state matches an existing checkpoint), bounded by a
//!   per-pass byte budget so pathological edits can't degenerate into
//!   whole-file reparses.
//! - **Cold start / fallback** — no cache, or none of the above applies;
//!   parse the appropriate range from a fresh state or nearest checkpoint.
//!
//! For files at or below `MAX_PARSE_BYTES` the parse range is the whole
//! file, so the cache is whole-file after the first parse and scrolling
//! becomes filter-only. Larger files use a viewport-centred window of
//! `±context_bytes` and rely on the forward-extension path to keep
//! scroll-cost bounded.
//!
//! Edits go through `notify_insert` / `notify_delete`, which shift cached
//! span byte offsets in place, set `dirty_from`, and invalidate `tail_state`
//! when the edit lies inside the cached range.

use crate::model::buffer::Buffer;
use crate::model::marker::{MarkerId, MarkerList};
use crate::primitives::grammar::GrammarRegistry;
use crate::primitives::highlighter::{
    highlight_bg, highlight_color, HighlightCategory, HighlightSpan, Highlighter, Language,
};
use crate::view::theme::Theme;
use std::collections::HashMap;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use syntect::parsing::SyntaxSet;

/// Map TextMate scope to highlight category
fn scope_to_category(scope: &str) -> Option<HighlightCategory> {
    let scope_lower = scope.to_lowercase();

    // Comments - highest priority
    if scope_lower.starts_with("comment") {
        return Some(HighlightCategory::Comment);
    }

    // Strings
    if scope_lower.starts_with("string") {
        return Some(HighlightCategory::String);
    }

    // Markdown/markup scopes - handle before generic keyword/punctuation checks
    // See: https://macromates.com/manual/en/language_grammars (TextMate scope naming)
    // Headings: markup.heading and entity.name.section (used by syntect's markdown grammar)
    if scope_lower.starts_with("markup.heading") || scope_lower.starts_with("entity.name.section") {
        return Some(HighlightCategory::Keyword); // Headers styled like keywords (bold, prominent)
    }
    // Bold: markup.bold
    if scope_lower.starts_with("markup.bold") {
        return Some(HighlightCategory::Constant); // Bold styled like constants (bright)
    }
    // Italic: markup.italic
    if scope_lower.starts_with("markup.italic") {
        return Some(HighlightCategory::Variable); // Italic styled like variables
    }
    // Inline code and code blocks: markup.raw, markup.inline.raw
    if scope_lower.starts_with("markup.raw") || scope_lower.starts_with("markup.inline.raw") {
        return Some(HighlightCategory::String); // Code styled like strings
    }
    // Links: markup.underline.link
    if scope_lower.starts_with("markup.underline.link") {
        return Some(HighlightCategory::Function); // Links styled like functions (distinct color)
    }
    // Generic underline (often links)
    if scope_lower.starts_with("markup.underline") {
        return Some(HighlightCategory::Function);
    }
    // Block quotes: markup.quote
    if scope_lower.starts_with("markup.quote") {
        return Some(HighlightCategory::Comment); // Quotes styled like comments (subdued)
    }
    // Lists: markup.list
    if scope_lower.starts_with("markup.list") {
        return Some(HighlightCategory::Operator); // List markers styled like operators
    }
    // Strikethrough: markup.strikethrough
    if scope_lower.starts_with("markup.strikethrough") {
        return Some(HighlightCategory::Comment); // Strikethrough styled subdued
    }

    // Diff scopes (syntect's bundled `Diff` grammar). These scope the
    // entire row, not just the leading +/-/@@ marker, so the renderer
    // can paint a whole-line background by reading the span's bg.
    //
    //   markup.inserted.diff      — `+` line
    //   markup.deleted.diff       — `-` line
    //   meta.diff.range.unified   — `@@ ... @@` hunk header
    //   markup.changed.*          — generic "changed" marker (rare)
    //   meta.diff.header.*        — `diff --git`, `index ...`, file
    //                               headers; render like Type so they
    //                               stand out without a bg wash.
    if scope_lower.starts_with("markup.inserted") {
        return Some(HighlightCategory::Inserted);
    }
    if scope_lower.starts_with("markup.deleted") {
        return Some(HighlightCategory::Deleted);
    }
    if scope_lower.starts_with("markup.changed") || scope_lower.starts_with("meta.diff.range") {
        return Some(HighlightCategory::Changed);
    }
    if scope_lower.starts_with("meta.diff.header") {
        return Some(HighlightCategory::Type);
    }

    // Keywords
    if scope_lower.starts_with("keyword.control")
        || scope_lower.starts_with("keyword.other")
        || scope_lower.starts_with("keyword.declaration")
        || scope_lower.starts_with("keyword")
    {
        // keyword.operator should map to Operator, not Keyword
        if !scope_lower.starts_with("keyword.operator") {
            return Some(HighlightCategory::Keyword);
        }
    }

    // Punctuation that belongs to a parent construct (comment/string delimiters)
    // These must be checked before the generic punctuation rule below.
    // TextMate grammars assign e.g. `punctuation.definition.comment` to # // /* etc.
    if scope_lower.starts_with("punctuation.definition.comment") {
        return Some(HighlightCategory::Comment);
    }
    if scope_lower.starts_with("punctuation.definition.string") {
        return Some(HighlightCategory::String);
    }

    // Operators (keyword.operator only)
    if scope_lower.starts_with("keyword.operator") {
        return Some(HighlightCategory::Operator);
    }

    // Punctuation brackets ({, }, (, ), [, ], <, >)
    // Covers punctuation.section.*, punctuation.bracket.*,
    // and punctuation.definition.{array,block,brackets,group,inline-table,section,table,tag}
    if scope_lower.starts_with("punctuation.section")
        || scope_lower.starts_with("punctuation.bracket")
        || scope_lower.starts_with("punctuation.definition.array")
        || scope_lower.starts_with("punctuation.definition.block")
        || scope_lower.starts_with("punctuation.definition.brackets")
        || scope_lower.starts_with("punctuation.definition.group")
        || scope_lower.starts_with("punctuation.definition.inline-table")
        || scope_lower.starts_with("punctuation.definition.section")
        || scope_lower.starts_with("punctuation.definition.table")
        || scope_lower.starts_with("punctuation.definition.tag")
    {
        return Some(HighlightCategory::PunctuationBracket);
    }

    // Punctuation delimiters (;, ,, .)
    if scope_lower.starts_with("punctuation.separator")
        || scope_lower.starts_with("punctuation.terminator")
        || scope_lower.starts_with("punctuation.accessor")
    {
        return Some(HighlightCategory::PunctuationDelimiter);
    }

    // Functions
    if scope_lower.starts_with("entity.name.function")
        || scope_lower.starts_with("support.function")
        || scope_lower.starts_with("meta.function-call")
        || scope_lower.starts_with("variable.function")
    {
        return Some(HighlightCategory::Function);
    }

    // Types
    if scope_lower.starts_with("entity.name.type")
        || scope_lower.starts_with("entity.name.class")
        || scope_lower.starts_with("entity.name.struct")
        || scope_lower.starts_with("entity.name.enum")
        || scope_lower.starts_with("entity.name.interface")
        || scope_lower.starts_with("entity.name.trait")
        || scope_lower.starts_with("support.type")
        || scope_lower.starts_with("support.class")
    {
        return Some(HighlightCategory::Type);
    }

    // Storage keywords (class, def, function, var, let, const, etc.) and modifiers
    if scope_lower.starts_with("storage.type") || scope_lower.starts_with("storage.modifier") {
        return Some(HighlightCategory::Keyword);
    }

    // Constants and numbers
    if scope_lower.starts_with("constant.numeric")
        || scope_lower.starts_with("constant.language.boolean")
    {
        return Some(HighlightCategory::Number);
    }
    if scope_lower.starts_with("constant") {
        return Some(HighlightCategory::Constant);
    }

    // Variables
    if scope_lower.starts_with("variable.language") {
        return Some(HighlightCategory::VariableBuiltin);
    }
    if scope_lower.starts_with("variable.parameter") || scope_lower.starts_with("variable.other") {
        return Some(HighlightCategory::Variable);
    }

    // Properties / object keys
    if scope_lower.starts_with("entity.name.tag")
        || scope_lower.starts_with("support.other.property")
        || scope_lower.starts_with("meta.object-literal.key")
        || scope_lower.starts_with("variable.other.property")
        || scope_lower.starts_with("variable.other.object.property")
    {
        return Some(HighlightCategory::Property);
    }

    // Attributes (decorators, annotations)
    if scope_lower.starts_with("entity.other.attribute")
        || scope_lower.starts_with("meta.attribute")
        || scope_lower.starts_with("entity.name.decorator")
    {
        return Some(HighlightCategory::Attribute);
    }

    // Generic variable fallback
    if scope_lower.starts_with("variable") {
        return Some(HighlightCategory::Variable);
    }

    None
}

/// Unified highlighting engine supporting multiple backends
#[derive(Default)]
pub enum HighlightEngine {
    /// Tree-sitter based highlighting (built-in languages)
    TreeSitter(Box<Highlighter>),
    /// TextMate grammar based highlighting
    TextMate(Box<TextMateEngine>),
    /// No highlighting available
    #[default]
    None,
}

/// TextMate highlighting engine. See module docs for the cache design.
pub struct TextMateEngine {
    syntax_set: Arc<SyntaxSet>,
    syntax_index: usize,
    checkpoint_markers: MarkerList,
    checkpoint_states: HashMap<MarkerId, ParseSnapshot>,
    dirty_from: Option<usize>,
    cache: Option<TextMateCache>,
    last_buffer_len: usize,
    ts_language: Option<Language>,
    stats: HighlightStats,
    // Region kinds this syntax hosts (see `EMBEDDING_SPECS`); empty for
    // the vast majority of syntaxes.
    embedding: Vec<EmbeddingSpec>,
    // Fence-language-token → syntax index memo (None = unrecognized).
    embedded_syntax_memo: HashMap<String, Option<usize>>,
    // Reusable per-line span buffers for embedding hosts, so region
    // classification in `parse_snapshot_line` doesn't allocate per line.
    host_span_scratch: Vec<(usize, usize, HighlightCategory)>,
    child_span_scratch: Vec<(usize, usize, HighlightCategory)>,
    // Scope→Category memo. Syntect Scope atoms are append-only-interned
    // globally, so entries never need invalidation.
    scope_category_cache: HashMap<syntect::parsing::Scope, Option<HighlightCategory>>,
}

/// Counters for monitoring highlighting performance in tests.
#[derive(Debug, Default, Clone)]
pub struct HighlightStats {
    /// Number of bytes parsed by syntect (total across all highlight_viewport calls).
    pub bytes_parsed: usize,
    /// Number of highlight_viewport calls that hit the span cache.
    pub cache_hits: usize,
    /// Number of highlight_viewport calls that missed the cache and re-parsed.
    pub cache_misses: usize,
    /// Number of checkpoint states updated during convergence.
    pub checkpoints_updated: usize,
    /// Number of times convergence was detected (state matched existing checkpoint).
    pub convergences: usize,
}

#[derive(Debug, Clone)]
struct TextMateCache {
    range: Range<usize>,
    spans: Vec<CachedSpan>,
    // Parse state at `range.end`; powers forward extension. None when the
    // last mutation didn't end at `range.end`.
    tail_state: Option<ParseSnapshot>,
}

#[derive(Debug, Clone)]
struct CachedSpan {
    range: Range<usize>,
    category: crate::primitives::highlighter::HighlightCategory,
}

/// Declares how a host syntax delimits embedded-language regions whose
/// language is named *by the document* (e.g. Markdown code fences). The
/// host grammar itself must scope both the region and the language name;
/// the engine then runs a child parser over region content lines. Hosts
/// whose embedded language is known statically (HTML `<style>` → CSS)
/// don't belong here — the grammar embeds those directly via the
/// `SyntaxSet`. See `docs/internal/embedded-language-highlighting.md`.
struct EmbeddingSpecDef {
    host_syntax: &'static str,
    /// Scope (prefix) the host grammar keeps on the stack for the whole
    /// region, including its delimiter lines.
    region_scope: &'static str,
    /// Scope (prefix) the host grammar assigns to the language token on
    /// the region's opening line.
    language_scope: &'static str,
    /// Language token used when the opening line names no language, or
    /// names one that doesn't resolve to a syntax (e.g. `lang="ts"` in a
    /// Vue `<script>` — there is no TextMate TS grammar in the set, and
    /// JS is the standard approximation). `None` = keep the host's own
    /// styling for such regions (Markdown's raw-code look).
    default_language: Option<&'static str>,
}

/// A host may declare several region kinds (Vue: `<script>` and
/// `<style>`), each with its own default language. Region scopes of one
/// host must not be prefixes of each other.
const EMBEDDING_SPECS: &[EmbeddingSpecDef] = &[
    EmbeddingSpecDef {
        host_syntax: "Markdown",
        region_scope: "markup.raw.code-fence",
        language_scope: "constant.other.language-name",
        default_language: None,
    },
    EmbeddingSpecDef {
        host_syntax: "Vue",
        region_scope: "meta.embedded.block.script",
        language_scope: "constant.other.language-name",
        default_language: Some("js"),
    },
    EmbeddingSpecDef {
        host_syntax: "Vue",
        region_scope: "meta.embedded.block.style",
        language_scope: "constant.other.language-name",
        default_language: Some("css"),
    },
];

/// Cap on watched language scopes per host; far above any real spec
/// table (Vue, the largest host, has one distinct language scope).
const MAX_WATCH_SCOPES: usize = 8;

/// `EmbeddingSpecDef` with its scope selectors interned as syntect `Scope`
/// atoms for cheap prefix matching in the per-line parse loop.
#[derive(Debug, Clone, Copy)]
struct EmbeddingSpec {
    region_scope: syntect::parsing::Scope,
    language_scope: syntect::parsing::Scope,
    default_language: Option<&'static str>,
}

impl EmbeddingSpec {
    fn compile_for_syntax(name: &str) -> Vec<Self> {
        EMBEDDING_SPECS
            .iter()
            .filter(|d| d.host_syntax == name)
            .filter_map(|d| {
                Some(Self {
                    region_scope: syntect::parsing::Scope::new(d.region_scope).ok()?,
                    language_scope: syntect::parsing::Scope::new(d.language_scope).ok()?,
                    default_language: d.default_language,
                })
            })
            .collect()
    }
}

/// True when any scope on the stack has `scope` as a prefix.
fn scopes_contain(stack: &syntect::parsing::ScopeStack, scope: syntect::parsing::Scope) -> bool {
    stack.as_slice().iter().any(|s| scope.is_prefix_of(*s))
}

/// Parse state of an embedded-language region's child parser.
#[derive(Debug, Clone, PartialEq)]
struct EmbeddedParseState {
    /// Index of the embedded syntax in the shared `SyntaxSet`. Part of the
    /// state identity: two parses that entered the same region with
    /// different fence languages must not be considered converged.
    syntax_index: usize,
    state: syntect::parsing::ParseState,
    scopes: syntect::parsing::ScopeStack,
}

/// Complete resumable parse state: the host parser plus, while inside an
/// embedded-language region with a recognized language, the child parser.
/// This is what checkpoints, the cache tail, and convergence comparison
/// carry, so all incremental paths (resume-from-checkpoint, forward
/// extension, partial update) work identically inside embedded regions.
#[derive(Debug, Clone, PartialEq)]
struct ParseSnapshot {
    state: syntect::parsing::ParseState,
    scopes: syntect::parsing::ScopeStack,
    embedded: Option<EmbeddedParseState>,
}

impl ParseSnapshot {
    fn new(syntax: &syntect::parsing::SyntaxReference) -> Self {
        Self {
            state: syntect::parsing::ParseState::new(syntax),
            scopes: syntect::parsing::ScopeStack::new(),
            embedded: None,
        }
    }
}

/// Small/large file threshold (whole-file cache vs viewport window).
const MAX_PARSE_BYTES: usize = 1024 * 1024;

/// Distance between checkpoint anchors. Smaller = faster convergence on edit.
const CHECKPOINT_INTERVAL: usize = 256;

/// Per-pass cap on partial-update parsing past `dirty_pos`. Bounds work for
/// pathological edits whose effect doesn't converge.
const CONVERGENCE_BUDGET: usize = 64 * 1024;

/// Byte position one past the end of the line that starts at `pos`.
/// Accepts `\n` and `\r\n` terminators; returns `content_bytes.len()`
/// when the buffer ends without a terminator (the streaming tail).
fn find_line_end(content_bytes: &[u8], pos: usize) -> usize {
    let mut line_end = pos;
    while line_end < content_bytes.len() {
        if content_bytes[line_end] == b'\n' {
            line_end += 1;
            break;
        } else if content_bytes[line_end] == b'\r' {
            if line_end + 1 < content_bytes.len() && content_bytes[line_end + 1] == b'\n' {
                line_end += 2;
            } else {
                line_end += 1;
            }
            break;
        }
        line_end += 1;
    }
    line_end
}

/// UTF-8-decoded line ready to feed `state.parse_line`.
struct PreparedLine {
    /// What `parse_line` sees: line content always terminated by `\n`,
    /// EXCEPT for the buffer's final partial line where no `\n` has
    /// arrived yet (caller must not commit cache state past such a
    /// line — see `extend_cache_forward`).
    line_for_syntect: String,
    /// Byte length of the line excluding `\r` / `\n` terminator.
    line_content_len: usize,
    /// Whether the original line ended with `\n` (true for every line
    /// except the streaming tail).
    ends_with_newline: bool,
}

/// Longest logical line highlighted in full. Beyond this, only the
/// window of the line around the viewport is parsed (see
/// [`prepare_line_window`]).
///
/// Syntect parses a line in one shot, so a pathological line (minified
/// JS/JSON one-liners are routinely hundreds of KB) costs O(line length)
/// per parse. Worse, such a line is usually the *whole file* and carries
/// no trailing newline, so the cache can never commit a safe tail state
/// (see `extend_cache_forward`) and every render re-parses all of it —
/// which is what pinned a CPU core on a 441 KB single-line JSON.
/// Set to `MAX_PARSE_BYTES` deliberately: at or below that size the
/// parse range is the whole file, so a huge line is parsed once and
/// committed at EOF (exact colours, cached, no per-frame cost). Only
/// past it — where the parse range is already a viewport window and the
/// EOF commit cannot apply — is windowing the lesser evil.
const MAX_HIGHLIGHT_LINE_BYTES: usize = MAX_PARSE_BYTES;

/// Extra bytes parsed either side of the viewport inside an over-long
/// line, so small scrolls stay inside the parsed window and spans that
/// start just off-screen still reach it.
const HUGE_LINE_WINDOW_PAD: usize = 4 * 1024;

/// Prepare only the viewport-overlapping window of an over-long line.
///
/// Returns `None` when the line is short enough to parse whole (the
/// normal path) or the window isn't valid UTF-8. On `Some`, the caller
/// parses the window at the returned ABSOLUTE offset using a *clone* of
/// the parse state: the skipped bytes never reach syntect, so the state
/// after the line is taken to be the state before it. That is an
/// approximation (a huge line that opens a string or block comment would
/// mis-colour what follows), and it is the same trade every editor makes
/// for pathological lines — bounded, predictable work beats correct
/// colours nobody can read.
fn prepare_line_window(
    content_bytes: &[u8],
    pos: usize,
    line_end: usize,
    line_abs_start: usize,
    viewport_start: usize,
    viewport_end: usize,
) -> Option<(usize, PreparedLine)> {
    let line_bytes = &content_bytes[pos..line_end];
    // Content length excludes the terminator, which must never land
    // inside the window (syntect treats `\n` as the line end).
    let content_len = line_bytes.len() - {
        let mut n = 0;
        if line_bytes.last() == Some(&b'\n') {
            n += 1;
            if line_bytes.len() >= 2 && line_bytes[line_bytes.len() - 2] == b'\r' {
                n += 1;
            }
        }
        n
    };
    if content_len <= MAX_HIGHLIGHT_LINE_BYTES {
        return None;
    }

    // Desired window in absolute bytes, clamped to the line's content
    // and to the per-parse budget.
    let line_abs_end = line_abs_start + content_len;
    let mut start = viewport_start
        .saturating_sub(HUGE_LINE_WINDOW_PAD)
        .max(line_abs_start)
        .min(line_abs_end);
    let mut end = viewport_end
        .saturating_add(HUGE_LINE_WINDOW_PAD)
        .min(line_abs_end)
        .max(start);
    if end - start > MAX_HIGHLIGHT_LINE_BYTES {
        end = start + MAX_HIGHLIGHT_LINE_BYTES;
    }

    // Snap to UTF-8 char boundaries (widening start, narrowing end) so
    // the slice decodes.
    let rel = |abs: usize| pos + (abs - line_abs_start);
    while start > line_abs_start && (content_bytes[rel(start)] & 0xC0) == 0x80 {
        start -= 1;
    }
    while end > start && end < line_abs_end && (content_bytes[rel(end)] & 0xC0) == 0x80 {
        end -= 1;
    }
    if end <= start {
        return None;
    }

    let slice = std::str::from_utf8(&content_bytes[rel(start)..rel(end)]).ok()?;
    Some((
        start,
        PreparedLine {
            // No trailing `\n`: the window is a line fragment, so
            // syntect must not see it as a completed line.
            line_for_syntect: slice.to_string(),
            line_content_len: slice.len(),
            ends_with_newline: false,
        },
    ))
}

/// Slice the line starting at `pos` from `content_bytes` and prepare
/// it for `parse_line`. Returns `(line_end, line_byte_len, prepared)`:
/// callers always advance by `line_byte_len` to `line_end`; `prepared`
/// is `None` only when the line wasn't valid UTF-8 (skip & continue).
fn prepare_line_at(content_bytes: &[u8], pos: usize) -> (usize, usize, Option<PreparedLine>) {
    let line_end = find_line_end(content_bytes, pos);
    let line_bytes = &content_bytes[pos..line_end];
    let line_byte_len = line_bytes.len();
    let prepared = std::str::from_utf8(line_bytes).ok().map(|line_str| {
        let line_content = line_str.trim_end_matches(&['\r', '\n'][..]);
        let ends_with_newline = line_str.ends_with('\n');
        let is_streaming_tail = line_end == content_bytes.len() && !ends_with_newline;
        let line_for_syntect = if is_streaming_tail {
            line_content.to_string()
        } else {
            format!("{}\n", line_content)
        };
        PreparedLine {
            line_for_syntect,
            line_content_len: line_content.len(),
            ends_with_newline,
        }
    });
    (line_end, line_byte_len, prepared)
}

impl TextMateEngine {
    /// Create a new TextMate engine for the given syntax
    pub fn new(syntax_set: Arc<SyntaxSet>, syntax_index: usize) -> Self {
        Self::with_language(syntax_set, syntax_index, None)
    }

    /// Create a new TextMate engine with a tree-sitter language for non-highlighting features
    pub fn with_language(
        syntax_set: Arc<SyntaxSet>,
        syntax_index: usize,
        ts_language: Option<Language>,
    ) -> Self {
        let embedding =
            EmbeddingSpec::compile_for_syntax(&syntax_set.syntaxes()[syntax_index].name);
        Self {
            syntax_set,
            syntax_index,
            checkpoint_markers: MarkerList::new(),
            checkpoint_states: HashMap::new(),
            dirty_from: None,
            cache: None,
            last_buffer_len: 0,
            ts_language,
            stats: HighlightStats::default(),
            embedding,
            embedded_syntax_memo: HashMap::new(),
            host_span_scratch: Vec::new(),
            child_span_scratch: Vec::new(),
            scope_category_cache: HashMap::new(),
        }
    }

    /// Get performance stats for testing and diagnostics.
    pub fn stats(&self) -> &HighlightStats {
        &self.stats
    }

    /// Reset performance counters.
    pub fn reset_stats(&mut self) {
        self.stats = HighlightStats::default();
    }

    /// Get the tree-sitter language (for indentation, semantic highlighting, etc.)
    pub fn language(&self) -> Option<&Language> {
        self.ts_language.as_ref()
    }

    /// Buffer-insert notification. Shifts span offsets in place and marks
    /// the cache dirty so the partial-update path runs on next render.
    pub fn notify_insert(&mut self, position: usize, length: usize) {
        self.checkpoint_markers.adjust_for_insert(position, length);
        self.dirty_from = Some(self.dirty_from.map_or(position, |d| d.min(position)));
        if let Some(cache) = &mut self.cache {
            for span in &mut cache.spans {
                if span.range.start >= position {
                    span.range.start += length;
                    span.range.end += length;
                } else if span.range.end > position {
                    span.range.end += length;
                }
            }
            if cache.range.end >= position {
                cache.range.end += length;
                if position < cache.range.end {
                    cache.tail_state = None;
                }
            }
        }
    }

    /// Buffer-delete notification. Mirror of `notify_insert`.
    pub fn notify_delete(&mut self, position: usize, length: usize) {
        self.checkpoint_markers.adjust_for_delete(position, length);
        self.dirty_from = Some(self.dirty_from.map_or(position, |d| d.min(position)));
        if let Some(cache) = &mut self.cache {
            let delete_end = position + length;
            cache.spans.retain_mut(|span| {
                if span.range.start >= delete_end {
                    span.range.start -= length;
                    span.range.end -= length;
                    true
                } else if span.range.end <= position {
                    true
                } else if span.range.start >= position && span.range.end <= delete_end {
                    false
                } else {
                    if span.range.start < position {
                        span.range.end = position.min(span.range.end);
                    } else {
                        span.range.start = position;
                        span.range.end = position + span.range.end.saturating_sub(delete_end);
                    }
                    span.range.start < span.range.end
                }
            });
            if cache.range.end > delete_end {
                cache.range.end -= length;
            } else if cache.range.end > position {
                cache.range.end = position;
            }
            if position < cache.range.end {
                cache.tail_state = None;
            }
        }
    }

    /// Create a checkpoint at `current_offset` carrying the supplied
    /// parse state, unless one already exists within half an interval
    /// (which would shadow it). Callers gate on
    /// `bytes_since_checkpoint >= CHECKPOINT_INTERVAL` to control
    /// spacing.
    fn maybe_create_checkpoint(&mut self, current_offset: usize, snapshot: &ParseSnapshot) {
        let nearby = self.checkpoint_markers.query_range(
            current_offset.saturating_sub(CHECKPOINT_INTERVAL / 2),
            current_offset + CHECKPOINT_INTERVAL / 2,
        );
        if nearby.is_empty() {
            let marker_id = self.checkpoint_markers.create(current_offset, true);
            self.checkpoint_states.insert(marker_id, snapshot.clone());
        }
    }

    /// Drive `state.parse_line(prepared.line_for_syntect)` and emit one
    /// span per category-carrying byte range via `on_span(start, end,
    /// category)`. Returns `(false, None)` when `parse_line` errored —
    /// caller should advance past the line and continue (state may have
    /// been mutated mid-parse but is left as-is, matching prior
    /// behaviour).
    ///
    /// When `watch_scopes` is non-empty, additionally returns the
    /// absolute byte range of the first contiguous run where any of
    /// those scopes was on the stack (used to locate the language token
    /// on a region-opening line).
    ///
    /// Span emission is in two passes: the op iterator emits the
    /// segment between consecutive ops with the scope-stack-active
    /// category, and a trailing segment covers `[syntect_offset,
    /// line_content_len)` when the final op didn't reach end-of-line.
    fn parse_line_into_spans(
        &mut self,
        state: &mut syntect::parsing::ParseState,
        current_scopes: &mut syntect::parsing::ScopeStack,
        prepared: &PreparedLine,
        current_offset: usize,
        watch_scopes: &[syntect::parsing::Scope],
        mut on_span: impl FnMut(usize, usize, HighlightCategory),
    ) -> (bool, Option<Range<usize>>) {
        let ops = match state.parse_line(&prepared.line_for_syntect, &self.syntax_set) {
            Ok(ops) => ops,
            Err(_) => return (false, None),
        };

        let line_content_len = prepared.line_content_len;
        let mut syntect_offset = 0;
        let mut watch_run_start: Option<usize> = None;
        let mut watched_range: Option<Range<usize>> = None;

        for (op_offset, op) in ops {
            let clamped_op_offset = op_offset.min(line_content_len);
            if clamped_op_offset > syntect_offset {
                if let Some(category) = self.scope_stack_to_category(current_scopes) {
                    on_span(
                        current_offset + syntect_offset,
                        current_offset + clamped_op_offset,
                        category,
                    );
                }
            }
            syntect_offset = clamped_op_offset;
            #[allow(clippy::let_underscore_must_use)]
            let _ = current_scopes.apply(&op);
            if !watch_scopes.is_empty() && watched_range.is_none() {
                let active = watch_scopes
                    .iter()
                    .any(|w| scopes_contain(current_scopes, *w));
                match (watch_run_start, active) {
                    (None, true) => watch_run_start = Some(clamped_op_offset),
                    (Some(start), false) => {
                        watched_range =
                            Some(current_offset + start..current_offset + clamped_op_offset);
                    }
                    _ => {}
                }
            }
        }

        if syntect_offset < line_content_len {
            if let Some(category) = self.scope_stack_to_category(current_scopes) {
                on_span(
                    current_offset + syntect_offset,
                    current_offset + line_content_len,
                    category,
                );
            }
        }
        if let (Some(start), None) = (watch_run_start, watched_range.as_ref()) {
            watched_range = Some(current_offset + start..current_offset + line_content_len);
        }
        (true, watched_range)
    }

    /// Parse one line advancing the composite `snapshot` (host parser +
    /// optional embedded-language child parser). This is the single
    /// per-line entry point for all parse paths, so embedded regions
    /// behave identically under cold start, forward extension, and
    /// partial update.
    ///
    /// Line classification is driven purely by the host grammar's scope
    /// stack (no second lexer that could disagree with what the host
    /// grammar recognizes as a region):
    /// - no region scope before or after → plain host line;
    /// - none before, one after → region opened: host-styled, and the
    ///   language token (located by `language_scope`), or the spec's
    ///   `default_language` when absent/unresolvable, selects the child
    ///   syntax for subsequent lines;
    /// - same region before and after → region content: parsed by the
    ///   child (host spans for the line are suppressed); with no child
    ///   (no default and no recognizable token) the host's own styling
    ///   (e.g. `markup.raw`) is kept;
    /// - present before, absent after → closing delimiter: host-styled.
    ///
    /// The host parser always consumes the line first — inside a region
    /// it is in a cheap "raw" context — because only it knows where the
    /// region ends. Content lines therefore cost one extra (trivial)
    /// host `parse_line` on top of the child parse.
    fn parse_snapshot_line(
        &mut self,
        snapshot: &mut ParseSnapshot,
        prepared: &PreparedLine,
        current_offset: usize,
        mut on_span: impl FnMut(usize, usize, HighlightCategory),
    ) -> bool {
        if self.embedding.is_empty() {
            return self
                .parse_line_into_spans(
                    &mut snapshot.state,
                    &mut snapshot.scopes,
                    prepared,
                    current_offset,
                    &[],
                    on_span,
                )
                .0;
        }

        let was_in = self.active_region_spec(&snapshot.scopes);
        // Watch for a language token only on lines that could open a
        // region. Copy the (deduplicated) language scopes to the stack so
        // no borrow of self outlives the parse call below.
        let mut watch_buf = [self.embedding[0].language_scope; MAX_WATCH_SCOPES];
        let mut watch_len = 0;
        if was_in.is_none() {
            for spec in &self.embedding {
                if watch_len < MAX_WATCH_SCOPES
                    && !watch_buf[..watch_len].contains(&spec.language_scope)
                {
                    watch_buf[watch_len] = spec.language_scope;
                    watch_len += 1;
                }
            }
        }

        let mut host_spans = std::mem::take(&mut self.host_span_scratch);
        host_spans.clear();
        let (parse_ok, language_range) = self.parse_line_into_spans(
            &mut snapshot.state,
            &mut snapshot.scopes,
            prepared,
            current_offset,
            &watch_buf[..watch_len],
            |start, end, category| host_spans.push((start, end, category)),
        );
        if !parse_ok {
            // Transient host parse error (a regex-engine failure): emit
            // nothing for this line, matching the single-layer path, and
            // leave region membership untouched. The host scope stack is
            // left as-is on error, so treating the error as a region
            // close would silently drop the child parser and
            // de-highlight the rest of the region.
            self.host_span_scratch = host_spans;
            return false;
        }
        let now_in = self.active_region_spec(&snapshot.scopes);

        // NOTE for future `EMBEDDING_SPECS` hosts: classification is
        // line-granular, so a host whose regions can close AND reopen
        // within one line reads as continuous region content (same kind)
        // or as a default-language restart (different kind). Markdown
        // fences and Vue script/style tags occupy whole lines, so
        // neither arises for the current hosts.
        if let (Some(i), Some(j)) = (was_in, now_in) {
            if i == j {
                // Region content line.
                if snapshot.embedded.is_some() {
                    let mut embedded = snapshot
                        .embedded
                        .take()
                        .expect("checked embedded.is_some() above");
                    let mut child_spans = std::mem::take(&mut self.child_span_scratch);
                    child_spans.clear();
                    let (child_ok, _) = self.parse_line_into_spans(
                        &mut embedded.state,
                        &mut embedded.scopes,
                        prepared,
                        current_offset,
                        &[],
                        |start, end, category| child_spans.push((start, end, category)),
                    );
                    // On a child parse error, fall back to the host's
                    // raw styling for the line rather than leaving it
                    // unstyled.
                    let chosen = if child_ok { &child_spans } else { &host_spans };
                    for &(start, end, category) in chosen {
                        on_span(start, end, category);
                    }
                    snapshot.embedded = Some(embedded);
                    self.child_span_scratch = child_spans;
                    self.host_span_scratch = host_spans;
                    return true;
                }
                // No child (no default and no recognizable token): the
                // line keeps the host's own styling below.
            }
        }

        for &(start, end, category) in &host_spans {
            on_span(start, end, category);
        }
        match (was_in, now_in) {
            (None, Some(j)) => {
                // Region opened: language token if the grammar scoped
                // one, else the spec default.
                snapshot.embedded =
                    self.embedded_state_for_region(prepared, current_offset, language_range, j);
            }
            (Some(i), Some(j)) if i != j => {
                // One region kind closed and another opened on the same
                // line; the opening token wasn't watched, so start the
                // new region with its default language.
                snapshot.embedded =
                    self.embedded_state_for_region(prepared, current_offset, None, j);
            }
            (Some(_), None) => snapshot.embedded = None,
            _ => {}
        }
        self.host_span_scratch = host_spans;
        true
    }

    /// Index of the spec whose region scope is on the host stack, if any.
    fn active_region_spec(&self, scopes: &syntect::parsing::ScopeStack) -> Option<usize> {
        self.embedding
            .iter()
            .position(|spec| scopes_contain(scopes, spec.region_scope))
    }

    /// Build the child parser for a region that just opened, from the
    /// language token the host grammar scoped on the opening line, or
    /// the spec's `default_language` when the token is absent or doesn't
    /// resolve to a syntax.
    fn embedded_state_for_region(
        &mut self,
        prepared: &PreparedLine,
        current_offset: usize,
        language_range: Option<Range<usize>>,
        spec_index: usize,
    ) -> Option<EmbeddedParseState> {
        let explicit = language_range
            .and_then(|range| {
                let rel = range.start.checked_sub(current_offset)?
                    ..range.end.checked_sub(current_offset)?;
                prepared.line_for_syntect.get(rel)
            })
            .and_then(|token| self.resolve_embedded_syntax(token));
        let syntax_index = match explicit {
            Some(index) => index,
            None => {
                let default = self.embedding[spec_index].default_language?;
                self.resolve_embedded_syntax(default)?
            }
        };
        Some(EmbeddedParseState {
            syntax_index,
            state: syntect::parsing::ParseState::new(&self.syntax_set.syntaxes()[syntax_index]),
            scopes: syntect::parsing::ScopeStack::new(),
        })
    }

    /// Resolve a fence-style language token ("rust", "py",
    /// "language-c++", "{.python}") to a syntax index, memoised. Plain
    /// text resolves to None so unrecognized fences keep the host's raw
    /// styling instead of losing it to a no-op child parse.
    fn resolve_embedded_syntax(&mut self, raw_token: &str) -> Option<usize> {
        let token = raw_token.trim();
        let token = token
            .strip_prefix("{.")
            .and_then(|t| t.strip_suffix('}'))
            .unwrap_or(token);
        let token = token.strip_prefix("language-").unwrap_or(token);
        if token.is_empty() {
            return None;
        }
        if let Some(cached) = self.embedded_syntax_memo.get(token) {
            return *cached;
        }
        let plain = self.syntax_set.find_syntax_plain_text();
        let resolved = self
            .syntax_set
            .find_syntax_by_token(token)
            .filter(|found| !std::ptr::eq(*found, plain))
            .and_then(|found| {
                self.syntax_set
                    .syntaxes()
                    .iter()
                    .position(|s| std::ptr::eq(s, found))
            });
        self.embedded_syntax_memo
            .insert(token.to_string(), resolved);
        resolved
    }

    /// Highlight the visible viewport. Path selection is documented in the
    /// module-level docs ("TextMate cache design").
    /// Test-only: inspect the cache commit point (range.end) and
    /// whether tail_state is populated. The cache must commit at a
    /// newline boundary — anything past that risks streaming
    /// forward-extension picking up where partial-line state poisoned
    /// the parser, see `test_partial_trailing_line_not_committed_to_cache`.
    #[cfg(test)]
    pub fn cache_commit_for_test(&self) -> (usize, bool) {
        match &self.cache {
            Some(c) => (c.range.end, c.tail_state.is_some()),
            None => (0, false),
        }
    }

    pub fn highlight_viewport(
        &mut self,
        buffer: &Buffer,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
        context_bytes: usize,
    ) -> Vec<HighlightSpan> {
        let buf_len = buffer.len();
        let (desired_parse_start, parse_end) = if buf_len <= MAX_PARSE_BYTES {
            (0, buf_len)
        } else {
            let s = viewport_start.saturating_sub(context_bytes);
            let e = (viewport_end + context_bytes).min(buf_len);
            (s, e)
        };

        let dirty = self.dirty_from.take();
        let cache_covers_viewport = self.cache.as_ref().is_some_and(|c| {
            c.range.start <= desired_parse_start && c.range.end >= desired_parse_start
        });
        let exact_cache_hit = cache_covers_viewport
            && dirty.is_none()
            && self.last_buffer_len == buffer.len()
            && self
                .cache
                .as_ref()
                .is_some_and(|c| c.range.end >= parse_end);

        // Cache hit.
        if exact_cache_hit {
            self.stats.cache_hits += 1;
            return self.filter_cached_spans(viewport_start, viewport_end, theme);
        }

        // Forward extension.
        if dirty.is_none()
            && cache_covers_viewport
            && self.last_buffer_len == buffer.len()
            && self
                .cache
                .as_ref()
                .is_some_and(|c| c.range.end < parse_end && c.tail_state.is_some())
        {
            return self.extend_cache_forward(
                buffer,
                parse_end,
                viewport_start,
                viewport_end,
                theme,
            );
        }

        // Partial update.
        if cache_covers_viewport && dirty.is_some() {
            if let Some(dirty_pos) = dirty {
                if dirty_pos < parse_end {
                    if let Some(result) = self.try_partial_update(
                        buffer,
                        dirty_pos,
                        desired_parse_start,
                        parse_end,
                        viewport_start,
                        viewport_end,
                        theme,
                    ) {
                        return result;
                    }
                } else {
                    // Dirty region past viewport: cached spans are still valid.
                    self.dirty_from = Some(dirty_pos);
                    self.stats.cache_hits += 1;
                    return self.filter_cached_spans(viewport_start, viewport_end, theme);
                }
            }
        } else if let Some(d) = dirty {
            self.dirty_from = Some(d);
        }

        // Cold start / fallback.
        self.full_parse(
            buffer,
            desired_parse_start,
            parse_end,
            viewport_start,
            viewport_end,
            theme,
            context_bytes,
        )
    }

    /// Filter cached spans for the viewport and resolve colors.
    fn filter_cached_spans(
        &self,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
    ) -> Vec<HighlightSpan> {
        let cache = self.cache.as_ref().unwrap();
        cache
            .spans
            .iter()
            .filter(|span| span.range.start < viewport_end && span.range.end > viewport_start)
            .map(|span| HighlightSpan {
                range: span.range.clone(),
                color: highlight_color(span.category, theme),
                bg: highlight_bg(span.category, theme),
                category: Some(span.category),
            })
            .collect()
    }

    /// Partial update path. Returns `Some` whenever an anchor was available,
    /// even on budget hit or EOF (see post-loop classification). `None` only
    /// when no checkpoint anchor reaches the dirty point.
    #[allow(clippy::too_many_arguments)]
    fn try_partial_update(
        &mut self,
        buffer: &Buffer,
        dirty_pos: usize,
        desired_parse_start: usize,
        parse_end: usize,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
    ) -> Option<Vec<HighlightSpan>> {
        let syntax = &self.syntax_set.syntaxes()[self.syntax_index];

        // Find checkpoint before the dirty point (bounded search)
        let (actual_start, mut snapshot) = {
            let search_start = dirty_pos.saturating_sub(MAX_PARSE_BYTES);
            let markers = self.checkpoint_markers.query_range(search_start, dirty_pos);
            let nearest = markers.into_iter().max_by_key(|(_, start, _)| *start);
            if let Some((id, cp_pos, _)) = nearest {
                if let Some(snap) = self.checkpoint_states.get(&id) {
                    (cp_pos, snap.clone())
                } else {
                    return None; // orphan, fall back
                }
            } else if parse_end <= MAX_PARSE_BYTES {
                (0, ParseSnapshot::new(syntax))
            } else {
                return None; // large file, no nearby checkpoint, fall back
            }
        };

        // Get markers from dirty point forward for convergence checking
        let mut markers_ahead: Vec<(MarkerId, usize)> = self
            .checkpoint_markers
            .query_range(dirty_pos, parse_end)
            .into_iter()
            .map(|(id, start, _)| (id, start))
            .collect();
        markers_ahead.sort_by_key(|(_, pos)| *pos);
        let mut marker_idx = 0;

        // Parse from actual_start to parse_end, looking for convergence
        let content_end = parse_end.min(buffer.len());
        if actual_start >= content_end {
            return None;
        }
        let content = buffer.slice_bytes(actual_start..content_end);
        let content_str = match std::str::from_utf8(&content) {
            Ok(s) => s,
            Err(_) => return None,
        };

        let mut new_spans = Vec::new();
        let content_bytes = content_str.as_bytes();
        let mut pos = 0;
        let mut current_offset = actual_start;
        let mut converged_at: Option<usize> = None;
        let mut budget_hit_at: Option<usize> = None;
        let mut bytes_since_checkpoint: usize = 0;

        while pos < content_bytes.len() {
            // Create checkpoints in new territory
            if bytes_since_checkpoint >= CHECKPOINT_INTERVAL {
                self.maybe_create_checkpoint(current_offset, &snapshot);
                bytes_since_checkpoint = 0;
            }

            let (line_end, line_byte_len, prepared) = prepare_line_at(content_bytes, pos);
            // Collect spans for the dirty region
            let collect_spans =
                current_offset + line_byte_len > desired_parse_start.max(actual_start);
            if let Some(prepared) = prepared {
                let _ = self.parse_snapshot_line(
                    &mut snapshot,
                    &prepared,
                    current_offset,
                    |byte_start, byte_end, category| {
                        if !collect_spans {
                            return;
                        }
                        let clamped_start = byte_start.max(actual_start);
                        if clamped_start < byte_end {
                            new_spans.push(CachedSpan {
                                range: clamped_start..byte_end,
                                category,
                            });
                        }
                    },
                );
            }

            pos = line_end;
            current_offset += line_byte_len;
            bytes_since_checkpoint += line_byte_len;

            // Check convergence at checkpoint markers
            while marker_idx < markers_ahead.len() && markers_ahead[marker_idx].1 <= current_offset
            {
                let (marker_id, _) = markers_ahead[marker_idx];
                marker_idx += 1;
                if let Some(stored) = self.checkpoint_states.get(&marker_id) {
                    if *stored == snapshot {
                        self.stats.convergences += 1;
                        converged_at = Some(current_offset);
                        break;
                    }
                }
                self.stats.checkpoints_updated += 1;
                self.checkpoint_states.insert(marker_id, snapshot.clone());
            }

            if converged_at.is_some() {
                break;
            }

            // Bound work per pass: pathological edits (e.g. unclosed `/*`
            // re-scoping the rest of the file) can never converge. Stop here
            // and resume from `current_offset` on the next render.
            if current_offset.saturating_sub(dirty_pos) >= CONVERGENCE_BUDGET {
                budget_hit_at = Some(current_offset);
                break;
            }
        }

        self.stats.bytes_parsed += current_offset.saturating_sub(actual_start);

        // Splice classification: converged → clear dirty; budget hit → keep
        // dirty for next pass; EOF → clear dirty.
        let (splice_end, dirty_after) = if let Some(c) = converged_at {
            (c, None)
        } else if let Some(b) = budget_hit_at {
            (b, Some(b))
        } else {
            (current_offset, None)
        };

        self.stats.cache_misses += 1; // partial update counts as a miss

        Self::merge_adjacent_spans(&mut new_spans);

        if let Some(cache) = &mut self.cache {
            let splice_start = actual_start;
            cache
                .spans
                .retain(|span| span.range.end <= splice_start || span.range.start >= splice_end);
            cache.spans.extend(new_spans);
            cache.spans.sort_by_key(|s| s.range.start);
            Self::merge_adjacent_spans(&mut cache.spans);
            if splice_end > cache.range.end {
                cache.range.end = splice_end;
            }
            cache.tail_state = None;
        }

        self.last_buffer_len = buffer.len();
        self.dirty_from = dirty_after;

        Some(self.filter_cached_spans(viewport_start, viewport_end, theme))
    }

    /// Forward extension path (see module docs). Caller checks the cache
    /// exists, has a `tail_state`, has no dirty edits, and `cache.range.end
    /// < parse_end`.
    fn extend_cache_forward(
        &mut self,
        buffer: &Buffer,
        parse_end: usize,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
    ) -> Vec<HighlightSpan> {
        self.stats.cache_misses += 1;
        let buf_len = buffer.len();
        let parse_end = parse_end.min(buf_len);

        let (extension_start, mut snapshot) = {
            let cache = self
                .cache
                .as_ref()
                .expect("extend_cache_forward: cache must exist");
            let snap = cache
                .tail_state
                .as_ref()
                .expect("extend_cache_forward: tail_state must exist")
                .clone();
            (cache.range.end, snap)
        };

        if parse_end <= extension_start {
            return self.filter_cached_spans(viewport_start, viewport_end, theme);
        }

        let content = buffer.slice_bytes(extension_start..parse_end);
        let content_str = match std::str::from_utf8(&content) {
            Ok(s) => s,
            Err(_) => return self.filter_cached_spans(viewport_start, viewport_end, theme),
        };

        let mut new_spans = Vec::new();
        let content_bytes = content_str.as_bytes();
        let mut pos = 0;
        let mut current_offset = extension_start;
        let mut bytes_since_checkpoint: usize = 0;
        // Snapshot of the last newline-aligned cache commit point. We never
        // commit parse state for a partial trailing line: with a streaming
        // grammar like syntect's `Diff` (line-anchored `^\+.*` etc.) the
        // state at end-of-input has already popped `markup.inserted`, so
        // resuming from there parses the rest of the same line in
        // `source.diff` with no scope — bytes of the line streamed in
        // later get default editor bg, producing the dark-bar artifact
        // inside `+` lines. Re-parsing the trailing partial line on every
        // refresh costs at most one extra `parse_line` and is correct.
        let mut safe_offset = extension_start;
        let mut safe_snapshot = snapshot.clone();

        while pos < content_bytes.len() {
            if bytes_since_checkpoint >= CHECKPOINT_INTERVAL {
                self.maybe_create_checkpoint(current_offset, &snapshot);
                bytes_since_checkpoint = 0;
            }

            let (line_end, line_byte_len, prepared) = prepare_line_at(content_bytes, pos);
            let mut newline_terminated = false;
            // Over-long line: parse only the viewport window, from a
            // clone of the state, so the per-frame cost is bounded by
            // the window rather than the line (see prepare_line_window).
            if let Some((window_offset, window)) = prepare_line_window(
                content_bytes,
                pos,
                line_end,
                current_offset,
                viewport_start,
                viewport_end,
            ) {
                let mut windowed = snapshot.clone();
                self.parse_snapshot_line(
                    &mut windowed,
                    &window,
                    window_offset,
                    |byte_start, byte_end, category| {
                        new_spans.push(CachedSpan {
                            range: byte_start..byte_end,
                            category,
                        });
                    },
                );
                // `snapshot` is deliberately untouched: the state after
                // the line is taken to be the state before it.
                newline_terminated = prepared.is_some_and(|p| p.ends_with_newline);
            } else if let Some(prepared) = prepared {
                let parse_ok = self.parse_snapshot_line(
                    &mut snapshot,
                    &prepared,
                    current_offset,
                    |byte_start, byte_end, category| {
                        new_spans.push(CachedSpan {
                            range: byte_start..byte_end,
                            category,
                        });
                    },
                );
                if parse_ok {
                    newline_terminated = prepared.ends_with_newline;
                }
            }

            pos = line_end;
            current_offset += line_byte_len;
            bytes_since_checkpoint += line_byte_len;

            if newline_terminated {
                safe_offset = current_offset;
                safe_snapshot = snapshot.clone();
            }
        }

        // The trailing partial line is committable when the parse
        // reached the buffer end: those bytes are final unless the
        // buffer grows, and both cache-consuming paths in
        // `highlight_viewport` require `last_buffer_len == buffer.len()`
        // (plus no pending edit), so a grown buffer can never resume
        // from this state. Without this, a file that is one huge line
        // with no trailing newline (a minified JSON one-liner) can
        // never commit anything and re-parses in full on every frame.
        if parse_end >= buf_len {
            safe_offset = current_offset;
            safe_snapshot = snapshot.clone();
        }

        self.stats.bytes_parsed += parse_end - extension_start;

        Self::merge_adjacent_spans(&mut new_spans);

        // Split spans into safe (fully before the trailing partial line,
        // cacheable) and unsafe (overlap the partial line, render-only).
        // Unsafe spans are returned in this pass so the partial line is
        // still highlighted, but won't be cached — they'll be recomputed
        // on the next refresh once more bytes (and a newline) stream in.
        let (safe_spans, unsafe_spans): (Vec<_>, Vec<_>) = new_spans
            .into_iter()
            .partition(|s| s.range.end <= safe_offset);

        let cache = self
            .cache
            .as_mut()
            .expect("extend_cache_forward: cache must still exist");
        cache.spans.extend(safe_spans);
        Self::merge_adjacent_spans(&mut cache.spans);
        cache.range.end = safe_offset;
        cache.tail_state = Some(safe_snapshot);
        self.last_buffer_len = buf_len;

        let mut result = self.filter_cached_spans(viewport_start, viewport_end, theme);
        result.extend(
            unsafe_spans
                .into_iter()
                .filter(|s| s.range.start < viewport_end && s.range.end > viewport_start)
                .map(|s| HighlightSpan {
                    range: s.range,
                    color: highlight_color(s.category, theme),
                    bg: highlight_bg(s.category, theme),
                    category: Some(s.category),
                }),
        );
        result
    }

    /// Full re-parse from desired_parse_start to parse_end. Used on cold start
    /// or when partial update fails (no convergence).
    #[allow(clippy::too_many_arguments)]
    fn full_parse(
        &mut self,
        buffer: &Buffer,
        desired_parse_start: usize,
        parse_end: usize,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
        _context_bytes: usize,
    ) -> Vec<HighlightSpan> {
        self.stats.cache_misses += 1;
        self.dirty_from = None; // consumed

        if parse_end <= desired_parse_start {
            return Vec::new();
        }

        let syntax = &self.syntax_set.syntaxes()[self.syntax_index];
        let (actual_start, mut snapshot, create_checkpoints) =
            self.find_parse_resume_point(desired_parse_start, parse_end, syntax);

        let content = buffer.slice_bytes(actual_start..parse_end);
        let content_str = match std::str::from_utf8(&content) {
            Ok(s) => s,
            Err(_) => return Vec::new(),
        };

        let mut spans = Vec::new();
        let content_bytes = content_str.as_bytes();
        let mut pos = 0;
        let mut current_offset = actual_start;
        let mut bytes_since_checkpoint: usize = 0;
        // See `extend_cache_forward` for rationale: never commit cache
        // state past the last newline. `safe_offset` ends up == parse_end
        // for buffers that end on `\n` (no behaviour change), and at the
        // start of the trailing partial line otherwise so the next
        // refresh re-parses it from scratch.
        let mut safe_offset = actual_start;
        let mut safe_snapshot = snapshot.clone();

        while pos < content_bytes.len() {
            if create_checkpoints && bytes_since_checkpoint >= CHECKPOINT_INTERVAL {
                self.maybe_create_checkpoint(current_offset, &snapshot);
                bytes_since_checkpoint = 0;
            }

            let (line_end, line_byte_len, prepared) = prepare_line_at(content_bytes, pos);
            // Skip span collection for lines that ended before the viewport's
            // desired_parse_start — we still need to drive `parse_line` for
            // state continuity, but their spans wouldn't be returned anyway.
            let collect_spans = current_offset + line_byte_len > desired_parse_start;
            let mut newline_terminated = false;
            // Over-long line: parse only the viewport window, from a
            // clone of the state (see prepare_line_window).
            if let Some((window_offset, window)) = prepare_line_window(
                content_bytes,
                pos,
                line_end,
                current_offset,
                viewport_start,
                viewport_end,
            ) {
                let mut windowed = snapshot.clone();
                self.parse_snapshot_line(
                    &mut windowed,
                    &window,
                    window_offset,
                    |byte_start, byte_end, category| {
                        if !collect_spans {
                            return;
                        }
                        let clamped_start = byte_start.max(desired_parse_start);
                        if clamped_start < byte_end {
                            spans.push(CachedSpan {
                                range: clamped_start..byte_end,
                                category,
                            });
                        }
                    },
                );
                newline_terminated = prepared.is_some_and(|p| p.ends_with_newline);
            } else if let Some(prepared) = prepared {
                let parse_ok = self.parse_snapshot_line(
                    &mut snapshot,
                    &prepared,
                    current_offset,
                    |byte_start, byte_end, category| {
                        if !collect_spans {
                            return;
                        }
                        let clamped_start = byte_start.max(desired_parse_start);
                        if clamped_start < byte_end {
                            spans.push(CachedSpan {
                                range: clamped_start..byte_end,
                                category,
                            });
                        }
                    },
                );
                if parse_ok {
                    newline_terminated = prepared.ends_with_newline;
                }
            }

            pos = line_end;
            current_offset += line_byte_len;
            bytes_since_checkpoint += line_byte_len;

            if newline_terminated {
                safe_offset = current_offset;
                safe_snapshot = snapshot.clone();
            }

            // Update checkpoint states as we pass them. Done after the
            // line is parsed (state now reflects end-of-line) so a
            // checkpoint placed at the line's start position carries
            // the state at that position, ready to feed the next line.
            let markers_here: Vec<(MarkerId, usize)> = self
                .checkpoint_markers
                .query_range(current_offset.saturating_sub(line_byte_len), current_offset)
                .into_iter()
                .map(|(id, start, _)| (id, start))
                .collect();
            for (marker_id, _) in markers_here {
                self.checkpoint_states.insert(marker_id, snapshot.clone());
            }
        }

        // Same EOF rule as `extend_cache_forward`: a trailing partial
        // line that ends at the buffer end is committable, because the
        // cache-consuming paths require an unchanged buffer length.
        if parse_end >= buffer.len() {
            safe_offset = current_offset;
            safe_snapshot = snapshot.clone();
        }

        self.stats.bytes_parsed += parse_end.saturating_sub(actual_start);

        Self::merge_adjacent_spans(&mut spans);

        // Cache only the prefix up to the last newline. Spans straddling
        // or past the trailing partial line are returned for THIS render
        // pass (so the partial line is highlighted now), but excluded
        // from the cache — the next refresh re-parses them from
        // `safe_state` once more bytes have streamed in.
        let cache_range_end = safe_offset.max(desired_parse_start);
        let cached_spans: Vec<CachedSpan> = spans
            .iter()
            .filter(|s| s.range.end <= cache_range_end)
            .cloned()
            .collect();

        self.cache = Some(TextMateCache {
            range: desired_parse_start..cache_range_end,
            spans: cached_spans,
            tail_state: Some(safe_snapshot),
        });
        self.last_buffer_len = buffer.len();

        spans
            .into_iter()
            .filter(|span| span.range.start < viewport_end && span.range.end > viewport_start)
            .map(|span| {
                let cat = span.category;
                HighlightSpan {
                    range: span.range,
                    color: highlight_color(cat, theme),
                    bg: highlight_bg(cat, theme),
                    category: Some(cat),
                }
            })
            .collect()
    }

    /// Find the best point to resume parsing from for the viewport.
    fn find_parse_resume_point(
        &self,
        desired_start: usize,
        parse_end: usize,
        syntax: &syntect::parsing::SyntaxReference,
    ) -> (usize, ParseSnapshot, bool) {
        // Look for a checkpoint near the desired start. For large files, only
        // consider checkpoints that are within MAX_PARSE_BYTES of desired_start
        // to avoid parsing hundreds of MB from a distant checkpoint.
        let search_start = desired_start.saturating_sub(MAX_PARSE_BYTES);
        let markers = self
            .checkpoint_markers
            .query_range(search_start, desired_start + 1);
        let nearest = markers.into_iter().max_by_key(|(_, start, _)| *start);

        if let Some((id, cp_pos, _)) = nearest {
            if let Some(snap) = self.checkpoint_states.get(&id) {
                return (cp_pos, snap.clone(), true);
            }
        }

        if parse_end <= MAX_PARSE_BYTES {
            // File is small enough to parse from byte 0
            (0, ParseSnapshot::new(syntax), true)
        } else {
            // Large file, no nearby checkpoint — start fresh from desired_start.
            // Still create checkpoints so future visits to this region can resume.
            (desired_start, ParseSnapshot::new(syntax), true)
        }
    }

    /// Map scope stack to highlight category, memoising per-scope lookups.
    /// `scope.build_string()` is the costly step; the cache hides it after
    /// each scope atom has been seen once.
    fn scope_stack_to_category(
        &mut self,
        scopes: &syntect::parsing::ScopeStack,
    ) -> Option<HighlightCategory> {
        for scope in scopes.as_slice().iter().rev() {
            let cat = match self.scope_category_cache.get(scope) {
                Some(c) => *c,
                None => {
                    let computed = scope_to_category(&scope.build_string());
                    self.scope_category_cache.insert(*scope, computed);
                    computed
                }
            };
            if let Some(c) = cat {
                return Some(c);
            }
        }
        None
    }

    /// Merge adjacent spans with same category
    fn merge_adjacent_spans(spans: &mut Vec<CachedSpan>) {
        if spans.len() < 2 {
            return;
        }

        let mut write_idx = 0;
        for read_idx in 1..spans.len() {
            if spans[write_idx].category == spans[read_idx].category
                && spans[write_idx].range.end == spans[read_idx].range.start
            {
                spans[write_idx].range.end = spans[read_idx].range.end;
            } else {
                write_idx += 1;
                if write_idx != read_idx {
                    spans[write_idx] = spans[read_idx].clone();
                }
            }
        }
        spans.truncate(write_idx + 1);
    }

    /// Invalidate span cache for an edited range.
    /// Checkpoint positions are handled by notify_insert/notify_delete.
    /// The span cache is NOT cleared here — it will be patched (partial update)
    /// during the next highlight_viewport call using convergence. Only dirty_from
    /// (set by notify_insert/notify_delete) controls re-parsing scope.
    pub fn invalidate_range(&mut self, _edit_range: Range<usize>) {
        // Intentionally does NOT clear self.cache.
        // The cache will be partially updated in highlight_viewport when
        // dirty_from is set. This avoids full re-parses for small edits.
    }

    /// Invalidate all cache and checkpoints (file reload, language change, etc.)
    pub fn invalidate_all(&mut self) {
        self.cache = None;
        let ids: Vec<MarkerId> = self.checkpoint_states.keys().copied().collect();
        for id in ids {
            self.checkpoint_markers.delete(id);
        }
        self.checkpoint_states.clear();
        self.dirty_from = None;
    }

    /// Get the highlight category at a byte position from the cache.
    ///
    /// Returns the category if the position falls within a cached highlight span.
    /// The position must be within the last highlighted viewport range for a result.
    pub fn category_at_position(&self, position: usize) -> Option<HighlightCategory> {
        let cache = self.cache.as_ref()?;
        cache
            .spans
            .iter()
            .find(|span| span.range.start <= position && position < span.range.end)
            .map(|span| span.category)
    }

    /// Get syntax name
    pub fn syntax_name(&self) -> &str {
        &self.syntax_set.syntaxes()[self.syntax_index].name
    }
}

impl HighlightEngine {
    /// Build a highlighting engine for a catalog entry.
    ///
    /// Single chokepoint for the "prefer syntect, fall back to tree-sitter"
    /// logic. Callers that start from a path or a syntax name should resolve
    /// the entry through `GrammarRegistry::find_by_path` / `find_by_name` and
    /// then call this.
    pub fn from_entry(
        entry: &crate::primitives::grammar::GrammarEntry,
        registry: &GrammarRegistry,
    ) -> Self {
        let syntax_set = registry.syntax_set_arc();
        if let Some(index) = entry.engines.syntect {
            return Self::TextMate(Box::new(TextMateEngine::with_language(
                syntax_set,
                index,
                entry.engines.tree_sitter,
            )));
        }
        if let Some(lang) = entry.engines.tree_sitter {
            if let Ok(highlighter) = Highlighter::new(lang) {
                return Self::TreeSitter(Box::new(highlighter));
            }
        }
        Self::None
    }

    /// Create a highlighting engine for a file.
    ///
    /// Thin wrapper around `from_entry` that resolves the path via the catalog.
    /// User-config-declared filename/extension mappings are honoured as long as
    /// `GrammarRegistry::apply_language_config` has been called on the registry.
    /// `first_line` is used for shebang / first-line regex fallback — pass
    /// `None` when no content is available.
    pub fn for_file(path: &Path, first_line: Option<&str>, registry: &GrammarRegistry) -> Self {
        if let Some(entry) = registry.find_by_path(path, first_line) {
            return Self::from_entry(entry, registry);
        }
        Self::None
    }

    /// Create a highlighting engine for a syntax by name.
    ///
    /// Thin wrapper around `from_entry` that performs the lookup via
    /// `find_by_name`. The catalog entry already knows which tree-sitter
    /// `Language` (if any) serves it, so no separate hint is needed.
    pub fn for_syntax_name(name: &str, registry: &GrammarRegistry) -> Self {
        if let Some(entry) = registry.find_by_name(name) {
            return Self::from_entry(entry, registry);
        }
        Self::None
    }

    /// Highlight the visible viewport
    ///
    /// `context_bytes` controls how far before/after the viewport to parse for accurate
    /// highlighting of multi-line constructs (strings, comments, nested blocks).
    pub fn highlight_viewport(
        &mut self,
        buffer: &Buffer,
        viewport_start: usize,
        viewport_end: usize,
        theme: &Theme,
        context_bytes: usize,
    ) -> Vec<HighlightSpan> {
        match self {
            Self::TreeSitter(h) => {
                h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
            }
            Self::TextMate(h) => {
                h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
            }
            Self::None => Vec::new(),
        }
    }

    /// Notify the highlighting engine of a buffer insert (for checkpoint position tracking).
    pub fn notify_insert(&mut self, position: usize, length: usize) {
        if let Self::TextMate(h) = self {
            h.notify_insert(position, length);
        }
    }

    /// Notify the highlighting engine of a buffer delete (for checkpoint position tracking).
    pub fn notify_delete(&mut self, position: usize, length: usize) {
        if let Self::TextMate(h) = self {
            h.notify_delete(position, length);
        }
    }

    /// Invalidate cache for an edited range
    pub fn invalidate_range(&mut self, edit_range: Range<usize>) {
        match self {
            Self::TreeSitter(h) => h.invalidate_range(edit_range),
            Self::TextMate(h) => h.invalidate_range(edit_range),
            Self::None => {}
        }
    }

    /// Invalidate entire cache
    pub fn invalidate_all(&mut self) {
        match self {
            Self::TreeSitter(h) => h.invalidate_all(),
            Self::TextMate(h) => h.invalidate_all(),
            Self::None => {}
        }
    }

    /// Track a sequence of bulk edits in the cache.
    ///
    /// Each edit is `(pos, del_len, ins_len)`. The slice must be sorted in
    /// descending position order — the same order `apply_bulk_edits` uses to
    /// mutate the buffer — so positions remain valid as the buffer changes.
    ///
    /// This mirrors the `notify_*` + `invalidate_range` pattern used by
    /// single-edit paths. It preserves the TextMate engine's checkpoints and
    /// dirty-from anchor (so the next render uses the partial-update path
    /// rather than a cold reparse from byte zero) and drops the tree-sitter
    /// viewport cache only when an edit overlaps it.
    pub fn notify_edits(&mut self, edits: &[(usize, usize, usize)]) {
        for &(pos, del_len, ins_len) in edits {
            if del_len > 0 {
                self.notify_delete(pos, del_len);
            }
            if ins_len > 0 {
                self.notify_insert(pos, ins_len);
            }
            let edit_end = pos + del_len.max(ins_len);
            self.invalidate_range(pos..edit_end);
        }
    }

    /// Check if this engine has highlighting available
    pub fn has_highlighting(&self) -> bool {
        !matches!(self, Self::None)
    }

    /// Get a description of the active backend
    pub fn backend_name(&self) -> &str {
        match self {
            Self::TreeSitter(_) => "tree-sitter",
            Self::TextMate(_) => "textmate",
            Self::None => "none",
        }
    }

    /// Get performance stats (TextMate engine only).
    pub fn highlight_stats(&self) -> Option<&HighlightStats> {
        if let Self::TextMate(h) = self {
            Some(h.stats())
        } else {
            None
        }
    }

    /// Reset performance counters.
    pub fn reset_highlight_stats(&mut self) {
        if let Self::TextMate(h) = self {
            h.reset_stats();
        }
    }

    /// Get the language/syntax name if available
    pub fn syntax_name(&self) -> Option<&str> {
        match self {
            Self::TreeSitter(_) => None, // Tree-sitter doesn't expose name easily
            Self::TextMate(h) => Some(h.syntax_name()),
            Self::None => None,
        }
    }

    /// Get the highlight category at a byte position from the cache.
    ///
    /// Returns the category if the position falls within a cached highlight span.
    /// Useful for detecting whether the cursor is inside a string, comment, etc.
    pub fn category_at_position(&self, position: usize) -> Option<HighlightCategory> {
        match self {
            Self::TreeSitter(h) => h.category_at_position(position),
            Self::TextMate(h) => h.category_at_position(position),
            Self::None => None,
        }
    }

    /// Get the tree-sitter Language for non-highlighting features
    /// Returns the language even when using TextMate for highlighting
    pub fn language(&self) -> Option<&Language> {
        match self {
            Self::TreeSitter(h) => Some(h.language()),
            Self::TextMate(h) => h.language(),
            Self::None => None,
        }
    }
}

/// Highlight a code string using syntect (for markdown code blocks, hover popups, etc.)
/// Returns spans with byte ranges relative to the input string.
///
/// This uses TextMate grammars via syntect which provides broader language coverage
/// than tree-sitter (~150+ languages vs ~17).
pub fn highlight_string(
    code: &str,
    lang_hint: &str,
    registry: &GrammarRegistry,
    theme: &Theme,
) -> Vec<HighlightSpan> {
    use syntect::parsing::{ParseState, ScopeStack};

    // Find syntax by language token (handles aliases like "py" -> Python)
    let syntax = match registry.syntax_set().find_syntax_by_token(lang_hint) {
        Some(s) => s,
        None => return Vec::new(),
    };

    let syntax_set = registry.syntax_set();
    let mut state = ParseState::new(syntax);
    let mut spans = Vec::new();
    let mut current_scopes = ScopeStack::new();
    let mut current_offset = 0;

    // Parse line by line
    for line in code.split_inclusive('\n') {
        let line_start = current_offset;
        let line_len = line.len();

        // Remove trailing newline for syntect, then add it back
        let line_content = line.trim_end_matches(&['\r', '\n'][..]);
        let line_for_syntect = if line.ends_with('\n') {
            format!("{}\n", line_content)
        } else {
            line_content.to_string()
        };

        let ops = match state.parse_line(&line_for_syntect, syntax_set) {
            Ok(ops) => ops,
            Err(_) => {
                current_offset += line_len;
                continue;
            }
        };

        let mut syntect_offset = 0;
        let line_content_len = line_content.len();

        for (op_offset, op) in ops {
            let clamped_op_offset = op_offset.min(line_content_len);
            if clamped_op_offset > syntect_offset {
                if let Some(category) = scope_stack_to_category(&current_scopes) {
                    let byte_start = line_start + syntect_offset;
                    let byte_end = line_start + clamped_op_offset;
                    if byte_start < byte_end {
                        spans.push(HighlightSpan {
                            range: byte_start..byte_end,
                            color: highlight_color(category, theme),
                            bg: highlight_bg(category, theme),
                            category: Some(category),
                        });
                    }
                }
            }
            syntect_offset = clamped_op_offset;
            // Scope stack errors are non-fatal for highlighting
            #[allow(clippy::let_underscore_must_use)]
            let _ = current_scopes.apply(&op);
        }

        // Handle remaining text on line
        if syntect_offset < line_content_len {
            if let Some(category) = scope_stack_to_category(&current_scopes) {
                let byte_start = line_start + syntect_offset;
                let byte_end = line_start + line_content_len;
                if byte_start < byte_end {
                    spans.push(HighlightSpan {
                        range: byte_start..byte_end,
                        color: highlight_color(category, theme),
                        bg: highlight_bg(category, theme),
                        category: Some(category),
                    });
                }
            }
        }

        current_offset += line_len;
    }

    // Merge adjacent spans with same color
    merge_adjacent_highlight_spans(&mut spans);

    spans
}

/// Map scope stack to highlight category (for highlight_string)
fn scope_stack_to_category(scopes: &syntect::parsing::ScopeStack) -> Option<HighlightCategory> {
    for scope in scopes.as_slice().iter().rev() {
        let scope_str = scope.build_string();
        if let Some(cat) = scope_to_category(&scope_str) {
            return Some(cat);
        }
    }
    None
}

/// Merge adjacent spans with same color
fn merge_adjacent_highlight_spans(spans: &mut Vec<HighlightSpan>) {
    if spans.len() < 2 {
        return;
    }

    let mut write_idx = 0;
    for read_idx in 1..spans.len() {
        if spans[write_idx].color == spans[read_idx].color
            && spans[write_idx].range.end == spans[read_idx].range.start
        {
            spans[write_idx].range.end = spans[read_idx].range.end;
        } else {
            write_idx += 1;
            if write_idx != read_idx {
                spans[write_idx] = spans[read_idx].clone();
            }
        }
    }
    spans.truncate(write_idx + 1);
}

#[cfg(test)]
mod tests {
    use crate::model::filesystem::StdFileSystem;
    use std::sync::Arc;

    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
        Arc::new(StdFileSystem)
    }
    use super::*;
    use crate::view::theme;

    #[test]
    fn test_highlight_engine_default() {
        let engine = HighlightEngine::default();
        assert!(!engine.has_highlighting());
        assert_eq!(engine.backend_name(), "none");
    }

    #[test]
    fn test_textmate_backend_selection() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        // Languages with TextMate grammars use TextMate for highlighting
        let engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        // Tree-sitter language should still be detected for other features
        assert!(engine.language().is_some());

        let engine = HighlightEngine::for_file(Path::new("test.py"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.language().is_some());

        // JavaScript is routed to tree-sitter (issue #899: syntect's JS
        // grammar bleeds template-literal string state past the closing
        // backtick).
        let engine = HighlightEngine::for_file(Path::new("test.js"), None, &registry);
        assert_eq!(engine.backend_name(), "tree-sitter");
        assert!(engine.language().is_some());

        // TypeScript falls back to tree-sitter (syntect doesn't include TS by default)
        let engine = HighlightEngine::for_file(Path::new("test.ts"), None, &registry);
        assert_eq!(engine.backend_name(), "tree-sitter");
        assert!(engine.language().is_some());

        let engine = HighlightEngine::for_file(Path::new("test.tsx"), None, &registry);
        assert_eq!(engine.backend_name(), "tree-sitter");
        assert!(engine.language().is_some());
    }

    #[test]
    fn test_fish_indented_control_keywords_stay_keywords() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("config.fish"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");

        let content =
            "function greet\n    if test -n \"$name\"\n        echo $name\n    end\nend\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let indented_if = content.find("if test").unwrap();
        let indented_end = content.find("    end").unwrap() + 4;
        let column_zero_end = content.rfind("end").unwrap();

        assert_eq!(
            engine.category_at_position(indented_if),
            Some(HighlightCategory::Keyword)
        );
        assert_eq!(
            engine.category_at_position(indented_end),
            Some(HighlightCategory::Keyword)
        );
        assert_eq!(
            engine.category_at_position(column_zero_end),
            Some(HighlightCategory::Keyword)
        );
    }

    #[test]
    fn test_toml_multiline_array_strings_stay_strings() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("config.toml"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");

        let content = concat!(
            "cmdline = [\n",
            "  \"USER=root\",\n",
            "  \"PATH=/bin:/benchmark\",\n",
            "  \"init=/init\",\n",
            "]\n",
            "initramfs = \"test/initramfs/build/initramfs.cpio.gz\"\n",
            "args = \"$(./tools/qemu_args.sh test)\"\n",
            "package.metadata = { enabled = true, label = \"v1.2\" }\n",
            "released = 2026-08-02\n",
        );
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        for needle in [
            "USER=root",
            "/benchmark",
            "init=/init",
            ".cpio.gz",
            "./tools",
        ] {
            let position = content.find(needle).unwrap();
            assert_eq!(
                engine.category_at_position(position),
                Some(HighlightCategory::String),
                "{needle:?} should be highlighted as part of its TOML string"
            );
        }

        for needle in ["package.metadata", "enabled", "label"] {
            let position = content.find(needle).unwrap();
            assert_eq!(
                engine.category_at_position(position),
                Some(HighlightCategory::Property),
                "{needle:?} should be highlighted as a TOML key"
            );
        }
        assert_eq!(
            engine.category_at_position(content.find("true").unwrap()),
            Some(HighlightCategory::Number)
        );
        assert_eq!(
            engine.category_at_position(content.find("2026-08-02").unwrap()),
            Some(HighlightCategory::Constant)
        );
    }

    /// A recognized Markdown fence language must be highlighted with that
    /// language's grammar instead of the uniform `markup.raw` string color.
    #[test]
    fn test_markdown_fence_embedded_language_highlighting() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");

        let content = "# Example\n\n```rust\nfn answer() -> u32 { 42 }\n```\n\ntext\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let category_at = |needle: &str| {
            let position = content.find(needle).unwrap();
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category)
        };

        assert_eq!(
            category_at("fn answer"),
            Some(HighlightCategory::Keyword),
            "rust keyword inside the fence must use the rust grammar"
        );
        assert_eq!(category_at("42"), Some(HighlightCategory::Number));
        // The fence delimiter lines still belong to the host grammar.
        assert_eq!(category_at("```rust"), Some(HighlightCategory::String));
        assert_eq!(
            engine.category_at_position(content.find("fn answer").unwrap()),
            Some(HighlightCategory::Keyword),
            "category lookups must agree with the returned spans"
        );
    }

    /// Fences naming an unknown language keep the host's raw-code styling.
    #[test]
    fn test_markdown_unknown_fence_language_keeps_raw_style() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        let content = "```definitely-not-a-language\nfn answer() { 42 }\n```\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);
        let position = content.find("fn answer").unwrap();

        assert_eq!(
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category),
            Some(HighlightCategory::String)
        );
    }

    /// CRLF fences must produce correctly-offset embedded spans.
    #[test]
    fn test_markdown_fence_crlf_offsets() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        let content = "```rust\r\nfn answer() -> u32 { 42 }\r\n```\r\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let position = content.find("fn answer").unwrap();
        assert_eq!(
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category),
            Some(HighlightCategory::Keyword)
        );
    }

    /// A fence left open at EOF (streaming, or mid-typing) still gets
    /// embedded highlighting for the content streamed in so far.
    #[test]
    fn test_markdown_fence_unclosed_at_eof() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        let content = "```rust\nfn answer() -> u32 { 42 }\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let position = content.find("fn answer").unwrap();
        assert_eq!(
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category),
            Some(HighlightCategory::Keyword)
        );
    }

    /// Editing the fence's language token must re-style the whole region.
    ///
    /// This pins the reason checkpoints and convergence carry the
    /// *composite* snapshot (host + embedded child state): inside a fence
    /// the host parser's state is identical no matter which language the
    /// fence names, so host-only convergence could falsely converge at
    /// the first checkpoint inside the region and leave stale spans from
    /// the old language beyond it. The fence here is longer than
    /// CHECKPOINT_INTERVAL to guarantee such a checkpoint exists.
    #[test]
    fn test_markdown_fence_language_edit_restyles_whole_region() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let code_line = "fn deep_in_the_fence() -> u32 { 42 }\n";
        let lines = 2 * CHECKPOINT_INTERVAL / code_line.len() + 2;
        let fence_body = code_line.repeat(lines);
        let before = format!("```rust\n{fence_body}```\n\nafter\n");
        let buffer = Buffer::from_str(&before, 0, test_fs());
        let _ = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        // A position in the fence's *last* code line — past any checkpoint
        // created inside the region.
        let last_line_pos = before.rfind("fn deep_in_the_fence").unwrap();
        assert_eq!(
            engine.category_at_position(last_line_pos),
            Some(HighlightCategory::Keyword),
            "precondition: rust highlighting active deep in the fence"
        );

        // Delete "rust" from the opening fence line.
        let lang_pos = before.find("rust").unwrap();
        let after = before.replacen("rust", "", 1);
        let buffer = Buffer::from_str(&after, 0, test_fs());
        engine.notify_delete(lang_pos, "rust".len());
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let position = after.rfind("fn deep_in_the_fence").unwrap();
        assert_eq!(
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category),
            Some(HighlightCategory::String),
            "after removing the language token the whole region must fall \
             back to raw-code styling — stale rust spans past a checkpoint \
             inside the fence mean convergence ignored the embedded state"
        );
    }

    /// Typing inside a fence must stay on the incremental partial-update
    /// path — no whole-file rescan per edit (the flaw that motivated
    /// replacing full-buffer fence detection).
    ///
    /// Convergence granularity note: syntect parse states carrying regex
    /// captures (which the fence context does, for its close-marker
    /// backreference) never compare equal across parses — `onig::Region`
    /// equality is allocation identity, a pre-existing syntect/onig
    /// limitation that master's tuple comparison had too. So an edit
    /// inside a fence re-parses to the end of the *region* and converges
    /// at the first checkpoint after it, rather than 256 bytes after the
    /// edit. This test pins exactly that contract: the long tail after
    /// the fence must not be re-parsed.
    #[test]
    fn test_markdown_fence_edit_reparses_region_not_file() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let mut content = String::from("# Doc\n\n```rust\n");
        for i in 0..40 {
            content.push_str(&format!("fn f_{i}() -> u32 {{ {i} }}\n"));
        }
        content.push_str("```\n\n");
        let fence_end = content.len();
        for i in 0..400 {
            content.push_str(&format!("plain prose line {i}\n"));
        }
        let buffer = Buffer::from_str(&content, 0, test_fs());

        let HighlightEngine::TextMate(ref mut tm) = engine else {
            panic!("expected TextMate engine for .md");
        };
        let _ = tm.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);
        let bytes_before = tm.stats().bytes_parsed;

        // Simulate typing one character in the middle of the fence.
        let edit_pos = fence_end / 2;
        tm.notify_insert(edit_pos, 1);
        let _ = tm.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let stats = tm.stats();
        let parsed = stats.bytes_parsed - bytes_before;
        let slack = 2 * CHECKPOINT_INTERVAL;
        assert!(
            parsed < fence_end + slack,
            "an edit inside a fence must converge shortly after the region \
             ends instead of re-parsing the rest of the file \
             (parsed {parsed}, fence ends at {fence_end}, file {} bytes)",
            content.len()
        );
        assert!(
            stats.convergences >= 1,
            "the edit must converge at a checkpoint after the fence"
        );
    }

    /// Embedded highlighting must keep working past MAX_PARSE_BYTES — the
    /// whole point of integrating regions into the windowed/checkpointed
    /// engine instead of a full-buffer scan with a size cutoff.
    #[test]
    fn test_markdown_fence_beyond_max_parse_bytes_no_size_cliff() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let mut content = "plain prose line\n".repeat(MAX_PARSE_BYTES / 16);
        assert!(content.len() > MAX_PARSE_BYTES);
        let fence_pos = content.len();
        content.push_str("```rust\nfn deep() -> u32 { 42 }\n```\n");
        let buffer = Buffer::from_str(&content, 0, test_fs());

        let HighlightEngine::TextMate(ref mut tm) = engine else {
            panic!("expected TextMate engine for .md");
        };

        // Scroll from the top to the fence in steps small enough for the
        // forward-extension path (mirrors a user paging through the file,
        // building checkpoints along the way).
        let context = 10_000;
        let step = 16_000;
        let mut viewport_start = 0;
        loop {
            let viewport_end = (viewport_start + 1_000).min(buffer.len());
            let _ = tm.highlight_viewport(&buffer, viewport_start, viewport_end, &theme, context);
            if viewport_end == buffer.len() {
                break;
            }
            viewport_start += step;
        }

        let position = fence_pos + "```rust\n".len();
        assert_eq!(
            tm.category_at_position(position),
            Some(HighlightCategory::Keyword),
            "a fence starting past MAX_PARSE_BYTES must still get embedded \
             highlighting once reached via scrolling — a size-based cutoff \
             here means the mechanism bypassed the windowed engine"
        );
    }

    /// Vue `<script>`/`<style>` blocks with no `lang` attribute are
    /// parsed with the spec's default languages (js / css) — proving the
    /// embedding mechanism generalizes beyond Markdown with nothing but
    /// spec-table entries and grammar scopes.
    #[test]
    fn test_vue_script_and_style_default_languages() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("App.vue"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");

        let content = "<template>\n  <p>hi</p>\n</template>\n\n<script>\nconst answer = 42;\n</script>\n\n<style>\n.foo { color: red; }\n</style>\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let category_at = |needle: &str| {
            let position = content.find(needle).unwrap();
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category)
        };

        assert_eq!(
            category_at("const"),
            Some(HighlightCategory::Keyword),
            "script block without lang must be parsed as JavaScript"
        );
        assert_eq!(category_at("42"), Some(HighlightCategory::Number));
        assert_eq!(
            category_at("color"),
            Some(HighlightCategory::Type),
            "style block without lang must be parsed as CSS"
        );
        // Delimiter lines stay host-styled (tag names).
        assert_eq!(category_at("script>"), Some(HighlightCategory::Property));
    }

    /// A `lang` attribute dynamically selects the embedded language.
    #[test]
    fn test_vue_lang_attribute_selects_language() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("App.vue"), None, &registry);
        let content = "<style lang=\"scss\">\n$width: 10px;\n.a { width: $width; }\n</style>\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let position = content.find("$width").unwrap();
        assert_eq!(
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category),
            Some(HighlightCategory::Variable),
            "lang=\"scss\" must switch the style block to the SCSS grammar \
             ($variables are not CSS syntax)"
        );
    }

    /// A `lang` naming a language with no grammar in the set falls back
    /// to the region's default language rather than dropping
    /// highlighting entirely.
    #[test]
    fn test_vue_unresolvable_lang_falls_back_to_default() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("App.vue"), None, &registry);
        let content = "<script setup lang=\"mysterylang\">\nconst answer = 42;\n</script>\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let position = content.find("const").unwrap();
        assert_eq!(
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category),
            Some(HighlightCategory::Keyword),
            "unresolvable lang token must fall back to the spec default (js)"
        );
    }

    /// `lang="ts"` resolves to the bundled TypeScript grammar — the
    /// dominant real-world Vue pattern gets TS-aware highlighting
    /// (`interface` is not a keyword to the JS default it previously
    /// fell back to).
    #[test]
    fn test_vue_lang_ts_uses_typescript_grammar() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("App.vue"), None, &registry);
        let content =
            "<script setup lang=\"ts\">\ninterface Greeting {\n  message: string;\n}\n</script>\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let category_at = |needle: &str| {
            let position = content.find(needle).unwrap();
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category)
        };
        assert_eq!(
            category_at("interface"),
            Some(HighlightCategory::Keyword),
            "TS-only keyword must be styled by the TypeScript grammar"
        );
        assert_eq!(
            category_at("string"),
            Some(HighlightCategory::Type),
            "TS primitive type must be styled by the TypeScript grammar"
        );
    }

    /// Markdown ```ts fences resolve to the same TypeScript grammar.
    #[test]
    fn test_markdown_ts_fence_uses_typescript_grammar() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
        let content = "```ts\ntype Answer = number;\n```\n";
        let buffer = Buffer::from_str(content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
        let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);

        let position = content.find("type").unwrap();
        assert_eq!(
            spans
                .iter()
                .find(|span| span.range.start <= position && position < span.range.end)
                .and_then(|span| span.category),
            Some(HighlightCategory::Keyword),
            "```ts fences must be parsed with the TypeScript grammar"
        );
    }

    #[test]
    fn test_tree_sitter_direct() {
        // Verify a tree-sitter highlighter can be created directly for a
        // bundled grammar (TypeScript — most grammars were dropped and are now
        // highlighted by syntect instead).
        let highlighter = Highlighter::new(Language::TypeScript);
        assert!(highlighter.is_ok());
    }

    #[test]
    fn test_unknown_extension() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        // Unknown extension
        let engine = HighlightEngine::for_file(Path::new("test.unknown_xyz_123"), None, &registry);
        // Might be none or might find something via syntect
        // Just verify it doesn't panic
        let _ = engine.backend_name();
    }

    #[test]
    fn test_highlight_viewport_empty_buffer_no_panic() {
        // Regression test: calling highlight_viewport with an empty buffer
        // and non-zero viewport range previously caused subtraction overflow panic.
        //
        // The bug occurred when:
        // - buffer is empty (len = 0)
        // - viewport_start > context_bytes (so parse_start > 0 after saturating_sub)
        // - parse_end = min(viewport_end + context_bytes, buffer.len()) = 0
        // - parse_end - parse_start would underflow (0 - positive = overflow)
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);

        // Create empty buffer
        let buffer = Buffer::from_str("", 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        // Test the specific case that triggered the overflow:
        // viewport_start=100, context_bytes=10 => parse_start=90, parse_end=0
        // 0 - 90 = overflow!
        if let HighlightEngine::TextMate(ref mut tm) = engine {
            // Small context_bytes so parse_start remains > 0
            let spans = tm.highlight_viewport(&buffer, 100, 200, &theme, 10);
            assert!(spans.is_empty());
        }
    }

    /// Test that TextMateEngine produces correct byte offsets for CRLF content.
    /// This is a regression test for a bug where using str::lines() caused 1-byte
    /// offset drift per line because it strips line terminators.
    #[test]
    fn test_textmate_engine_crlf_byte_offsets() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        let mut engine = HighlightEngine::for_file(Path::new("test.java"), None, &registry);

        // Create CRLF content with keywords on each line
        // Each "public" keyword should be highlighted at byte positions:
        // Line 1: "public" at bytes 0-5
        // Line 2: "public" at bytes 8-13 (after "public\r\n" = 8 bytes)
        // Line 3: "public" at bytes 16-21 (after two "public\r\n" = 16 bytes)
        let content = b"public\r\npublic\r\npublic\r\n";
        let buffer = Buffer::from_bytes(content.to_vec(), test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        if let HighlightEngine::TextMate(ref mut tm) = engine {
            // Highlight the entire content
            let spans = tm.highlight_viewport(&buffer, 0, content.len(), &theme, 0);

            // Find spans that cover keyword positions
            // The keyword "public" should have spans at these byte ranges:
            // Line 1: 0..6
            // Line 2: 8..14 (NOT 7..13 which would be the buggy offset)
            // Line 3: 16..22 (NOT 14..20 which would be the buggy offset)

            eprintln!(
                "Spans: {:?}",
                spans.iter().map(|s| &s.range).collect::<Vec<_>>()
            );

            // Check that we have spans covering the correct positions
            let has_span_at = |start: usize, end: usize| -> bool {
                spans
                    .iter()
                    .any(|s| s.range.start <= start && s.range.end >= end)
            };

            // Line 1: "public" at bytes 0-6
            assert!(
                has_span_at(0, 6),
                "Should have span covering bytes 0-6 (line 1 'public'). Spans: {:?}",
                spans.iter().map(|s| &s.range).collect::<Vec<_>>()
            );

            // Line 2: "public" at bytes 8-14 (after "public\r\n")
            // If buggy, would be at 7-13
            assert!(
                has_span_at(8, 14),
                "Should have span covering bytes 8-14 (line 2 'public'). \
                 If this fails, CRLF offset drift is occurring. Spans: {:?}",
                spans.iter().map(|s| &s.range).collect::<Vec<_>>()
            );

            // Line 3: "public" at bytes 16-22 (after two "public\r\n")
            // If buggy, would be at 14-20
            assert!(
                has_span_at(16, 22),
                "Should have span covering bytes 16-22 (line 3 'public'). \
                 If this fails, CRLF offset drift is occurring. Spans: {:?}",
                spans.iter().map(|s| &s.range).collect::<Vec<_>>()
            );
        } else {
            panic!("Expected TextMate engine for .java file");
        }
    }

    /// A buffer parsed to its end with no trailing newline (the
    /// streaming case for `git show` output between writes) DOES commit
    /// its trailing partial line to the cache.
    ///
    /// The bytes are final unless the buffer grows, and both
    /// cache-consuming paths in `highlight_viewport` require
    /// `last_buffer_len == buffer.len()` and no pending edit — so a
    /// grown buffer can never resume from this state. That length gate,
    /// not the commit boundary, is what prevents the streaming artefact;
    /// `test_streaming_partial_diff_line_keeps_bg_when_rest_arrives`
    /// pins the rendered outcome.
    ///
    /// Committing here is what lets a file that is one huge line with no
    /// trailing newline (a minified JSON one-liner, fresh#2838) be
    /// parsed once instead of re-parsed on every frame.
    #[test]
    fn test_partial_trailing_line_committed_when_parse_reaches_eof() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("commit.diff"), None, &registry);
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();

        // A complete `+` line followed by a partial `+` line (no \n).
        let content = "+complete\n+partial";
        let buffer = Buffer::from_str(content, 0, test_fs());

        if let HighlightEngine::TextMate(ref mut tm) = engine {
            let _ = tm.highlight_viewport(&buffer, 0, content.len(), &theme, 0);
            let (cache_end, has_tail) = tm.cache_commit_for_test();
            assert_eq!(
                cache_end,
                content.len(),
                "a parse that reached the buffer end should commit the whole \
                 range, so the next frame is a cache hit rather than a re-parse"
            );
            assert!(
                has_tail,
                "tail state should be saved at the commit boundary"
            );
        }
    }

    /// Behavioural guard for the streaming case: when more bytes of a
    /// partially-streamed `+` line arrive, every byte of the completed
    /// line must still carry the diff-add background.
    ///
    /// This is the artefact the "never commit past the last newline"
    /// rule exists to prevent (see `extend_cache_forward`): with
    /// syntect's line-anchored `Diff` grammar, resuming from a state
    /// captured mid-line parses the continuation in `source.diff` with
    /// no scope, leaving a bg-less dark bar inside an otherwise green
    /// line.  Unlike `test_partial_trailing_line_not_committed_to_cache`
    /// (which pins where the cache commits), this asserts the rendered
    /// outcome, so it stays valid if the commit strategy changes.
    #[test]
    fn test_streaming_partial_diff_line_keeps_bg_when_rest_arrives() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("commit.diff"), None, &registry);
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();

        // Refresh 1: the second `+` line has only partially arrived.
        let first = "+complete\n+partial";
        let buf1 = Buffer::from_str(first, 0, test_fs());
        if let HighlightEngine::TextMate(ref mut tm) = engine {
            let _ = tm.highlight_viewport(&buf1, 0, first.len(), &theme, 0);
        }

        // Refresh 2: the rest of that same line (and its newline) arrived.
        let second = "+complete\n+partial line continues to here\n";
        let buf2 = Buffer::from_str(second, 0, test_fs());
        if let HighlightEngine::TextMate(ref mut tm) = engine {
            let spans = tm.highlight_viewport(&buf2, 0, second.len(), &theme, 0);
            let line_start = "+complete\n".len();
            let line_end = second.len() - 1; // exclude the trailing newline
            for byte_pos in line_start..line_end {
                let span = spans
                    .iter()
                    .find(|s| s.range.start <= byte_pos && s.range.end > byte_pos);
                assert_eq!(
                    span.and_then(|s| s.bg),
                    Some(theme.diff_add_bg),
                    "byte {} (`{}`) of the completed `+` line lost its diff bg — \
                     the streaming continuation was parsed from a mid-line state",
                    byte_pos,
                    second[byte_pos..byte_pos + 1].escape_debug(),
                );
            }
        }
    }

    /// Reproduce: artifacts inside `+` lines whose content contains
    /// JS template literals — `\`...\`` with `${}` interpolation.
    /// The whole `+` line should be one contiguous Inserted span
    /// carrying `theme.diff_add_bg`, with no bg-less holes mid-line.
    #[test]
    fn test_diff_inserted_line_is_fully_covered() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("commit.diff"), None, &registry);
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();

        let content =
            "diff --git a/file.ts b/file.ts\n\
             index aaa..bbb 100644\n\
             --- a/file.ts\n\
             +++ b/file.ts\n\
             @@ -1,3 +1,5 @@\n\
             +${seen[g.subtree] > 1 ? `**Seen ${seen[g.subtree]}× — likely cross-subtree type seam.**` : \"\"}\n\
             +              const k = `${b.fn}::${(b.what || \"\").slice(0, 80)}`;\n";
        let buffer = Buffer::from_str(content, 0, test_fs());

        if let HighlightEngine::TextMate(ref mut tm) = engine {
            let spans = tm.highlight_viewport(&buffer, 0, content.len(), &theme, 0);

            let bytes = content.as_bytes();
            let mut line_start = 0;
            while line_start < bytes.len() {
                let mut line_end = line_start;
                while line_end < bytes.len() && bytes[line_end] != b'\n' {
                    line_end += 1;
                }
                if bytes[line_start] == b'+' && !content[line_start..line_end].starts_with("+++") {
                    for byte_pos in line_start..line_end {
                        let span = spans
                            .iter()
                            .find(|s| s.range.start <= byte_pos && s.range.end > byte_pos);
                        let bg = span.and_then(|s| s.bg);
                        assert_eq!(
                            bg,
                            Some(theme.diff_add_bg),
                            "byte {} (`{}`) of `+` line starting at {} should carry diff_add_bg; \
                             got span={:?}",
                            byte_pos,
                            content[byte_pos..byte_pos + 1].escape_debug(),
                            line_start,
                            span,
                        );
                    }
                }
                line_start = line_end + 1;
            }
        } else {
            panic!("Expected TextMate engine for .diff file");
        }
    }

    #[test]
    fn test_git_rebase_todo_highlighting() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        // git-rebase-todo files should use the Git Rebase Todo grammar
        let engine = HighlightEngine::for_file(Path::new("git-rebase-todo"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());
    }

    #[test]
    fn test_git_commit_message_highlighting() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        // COMMIT_EDITMSG should use the Git Commit Message grammar
        let engine = HighlightEngine::for_file(Path::new("COMMIT_EDITMSG"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());

        // MERGE_MSG should also work
        let engine = HighlightEngine::for_file(Path::new("MERGE_MSG"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());
    }

    #[test]
    fn test_gitignore_highlighting() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        // .gitignore should use the Gitignore grammar
        let engine = HighlightEngine::for_file(Path::new(".gitignore"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());

        // .dockerignore should also work
        let engine = HighlightEngine::for_file(Path::new(".dockerignore"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());
    }

    #[test]
    fn test_gitconfig_highlighting() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        // .gitconfig should use the Git Config grammar
        let engine = HighlightEngine::for_file(Path::new(".gitconfig"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());

        // .gitmodules should also work
        let engine = HighlightEngine::for_file(Path::new(".gitmodules"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());
    }

    #[test]
    fn test_gitattributes_highlighting() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());

        // .gitattributes should use the Git Attributes grammar
        let engine = HighlightEngine::for_file(Path::new(".gitattributes"), None, &registry);
        assert_eq!(engine.backend_name(), "textmate");
        assert!(engine.has_highlighting());
    }

    #[test]
    fn test_comment_delimiter_uses_comment_color() {
        // Comment delimiters (#, //, /*) should use comment color, not operator
        assert_eq!(
            scope_to_category("punctuation.definition.comment"),
            Some(HighlightCategory::Comment)
        );
        assert_eq!(
            scope_to_category("punctuation.definition.comment.python"),
            Some(HighlightCategory::Comment)
        );
        assert_eq!(
            scope_to_category("punctuation.definition.comment.begin"),
            Some(HighlightCategory::Comment)
        );
    }

    #[test]
    fn test_variable_builtin_category() {
        assert_eq!(
            scope_to_category("variable.language.this"),
            Some(HighlightCategory::VariableBuiltin)
        );
        assert_eq!(
            scope_to_category("variable.language.super"),
            Some(HighlightCategory::VariableBuiltin)
        );
    }

    #[test]
    fn test_string_delimiter_uses_string_color() {
        // String delimiters (", ', `) should use string color, not operator
        assert_eq!(
            scope_to_category("punctuation.definition.string.begin"),
            Some(HighlightCategory::String)
        );
        assert_eq!(
            scope_to_category("punctuation.definition.string.end"),
            Some(HighlightCategory::String)
        );
    }

    #[test]
    fn test_punctuation_bracket() {
        // punctuation.section (TextMate standard for block delimiters)
        assert_eq!(
            scope_to_category("punctuation.section"),
            Some(HighlightCategory::PunctuationBracket)
        );
        assert_eq!(
            scope_to_category("punctuation.section.block.begin.c"),
            Some(HighlightCategory::PunctuationBracket)
        );
        assert_eq!(
            scope_to_category("punctuation.bracket"),
            Some(HighlightCategory::PunctuationBracket)
        );
        // punctuation.definition.* bracket-like scopes from sublime-syntax grammars
        assert_eq!(
            scope_to_category("punctuation.definition.array.begin.toml"),
            Some(HighlightCategory::PunctuationBracket)
        );
        assert_eq!(
            scope_to_category("punctuation.definition.block.code.typst"),
            Some(HighlightCategory::PunctuationBracket)
        );
        assert_eq!(
            scope_to_category("punctuation.definition.group.typst"),
            Some(HighlightCategory::PunctuationBracket)
        );
        assert_eq!(
            scope_to_category("punctuation.definition.inline-table.begin.toml"),
            Some(HighlightCategory::PunctuationBracket)
        );
        assert_eq!(
            scope_to_category("punctuation.definition.tag.end.svelte"),
            Some(HighlightCategory::PunctuationBracket)
        );
    }

    #[test]
    fn test_punctuation_delimiter() {
        assert_eq!(
            scope_to_category("punctuation.separator"),
            Some(HighlightCategory::PunctuationDelimiter)
        );
        assert_eq!(
            scope_to_category("punctuation.terminator.statement.c"),
            Some(HighlightCategory::PunctuationDelimiter)
        );
        assert_eq!(
            scope_to_category("punctuation.accessor"),
            Some(HighlightCategory::PunctuationDelimiter)
        );
    }

    #[test]
    fn test_storage_type_keyword() {
        assert_eq!(
            scope_to_category("storage.type"),
            Some(HighlightCategory::Keyword)
        );
        assert_eq!(
            scope_to_category("storage.type.class"),
            Some(HighlightCategory::Keyword)
        );
        assert_ne!(
            scope_to_category("storage.type"),
            Some(HighlightCategory::Type)
        );
    }

    /// First parse of a small file populates a whole-file cache; subsequent
    /// scrolls anywhere in the file are exact cache hits with no extra parse
    /// work.
    #[test]
    fn test_small_file_scroll_is_cache_hit() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);

        let mut content = String::new();
        for i in 0..200 {
            content.push_str(&format!("fn f_{i}() {{ let x = {i}; }}\n"));
        }
        let buffer = Buffer::from_str(&content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let HighlightEngine::TextMate(ref mut tm) = engine else {
            panic!("expected TextMate engine for .rs");
        };

        // First call: cold start, full parse.
        let _ = tm.highlight_viewport(&buffer, 0, 200, &theme, 10_000);
        let stats_after_first = tm.stats().clone();
        assert_eq!(
            stats_after_first.cache_hits, 0,
            "first call cannot hit cache"
        );
        assert_eq!(
            stats_after_first.cache_misses, 1,
            "first call must be a miss"
        );

        // Scroll anywhere — top, middle, end. All must be cache hits.
        let mid = buffer.len() / 2;
        let near_end = buffer.len().saturating_sub(200);
        let probes = [(0, 200), (mid, mid + 200), (near_end, buffer.len())];
        for (vs, ve) in probes {
            let _ = tm.highlight_viewport(&buffer, vs, ve, &theme, 10_000);
        }

        let stats_after_scroll = tm.stats().clone();
        assert_eq!(
            stats_after_scroll.cache_misses,
            1,
            "scrolling must not add cache misses (got extra: {})",
            stats_after_scroll.cache_misses - 1
        );
        assert_eq!(
            stats_after_scroll.cache_hits, 3,
            "all three scroll probes must hit the cache"
        );
        assert_eq!(
            stats_after_scroll.bytes_parsed, stats_after_first.bytes_parsed,
            "scrolling must not parse any new bytes"
        );
    }

    /// After a small edit, the next render takes the partial-update path
    /// (convergence) and continues to serve cache hits afterwards. Crucially:
    /// the partial update parses far fewer bytes than the file is long.
    #[test]
    fn test_small_file_edit_uses_partial_update() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);

        let mut content = String::new();
        for i in 0..200 {
            content.push_str(&format!("fn f_{i}() {{ let x = {i}; }}\n"));
        }
        let buffer = Buffer::from_str(&content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let HighlightEngine::TextMate(ref mut tm) = engine else {
            panic!("expected TextMate engine for .rs");
        };

        // Warm cache.
        let _ = tm.highlight_viewport(&buffer, 0, 100, &theme, 10_000);
        let bytes_before_edit = tm.stats().bytes_parsed;
        let buf_len = buffer.len();
        assert!(
            buf_len > 4000,
            "test needs a buffer larger than the partial-update region"
        );

        // Simulate an edit deep in the file.
        let edit_pos = buf_len / 2;
        tm.notify_insert(edit_pos, 1);
        // The buffer itself doesn't change here (we test the engine in isolation),
        // but notify_insert sets dirty_from and shifts spans, which is what the
        // partial-update path consumes.

        let _ = tm.highlight_viewport(&buffer, 0, 100, &theme, 10_000);
        let bytes_after_edit = tm.stats().bytes_parsed;
        let parsed = bytes_after_edit - bytes_before_edit;

        assert!(
            parsed < buf_len,
            "edit must not trigger a whole-file reparse (parsed {parsed}, file {buf_len})"
        );
    }

    /// Bulk edits (multi-cursor typing, "select word + type letter" replace,
    /// search-replace, etc.) must take the same partial-update path as single
    /// edits. Regression for #1958: the previous code called `invalidate_all()`
    /// after a bulk edit, wiping every checkpoint and forcing a cold reparse
    /// from byte zero on the next keystroke.
    #[test]
    fn test_bulk_edit_uses_partial_update() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);

        let mut content = String::new();
        for i in 0..200 {
            content.push_str(&format!("fn f_{i}() {{ let x = {i}; }}\n"));
        }
        let buffer = Buffer::from_str(&content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        // Warm cache.
        let _ = engine.highlight_viewport(&buffer, 0, 100, &theme, 10_000);
        let bytes_before_edit = match &engine {
            HighlightEngine::TextMate(h) => h.stats().bytes_parsed,
            _ => panic!("expected TextMate engine for .rs"),
        };
        let buf_len = buffer.len();
        assert!(
            buf_len > 4000,
            "test needs a buffer larger than the partial-update region"
        );

        // Simulate "select a word, type a letter" deep in the file: a single
        // bulk edit that deletes 8 bytes and inserts 1 byte at the same
        // position. This is exactly the user-facing scenario in #1958.
        let edit_pos = buf_len / 2;
        let edits = vec![(edit_pos, 8usize, 1usize)];
        engine.notify_edits(&edits);

        let _ = engine.highlight_viewport(&buffer, 0, 100, &theme, 10_000);
        let bytes_after_edit = match &engine {
            HighlightEngine::TextMate(h) => h.stats().bytes_parsed,
            _ => unreachable!(),
        };
        let parsed = bytes_after_edit - bytes_before_edit;

        assert!(
            parsed < buf_len,
            "bulk edit must not trigger a whole-file reparse \
             (parsed {parsed}, file {buf_len})"
        );
    }

    /// Bulk edits whose positions are all outside the cached viewport must
    /// not invalidate the cache at all on the tree-sitter / `Highlighter`
    /// path. (TextMate has a richer convergence model, but for both engines
    /// the regression to guard against is: "any bulk edit, even a tiny one,
    /// destroys the cache and forces a full reparse.")
    #[test]
    fn test_bulk_edit_outside_cache_keeps_textmate_partial_update() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);

        let mut content = String::new();
        for i in 0..400 {
            content.push_str(&format!("fn f_{i}() {{ let x = {i}; }}\n"));
        }
        let buffer = Buffer::from_str(&content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        // Warm a viewport near the start.
        let _ = engine.highlight_viewport(&buffer, 0, 200, &theme, 1_000);
        let bytes_before = match &engine {
            HighlightEngine::TextMate(h) => h.stats().bytes_parsed,
            _ => panic!("expected TextMate engine for .rs"),
        };

        // Apply a bulk edit far past the warmed viewport.
        let far_pos = buffer.len() - 100;
        engine.notify_edits(&[(far_pos, 3, 1)]);

        // Re-render the original viewport. The partial-update path must keep
        // parsed bytes well below a whole-file reparse.
        let _ = engine.highlight_viewport(&buffer, 0, 200, &theme, 1_000);
        let bytes_after = match &engine {
            HighlightEngine::TextMate(h) => h.stats().bytes_parsed,
            _ => unreachable!(),
        };
        let parsed = bytes_after - bytes_before;
        let buf_len = buffer.len();
        assert!(
            parsed < buf_len,
            "bulk edit outside the viewport must not force a whole-file \
             reparse (parsed {parsed}, file {buf_len})"
        );
    }

    /// Convergence budget caps per-pass work even when the parse state never
    /// agrees with any existing checkpoint. Without the cap, a non-converging
    /// edit would parse the rest of the file on every keystroke.
    #[test]
    fn test_partial_update_budget_caps_work() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);

        // Build a buffer comfortably larger than CONVERGENCE_BUDGET.
        let mut content = String::new();
        while content.len() < (CONVERGENCE_BUDGET * 4) {
            content.push_str("fn name() { let mut v = 0; v += 1; }\n");
        }
        let buffer = Buffer::from_str(&content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let HighlightEngine::TextMate(ref mut tm) = engine else {
            panic!("expected TextMate engine for .rs");
        };

        // Warm cache (whole-file parse).
        let _ = tm.highlight_viewport(&buffer, 0, 200, &theme, 10_000);
        // Simulate an edit and force every checkpoint to disagree by clearing
        // their stored states. The convergence loop will look at each marker,
        // find the slot empty, and never converge.
        tm.notify_insert(100, 0);
        tm.checkpoint_states.clear();

        let bytes_before = tm.stats().bytes_parsed;
        let _ = tm.highlight_viewport(&buffer, 0, 200, &theme, 10_000);
        let parsed = tm.stats().bytes_parsed - bytes_before;

        // Budget bounds the work to roughly CONVERGENCE_BUDGET past the dirty
        // point (plus the prefix back to the resume checkpoint). Allow a small
        // overshoot for the line that crossed the budget threshold.
        assert!(
            parsed <= CONVERGENCE_BUDGET + 4096,
            "partial update parsed {parsed}, expected <= {} \
             (budget {CONVERGENCE_BUDGET} + slack)",
            CONVERGENCE_BUDGET + 4096
        );

        // Budget hit must leave dirty_from set for follow-up passes.
        assert!(
            tm.dirty_from.is_some(),
            "budget exit must keep dirty_from set"
        );
    }

    /// Large files (above MAX_PARSE_BYTES) keep the existing windowed
    /// behaviour: parse range is bounded by ±context_bytes around the
    /// viewport, not the whole file.
    ///
    /// The viewport is placed past `MAX_PARSE_BYTES` so we exercise the
    /// "large file, no nearby checkpoint" branch in `find_parse_resume_point`
    /// — the symmetric branch that fires when `parse_end <= MAX_PARSE_BYTES`
    /// still parses from byte 0 even on big files (pre-existing behaviour,
    /// addressed in a later phase).
    #[test]
    fn test_large_file_uses_windowed_parse() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, &registry);

        // Build content well past MAX_PARSE_BYTES so we can put the viewport
        // beyond it.
        let line = "fn long_name_for_padding() { let v = 1; v + 1; }\n";
        let bytes_needed = MAX_PARSE_BYTES * 2;
        let lines_needed = bytes_needed / line.len() + 100;
        let mut content = String::with_capacity(lines_needed * line.len());
        for _ in 0..lines_needed {
            content.push_str(line);
        }
        assert!(content.len() > MAX_PARSE_BYTES * 2);
        let buffer = Buffer::from_str(&content, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let HighlightEngine::TextMate(ref mut tm) = engine else {
            panic!("expected TextMate engine for .rs");
        };

        // Viewport past MAX_PARSE_BYTES: parse_end > MAX_PARSE_BYTES, so the
        // resume-from-byte-0 fallback in find_parse_resume_point doesn't fire.
        let context_bytes = 10_000usize;
        let viewport_start = MAX_PARSE_BYTES + 200_000;
        let viewport_end = viewport_start + 1000;
        let _ = tm.highlight_viewport(&buffer, viewport_start, viewport_end, &theme, context_bytes);
        let parsed = tm.stats().bytes_parsed;

        // Windowed parse covers viewport ± context_bytes plus a tiny prefix
        // for the resume anchor. Allow generous slack (4×) but reject
        // anything close to whole-file.
        let window = (viewport_end - viewport_start) + 2 * context_bytes;
        assert!(
            parsed <= window * 4,
            "large file windowed parse should be ~{window} bytes, got {parsed} \
             (file {})",
            buffer.len()
        );
    }

    /// Regression for issue #899: a class field initialised with an arrow
    /// function that returns a template literal must not bleed string
    /// highlighting onto the rest of the class body. The user-reported
    /// repro pinned the syntect JavaScript grammar to a string state from
    /// the trailing `;` until EOF; the constructor keyword, comments, and
    /// the closing `}` were all painted as a string.
    #[test]
    fn test_javascript_template_literal_does_not_bleed() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("repro.js"), None, &registry);

        // Reproduction code from issue #899.
        let source = "class ExampleClass {\n\
                      \texampleFunction = exampleArg => `${exampleArg}`;\n\
                      \n\
                      \tconstructor() {\n\
                      \t\t// constructor body\n\
                      \t}\n\
                      \n\
                      \t/* multiline comment */\n\
                      }\n";
        let buffer = Buffer::from_str(source, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let _ = engine.highlight_viewport(&buffer, 0, source.len(), &theme, 0);

        // The `constructor` keyword sits well after the template literal.
        // If string state bleeds, this position is reported as String.
        let ctor_pos = source.find("constructor").expect("locate constructor");
        let ctor_cat = engine.category_at_position(ctor_pos);
        assert_ne!(
            ctor_cat,
            Some(HighlightCategory::String),
            "constructor keyword must not inherit string state from earlier \
             template literal (got {:?})",
            ctor_cat,
        );

        // The closing brace of the class — the very last non-whitespace char
        // — also lives outside any string in correct JS.
        let last_brace = source.rfind('}').expect("locate closing brace");
        let brace_cat = engine.category_at_position(last_brace);
        assert_ne!(
            brace_cat,
            Some(HighlightCategory::String),
            "closing class brace must not be highlighted as string \
             (got {:?})",
            brace_cat,
        );
    }

    /// The closing `}` of a `${…}` template substitution and the closing
    /// backtick of the surrounding template literal must keep template
    /// string colouring — not inherit the `@variable` highlight from the
    /// substitution's expression. Tree-sitter-highlight emits one
    /// HighlightEnd event per started highlight; if the editor's
    /// span-flattening logic doesn't pop the inner `@variable` correctly
    /// when the substitution closes, the variable colour bleeds across
    /// `}` and the trailing `\`` until the next sibling capture (here,
    /// the `;` operator).
    #[test]
    fn test_javascript_template_substitution_closing_tokens_are_string() {
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
        let mut engine = HighlightEngine::for_file(Path::new("tmpl.js"), None, &registry);

        // Minimal template literal: `${name}` — wrapped in a statement so
        // the parser sees a complete program.
        let source = "const x = `${name}`;\n";
        let buffer = Buffer::from_str(source, 0, test_fs());
        let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();

        let _ = engine.highlight_viewport(&buffer, 0, source.len(), &theme, 0);

        // Locate the closing `}` of the substitution and the closing
        // backtick of the template literal.
        let close_brace = source
            .find("}`")
            .expect("locate substitution closing brace");
        let close_backtick = close_brace + 1;

        // Sanity: the inner identifier `name` is correctly tagged as a
        // variable (this guards us against an unrelated regression where
        // the entire template gets typed wrong).
        let name_pos = source.find("name").expect("locate identifier");
        let name_cat = engine.category_at_position(name_pos);
        assert_eq!(
            name_cat,
            Some(HighlightCategory::Variable),
            "substitution identifier should be Variable (got {:?})",
            name_cat,
        );

        // The closing `}` and `` ` `` live inside the surrounding
        // `template_string` node, so tree-sitter assigns them the
        // `@string` capture. They must surface as String here — not
        // as Variable (the previous symptom of the bleed) and not as
        // None (which would make the editor render them with the
        // default foreground colour, equally wrong).
        let brace_cat = engine.category_at_position(close_brace);
        assert_eq!(
            brace_cat,
            Some(HighlightCategory::String),
            "closing }} of ${{…}} must be String (got {:?})",
            brace_cat,
        );
        let backtick_cat = engine.category_at_position(close_backtick);
        assert_eq!(
            backtick_cat,
            Some(HighlightCategory::String),
            "closing backtick of template literal must be String \
             (got {:?})",
            backtick_cat,
        );
    }
}
