From d310be6e6e1ddb155be186288ab2b483ec5721ce Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Fri, 9 Oct 2026 23:51:48 +0200 Subject: [PATCH] fix: preserve semantic roles in terminal palette highlighting --- docs/configuration.md | 2 +- docs/editing.md | 2 +- src/tui.rs | 90 ++++++++++++++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 7c59f48..2adfb6a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -203,6 +203,6 @@ Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString form Semantic highlighting is **enabled automatically** when the first server advertises [LSP semantic tokens](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens). No additional configuration or binding is needed. Full-document requests are preferred; range-only servers receive a whole-document range request. Token colors override Tree-sitter where recognized; unsupported servers, unknown custom token types, missing tokens, and failed replies retain syntax highlighting. Text/path changes clear old semantic colors immediately, and stale replies cannot restore them. Same-text refreshes keep the current overlay until the replacement arrives. -rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/properties use yellow. Diagnostics, search, selections, and carets retain their existing styling precedence. +rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/parameters/properties use italics without changing their role's color. Diagnostics, search, selections, and carets retain their existing styling precedence. Native LSP currently provides synchronization, diagnostics, go-to-definition, hover, and semantic highlighting—not completion, rename, formatting, code actions, or workspace edits. Semantic token deltas, dynamic registration, and partial-result streaming are not implemented. diff --git a/docs/editing.md b/docs/editing.md index d12357d..ceb520a 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -206,4 +206,4 @@ Colors retain the terminal palette: comments dark gray, strings green, keywords Markdown heading markers (`#` through `######`, or Setext underlines) share the heading text's markup color. Plain-text hashes and other punctuation keep their existing styling. -An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Variables/parameters use light cyan, properties blue, and readonly variables/properties yellow; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Tree-sitter fills gaps and remains available while new tokens are pending after edits. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing. +An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Semantic colors also use the terminal palette: namespaces neutral gray, types cyan, variables/parameters light cyan, and properties blue. Readonly variables/parameters/properties keep those role colors and add italics; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Italic emphasis depends on terminal support. Tree-sitter fills gaps and remains available while new tokens are pending after edits. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing. diff --git a/src/tui.rs b/src/tui.rs index e682f03..8919579 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1080,9 +1080,9 @@ fn semantic_style(token: &SemanticToken) -> Style { use SemanticTokenKind::*; let readonly = token.modifiers & SemanticTokenModifier::Readonly as u16 != 0; let color = match token.kind { - Namespace | Type | Class | Enum | Interface | Struct | TypeParameter => Color::Cyan, + Namespace => Color::Gray, + Type | Class | Enum | Interface | Struct | TypeParameter => Color::Cyan, Function | Method | Event | Macro => Color::LightBlue, - Parameter | Variable | Property if readonly => Color::Yellow, Parameter | Variable => Color::LightCyan, Property => Color::Blue, EnumMember | Number => Color::Yellow, @@ -1091,6 +1091,9 @@ fn semantic_style(token: &SemanticToken) -> Style { Comment => Color::DarkGray, }; let mut style = Style::default().fg(color); + if readonly && matches!(token.kind, Parameter | Variable | Property) { + style = style.add_modifier(ratatui::style::Modifier::ITALIC); + } if token.modifiers & (SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Definition as u16) != 0 @@ -1655,6 +1658,60 @@ end } } + #[test] + fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + "ns Type local frozen field fixed param frozenparam".into(), + )]) + .unwrap(); + let readonly = SemanticTokenModifier::Readonly as u16; + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([ + semantic_token(0, 0, 2, SemanticTokenKind::Namespace, 0), + semantic_token(0, 3, 7, SemanticTokenKind::Type, 0), + semantic_token(0, 8, 13, SemanticTokenKind::Variable, 0), + semantic_token(0, 14, 20, SemanticTokenKind::Variable, readonly), + semantic_token(0, 21, 26, SemanticTokenKind::Property, 0), + semantic_token(0, 27, 32, SemanticTokenKind::Property, readonly), + semantic_token(0, 33, 38, SemanticTokenKind::Parameter, 0), + semantic_token(0, 39, 50, SemanticTokenKind::Parameter, readonly), + ])), + ) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 4)).unwrap(); + terminal + .draw(|frame| BufferViewports::default().draw(frame, &editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let foregrounds = [0, 3, 8, 14, 21, 27, 33, 39].map(|column| buffer[(column + 2, 0)].fg); + assert_ne!(foregrounds[0], foregrounds[1]); + assert_ne!(foregrounds[1], foregrounds[2]); + assert_ne!(foregrounds[2], foregrounds[4]); + assert_eq!(foregrounds[2], foregrounds[3]); + assert_eq!(foregrounds[4], foregrounds[5]); + assert_eq!(foregrounds[6], foregrounds[7]); + for (column, is_readonly) in [ + (0, false), + (3, false), + (8, false), + (14, true), + (21, false), + (27, true), + (33, false), + (39, true), + ] { + assert_eq!( + buffer[(column + 2, 0)].modifier.contains(Modifier::ITALIC), + is_readonly, + ); + } + } + #[test] fn semantic_colors_modifiers_and_syntax_fallback_cover_whole_graphemes_and_clipped_tabs() { let directory = Directory::new(); @@ -1700,23 +1757,12 @@ end ) .unwrap(); let mut syntax = SyntaxCache::new(); - for (offset, width, caret, colors) in [ - (2, 5, 4, vec![Color::Yellow; 5]), - (5, 2, 4, vec![Color::Reset, Color::Yellow]), - (7, 2, 5, vec![Color::Yellow; 2]), - (8, 2, 6, vec![Color::Reset, Color::DarkGray]), - ( - 0, - 5, - 2, - vec![ - Color::DarkGray, - Color::DarkGray, - Color::Yellow, - Color::Yellow, - Color::Reset, - ], - ), + for (offset, width, caret, coverage) in [ + (2, 5, 4, vec![true; 5]), + (5, 2, 4, vec![false, true]), + (7, 2, 5, vec![true; 2]), + (8, 2, 6, vec![false; 2]), + (0, 5, 2, vec![false, false, true, true, false]), ] { editor.handle(Input::Home); for _ in 0..caret { @@ -1743,12 +1789,10 @@ end Paragraph::new(visible_line(source, offset, usize::from(width))) .render(text_area, &mut expected); let buffer = terminal.backend().buffer(); - for (cell, color) in colors.into_iter().enumerate() { + for (cell, semantic_cell) in coverage.into_iter().enumerate() { let position = (text_area.x + cell as u16, text_area.y); assert_eq!(buffer[position].symbol(), expected[position].symbol()); - assert_eq!(rendered[position].fg, color, "left={offset}, cell={cell}"); assert_eq!(rendered[position].bg, Color::Reset); - let semantic_cell = color == Color::Yellow; assert_eq!( rendered[position].modifier.contains(Modifier::BOLD), semantic_cell @@ -1766,7 +1810,7 @@ end } assert_eq!( rendered[position].modifier.contains(Modifier::ITALIC), - offset == 0 && cell == 0 + semantic_cell || (offset == 0 && cell == 0) ); } assert_eq!(buffer[(area.x, area.y)].fg, Color::Yellow);