pmacs/src/highlight.rs

1905 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 max_cols = viewport.cell_size.cols;
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 clamped_start = start_col.min(max_cols);
let clamped_end = end_col.min(max_cols);
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 max_cols = viewport.cell_size.cols;
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);
if end_col <= start_col {
continue;
}
let cell_row = cell_origin.row + row_offset;
let clamped_start = start_col.min(max_cols);
let clamped_end = end_col.min(max_cols);
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::*;
#[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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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"
);
}
}