diff --git a/src/highlight.rs b/src/highlight.rs index b4b823f..b925bd0 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -76,111 +76,78 @@ impl Theme { } /// 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. + /// 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. /// - /// 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`. + /// 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 { - let mut by_capture = HashMap::new(); - let bold = Style { + // 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() }; - // 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()); + 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)), + ]; + let by_capture = entries + .iter() + .map(|(name, style)| ((*name).to_owned(), *style)) + .collect(); Self { by_capture, default_style: Style::default(), @@ -606,10 +573,26 @@ impl View for LspStyleView { // 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 Some(legend) = ctx.legend.as_ref() else { + continue; // No legend ⇒ cannot name a style. }; - let style = theme.lookup(name); + let Some(name) = legend.type_name(t.token_type) else { + continue; // Unknown type index. + }; + // Build the lookup name as `.` + // when modifiers are set, else just ``. 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; } @@ -729,6 +712,36 @@ mod tests { assert_eq!(byte_range_to_display_cols(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())); @@ -847,4 +860,129 @@ mod tests { "col 3 (space) is outside the token range" ); } + + #[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 = 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); + + // "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); + } }