From bc711d12f6a1646ba067695732e3f369ba72ffb8 Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Sun, 26 Jul 2026 21:07:42 +0900 Subject: [PATCH] feat: add Phase 5 document highlights provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 — tree-sitter-backed DocumentHighlightProvider: highlight all occurrences of the symbol at the cursor. Message::DocumentHighlights { uri, line, column } -> DocumentHighlightsResponse { highlights: [{ range, kind }] } or null. Resolve the identifier at the position, then recursively walk the document AST collecting ALL identifier-like nodes whose text matches; kind heuristic: declaration name (is_declaration_name) -> Write(2), otherwise Read(1). File-local only. Refactor: extracted point_for_position helper (shared Point resolution between hover and find_identifier_at_position); unified response interfaces to use IRange. Fix: corrected DocumentHighlightKind comment (Text=0, not Text=3 — VS Code enum, not LSP). No runtime impact (only Read=1/Write=2 are emitted). Known limitation (documented): file-local, name-based — no cross-file type resolution, no scope-aware filtering. Coexists with the built-in TS provider (non-exclusive, Phase 1 pattern). Co-Authored-By: Claude --- README.ja.md | 2 +- README.md | 2 +- rust/crates/language-host/src/main.rs | 127 +++++++++++++++--- .../languageHost/common/languageFeatures.ts | 55 +++++++- .../languageHost/common/languageHost.ts | 6 + .../electron-browser/languageHostService.ts | 4 + 6 files changed, 169 insertions(+), 27 deletions(-) diff --git a/README.ja.md b/README.ja.md index 2825bd982eb..bd97612f85d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -80,7 +80,7 @@ VS Codeのフォーク。本家にはないUI/UX改善を追加した(してい | `coderm.workbench.editor.separateTerminalEditors` | `boolean` | `true` | ターミナルエディタとテキストエディタを同じグループに混在させない。Quick Open等のデフォルトの開く操作で、既存の同種グループ(なければ新規)に振り分ける | | `coderm.workbench.editor.singleTerminalEditorPerGroup` | `boolean` | `true` | 1つのエディタグループにターミナルエディタは1つだけ。新規ターミナルは空きグループ(なければ新規)に開き、既存ターミナルグループにタブを追加しない | | `coderm.workbench.editor.disableGroupLock` | `boolean` | `true` | エディタグループのロック機能を完全に無効化。グループは自動・手動を問わずロックできず、常にロック解除状態で動作 | -| `coderm.languageHost.enabled` | `boolean` | `false` | (実験的)ネイティブ(Rust)Language Host を有効化。設定した言語で tree-sitter ベースの documentSymbol・foldingRange・hover・definition・references を提供(Phase 4) | +| `coderm.languageHost.enabled` | `boolean` | `false` | (実験的)ネイティブ(Rust)Language Host を有効化。設定した言語で tree-sitter ベースの documentSymbol・foldingRange・hover・definition・references・documentHighlights を提供(Phase 5) | | `coderm.languageHost.languages` | `array` | `[]` | (実験的)ネイティブ Host が扱う言語 ID(例: "typescript", "tsx")。空の場合は機能無効 | --- diff --git a/README.md b/README.md index 4c89888ac1f..4941985d857 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Settings unique to Coderm that are not available in upstream VS Code. | `coderm.workbench.editor.separateTerminalEditors` | `boolean` | `true` | Never mix terminal and text editors in one group; Quick Open and other default open paths route to an existing same-type group (or a new one) instead of mixing | | `coderm.workbench.editor.singleTerminalEditorPerGroup` | `boolean` | `true` | Limit each editor group to a single terminal editor; opening a new terminal routes to an empty group (or creates one) instead of adding a tab to an existing terminal group | | `coderm.workbench.editor.disableGroupLock` | `boolean` | `true` | Completely disable the editor group lock feature — groups can never be locked (automatically or manually) and always behave as unlocked | -| `coderm.languageHost.enabled` | `boolean` | `false` | _(experimental)_ Enable the native (Rust) Language Host. Provides tree-sitter-backed documentSymbol, foldingRange, hover, definition, and references for the configured languages (Phase 4) | +| `coderm.languageHost.enabled` | `boolean` | `false` | _(experimental)_ Enable the native (Rust) Language Host. Provides tree-sitter-backed documentSymbol, foldingRange, hover, definition, references, and document highlights for the configured languages (Phase 5) | | `coderm.languageHost.languages` | `array` | `[]` | _(experimental)_ Language IDs handled by the native host (e.g. "typescript", "tsx"). Empty keeps the feature inert | --- diff --git a/rust/crates/language-host/src/main.rs b/rust/crates/language-host/src/main.rs index 35e039c9fe2..78f5107e333 100644 --- a/rust/crates/language-host/src/main.rs +++ b/rust/crates/language-host/src/main.rs @@ -1,17 +1,21 @@ -// Coderm Language Host (Phase 4: references + Phase 3: definition + Phase 2 hover + Phase 1.5 robustness). +// Coderm Language Host (Phase 5: document highlights + Phase 4: references + Phase 3: definition + Phase 2 hover + Phase 1.5 robustness). // // Wire format: [4 bytes LE request_id][4 bytes LE length][payload(JSON)]. // request_id == 0 → notification (no response). Used for document sync. // request_id > 0 → request (response expected). Used for language features. // Payload is JSON tagged by "type": -// - document/open: {type:"document/open", uri, version, languageId, text} -// - document/change: {type:"document/change", uri, version, text} // full-text replace -// - document/close: {type:"document/close", uri} -// - documentSymbol: {type:"documentSymbol", uri} → [DocumentSymbol] -// - foldingRange: {type:"foldingRange", uri} → [FoldingRange] -// - hover: {type:"hover", uri, line, column} → HoverResponse | null -// - definition: {type:"definition", uri, line, column} → DefinitionResponse | null -// - references: {type:"references", uri, line, column, includeDeclaration} → DefinitionResponse | null +// - document/open: {type:"document/open", uri, version, languageId, text} +// - document/change: {type:"document/change", uri, version, text} // full-text replace +// - document/close: {type:"document/close", uri} +// - documentSymbol: {type:"documentSymbol", uri} → [DocumentSymbol] +// - foldingRange: {type:"foldingRange", uri} → [FoldingRange] +// - hover: {type:"hover", uri, line, column} → HoverResponse | null +// - definition: {type:"definition", uri, line, column} → DefinitionResponse | null +// - references: {type:"references", uri, line, column, includeDeclaration} → DefinitionResponse | null +// - documentHighlights: {type:"documentHighlights", uri, line, column} → DocumentHighlightsResponse | null +// +// Phase 5 additions: +// - documentHighlights: file-local identifier highlights (Write for declaration names, Read otherwise) // // Phase 4 additions: // - references: file-local symbol references (find all references matching the identifier name) @@ -74,6 +78,8 @@ enum Message { column: u32, include_declaration: bool, }, + #[serde(rename = "documentHighlights", rename_all = "camelCase")] + DocumentHighlights { uri: String, line: u32, column: u32 }, } struct Document { @@ -133,6 +139,21 @@ struct Location { range: Range, } +// Phase 5 document highlight response. `kind` mirrors VS Code's DocumentHighlightKind +// (Text=0, Read=1, Write=2); Text is unused in v1. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentHighlightsResponse { + highlights: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentHighlightResponse { + range: Range, + kind: u32, +} + struct LanguageHost { documents: HashMap, } @@ -213,6 +234,9 @@ impl LanguageHost { column, include_declaration, ))), + Message::DocumentHighlights { uri, line, column } => { + Ok(Some(self.document_highlights_json(&uri, line, column))) + } } } @@ -276,6 +300,12 @@ impl LanguageHost { ) }) } + + fn document_highlights_json(&self, uri: &str, line: u32, column: u32) -> String { + self.feature_json(uri, "document highlights", |doc| { + document_highlights_at_position(&doc.language_id, &doc.content, line, column) + }) + } } fn parse_source(language_id: &str, content: &str) -> Result { @@ -409,12 +439,7 @@ fn hover_at_position( column_1: u32, ) -> Result, String> { let tree = parse_source(language_id, content)?; - let row_0 = line_1.saturating_sub(1) as usize; - let column_0_utf8 = utf16_column_to_utf8_byte(content, row_0, column_1)?; - let point = Point { - row: row_0, - column: column_0_utf8, - }; + let point = point_for_position(content, line_1, column_1)?; // Deepest named node at the point, then walk up to the enclosing declaration. let leaf = match tree @@ -478,6 +503,18 @@ fn utf16_column_to_utf8_byte(content: &str, row_0: usize, column_1: u32) -> Resu Ok(byte_offset) } +// Resolve a renderer (line_1, column_1) Position to a tree-sitter Point. Wraps the +// 1-indexed → 0-indexed row conversion and the UTF-16 → UTF-8 byte column reconciliation +// performed by utf16_column_to_utf8_byte (see its comment for the per-row walk rationale). +fn point_for_position(content: &str, line_1: u32, column_1: u32) -> Result { + let row_0 = line_1.saturating_sub(1) as usize; + let column_0_utf8 = utf16_column_to_utf8_byte(content, row_0, column_1)?; + Ok(Point { + row: row_0, + column: column_0_utf8, + }) +} + fn walk_up_to_declaration<'a>(start: Node<'a>) -> Option> { let mut cursor = Some(start); while let Some(n) = cursor { @@ -588,12 +625,7 @@ fn find_identifier_at_position<'a>( column_1: u32, ) -> Result, String> { let tree = parse_source(language_id, content)?; - let row_0 = line_1.saturating_sub(1) as usize; - let column_0_utf8 = utf16_column_to_utf8_byte(content, row_0, column_1)?; - let point = Point { - row: row_0, - column: column_0_utf8, - }; + let point = point_for_position(content, line_1, column_1)?; // Get the identifier at the cursor position. let leaf = match tree @@ -789,6 +821,59 @@ fn is_declaration_name(node: Node) -> bool { false } +// Phase 5: document highlights provider. Returns highlight ranges for all occurrences of the +// identifier at the given position. File-local only. kind heuristic: declaration name → +// Write(2), otherwise Read(1); Text(0) is unused in v1. +fn document_highlights_at_position( + language_id: &str, + content: &str, + line_1: u32, + column_1: u32, +) -> Result, String> { + let (tree, identifier) = + match find_identifier_at_position(language_id, content, line_1, column_1)? { + Some(v) => v, + None => return Ok(None), + }; + + // Caution: name-based, not scope-aware — highlights every same-named identifier in the file + // (shadowed locals too), since Phase 5 is file-local only. + let mut highlights = Vec::new(); + collect_document_highlights(tree.root_node(), content, identifier, &mut highlights); + + if highlights.is_empty() { + Ok(None) + } else { + Ok(Some(DocumentHighlightsResponse { highlights })) + } +} + +// Why recursive named-child walk: occurrences nest arbitrarily (methods inside classes, +// references inside expressions), so we descend into named children like the other collect_* +// helpers — a single goto_next() loop only visits one level of siblings. +fn collect_document_highlights( + node: Node, + source: &str, + identifier: &str, + out: &mut Vec, +) { + if is_definition_trigger_kind(node.kind()) && node_text(node, source) == identifier { + // Declaration name (the identifier bound as a declaration's "name" field) is a Write + // site; every other occurrence is a Read. + let kind = if is_declaration_name(node) { 2 } else { 1 }; + out.push(DocumentHighlightResponse { + range: range_from_points(node.start_position(), node.end_position()), + kind, + }); + } + let count = node.named_child_count(); + for i in 0..count { + if let Some(child) = node.named_child(i) { + collect_document_highlights(child, source, identifier, out); + } + } +} + // Emits one [reqId(4)][length(4)][payload] frame to stdout. fn write_frame(request_id: u32, payload: &[u8]) -> io::Result<()> { let mut header = [0u8; 8]; diff --git a/src/vs/workbench/services/languageHost/common/languageFeatures.ts b/src/vs/workbench/services/languageHost/common/languageFeatures.ts index 7d8f2ee63a2..3ac43d7c5f8 100644 --- a/src/vs/workbench/services/languageHost/common/languageFeatures.ts +++ b/src/vs/workbench/services/languageHost/common/languageFeatures.ts @@ -24,19 +24,24 @@ import { HoverProvider, ReferenceContext, ReferenceProvider, + DocumentHighlight, + DocumentHighlightKind, + DocumentHighlightProvider, SymbolKind, } from '../../../../editor/common/languages.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IRange } from '../../../../editor/common/core/range.js'; import { Position } from '../../../../editor/common/core/position.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { ILanguageHostService } from './languageHost.js'; // Shapes returned by the Rust host (camelCase, matching the renderer's DocumentSymbol/IRange). +// `range` fields reuse the editor's IRange since the host emits the identical shape. interface DocumentSymbolResponse { name: string; kind: number; - range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; - selectionRange: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; + range: IRange; + selectionRange: IRange; children?: DocumentSymbolResponse[]; } @@ -48,11 +53,15 @@ interface FoldingRangeResponse { interface HoverResponse { signature: string; documentation: string; - range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; + range: IRange; } interface DefinitionResponse { - locations: Array<{ uri: string; range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number } }>; + locations: Array<{ uri: string; range: IRange }>; +} + +interface DocumentHighlightsResponse { + highlights: Array<{ range: IRange; kind: number }>; } class CodermDocumentSymbolProvider implements DocumentSymbolProvider { @@ -163,6 +172,29 @@ class CodermReferenceProvider implements ReferenceProvider { } } +class CodermDocumentHighlightProvider implements DocumentHighlightProvider { + readonly displayName = 'Coderm Language Host'; + + constructor(private readonly languageHostService: ILanguageHostService) { } + + async provideDocumentHighlights(model: ITextModel, position: Position, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + try { + const json = await this.languageHostService.requestDocumentHighlights( + model.uri.toString(), + position.lineNumber, + position.column + ); + return parseDocumentHighlights(json); + } catch (err) { + console.error('[CodermDocumentHighlightProvider] request failed', err); + return undefined; + } + } +} + // Parses a JSON array from the host's response. Returns [] on parse failure or a // non-array payload — a malformed host reply should not crash the provider. function parseJsonArray(json: string): T[] { @@ -252,6 +284,19 @@ function parseLocations(json: string): Location[] | undefined { })); } +// Note: kind mirrors VS Code's DocumentHighlightKind (Text=0, Read=1, Write=2). The host sends +// numbers directly (no enum on the wire), so a plain cast is sufficient. +function parseDocumentHighlights(json: string): DocumentHighlight[] | undefined { + const response = parseJsonObject(json); + if (!response) { + return undefined; + } + return response.highlights.map((h): DocumentHighlight => ({ + range: h.range, + kind: h.kind as DocumentHighlightKind, + })); +} + export function registerLanguageFeatureProviders( languageHostService: ILanguageHostService, languages: string[], @@ -262,6 +307,7 @@ export function registerLanguageFeatureProviders( const hoverProvider = new CodermHoverProvider(languageHostService); const definitionProvider = new CodermDefinitionProvider(languageHostService); const referenceProvider = new CodermReferenceProvider(languageHostService); + const documentHighlightProvider = new CodermDocumentHighlightProvider(languageHostService); // Why a flat string[] selector: each entry is a language id; VS Code invokes the provider // only for models whose language matches, so no extra in-provider filtering is needed. @@ -271,5 +317,6 @@ export function registerLanguageFeatureProviders( languageFeaturesService.hoverProvider.register(languages, hoverProvider), languageFeaturesService.definitionProvider.register(languages, definitionProvider), languageFeaturesService.referenceProvider.register(languages, referenceProvider), + languageFeaturesService.documentHighlightProvider.register(languages, documentHighlightProvider), ); } diff --git a/src/vs/workbench/services/languageHost/common/languageHost.ts b/src/vs/workbench/services/languageHost/common/languageHost.ts index fdb5fb79ace..59da12d149a 100644 --- a/src/vs/workbench/services/languageHost/common/languageHost.ts +++ b/src/vs/workbench/services/languageHost/common/languageHost.ts @@ -61,6 +61,12 @@ export interface ILanguageHostService { * Returns the host's JSON string (a DefinitionResponse shape or null). */ requestReferences(uri: string, line: number, column: number, includeDeclaration: boolean): Promise; + + /** + * Request document highlights for a position in a synced document. + * Returns the host's JSON string (a DocumentHighlightsResponse shape or null). + */ + requestDocumentHighlights(uri: string, line: number, column: number): Promise; } // --- Coderm end --- diff --git a/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts b/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts index e0dff75081a..6c88da8c871 100644 --- a/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts +++ b/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts @@ -115,6 +115,10 @@ export class NativeLanguageHostService extends Disposable implements ILanguageHo return this.requestFeature({ type: 'references', uri, line, column, includeDeclaration }); } + async requestDocumentHighlights(uri: string, line: number, column: number): Promise { + return this.requestFeature({ type: 'documentHighlights', uri, line, column }); + } + private async requestFeature(message: { type: string; uri: string; line?: number; column?: number; includeDeclaration?: boolean }): Promise { if (!this.protocol) { throw new Error('Language Host not ready');