// highlight.rs --- T M4.3 syntax-highlight view + theme.

//! Syntax-highlight view (T M4.3).
//!
//! Glue between [`crate::syntax`] (parse trees + highlight queries)
//! and the [`crate::view::View`] composition stack
//! ([`crate::overlay`]). Reads the active [`Theme`] to translate
//! tree-sitter capture names into [`Style`] values, then writes
//! merged styles into the cells the base [`crate::text_view::TextView`]
//! has already painted.
//!
//! # Lifecycle
//!
//! 1. [`crate::syntax::SyntaxRegistry`] holds the shared
//!    [`ThemeHandle`] and per-language compiled queries.
//! 2. The Lua side ([`crate::lua_bindings`]) creates a
//!    [`SyntaxHighlightView`] when a buffer's grammar is detected
//!    and pushes it onto the active window's overlay stack.
//! 3. Every render: the view checks whether the buffer's
//!    [`crate::syntax::ParseViewHandle`] holds a different parse
//!    tree than last frame (compared by `Arc::ptr_eq`); if so it
//!    re-runs the highlight query, caches the result, and recomputes
//!    a per-line spans index. Render reads from the cached index
//!    to apply styles cell by cell.
//!
//! # Threading
//!
//! [`SyntaxHighlightView`] holds only `Arc<...>`-based state so it
//! satisfies the [`crate::view::View`]'s `Send` bound. In practice
//! everything runs main-thread; the `Send` bound exists because
//! `Box<dyn View>` is held by the buffer / window machinery.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Style, UnderlineStyle};
use crate::display_width::byte_range_to_columns;
use crate::lsp::SharedLspManager;
use crate::overlay::merge_styles;
use crate::syntax::{HighlightSpan, ParseTreeBundle, ParseViewHandle, compute_highlight_spans_for};
use crate::view::{View, Viewport};

// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------

/// Map from tree-sitter capture name to [`Style`] plus a fallback
/// `default_style`.
///
/// Capture names are dotted: `function.method`, `variable.parameter`,
/// `keyword.control.return`. [`Self::lookup`] tries the full name
/// first, then progressively shorter dot-separated prefixes, then
/// falls back to `default_style`. This matches conventional editor
/// theme behavior --- a theme can either be coarse (just `keyword`)
/// or fine-grained (`keyword.control.return`) and the lookup walks
/// the same hierarchy in both cases.
#[derive(Clone, Debug, Default)]
pub struct Theme {
    /// Direct map from capture name → style. T M4.3. Names matching
    /// [`is_face_name`] (`ui` / `ui.*`) are UI faces (themes arc
    /// Q#TH2), reserved by convention — no tree-sitter capture or LSP
    /// token type uses them.
    pub by_capture: HashMap<String, Style>,
    /// Fallback style when no capture matches. Defaults to the
    /// terminal default colors (no override) so unhighlighted text
    /// looks identical to plain rendering.
    pub default_style: Style,
    /// Monotonic syntax-mutation counter (themes arc Q#TH6). Bumped
    /// by every successful Lua mutation that commits a non-face key
    /// (or touches `default_style`); keys the `StyleGate` and the
    /// minimap summary so a mid-session recolor re-ships spans.
    /// INVARIANT: only ever incremented — a wholesale `set` must
    /// replace `by_capture`, never the whole `Theme`, or consecutive
    /// mutations share an epoch and become invisible to every gate.
    pub syntax_epoch: u64,
    /// Monotonic face-mutation counter (themes arc Q#TH6). Bumped by
    /// every successful Lua mutation that commits a face key
    /// ([`is_face_name`]); keys the `ThemeFacts` producer and the
    /// minimap summary (`ui.diag.*` feeds its marks). Same
    /// increment-only invariant as `syntax_epoch`.
    pub face_epoch: u64,
}

/// Themes arc Q#TH2: the face predicate. A theme key names a UI face
/// iff it is exactly `ui` (the deliberate inheritance catch-all —
/// [`Theme::face`]'s walk terminal) or starts with `ui.`. Shared by
/// the namespace reservation, the mutation-counter classification,
/// and the `ThemeFacts` producer's key filter.
#[must_use]
pub fn is_face_name(name: &str) -> bool {
    pmacs_protocol::is_ui_face_name(name)
}

impl Theme {
    /// Empty theme: no captures match anything; the default style
    /// is the terminal default. Useful for tests that want to start
    /// from a clean slate.
    #[must_use]
    pub fn empty() -> Self {
        Self::default()
    }

    /// A small built-in dark theme that picks up the most common
    /// tree-sitter captures **and** the LSP semantic-token type names
    /// clangd / rust-analyzer / gopls actually emit, so opening a code
    /// file produces visible highlighting without a user theme. Uses
    /// 8/16-color indexed terminal colors for portability — truecolor
    /// themes can be pushed in from Lua.
    ///
    /// Themes that want more granularity layer on top via
    /// [`Self::insert`] / Lua's `pmacs.theme.set`. The dotted-prefix
    /// [`Self::lookup`] means a modifier-refined name like
    /// `function.defaultLibrary` falls back to `function` if not
    /// defined — safe to query unconditionally.
    #[must_use]
    pub fn default_dark() -> Self {
        // Indexed terminal palette: 1 red, 2 green, 3 yellow, 4 blue,
        // 5 magenta, 6 cyan, 8 bright black/gray; bright variants 9..14.
        let fg = |c: u8| Style {
            fg: Color::Indexed(c),
            ..Style::default()
        };
        let fg_bold = |c: u8| Style {
            fg: Color::Indexed(c),
            bold: true,
            ..Style::default()
        };
        let fg_italic = |c: u8| Style {
            fg: Color::Indexed(c),
            italic: true,
            ..Style::default()
        };
        let italic_only = Style {
            italic: true,
            ..Style::default()
        };

        // (capture name, style). LSP semantic-token type names are the
        // unprefixed entries (`macro`, `namespace`, `parameter`, …);
        // tree-sitter captures are the dotted ones (`keyword.control`,
        // `function.method`, `type.builtin`, `constant.builtin`).
        let entries: &[(&str, Style)] = &[
            ("keyword", fg_bold(5)),
            ("keyword.control", fg_bold(13)),
            ("function", fg(4)),
            ("function.method", fg(12)),
            ("type", fg(3)),
            ("type.builtin", fg(11)),
            ("string", fg(2)),
            ("comment", fg_italic(8)),
            ("constant", fg(1)),
            ("constant.builtin", fg(9)),
            ("number", fg(1)),
            ("operator", fg(6)),
            ("variable", Style::default()),
            ("punctuation", Style::default()),
            // LSP additions (no tree-sitter overlap).
            ("macro", fg_bold(13)),
            ("namespace", fg(11)),
            ("parameter", italic_only),
            ("property", fg(6)),
            ("class", fg(3)),
            ("struct", fg(3)),
            ("enum", fg(3)),
            ("interface", fg(3)),
            ("enumMember", fg(1)),
            ("modifier", fg_bold(5)),
            ("decorator", fg(13)),
            ("regexp", fg(2)),
            ("typeParameter", fg_italic(11)),
            // Web grammars (HTML/CSS, framing Q#WEB4): the only captures their
            // crate-exported queries use that the set above lacks. `@tag.error`
            // prefix-walks to `tag`.
            ("tag", fg(5)),
            ("attribute", fg(3)),
            // Lean 4 (framing Q#LN4). These four are the captures the Lean
            // query uses that the set above lacks — but three of them are
            // NOT Lean-only, and adding them here changes languages that
            // already ship. That is the #146 lesson (`attribute`, above,
            // retro-painted rust/lua/yaml) and it is deliberate, not
            // incidental:
            //
            //   * `constructor` reaches SEVEN entries — rust, lua, python,
            //     javascript, and (because `tree_sitter_javascript::
            //     HIGHLIGHT_QUERY` is concatenated base-first into them)
            //     javascriptreact, typescript, typescriptreact. Its shape is
            //     not "constructors": rust/python/javascript tag every
            //     capitalized identifier (`#match? "^[A-Z]"`), and lua tags
            //     every table-constructor brace. So this recolors `Some`,
            //     `None`, `Ok`, `Err`, every class-cased name, and every Lua
            //     `{}`. All of those render as unstyled default text today.
            //   * `character` reaches zig only.
            //   * `keyword.conditional` reaches cmake and zig, which
            //     currently flatten it to `keyword`; giving it
            //     `keyword.control`'s style makes their conditionals read the
            //     way rust's already do.
            //   * `warning` reaches no other grammar. It exists for Lean's
            //     `sorry` — an unproved goal, the single most important thing
            //     to see in a proof file.
            //
            // The alternative was an in-repo query overlay renaming these
            // into the existing vocabulary (the #144 LaTeX pattern), which
            // would fork a 213-line query we would then own and hand-merge
            // on every crate bump. There is no middle option: styling Lean's
            // constructors without touching the other seven entries requires
            // renaming the capture, which requires the overlay.
            ("constructor", fg(11)),
            ("character", fg(2)),
            ("keyword.conditional", fg_bold(13)),
            // Bold BRIGHT red, deliberately the loudest entry in the table
            // and deliberately distinct from `number`'s plain `fg(1)`: in a
            // proof file `sorry` means "this is admitted, not proved", which
            // is the one thing a reader must never skim past. Plain `fg(1)`
            // would have collided with every numeric literal on colour alone.
            ("warning", fg_bold(9)),
        ];
        let by_capture = entries
            .iter()
            .map(|(name, style)| ((*name).to_owned(), *style))
            .collect();
        Self {
            by_capture,
            default_style: Style::default(),
            syntax_epoch: 0,
            face_epoch: 0,
        }
    }

    /// Resolve a capture name to a [`Style`]. Tries the full name,
    /// then strips one `.`-separated segment at a time, then falls
    /// back to `default_style`.
    #[must_use]
    pub fn lookup(&self, capture_name: &str) -> Style {
        let mut name = capture_name;
        loop {
            if let Some(s) = self.by_capture.get(name) {
                return *s;
            }
            match name.rfind('.') {
                Some(idx) => name = &name[..idx],
                None => return self.default_style,
            }
        }
    }

    /// Resolve a UI face name to its style, or `None` when unset
    /// (themes arc Q#TH4). Same dotted-prefix walk as [`Self::lookup`]
    /// — so `ui.search.match.active` falls back to `ui.search.match`,
    /// the `ui.diag.*` children to `ui.diag`, and everything to the
    /// bare-`ui` catch-all — but the walk returns `None` instead of
    /// falling back to `default_style`: an unset face must leave the
    /// paint site's hardcoded default untouched, and a user's
    /// `pmacs.theme.default` (a *syntax* fallback) must never bleed
    /// into chrome. An exact entry stops the walk, so an explicitly
    /// empty child (e.g. `ui.diag.error = {}`) blocks inheritance
    /// from a themed parent. Callers pass full face names only.
    #[must_use]
    pub fn face(&self, name: &str) -> Option<Style> {
        debug_assert!(is_face_name(name), "face() takes ui/ui.* names");
        let mut name = name;
        loop {
            if let Some(s) = self.by_capture.get(name) {
                return Some(*s);
            }
            match name.rfind('.') {
                Some(idx) => name = &name[..idx],
                None => return None,
            }
        }
    }

    /// Resolve a custom modeline segment face relative to the already
    /// resolved `ui.modeline` surface (statusline framing Q#SL6).
    ///
    /// Only a concrete foreground from the exact child or an intermediate
    /// child is returned. The walk stops before `ui.modeline`: reaching the
    /// base means the segment keeps the base modeline's effective text
    /// color. An explicitly default foreground also stops inheritance and
    /// returns to that base. Out-of-mask style components are discarded.
    #[must_use]
    pub fn modeline_segment_face(&self, name: &str) -> Option<Style> {
        debug_assert!(
            pmacs_protocol::is_modeline_face_name(name),
            "modeline_segment_face() takes ui.modeline/ui.modeline.* names"
        );
        let mut name = name;
        while name != "ui.modeline" {
            if let Some(style) = self.by_capture.get(name) {
                return match style.fg {
                    Color::Default => None,
                    fg => Some(Style {
                        fg,
                        ..Style::default()
                    }),
                };
            }
            let index = name.rfind('.')?;
            name = &name[..index];
        }
        None
    }

    /// Set the style for one capture name, replacing any prior entry.
    pub fn insert(&mut self, capture_name: impl Into<String>, style: Style) {
        self.by_capture.insert(capture_name.into(), style);
    }

    /// Forget every capture-style entry. The `default_style` is
    /// preserved.
    pub fn clear(&mut self) {
        self.by_capture.clear();
    }
}

/// Cheaply-cloneable shared theme handle. Held by the
/// [`crate::syntax::SyntaxRegistry`] and by every attached
/// [`SyntaxHighlightView`]; a Lua-driven theme edit is observable
/// to all attached views on the next render.
pub type ThemeHandle = Arc<Mutex<Theme>>;

// ---------------------------------------------------------------------------
// SyntaxHighlightView
// ---------------------------------------------------------------------------

/// Per-layer cached highlight spans + capture names (framing Q#IJ7).
/// One entry per bundle layer that has a highlight query; kept in the
/// bundle's depth order so the render paints shallow-to-deep.
struct LayerSpans {
    /// Spans for this layer, sorted wider-first per
    /// [`compute_highlight_spans_for`].
    spans: Vec<HighlightSpan>,
    /// Capture names indexed by `HighlightSpan::capture_index` for this
    /// layer's own query. Layers may use different grammars, so this is
    /// per-layer, not shared.
    capture_names: Arc<[String]>,
}

/// Cached per-bundle highlight state. Keyed by the underlying
/// `Arc<ParseTreeBundle>`'s identity (compared via `Arc::ptr_eq`),
/// so a freshly-installed bundle invalidates the cache.
struct HighlightCache {
    /// The bundle the cache was built against. `None` until the
    /// first render observes a settled bundle.
    bundle: Option<Arc<ParseTreeBundle>>,
    /// Per-layer spans in depth-ascending order (framing Q#IJ6).
    layers: Vec<LayerSpans>,
    /// Per-row first-byte offsets into `bundle.source`. `Vec<u32>`
    /// because pmacs files cap at 4 GiB.
    line_offsets: Vec<u32>,
}

impl HighlightCache {
    fn empty() -> Self {
        Self {
            bundle: None,
            layers: Vec::new(),
            line_offsets: Vec::new(),
        }
    }
}

/// View that renders syntax highlighting from a tree-sitter parse
/// tree, including its injection layers. Composes over
/// [`crate::text_view::TextView`] per the M2.9 view-composition
/// contract --- it never writes glyphs, only merges styles into cells
/// the base view has already painted.
pub struct SyntaxHighlightView {
    parse: ParseViewHandle,
    theme: ThemeHandle,
    cache: HighlightCache,
}

impl SyntaxHighlightView {
    /// Construct a highlight view over `parse` using `theme`. Per-layer
    /// highlight queries come from each `Layer::highlight_query` in the
    /// bundle (resolved at settle, framing Q#IJ2), so no query is passed
    /// here. The initial cache is empty; the first render with a settled
    /// bundle populates it.
    #[must_use]
    pub fn new(parse: ParseViewHandle, theme: ThemeHandle) -> Self {
        Self {
            parse,
            theme,
            cache: HighlightCache::empty(),
        }
    }

    /// Test helper: total cached highlight spans across all layers.
    #[must_use]
    pub fn cached_span_count(&self) -> usize {
        self.cache.layers.iter().map(|l| l.spans.len()).sum()
    }

    /// Refresh `self.cache` if the parse view's current bundle
    /// differs from the cached one. No-op when the bundle pointer
    /// is unchanged --- the steady-state cost between parses. Rebuilds
    /// spans for every layer that carries a highlight query.
    fn refresh_cache_if_stale(&mut self) {
        let Some(bundle) = self.parse.current() else {
            return;
        };
        let stale = self
            .cache
            .bundle
            .as_ref()
            .is_none_or(|prev| !Arc::ptr_eq(prev, &bundle));
        if !stale {
            return;
        }
        let source = bundle.source.as_ref();
        let mut layers = Vec::new();
        for layer in &bundle.layers {
            let Some(query) = layer.highlight_query.as_ref() else {
                continue;
            };
            let spans = compute_highlight_spans_for(
                query,
                &layer.tree,
                source,
                layer.local_facts.as_deref(),
                None,
            );
            if spans.is_empty() {
                continue;
            }
            let capture_names: Arc<[String]> = query
                .capture_names()
                .iter()
                .map(|s| (*s).to_owned())
                .collect();
            layers.push(LayerSpans {
                spans,
                capture_names,
            });
        }
        let line_offsets = compute_line_offsets(source);
        self.cache = HighlightCache {
            bundle: Some(bundle),
            layers,
            line_offsets,
        };
    }

    /// Look up the style for span `s` within `layer`, consulting the
    /// active theme.
    fn style_for(theme: &Theme, layer: &LayerSpans, s: HighlightSpan) -> Style {
        let idx = s.capture_index as usize;
        let Some(name) = layer.capture_names.get(idx) else {
            return theme.default_style;
        };
        theme.lookup(name)
    }
}

impl View for SyntaxHighlightView {
    fn kind(&self) -> &'static str {
        "syntax-highlight"
    }

    fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
        self.refresh_cache_if_stale();
        let Some(bundle) = self.cache.bundle.clone() else {
            return;
        };
        if self.cache.layers.is_empty() || self.cache.line_offsets.is_empty() {
            return;
        }
        let source: &[u8] = bundle.source.as_ref();
        let theme = self.theme.lock().expect("theme mutex poisoned").clone();

        let start_line = line_at_offset(&self.cache.line_offsets, viewport.buffer_start as u32);
        let max_rows = viewport.cell_size.rows;
        let cell_origin = viewport.cell_origin;
        let total_lines = self.cache.line_offsets.len() as u32;

        // Paint layers shallow-to-deep (framing Q#IJ6): a deeper layer
        // merges on top, so an injected child's styling wins within its
        // region. Within a layer, wider-first ordering (from
        // `compute_highlight_spans_for`) lets narrower captures override.
        for layer in &self.cache.layers {
            for row_offset in 0..max_rows {
                // Arc 6 Stage 2: row `r` shows the `r`-th VISIBLE line,
                // matching `TextView::render`'s walk (Q#FD13).
                let line_idx =
                    u32::try_from(viewport.line_at_row_offset(start_line as usize, row_offset))
                        .unwrap_or(u32::MAX);
                if line_idx >= total_lines {
                    break;
                }
                let line_start = self.cache.line_offsets[line_idx as usize];
                let line_end = self
                    .cache
                    .line_offsets
                    .get(line_idx as usize + 1)
                    .copied()
                    .unwrap_or(source.len() as u32);
                // Trim a single trailing newline if any --- the text
                // view doesn't paint it as a glyph either.
                let line_end_no_nl = if line_end > line_start
                    && source.get(line_end as usize - 1).copied() == Some(b'\n')
                {
                    line_end - 1
                } else {
                    line_end
                };
                let line_bytes = &source[line_start as usize..line_end_no_nl as usize];

                // Spans whose start lies on this line. Spans that *cross*
                // lines apply on every line they touch --- this loop filters
                // by intersection, then clips to the line range below.
                for span in layer
                    .spans
                    .iter()
                    .filter(|s| s.start_byte < line_end_no_nl && s.end_byte > line_start)
                    .copied()
                {
                    let style = Self::style_for(&theme, layer, span);
                    if style == Style::default() {
                        // Nothing to merge --- skip the per-cell loop.
                        continue;
                    }
                    let s_start = span.start_byte.max(line_start);
                    let s_end = span.end_byte.min(line_end_no_nl);
                    let byte_col_start = (s_start - line_start) as usize;
                    let byte_col_end = (s_end - line_start) as usize;
                    let (start_col, end_col) =
                        byte_range_to_columns(line_bytes, byte_col_start, byte_col_end);
                    if end_col <= start_col {
                        continue;
                    }
                    let cell_row = cell_origin.row + row_offset;
                    let Some((clamped_start, clamped_end)) =
                        viewport.visible_cols(start_col, end_col)
                    else {
                        continue;
                    };
                    for col in clamped_start..clamped_end {
                        let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
                        cell.style = merge_styles(cell.style, style);
                    }
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Byte → row/col helpers
// ---------------------------------------------------------------------------

/// Build a sorted vector of line-start byte offsets for `source`.
/// `out[0] == 0` always; `out.len() == number of lines`. A trailing
/// newline produces one extra empty line, matching
/// [`crate::text_view::TextView`]'s convention.
fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
    let mut out = Vec::with_capacity(source.len() / 32 + 1);
    out.push(0);
    for (i, b) in source.iter().enumerate() {
        if *b == b'\n' {
            out.push(i as u32 + 1);
        }
    }
    out
}

/// Index of the line containing byte `offset`.
fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 {
    match line_offsets.binary_search(&offset) {
        Ok(i) => i as u32,
        Err(i) => i.saturating_sub(1) as u32,
    }
}

// ---------------------------------------------------------------------------
// Style equality helper
// ---------------------------------------------------------------------------

/// True iff `style` is the zero/default style. The style-merge
/// short-circuits in [`SyntaxHighlightView::render`] when this
/// holds, since merging a default style is a no-op.
#[must_use]
pub fn is_default_style(style: Style) -> bool {
    style.fg == Color::Default
        && style.bg == Color::Default
        && !style.bold
        && !style.italic
        && style.underline == UnderlineStyle::None
        && !style.reverse
}

// ---------------------------------------------------------------------------
// LspStyleView
// ---------------------------------------------------------------------------

/// View that paints LSP semantic-token styling into cells, layered
/// **alongside** [`SyntaxHighlightView`] when both exist for a
/// buffer. The grid TUI's `View::render` composition stack means
/// tree-sitter paints first (lexical: keywords, strings, operators,
/// the things a parser identifies from the source's *shape*) and
/// `LspStyleView` paints after (semantic: function vs variable,
/// macro vs constant, the things a language server identifies from
/// program *meaning*). Their styles compose through
/// [`crate::overlay::merge_styles`], so the final cell carries both
/// authorities' contributions — the `VSCode` / `Zed` "`TextMate` +
/// LSP semantic tokens" model, on a terminal grid.
///
/// For languages with no bundled tree-sitter grammar (Python, Go,
/// languages added later via LSP only), `LspStyleView` is the *sole*
/// styling source and still works — the merge with an absent
/// tree-sitter overlay is identity.
///
/// `M_B3` (this) dropped the earlier policy-A exclusivity gate in
/// `lsp.lua` that restricted attachment to grammar-less buffers. The
/// previous gate left grammar-backed languages (Rust, C, C++) without
/// LSP semantic refinement; dual-authority delivers richer coloring
/// without conflict.
///
/// Holds shared handles only (`SharedLspManager` + `ThemeHandle`), so
/// it survives buffer renames and LSP server restarts: every `render`
/// re-derives the buffer's URI from `buf.file_path()` and re-queries
/// the per-server encoding + legend via
/// `LspManager::semantic_style_context`. If any of those are absent
/// the render is a silent no-op — never spam, never panic.
pub struct LspStyleView {
    lsp: SharedLspManager,
    theme: ThemeHandle,
}

impl LspStyleView {
    /// Construct a view that paints LSP semantic-token styling using
    /// `lsp` as the source of truth and `theme` as the capture-name →
    /// style map. The view shares both handles; updates from elsewhere
    /// (a new LSP response, a theme edit) are observable on the next
    /// render.
    #[must_use]
    pub fn new(lsp: SharedLspManager, theme: ThemeHandle) -> Self {
        Self { lsp, theme }
    }
}

impl View for LspStyleView {
    fn kind(&self) -> &'static str {
        "lsp-style"
    }

    fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
        let Some(path) = buf.file_path() else {
            return; // No path ⇒ no URI ⇒ nothing to look up.
        };
        let uri = crate::lsp::path_to_file_uri(path);

        // Pull encoding + legend, and clone the token vec so we can
        // drop both the manager borrow and the store lock before
        // walking the viewport (cheap: tokens are small structs).
        let (ctx, tokens) = {
            let mgr = self.lsp.borrow();
            let Some(ctx) = mgr.semantic_style_context(&uri) else {
                return; // No server has tokens for this URI.
            };
            let store = mgr.semantic_token_store();
            let guard = store.lock().expect("semantic-token store mutex poisoned");
            if guard.is_stale(&uri) {
                return;
            }
            let Some((_, resp)) = guard.for_uri(&uri) else {
                return;
            };
            (ctx, resp.tokens.clone())
        };
        if tokens.is_empty() {
            return;
        }
        let theme = self.theme.lock().expect("theme mutex poisoned").clone();

        // Buffer source bytes (cheap rope slice, mirrors
        // `semantic_render::buffer_source_bytes`).
        let source = {
            let len = buf.len();
            let mut bytes = vec![0u8; len as usize];
            if !bytes.is_empty() {
                buf.snapshot_rope().slice(0, len, &mut bytes);
            }
            bytes
        };
        let line_offsets = compute_line_offsets(&source);
        if line_offsets.is_empty() {
            return;
        }

        let start_line = line_at_offset(&line_offsets, viewport.buffer_start as u32);
        let max_rows = viewport.cell_size.rows;
        let cell_origin = viewport.cell_origin;
        let total_lines = line_offsets.len() as u32;

        for row_offset in 0..max_rows {
            // Arc 6 Stage 2: row `r` shows the `r`-th VISIBLE line.
            let line_idx =
                u32::try_from(viewport.line_at_row_offset(start_line as usize, row_offset))
                    .unwrap_or(u32::MAX);
            if line_idx >= total_lines {
                break;
            }
            let line_start = line_offsets[line_idx as usize];
            let line_end = line_offsets
                .get(line_idx as usize + 1)
                .copied()
                .unwrap_or(source.len() as u32);
            let line_end_no_nl = if line_end > line_start
                && source.get(line_end as usize - 1).copied() == Some(b'\n')
            {
                line_end - 1
            } else {
                line_end
            };
            let line_bytes = &source[line_start as usize..line_end_no_nl as usize];
            let Ok(line_str) = std::str::from_utf8(line_bytes) else {
                continue; // Non-UTF-8 line ⇒ skip encoding conversion.
            };

            // O(tokens × visible_lines) — fine for the typical
            // semantic-token set; if it ever becomes the bottleneck,
            // pre-bucket tokens by line at the top of `render`.
            for t in tokens.iter().filter(|t| t.line == line_idx) {
                let Some(legend) = ctx.legend.as_ref() else {
                    continue; // No legend ⇒ cannot name a style.
                };
                let Some(name) = legend.type_name(t.token_type) else {
                    continue; // Unknown type index.
                };
                // Build the lookup name as `<type>.<first-modifier>`
                // when modifiers are set, else just `<type>`. The
                // theme's dotted-prefix `lookup` walks back to the
                // base if a more specific entry isn't defined, so
                // adding a modifier suffix is a strict refinement —
                // never worse than the unmodified lookup. Allocation
                // is skipped in the no-modifier case (the common one)
                // via `Cow::Borrowed`.
                let mods = legend.modifier_names(t.token_modifiers);
                let lookup_name: std::borrow::Cow<'_, str> = match mods.first() {
                    Some(m) => std::borrow::Cow::Owned(format!("{name}.{m}")),
                    None => std::borrow::Cow::Borrowed(name),
                };
                let style = theme.lookup(&lookup_name);
                if is_default_style(style) {
                    continue;
                }
                let start_b = crate::lsp::char_to_byte(line_str, t.start, ctx.encoding);
                let end_char = t.start.saturating_add(t.length);
                let end_b = crate::lsp::char_to_byte(line_str, end_char, ctx.encoding);
                if end_b <= start_b {
                    continue;
                }
                let (start_col, end_col) = byte_range_to_columns(line_bytes, start_b, end_b);
                // `visible_cols` returns `None` for an empty range too, so the
                // old `end_col <= start_col` guard is subsumed rather than
                // dropped.
                let Some((clamped_start, clamped_end)) = viewport.visible_cols(start_col, end_col)
                else {
                    continue;
                };
                let cell_row = cell_origin.row + row_offset;
                for col in clamped_start..clamped_end {
                    let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
                    cell.style = merge_styles(cell.style, style);
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::view::WrapMode;

    #[test]
    fn theme_lookup_walks_dotted_prefixes() {
        let mut t = Theme::empty();
        t.insert(
            "function",
            Style {
                bold: true,
                ..Style::default()
            },
        );
        // Exact match.
        assert!(t.lookup("function").bold);
        // One-segment fallback: function.method falls back to function.
        assert!(t.lookup("function.method").bold);
        // No match --- returns default.
        assert!(!t.lookup("variable").bold);
    }

    #[test]
    fn theme_lookup_specific_overrides_general() {
        let mut t = Theme::empty();
        t.insert(
            "function",
            Style {
                bold: true,
                ..Style::default()
            },
        );
        t.insert(
            "function.method",
            Style {
                italic: true,
                ..Style::default()
            },
        );
        // function.method matches its own entry, not the function fallback.
        let s = t.lookup("function.method");
        assert!(s.italic);
        assert!(!s.bold);
    }

    #[test]
    fn face_returns_none_when_unset_never_default_style() {
        // Q#TH4: an unset face leaves the paint site's hardcoded
        // default untouched — even a loud user default_style (a
        // SYNTAX fallback) must not bleed into chrome.
        let mut t = Theme::empty();
        t.default_style = Style {
            bold: true,
            ..Style::default()
        };
        assert_eq!(t.face("ui.modeline"), None);
        assert_eq!(t.face("ui"), None);
        // lookup, by contrast, resolves through to default_style.
        assert!(t.lookup("ui.modeline").bold);
    }

    #[test]
    fn face_walks_dotted_prefixes_to_the_ui_catch_all() {
        let mut t = Theme::empty();
        t.insert(
            "ui",
            Style {
                italic: true,
                ..Style::default()
            },
        );
        t.insert(
            "ui.search.match",
            Style {
                bold: true,
                ..Style::default()
            },
        );
        // Exact match.
        assert!(t.face("ui.search.match").expect("set").bold);
        // One-segment fallback: active inherits from ui.search.match.
        assert!(t.face("ui.search.match.active").expect("inherit").bold);
        // Everything else falls to the bare-ui catch-all.
        assert!(t.face("ui.modeline").expect("catch-all").italic);
        assert!(t.face("ui.diag.error").expect("catch-all").italic);
    }

    #[test]
    fn face_exact_empty_child_blocks_parent_inheritance() {
        // Q#TH5 (round 3 finding 4): with a themed ui.diag parent, an
        // explicitly empty ui.diag.error child stops the walk at the
        // exact entry — errors reset to the built-in (the Default fg
        // policy applies at the consumer) while siblings inherit.
        let mut t = Theme::empty();
        t.insert(
            "ui.diag",
            Style {
                fg: Color::Indexed(93),
                ..Style::default()
            },
        );
        t.insert("ui.diag.error", Style::default());
        assert_eq!(t.face("ui.diag.error"), Some(Style::default()));
        assert_eq!(
            t.face("ui.diag.warning").expect("inherits").fg,
            Color::Indexed(93)
        );
    }

    #[test]
    fn face_predicate_accepts_ui_root_and_prefix_only() {
        // Q#TH2: exactly `ui` or `ui.`-prefixed — nothing else. A
        // name like `uix` must classify as syntax, not face.
        assert!(is_face_name("ui"));
        assert!(is_face_name("ui.modeline"));
        assert!(is_face_name("ui.search.match.active"));
        assert!(!is_face_name("uix"));
        assert!(!is_face_name("u"));
        assert!(!is_face_name("keyword"));
        assert!(!is_face_name("gui.modeline"));
    }

    #[test]
    fn modeline_segment_face_is_base_relative_and_fg_only() {
        let mut t = Theme::empty();
        t.insert(
            "ui.modeline",
            Style {
                fg: Color::Indexed(1),
                bg: Color::Indexed(2),
                reverse: true,
                ..Style::default()
            },
        );
        assert_eq!(t.modeline_segment_face("ui.modeline"), None);
        assert_eq!(t.modeline_segment_face("ui.modeline.unset"), None);

        t.insert(
            "ui.modeline.project",
            Style {
                fg: Color::Indexed(6),
                bg: Color::Indexed(5),
                reverse: true,
                bold: true,
                ..Style::default()
            },
        );
        assert_eq!(
            t.modeline_segment_face("ui.modeline.project.branch"),
            Some(Style {
                fg: Color::Indexed(6),
                ..Style::default()
            })
        );

        t.insert("ui.modeline.project.branch", Style::default());
        assert_eq!(
            t.modeline_segment_face("ui.modeline.project.branch"),
            None,
            "an exact default foreground blocks the colored intermediate parent"
        );
    }

    #[test]
    fn line_offsets_basic() {
        let src = b"a\nbb\nccc";
        let off = compute_line_offsets(src);
        assert_eq!(off, vec![0, 2, 5]);
        assert_eq!(line_at_offset(&off, 0), 0);
        assert_eq!(line_at_offset(&off, 1), 0);
        assert_eq!(line_at_offset(&off, 2), 1);
        assert_eq!(line_at_offset(&off, 5), 2);
        assert_eq!(line_at_offset(&off, 7), 2);
    }

    #[test]
    fn line_offsets_trailing_newline_creates_empty_line() {
        let src = b"a\nb\n";
        let off = compute_line_offsets(src);
        // Three lines: "a", "b", "".
        assert_eq!(off, vec![0, 2, 4]);
    }

    #[test]
    fn byte_range_display_cols_ascii_round_trips() {
        let line = b"hello world";
        // "hello" → cols 0..5
        assert_eq!(byte_range_to_columns(line, 0, 5), (0, 5));
        // "world" → cols 6..11
        assert_eq!(byte_range_to_columns(line, 6, 11), (6, 11));
    }

    #[test]
    fn byte_range_display_cols_tabs_expand() {
        let line = b"\tx";
        // The full line: tab (0..8) + 'x' (8..9). Byte cols 0..2
        // map to display cols 0..9.
        assert_eq!(byte_range_to_columns(line, 0, 2), (0, 9));
        // Just the tab.
        assert_eq!(byte_range_to_columns(line, 0, 1), (0, 8));
    }

    #[test]
    fn byte_range_display_cols_clamps_past_end() {
        let line = b"hi";
        assert_eq!(byte_range_to_columns(line, 0, 999), (0, 2));
    }

    #[test]
    fn default_dark_covers_lsp_token_types() {
        // The LSP semantic-token type names clangd / rust-analyzer /
        // gopls actually emit must resolve to a non-default style in
        // the built-in theme — otherwise `LspStyleView` drops them as
        // "default-styled, nothing to render". Regression guard so a
        // future theme refactor doesn't silently lose coverage.
        let t = Theme::default_dark();
        for name in [
            "macro",
            "namespace",
            "parameter",
            "property",
            "class",
            "struct",
            "enum",
            "interface",
            "enumMember",
            "modifier",
            "decorator",
            "regexp",
            "typeParameter",
        ] {
            assert!(
                !is_default_style(t.lookup(name)),
                "default_dark must color LSP token type `{name}`"
            );
        }
    }

    #[test]
    fn is_default_style_round_trips() {
        assert!(is_default_style(Style::default()));
        assert!(!is_default_style(Style {
            bold: true,
            ..Style::default()
        }));
    }

    // --- M_B1: LspStyleView render ---

    #[test]
    fn lsp_style_view_paints_cells_from_semantic_tokens() {
        use crate::cell::{Cell, CellSize};
        use crate::editor::EditorState;
        use crate::lsp::PositionEncoding;
        use crate::semantic_tokens::{SemanticToken, SemanticTokenKey, SemanticTokensResponse};

        let state = EditorState::new();
        let buffer_id = state.core.borrow().active_window().buffer_id;

        // Seed the buffer: one line, `.cpp` path. The test renders
        // `LspStyleView` directly (no `SyntaxHighlightView` attached
        // alongside in this fixture), so the asserted cells reflect
        // the LSP authority alone — M_B3's dual-authority composition
        // is exercised end-to-end by Lua's `attach_buffer`, not here.
        {
            let mut core = state.core.borrow_mut();
            core.registry
                .clone()
                .borrow_mut()
                .get_mut(buffer_id)
                .expect("active buffer")
                .apply_edit(crate::buffer::EditOp::Insert {
                    pos: 0,
                    bytes: b"int main\n",
                })
                .expect("seed");
            core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.cpp")));
        }
        // Theme face for the legend's lone type name.
        state.syntax_registry.theme().lock().expect("theme").insert(
            "kw",
            Style {
                bold: true,
                ..Style::default()
            },
        );
        // Register an Initialized fake LSP client (no process) with a
        // legend that maps token_type 0 → "kw", and UTF-16 encoding.
        let sid = state
            .lsp_manager
            .borrow_mut()
            .insert_initialized_test_client(
                serde_json::json!({
                    "semanticTokensProvider": {
                        "legend": { "tokenTypes": ["kw"], "tokenModifiers": [] }
                    }
                }),
                PositionEncoding::Utf16,
            );
        // Seed a single token at line 0, cols [0, 3) — covers "int".
        // (Buffer's normalized path drives the URI; encode it the same
        // way the producer does so the store lookup matches.)
        let active_path = state
            .core
            .borrow()
            .active_buffer_path()
            .expect("path set above");
        let uri = crate::lsp::path_to_file_uri(&active_path);
        {
            let mgr = state.lsp_manager.borrow();
            let store = mgr.semantic_token_store();
            store.lock().expect("store").set(
                SemanticTokenKey::new(sid.raw().to_string(), uri),
                SemanticTokensResponse {
                    tokens: vec![SemanticToken {
                        line: 0,
                        start: 0,
                        length: 3,
                        token_type: 0,
                        token_modifiers: 0,
                    }],
                    result_id: None,
                    raw: Vec::new(),
                },
            );
        }
        // Render into a small grid.
        let mut view = LspStyleView::new(state.lsp_manager.clone(), state.syntax_registry.theme());
        let mut backing: Vec<Cell> = vec![Cell::default(); 20];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: 20,
            size: CellSize::new(1, 20),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(1, 20),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = state.core.borrow().registry.clone();
        let reg = registry.borrow();
        let buf = reg.get(buffer_id).expect("buffer");
        view.render(buf, viewport, &mut grid);

        // Cells [0,3) painted bold (the seeded theme face); cell 3
        // (the space after "int") unchanged.
        for col in 0..3 {
            assert!(
                grid.get(CellCoord::new(0, col)).style.bold,
                "col {col} should be styled by the LSP token"
            );
        }
        assert!(
            !grid.get(CellCoord::new(0, 3)).style.bold,
            "col 3 (space) is outside the token range"
        );
    }

    #[test]
    fn lsp_style_view_suppresses_stale_semantic_tokens() {
        use crate::cell::{Cell, CellSize};
        use crate::editor::EditorState;
        use crate::lsp::PositionEncoding;
        use crate::semantic_tokens::{SemanticToken, SemanticTokenKey, SemanticTokensResponse};

        let state = EditorState::new();
        let buffer_id = state.core.borrow().active_window().buffer_id;
        {
            let mut core = state.core.borrow_mut();
            core.registry
                .clone()
                .borrow_mut()
                .get_mut(buffer_id)
                .expect("active buffer")
                .apply_edit(crate::buffer::EditOp::Insert {
                    pos: 0,
                    bytes: b"foo\n",
                })
                .expect("seed");
            core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.c")));
        }
        state.syntax_registry.theme().lock().expect("theme").insert(
            "function",
            Style {
                bold: true,
                ..Style::default()
            },
        );
        let sid = state
            .lsp_manager
            .borrow_mut()
            .insert_initialized_test_client(
                serde_json::json!({
                    "semanticTokensProvider": {
                        "legend": { "tokenTypes": ["function"], "tokenModifiers": [] }
                    }
                }),
                PositionEncoding::Utf16,
            );
        let active_path = state.core.borrow().active_buffer_path().expect("path set");
        let uri = crate::lsp::path_to_file_uri(&active_path);
        {
            let mgr = state.lsp_manager.borrow();
            let store = mgr.semantic_token_store();
            let mut guard = store.lock().expect("store");
            guard.set(
                SemanticTokenKey::new(sid.raw().to_string(), uri.clone()),
                SemanticTokensResponse {
                    tokens: vec![SemanticToken {
                        line: 0,
                        start: 0,
                        length: 3,
                        token_type: 0,
                        token_modifiers: 0,
                    }],
                    result_id: None,
                    raw: Vec::new(),
                },
            );
            guard.mark_stale(uri);
        }

        let mut view = LspStyleView::new(state.lsp_manager.clone(), state.syntax_registry.theme());
        let mut backing: Vec<Cell> = vec![Cell::default(); 20];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: 20,
            size: CellSize::new(1, 20),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(1, 20),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = state.core.borrow().registry.clone();
        let reg = registry.borrow();
        let buf = reg.get(buffer_id).expect("buffer");
        view.render(buf, viewport, &mut grid);

        assert!(
            !grid.get(CellCoord::new(0, 0)).style.bold,
            "stale semantic tokens must not paint over current syntax/text"
        );
    }

    #[test]
    #[allow(clippy::too_many_lines)] // Scripted fixture; the headline
    // test next door is similarly
    // shaped. If a third lands, extract
    // a shared helper.
    fn lsp_style_view_uses_modifier_in_capture_lookup() {
        use crate::cell::{Cell, CellSize};
        use crate::editor::EditorState;
        use crate::lsp::PositionEncoding;
        use crate::semantic_tokens::{SemanticToken, SemanticTokenKey, SemanticTokensResponse};

        let state = EditorState::new();
        let buffer_id = state.core.borrow().active_window().buffer_id;
        {
            let mut core = state.core.borrow_mut();
            core.registry
                .clone()
                .borrow_mut()
                .get_mut(buffer_id)
                .expect("active buffer")
                .apply_edit(crate::buffer::EditOp::Insert {
                    pos: 0,
                    bytes: b"foo bar\n",
                })
                .expect("seed");
            core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.cpp")));
        }
        // Theme: base `function` is bold; the modifier-refined
        // `function.defaultLibrary` is italic (overrides bold).
        {
            let theme_handle = state.syntax_registry.theme();
            let mut theme = theme_handle.lock().expect("theme");
            theme.insert(
                "function",
                Style {
                    bold: true,
                    ..Style::default()
                },
            );
            theme.insert(
                "function.defaultLibrary",
                Style {
                    italic: true,
                    ..Style::default()
                },
            );
        }
        // Legend: tokenTypes=["function"], tokenModifiers=["defaultLibrary"].
        // Token 0: type=0, modifiers=0 (no bit set) ⇒ falls back to `function` (bold).
        // Token 1: type=0, modifiers=1 (bit 0 ⇒ defaultLibrary) ⇒ matches
        // `function.defaultLibrary` (italic, NOT bold).
        let sid = state
            .lsp_manager
            .borrow_mut()
            .insert_initialized_test_client(
                serde_json::json!({
                    "semanticTokensProvider": {
                        "legend": {
                            "tokenTypes": ["function"],
                            "tokenModifiers": ["defaultLibrary"]
                        }
                    }
                }),
                PositionEncoding::Utf16,
            );
        let active_path = state.core.borrow().active_buffer_path().expect("path set");
        let uri = crate::lsp::path_to_file_uri(&active_path);
        {
            let mgr = state.lsp_manager.borrow();
            let store = mgr.semantic_token_store();
            store.lock().expect("store").set(
                SemanticTokenKey::new(sid.raw().to_string(), uri),
                SemanticTokensResponse {
                    tokens: vec![
                        SemanticToken {
                            line: 0,
                            start: 0,
                            length: 3,
                            token_type: 0,
                            token_modifiers: 0,
                        },
                        SemanticToken {
                            line: 0,
                            start: 4,
                            length: 3,
                            token_type: 0,
                            token_modifiers: 1,
                        },
                    ],
                    result_id: None,
                    raw: Vec::new(),
                },
            );
        }
        let mut view = LspStyleView::new(state.lsp_manager.clone(), state.syntax_registry.theme());
        let mut backing: Vec<Cell> = vec![Cell::default(); 20];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: 20,
            size: CellSize::new(1, 20),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(1, 20),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = state.core.borrow().registry.clone();
        let reg = registry.borrow();
        let buf = reg.get(buffer_id).expect("buffer");
        view.render(buf, viewport, &mut grid);

        // "foo" (cols 0..3) ⇒ base `function` ⇒ bold.
        let c = grid.get(CellCoord::new(0, 0));
        assert!(c.style.bold, "unmodified token uses base `function`");
        assert!(!c.style.italic);
        // "bar" (cols 4..7) ⇒ `function.defaultLibrary` ⇒ italic, no bold.
        let c = grid.get(CellCoord::new(0, 4));
        assert!(
            c.style.italic,
            "modifier-refined token uses `function.defaultLibrary`"
        );
        assert!(!c.style.bold);
    }

    #[test]
    fn grid_paints_injected_child_keyword() {
        // Framing acceptance #10: the grid `SyntaxHighlightView` paints a
        // rust keyword cell INSIDE a markdown ```rust fence — styling only
        // the injected rust layer can produce. Layers paint shallow-to-deep
        // (framing Q#IJ6), so the rust keyword wins over the markdown root.
        use crate::buffer::{Buffer, BufferId, EditOp};
        use crate::cell::{Cell, CellSize};
        use crate::syntax::{ParseView, SyntaxRegistry};

        let reg = SyntaxRegistry::new();
        let language = reg.language("markdown").expect("markdown grammar");
        // Line 0: ```rust ; line 1: fn demo() {} ; line 2: ```
        let src = b"```rust\nfn demo() {}\n```\n";
        let mut buf = Buffer::new(BufferId::next(), "doc.md");
        buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
            .unwrap();
        let view = ParseView::new(&buf, language, "markdown".to_owned());
        let handle = view.handle();
        let _vid = buf.attach_view(Box::new(view));
        let mut req = handle.make_request();
        req.injection_aliases = reg.injection_alias_snapshot();
        let bundle = crate::syntax::run_parse(req).expect("markdown parse");
        handle.install(reg.resolve_layer_queries(&bundle));

        let mut hv = SyntaxHighlightView::new(handle, reg.theme());
        let cols = 40usize;
        let rows = 3usize;
        let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: cols as u32,
            size: CellSize::new(rows as u32, cols as u32),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(rows as u32, cols as u32),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = buf; // keep buf alive
        hv.render(&registry, viewport, &mut grid);

        // `fn` is at line 1, cols 0..2. default_dark styles `keyword` bold.
        let c = grid.get(CellCoord::new(1, 0));
        assert!(
            c.style.bold,
            "the injected rust `fn` keyword is painted (bold) inside the fence"
        );
    }

    #[test]
    fn latex_grid_paints_section_and_command() {
        // Acceptance 3, paint half. The compile gate in `syntax.rs`
        // (`latex_highlights_resolve`) proves the vendored overlay COMPILES
        // against the grammar, but tree-sitter also compiles a pattern that
        // uses a valid field on the wrong node and then silently never
        // matches. This test parses a real document and asserts painted
        // output, so a *compilable* structural drift in the sectioning or
        // command patterns (the same class as the caught `curly_group_label`
        // rename, but one the compile gate cannot see) fails here.
        use crate::buffer::{Buffer, BufferId, EditOp};
        use crate::cell::{Cell, CellSize};
        use crate::syntax::{ParseView, SyntaxRegistry};

        let reg = SyntaxRegistry::new();
        let language = reg.language("latex").expect("latex grammar");
        // Line 0: \section{Intro} ; line 1: \foo{bar}
        let src = b"\\section{Intro}\n\\foo{bar}\n";
        let mut buf = Buffer::new(BufferId::next(), "paper.tex");
        buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
            .unwrap();
        let view = ParseView::new(&buf, language, "latex".to_owned());
        let handle = view.handle();
        let _vid = buf.attach_view(Box::new(view));
        let mut req = handle.make_request();
        req.injection_aliases = reg.injection_alias_snapshot();
        let bundle = crate::syntax::run_parse(req).expect("latex parse");
        handle.install(reg.resolve_layer_queries(&bundle));

        let mut hv = SyntaxHighlightView::new(handle, reg.theme());
        let cols = 40usize;
        let rows = 2usize;
        let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: cols as u32,
            size: CellSize::new(rows as u32, cols as u32),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(rows as u32, cols as u32),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = buf; // keep buf alive
        hv.render(&registry, viewport, &mut grid);

        // `\foo` command_name (line 1, col 0) paints @function via the general
        // command rule — a non-default fg, proving the reconciled query
        // actually MATCHES, not merely compiles.
        assert_ne!(
            grid.get(CellCoord::new(1, 0)).style,
            Cell::default().style,
            "the \\foo command_name is painted (@function), not default"
        );
        // `Intro` section-title text (line 0, col 9) paints @keyword.control,
        // which `default_dark` renders bold — proving the sectioning pattern
        // structurally matches this grammar cut (would fail on a silent
        // never-match drift).
        assert!(
            grid.get(CellCoord::new(0, 9)).style.bold,
            "the \\section title text is painted @keyword.control (bold)"
        );
    }

    #[test]
    fn rust_attribute_repaints_via_shared_attribute_capture() {
        // Intended side effect, named in the framing (Q#WEB4): the
        // `("attribute", fg(3))` entry added for HTML/CSS also colours the
        // `@attribute` capture that tree-sitter-rust (attribute_item), -lua
        // (`<const>`), and -yaml (directives) already emit. A Rust
        // `#[derive(Debug)]` — previously unpainted, since `@attribute` was
        // unrecognized — now paints the attribute style throughout. Pinned so
        // the retro-paint on this repo's primary language is a chosen effect,
        // not an incidental one.
        use crate::buffer::{Buffer, BufferId, EditOp};
        use crate::cell::{Cell, CellSize};
        use crate::syntax::{ParseView, SyntaxRegistry};

        let reg = SyntaxRegistry::new();
        let language = reg.language("rust").expect("rust grammar");
        let src = b"#[derive(Debug)]\nstruct S;\n";
        let mut buf = Buffer::new(BufferId::next(), "a.rs");
        buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
            .unwrap();
        let view = ParseView::new(&buf, language, "rust".to_owned());
        let handle = view.handle();
        let _vid = buf.attach_view(Box::new(view));
        let mut req = handle.make_request();
        req.injection_aliases = reg.injection_alias_snapshot();
        let bundle = crate::syntax::run_parse(req).expect("rust parse");
        handle.install(reg.resolve_layer_queries(&bundle));

        let mut hv = SyntaxHighlightView::new(handle, reg.theme());
        let (rows, cols) = (1usize, 20usize);
        let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: cols as u32,
            size: CellSize::new(rows as u32, cols as u32),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(rows as u32, cols as u32),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = buf;
        hv.render(&registry, viewport, &mut grid);

        // `derive` (col 2) sits inside the `attribute_item` and paints the
        // shared @attribute style (fg 3) — the intended retro-paint.
        assert_eq!(
            grid.get(CellCoord::new(0, 2)).style.fg,
            pmacs_protocol::cell::Color::Indexed(3),
            "a Rust #[derive] attribute paints the shared @attribute style (fg 3)"
        );
    }

    /// Paint `src` as `language` into a one-row grid and return the style
    /// at column `col`. Shared by the Q#LN4 retro-paint pins below.
    fn painted_fg_at(
        language_name: &str,
        file: &str,
        src: &str,
        col: u32,
    ) -> pmacs_protocol::cell::Color {
        painted_style_at(language_name, file, src, col).fg
    }

    /// As [`painted_fg_at`], but returns the whole style — needed where a
    /// colour alone does not discriminate (Lean's `warning` vs `number`).
    fn painted_style_at(
        language_name: &str,
        file: &str,
        src: &str,
        col: u32,
    ) -> pmacs_protocol::cell::Style {
        use crate::buffer::{Buffer, BufferId, EditOp};
        use crate::cell::{Cell, CellSize};
        use crate::syntax::{ParseView, SyntaxRegistry};

        let reg = SyntaxRegistry::new();
        let language = reg.language(language_name).expect("grammar loads");
        let mut buf = Buffer::new(BufferId::next(), file);
        buf.apply_edit(EditOp::Insert {
            pos: 0,
            bytes: src.as_bytes(),
        })
        .unwrap();
        let view = ParseView::new(&buf, language, language_name.to_owned());
        let handle = view.handle();
        let _vid = buf.attach_view(Box::new(view));
        let mut req = handle.make_request();
        req.injection_aliases = reg.injection_alias_snapshot();
        let bundle = crate::syntax::run_parse(req).expect("parse");
        handle.install(reg.resolve_layer_queries(&bundle));

        let mut hv = SyntaxHighlightView::new(handle, reg.theme());
        let (rows, cols) = (1usize, 40usize);
        let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: cols as u32,
            size: CellSize::new(rows as u32, cols as u32),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(rows as u32, cols as u32),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        hv.render(&buf, viewport, &mut grid);
        grid.get(CellCoord::new(0, col)).style
    }

    /// Does `language`'s compiled highlight query use `capture`?
    fn query_uses_capture(language: &str, capture: &str) -> bool {
        let reg = crate::syntax::SyntaxRegistry::new();
        let Some(query) = reg.highlights_query(language) else {
            panic!("{language} has no highlights query");
        };
        query.capture_names().contains(&capture)
    }

    #[test]
    fn lean4_grid_paints_comment_keyword_name_operator_and_number() {
        // Framing acceptance 5: the grammar plus the crate query plus the
        // theme table actually produce distinct styles on a painted grid.
        // Asserted end-to-end rather than at the query level because a
        // capture that resolves to `Style::default()` is indistinguishable
        // from no capture at all to a reader.
        use pmacs_protocol::cell::Color;

        // `-- c` — the whole comment run.
        assert_eq!(
            painted_fg_at("lean4", "a.lean", "-- c\n", 0),
            Color::Indexed(8),
            "a Lean line comment paints the comment style"
        );

        // `def foo : Nat := 42`
        let src = "def foo : Nat := 42\n";
        assert_eq!(
            painted_fg_at("lean4", "a.lean", src, 0),
            Color::Indexed(5),
            "`def` paints the keyword style"
        );
        assert_eq!(
            painted_fg_at("lean4", "a.lean", src, 4),
            Color::Indexed(4),
            "the definition's name paints the function style"
        );
        assert_eq!(
            painted_fg_at("lean4", "a.lean", src, 14),
            Color::Indexed(6),
            "`:=` paints the operator style"
        );
        assert_eq!(
            painted_fg_at("lean4", "a.lean", src, 17),
            Color::Indexed(1),
            "a numeric literal paints the number style"
        );

        // A string literal, and `theorem` as a second declaration keyword.
        assert_eq!(
            painted_fg_at("lean4", "a.lean", "def s := \"hi\"\n", 9),
            Color::Indexed(2),
            "a string literal paints the string style"
        );
        assert_eq!(
            painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 0),
            Color::Indexed(5),
            "`theorem` paints the keyword style"
        );
        assert_eq!(
            painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 8),
            Color::Indexed(4),
            "the theorem's name paints the function style"
        );
    }

    #[test]
    fn lean4_sorry_paints_the_warning_style_distinctly_from_a_number() {
        // Framing acceptance 6. `sorry` admits a goal without proving it —
        // in a proof file it is the single most important token to notice,
        // and it is why Q#LN4 adds a `warning` entry at all.
        //
        // The style is asserted in FULL, not by colour: `number` and the
        // first-choice `warning` colour were both indexed red, so a
        // colour-only assertion would have passed with `sorry` painted
        // exactly like the literal `42` beside it. That is the whole failure
        // this test exists to prevent.
        use pmacs_protocol::cell::Color;

        let sorry = painted_style_at("lean4", "a.lean", "theorem t : True := sorry\n", 20);
        assert_eq!(
            sorry.fg,
            Color::Indexed(9),
            "`sorry` paints the warning colour"
        );
        assert!(sorry.bold, "`sorry` is bold");

        let number = painted_style_at("lean4", "a.lean", "def n := 42\n", 9);
        assert_ne!(
            (sorry.fg, sorry.bold),
            (number.fg, number.bold),
            "`sorry` must be visually distinct from a numeric literal"
        );
    }

    #[test]
    fn lean4_constructor_capture_retro_paints_the_whole_javascript_family() {
        // Framing acceptance 7 (Q#LN4), the breadth half. `constructor` was
        // added for Lean, but four crates emit it — and because
        // `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated
        // base-first into the react/typescript entries
        // (`src/syntax.rs`), it reaches SEVEN language entries, not four.
        //
        // Asserted at the query level rather than per-fixture precisely
        // because the composition is the fragile part: if someone stops
        // concatenating the JS base query into `typescript`, this fails
        // while any single-language fixture would still pass.
        for language in [
            "rust",
            "lua",
            "python",
            "javascript",
            "javascriptreact",
            "typescript",
            "typescriptreact",
        ] {
            assert!(
                query_uses_capture(language, "constructor"),
                "`{language}` emits @constructor, so Q#LN4's entry retro-paints it"
            );
        }
    }

    #[test]
    fn lean4_capture_additions_paint_rust_constructors_and_lua_braces() {
        // Framing acceptance 7, the "actually reaches painted cells" half —
        // a query-name check alone would not prove the theme entry resolves.
        // Both of these rendered as unstyled default text before Q#LN4.
        use pmacs_protocol::cell::Color;

        // `None` at col 8 — a bare capitalized identifier, which is what the
        // rust query's `#match? "^[A-Z]"` tags. Note that `Some(1)` does NOT
        // work here: in call position a narrower `@function` pattern wins and
        // paints fg 4. The distinction is worth keeping in the test, because
        // it is the difference between "capitalized identifiers recolor" and
        // "enum variants recolor" — only the former is true.
        assert_eq!(
            painted_fg_at("rust", "a.rs", "let x = None;\n", 8),
            Color::Indexed(11),
            "a bare Rust capitalized identifier paints the shared @constructor style"
        );
        // `Some` in pattern position (col 10) does reach @constructor.
        assert_eq!(
            painted_fg_at("rust", "a.rs", "match v { Some(z) => z, None => 0 };\n", 10),
            Color::Indexed(11),
            "a Rust pattern-position variant paints the shared @constructor style"
        );
        // ...but in CALL position the narrower @function pattern wins. Pinned
        // so the blast radius recorded in the framing stays accurate.
        assert_eq!(
            painted_fg_at("rust", "a.rs", "let e = Err(1);\n", 8),
            Color::Indexed(4),
            "a called variant keeps @function, not @constructor"
        );
        // Lua tags the table-constructor BRACES, not a name: `{` at col 10
        // of `local t = {}`.
        assert_eq!(
            painted_fg_at("lua", "a.lua", "local t = {}\n", 10),
            Color::Indexed(11),
            "a Lua table brace paints the shared @constructor style"
        );
    }

    #[test]
    fn lean4_capture_additions_do_not_reach_unrelated_languages() {
        // Framing acceptance 8 — the negative pin, redrawn in review round 1.
        //
        // Rev 1 named Lua and Python here, which was a self-contradiction:
        // both are retro-painted by `constructor`, so a "nothing moved"
        // assertion over them would have been vacuous — the #155 R2 shape.
        // These ten emit NONE of the four names, verified by grep over the
        // crate queries in the dependency graph.
        //
        // Stated at the query level, which is stronger than a fixture
        // snapshot: it holds for every construct in the language, not just
        // the one a fixture happened to exercise.
        const ADDED: [&str; 4] = ["constructor", "character", "keyword.conditional", "warning"];
        for language in [
            "markdown", "json", "yaml", "html", "css", "c", "cpp", "go", "toml", "bash",
        ] {
            for capture in ADDED {
                assert!(
                    !query_uses_capture(language, capture),
                    "`{language}` must not emit @{capture}; Q#LN4 would silently restyle it"
                );
            }
        }

        // Non-vacuity: the same predicate must find each name where it DOES
        // occur. Without this, a `query_uses_capture` that always returned
        // false would pass the loop above.
        assert!(query_uses_capture("lean4", "constructor"));
        assert!(query_uses_capture("zig", "character"));
        assert!(query_uses_capture("cmake", "keyword.conditional"));
        assert!(query_uses_capture("lean4", "warning"));
    }

    #[test]
    fn web_grid_paints_html_tag_and_attribute() {
        // Q#WEB4 acceptance: the two capture entries this lane adds (`tag`,
        // `attribute`) actually reach painted cells. The attribute assertion is
        // load-bearing — a tag-only test could pass with `@attribute` unverified.
        use crate::buffer::{Buffer, BufferId, EditOp};
        use crate::cell::{Cell, CellSize};
        use crate::syntax::{ParseView, SyntaxRegistry};

        let reg = SyntaxRegistry::new();
        let language = reg.language("html").expect("html grammar");
        // Line 0: <a href="x">Hi</a>  — `a` (tag_name) at col 1, `href`
        // (attribute_name) at col 3.
        let src = b"<a href=\"x\">Hi</a>\n";
        let mut buf = Buffer::new(BufferId::next(), "index.html");
        buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
            .unwrap();
        let view = ParseView::new(&buf, language, "html".to_owned());
        let handle = view.handle();
        let _vid = buf.attach_view(Box::new(view));
        let mut req = handle.make_request();
        req.injection_aliases = reg.injection_alias_snapshot();
        let bundle = crate::syntax::run_parse(req).expect("html parse");
        handle.install(reg.resolve_layer_queries(&bundle));

        let mut hv = SyntaxHighlightView::new(handle, reg.theme());
        let (rows, cols) = (1usize, 40usize);
        let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: cols as u32,
            size: CellSize::new(rows as u32, cols as u32),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(rows as u32, cols as u32),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = buf;
        hv.render(&registry, viewport, &mut grid);

        let tag_cell = grid.get(CellCoord::new(0, 1));
        let attr_cell = grid.get(CellCoord::new(0, 3));
        assert_ne!(
            tag_cell.style,
            Cell::default().style,
            "the <a> tag_name paints @tag (non-default)"
        );
        assert_ne!(
            attr_cell.style,
            Cell::default().style,
            "the href attribute_name paints @attribute (non-default)"
        );
        assert_ne!(
            tag_cell.style, attr_cell.style,
            "@tag and @attribute use the two distinct new styles"
        );
    }

    #[test]
    fn html_injects_css_and_js() {
        // The payoff (Q#WEB acceptance 5): HTML's INJECTIONS_QUERY parses
        // <style> as CSS and <script> as JavaScript, and the child layers paint
        // INSIDE the embedded regions — styling only the injected grammars can
        // produce. `css` is resolvable only because this lane registered it.
        use crate::buffer::{Buffer, BufferId, EditOp};
        use crate::cell::{Cell, CellSize};
        use crate::syntax::{ParseView, SyntaxRegistry};

        let reg = SyntaxRegistry::new();
        let language = reg.language("html").expect("html grammar");
        // Line 0: <style>a{color:red}</style>  — `color` (CSS property) at col 9.
        // Line 1: <script>let x=1</script>      — `let` (JS keyword) at col 8.
        let src = b"<style>a{color:red}</style>\n<script>let x=1</script>\n";
        let mut buf = Buffer::new(BufferId::next(), "page.html");
        buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
            .unwrap();
        let view = ParseView::new(&buf, language, "html".to_owned());
        let handle = view.handle();
        let _vid = buf.attach_view(Box::new(view));
        let mut req = handle.make_request();
        req.injection_aliases = reg.injection_alias_snapshot();
        let bundle = crate::syntax::run_parse(req).expect("html parse");
        handle.install(reg.resolve_layer_queries(&bundle));

        let mut hv = SyntaxHighlightView::new(handle, reg.theme());
        let (rows, cols) = (2usize, 60usize);
        let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
        let mut grid = CellGrid {
            cells: &mut backing,
            stride: cols as u32,
            size: CellSize::new(rows as u32, cols as u32),
        };
        let viewport = Viewport {
            buffer_start: 0,
            buffer_end: u64::MAX,
            cell_origin: CellCoord::new(0, 0),
            cell_size: CellSize::new(rows as u32, cols as u32),
            gutter_w: 0,
            folds: None,
            wrap: WrapMode::Truncate,
            view_left: 0,
        };
        let registry = buf;
        hv.render(&registry, viewport, &mut grid);

        // Inside <style>: the CSS `color` property paints (non-default) —
        // proves the <style> -> css injection resolved and parsed.
        assert_ne!(
            grid.get(CellCoord::new(0, 9)).style,
            Cell::default().style,
            "the CSS `color` property paints inside the <style> injection"
        );
        // Inside <script>: the JS `let` keyword paints bold — proves the
        // <script> -> javascript injection resolved and parsed.
        assert!(
            grid.get(CellCoord::new(1, 8)).style.bold,
            "the JS `let` keyword paints (bold) inside the <script> injection"
        );
    }
}
