From c660d558815ea56613dc1a912988b1ad89d71bad Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 19 May 2026 19:48:50 -0400 Subject: [PATCH] =?UTF-8?q?M=5FB1:=20LspStyleView=20=E2=80=94=20LSP-driven?= =?UTF-8?q?=20syntax=20coloring=20in=20the=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the visible "C++ has no syntax coloring in the grid TUI" gap. Sibling of SyntaxHighlightView: a View impl that paints LSP semantic tokens as cell styles, attached for buffers with no bundled tree-sitter grammar. Same policy A (one styling authority per buffer) the semantic-frontend producer arc enforces, applied to the grid renderer the user actually uses today. Mechanics: every render re-derives the buffer's URI from buf.file_path() and pulls (encoding, legend) via the existing LspManager::semantic_style_context plus tokens via for_uri. Per visible line, tokens are converted from LSP encoding units to byte ranges via char_to_byte, then to display columns via the existing byte_range_to_display_cols (UTF-8 + tab aware). Theme::lookup resolves token type names through the same dotted-prefix mechanism the tree-sitter capture names use, so "function", "variable", "type", "keyword" land on the existing theme vocabulary with no new style names. Default-styled spans skip the per-cell loop, matching SyntaxHighlightView's short-circuit. Wiring: pmacs.lsp._attach_style binding pushes the overlay on the active window (mirrors pmacs.parse._attach_highlight). install_lsp and make_lsp_manager take SharedSyntaxRegistry so the binding can hand the LspStyleView the shared ThemeHandle; editor.rs caller updated. builtin/runtime/lsp.lua's attach_buffer attaches the view when pmacs.parse.language_for_path returns nil (grammar-less signal), dedup'd via a styled_buffers set that mirrors syntax.lua's highlighted_buffers. Test: lsp_style_view_paints_cells_from_semantic_tokens — seeds an Initialized fake LSP client (using the cfg(test) helper from the producer arc) on a /tmp/x.cpp buffer with one token, asserts the expected cells are styled per the theme face and the cell just past the token range is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- builtin/runtime/lsp.lua | 19 +++ src/editor.rs | 1 + src/highlight.rs | 253 ++++++++++++++++++++++++++++++++++++++++ src/lua_bindings.rs | 42 ++++++- 4 files changed, 313 insertions(+), 2 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 12ffb9c..b36f724 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -321,6 +321,12 @@ local function server_is_live(sid) return false end +-- M_B1: buffers that already had an `LspStyleView` overlay pushed, +-- so the after-load / on-demand attach paths don't stack duplicate +-- overlays. Mirrors `highlighted_buffers` in `syntax.lua`; the entry +-- is keyed by the same `tostring(buf)` the attachments table uses. +local styled_buffers = {} + local function attach_buffer(buf) if not buf then return nil end local key = tostring(buf) @@ -341,6 +347,19 @@ local function attach_buffer(buf) -- did_open is a notification; the manager queues it cleanly even -- while the server is in `starting` / `initializing`. pcall(pmacs.lsp.did_open, sid, uri, rec.version, active_buffer_text()) + -- M_B1: policy A — when this buffer has no bundled tree-sitter + -- grammar (C/C++, …), push the LSP-driven style overlay onto the + -- active window so the grid TUI gets cell coloring from LSP + -- semantic tokens. Grammar-backed languages already have + -- `SyntaxHighlightView`; never both authorities on one buffer. + -- `language_for_path` returning nil is the grammar-less signal + -- (the LSP filetype map is consulted independently above). + if not styled_buffers[key] + and not pmacs.parse.language_for_path(path) + then + local ok, attached = pcall(pmacs.lsp._attach_style, buf) + if ok and attached then styled_buffers[key] = true end + end return rec end diff --git a/src/editor.rs b/src/editor.rs index 8f8b3c6..c3d86f5 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -187,6 +187,7 @@ impl EditorState { lua_host.lua(), process_supervisor.clone(), async_runtime.clone(), + &syntax_registry, ) .expect("install pmacs.lsp"); // T M9.1 MCP manager. Wires onto the same supervisor that LSP diff --git a/src/highlight.rs b/src/highlight.rs index fc440d6..b4b823f 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -37,6 +37,7 @@ 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}; @@ -492,6 +493,148 @@ pub fn is_default_style(style: Style) -> bool { && !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 // --------------------------------------------------------------------------- @@ -594,4 +737,114 @@ mod tests { ..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 = 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" + ); + } } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index a8cf484..3b6c148 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7120,7 +7120,11 @@ fn lua_to_lsp_error(value: Value) -> mlua::Result { clippy::too_many_lines, reason = "linear list of raw bindings; splitting fragments a coherent surface" )] -pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { +pub fn install_lsp( + lua: &Lua, + manager: &SharedLspManager, + syntax: &SharedSyntaxRegistry, +) -> mlua::Result<()> { lua.set_app_data(manager.clone()); let pmacs: Table = lua.globals().get("pmacs")?; let lsp_mod = lua.create_table()?; @@ -7891,6 +7895,39 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { )?; } + // M_B1: TUI-side LSP styling. Sibling of + // `pmacs.parse._attach_highlight`: pushes an `LspStyleView` overlay + // on the active window so the grid renderer paints LSP semantic + // tokens as cell styles for buffers with no bundled tree-sitter + // grammar. Lua callers gate against the grammar-backed case (policy + // A: one styling authority per buffer); double-attach pushes a + // fresh overlay each call, so the Lua side is also responsible for + // dedup, matching `_attach_highlight`'s contract. + { + let m = manager.clone(); + let s = syntax.clone(); + lsp_mod.set( + "_attach_style", + lua.create_function(move |lua, id: BufferIdLua| { + let theme = s.theme(); + let core = lua + .app_data_ref::() + .ok_or_else(|| mlua::Error::external("editor core not yet installed"))?; + let mut core_borrow = core.borrow_mut(); + let win = core_borrow.active_window_mut(); + if win.buffer_id != id.0 { + return Err(mlua::Error::external(format!( + "active window's buffer is not {:?}", + id.0 + ))); + } + let overlay = crate::highlight::LspStyleView::new(m.clone(), theme); + win.push_overlay(Box::new(overlay)); + Ok(true) + })?, + )?; + } + pmacs.set("lsp", lsp_mod)?; Ok(()) } @@ -7901,9 +7938,10 @@ pub fn make_lsp_manager( lua: &Lua, supervisor: SharedProcessSupervisor, runtime: crate::async_runtime::SharedAsyncRuntime, + syntax: &SharedSyntaxRegistry, ) -> mlua::Result { let manager = Rc::new(RefCell::new(LspManager::new(supervisor, runtime))); - install_lsp(lua, &manager)?; + install_lsp(lua, &manager, syntax)?; install_diag(lua, &manager)?; install_completion(lua, &manager)?; install_hover(lua, &manager)?;