pmacs/src/highlight.rs

851 lines
30 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 unicode_width::UnicodeWidthChar;
use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Style, UnderlineStyle};
use crate::lsp::SharedLspManager;
use crate::overlay::merge_styles;
use crate::syntax::{HighlightSpan, ParseTreeBundle, ParseViewHandle, compute_highlight_spans};
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.
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,
}
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 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.
///
/// Capture coverage is intentionally limited: keyword,
/// function, type, string, comment, constant, number, operator,
/// variable. Themes that want more granularity layer on top via
/// [`Self::insert`] / Lua's `pmacs.theme.set`.
#[must_use]
pub fn default_dark() -> Self {
let mut by_capture = HashMap::new();
let bold = Style {
bold: true,
..Style::default()
};
// 8-color indexed: 1 red, 2 green, 3 yellow, 4 blue,
// 5 magenta, 6 cyan; bright variants 9..14.
by_capture.insert(
"keyword".to_owned(),
Style {
fg: Color::Indexed(5),
..bold
},
);
by_capture.insert(
"keyword.control".to_owned(),
Style {
fg: Color::Indexed(13),
..bold
},
);
by_capture.insert(
"function".to_owned(),
Style {
fg: Color::Indexed(4),
..Style::default()
},
);
by_capture.insert(
"function.method".to_owned(),
Style {
fg: Color::Indexed(12),
..Style::default()
},
);
by_capture.insert(
"type".to_owned(),
Style {
fg: Color::Indexed(3),
..Style::default()
},
);
by_capture.insert(
"type.builtin".to_owned(),
Style {
fg: Color::Indexed(11),
..Style::default()
},
);
by_capture.insert(
"string".to_owned(),
Style {
fg: Color::Indexed(2),
..Style::default()
},
);
by_capture.insert(
"comment".to_owned(),
Style {
fg: Color::Indexed(8),
italic: true,
..Style::default()
},
);
by_capture.insert(
"constant".to_owned(),
Style {
fg: Color::Indexed(1),
..Style::default()
},
);
by_capture.insert(
"constant.builtin".to_owned(),
Style {
fg: Color::Indexed(9),
..Style::default()
},
);
by_capture.insert(
"number".to_owned(),
Style {
fg: Color::Indexed(1),
..Style::default()
},
);
by_capture.insert(
"operator".to_owned(),
Style {
fg: Color::Indexed(6),
..Style::default()
},
);
by_capture.insert("variable".to_owned(), Style::default());
by_capture.insert("punctuation".to_owned(), Style::default());
Self {
by_capture,
default_style: Style::default(),
}
}
/// 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,
}
}
}
/// 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
// ---------------------------------------------------------------------------
/// 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>>,
/// Compiled spans, sorted wider-first per
/// [`compute_highlight_spans`].
spans: Vec<HighlightSpan>,
/// Per-row first-byte offsets into `bundle.source`. `Vec<u32>`
/// because pmacs files cap at 4 GiB.
line_offsets: Vec<u32>,
/// Capture names indexed by `HighlightSpan::capture_index`.
/// Populated alongside `spans` so the render path doesn't need
/// to keep a reference into the [`tree_sitter::Query`].
capture_names: Arc<[String]>,
}
impl HighlightCache {
fn empty() -> Self {
Self {
bundle: None,
spans: Vec::new(),
line_offsets: Vec::new(),
capture_names: Arc::from(Vec::<String>::new().into_boxed_slice()),
}
}
}
/// Tab-stop width in display columns (must match
/// [`crate::text_view`]; both views write into the same cell grid).
const TAB_WIDTH: u32 = 8;
/// View that renders syntax highlighting from a tree-sitter parse
/// tree. 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,
query: Arc<tree_sitter::Query>,
theme: ThemeHandle,
cache: HighlightCache,
}
impl SyntaxHighlightView {
/// Construct a highlight view over `parse` using `query` and
/// `theme`. The initial cache is empty; the first render with
/// a settled bundle populates it.
#[must_use]
pub fn new(parse: ParseViewHandle, query: Arc<tree_sitter::Query>, theme: ThemeHandle) -> Self {
Self {
parse,
query,
theme,
cache: HighlightCache::empty(),
}
}
/// Test helper: number of cached highlight spans.
#[must_use]
pub fn cached_span_count(&self) -> usize {
self.cache.spans.len()
}
/// 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.
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 spans = compute_highlight_spans(&self.query, &bundle);
let line_offsets = compute_line_offsets(bundle.source.as_ref());
let capture_names: Arc<[String]> = self
.query
.capture_names()
.iter()
.map(|s| (*s).to_owned())
.collect();
self.cache = HighlightCache {
bundle: Some(bundle),
spans,
line_offsets,
capture_names,
};
}
/// Look up the style for span `s`, consulting the active theme.
fn style_for(&self, theme: &Theme, s: HighlightSpan) -> Style {
let idx = s.capture_index as usize;
let Some(name) = self.cache.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.spans.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;
for row_offset in 0..max_rows {
let line_idx = start_line + row_offset;
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. Wider-first
// ordering means parents apply before children.
// Spans that *cross* lines apply on every line they
// touch --- this loop only filters by start, then
// intersects with the line range below.
for span in self
.cache
.spans
.iter()
.filter(|s| s.start_byte < line_end_no_nl && s.end_byte > line_start)
.copied()
{
let style = self.style_for(&theme, 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_display_cols(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,
}
}
/// Convert a half-open byte-column range `[byte_start, byte_end)`
/// inside `line_bytes` to a display-column range. UTF-8 aware; tabs
/// expand to the next [`TAB_WIDTH`]-aligned column. Bytes that don't
/// form complete codepoints (because the byte range falls inside a
/// multi-byte char) are skipped, matching
/// [`crate::text_view::TextView::pos_to_display`]'s conservative
/// rounding.
fn byte_range_to_display_cols(line_bytes: &[u8], byte_start: usize, byte_end: usize) -> (u32, u32) {
let bs = byte_start.min(line_bytes.len());
let be = byte_end.min(line_bytes.len());
let display_to = |upto: usize| -> u32 {
// Drop trailing bytes that don't form complete codepoints.
let mut take = upto.min(line_bytes.len());
while take > 0 && std::str::from_utf8(&line_bytes[..take]).is_err() {
take -= 1;
}
let s = std::str::from_utf8(&line_bytes[..take]).unwrap_or("");
let mut col: u32 = 0;
for ch in s.chars() {
col += char_display_width(ch, col);
}
col
};
(display_to(bs), display_to(be))
}
fn char_display_width(ch: char, current_col: u32) -> u32 {
if ch == '\t' {
TAB_WIDTH - (current_col % TAB_WIDTH)
} else {
UnicodeWidthChar::width(ch).unwrap_or(0) 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 for buffers
/// with **no bundled tree-sitter grammar** (C/C++, …). Sibling of
/// [`SyntaxHighlightView`] for the same policy-A "one styling
/// authority per language" rule that `SemanticRenderState` applies on
/// the semantic-frontend side — but feeding the grid TUI's existing
/// cell-painter pipeline instead. Closes the visible "no syntax
/// coloring for grammar-less languages in the TUI" gap.
///
/// 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");
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 {
let line_idx = start_line + row_offset;
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(name) = ctx.legend.as_ref().and_then(|l| l.type_name(t.token_type)) else {
continue; // No legend / unknown index.
};
let style = theme.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_display_cols(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 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_display_cols(line, 0, 5), (0, 5));
// "world" → cols 6..11
assert_eq!(byte_range_to_display_cols(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_display_cols(line, 0, 2), (0, 9));
// Just the tab.
assert_eq!(byte_range_to_display_cols(line, 0, 1), (0, 8));
}
#[test]
fn byte_range_display_cols_clamps_past_end() {
let line = b"hi";
assert_eq!(byte_range_to_display_cols(line, 0, 999), (0, 2));
}
#[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 (no bundled grammar →
// SyntaxHighlightView never attaches, the LSP path owns the
// styling per policy A).
{
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),
};
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"
);
}
}