From 160ffdf3c29313500b40b9fb5af153a18a6e7174 Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Sun, 26 Jul 2026 00:50:22 +0900 Subject: [PATCH] feat: add Phase 3 definition provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — tree-sitter-backed DefinitionProvider (Go to Definition): Message::Definition { uri, line, column } -> DefinitionResponse { locations: Location[] } or null. Resolve the identifier at the position (named_descendant + is_definition_trigger_kind), then recursively walk the document AST collecting declarations whose name token matches; return all matches as Location[] (file-local only). All matches so VS Code shows a picker on ambiguous/overloaded/shadowed names. Reuses Phase 2 helpers (UTF-16->UTF-8 column reconciliation, declaration predicates, range_from_points). Refactored hover/definition into a shared feature_json envelope in Rust and parseJsonObject in the renderer to keep the two *_json / parse* pairs DRY. Known limitation (documented): file-local, name-based resolution — no cross-file type resolution like tsserver, no scope-aware shadowing/overload 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 | 181 ++++++++++++++++-- .../languageHost/common/languageFeatures.ts | 63 +++++- .../languageHost/common/languageHost.ts | 6 + .../electron-browser/languageHostService.ts | 4 + 6 files changed, 238 insertions(+), 20 deletions(-) diff --git a/README.ja.md b/README.ja.md index d992e76ac77..8270f561d3a 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 を提供(Phase 2) | +| `coderm.languageHost.enabled` | `boolean` | `false` | (実験的)ネイティブ(Rust)Language Host を有効化。設定した言語で tree-sitter ベースの documentSymbol・foldingRange・hover・definition を提供(Phase 3) | | `coderm.languageHost.languages` | `array` | `[]` | (実験的)ネイティブ Host が扱う言語 ID(例: "typescript", "tsx")。空の場合は機能無効 | --- diff --git a/README.md b/README.md index 5e83af81ad8..0a923a61780 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, and hover for the configured languages (Phase 2) | +| `coderm.languageHost.enabled` | `boolean` | `false` | _(experimental)_ Enable the native (Rust) Language Host. Provides tree-sitter-backed documentSymbol, foldingRange, hover, and definition for the configured languages (Phase 3) | | `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 f977d02878c..d702ba9b100 100644 --- a/rust/crates/language-host/src/main.rs +++ b/rust/crates/language-host/src/main.rs @@ -1,4 +1,4 @@ -// Coderm Language Host (Phase 2: hover + Phase 1.5 robustness). +// Coderm Language Host (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. @@ -10,6 +10,10 @@ // - 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 +// +// Phase 3 additions: +// - definition: file-local symbol resolution (find all declarations matching the identifier name) // // Phase 2 additions: // - hover: function/method/class/interface/type/typed-variable signatures with JSDoc @@ -18,7 +22,7 @@ // Caution: tree-sitter positions are 0-indexed byte offsets; VS Code positions are // 1-indexed. Phase 1 documentSymbol/foldingRange convert with byte-column + 1 (drifts on // non-ASCII selectionRange columns — TODO retained). Phase 2 reconciles UTF-8 byte offsets -// to UTF-16 code units for hover only, since hover receives a renderer (line, column). +// to UTF-16 code units for hover/definition only, since they receive a renderer (line, column). use std::collections::HashMap; use std::io::{self, Read, Write}; @@ -57,6 +61,8 @@ enum Message { // line/column are the renderer's Position (1-indexed; column is UTF-16 code units). #[serde(rename = "hover")] Hover { uri: String, line: u32, column: u32 }, + #[serde(rename = "definition")] + Definition { uri: String, line: u32, column: u32 }, } struct Document { @@ -101,6 +107,21 @@ struct HoverResponse { range: Range, // name-token range, so the highlight matches the hovered identifier } +// Phase 3 definition response: a list of locations where the symbol is declared. +// File-local only: all Locations share the same uri (the current document). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DefinitionResponse { + locations: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Location { + uri: String, + range: Range, +} + struct LanguageHost { documents: HashMap, } @@ -167,34 +188,53 @@ impl LanguageHost { )) } Message::Hover { uri, line, column } => Ok(Some(self.hover_json(&uri, line, column))), + Message::Definition { uri, line, column } => { + Ok(Some(self.definition_json(&uri, line, column))) + } } } - // Hover always emits a JSON string: the serialized response on success, or "null" on - // any failure. Why not bubble up via `?`: document() / hover_at_position() / - // to_string() errors must resolve to "null" here, not bubble to the main loop's - // generic `b"[]"` fallback — `[]` is a valid DocumentSymbol/FoldingRange shape but - // breaks hover (the renderer would build a "```typescript undefined```" tooltip). - fn hover_json(&self, uri: &str, line: u32, column: u32) -> String { + // Always emits a JSON string: the serialized response on success, or "null" on any + // failure. Why not bubble up via `?`: document() / compute() / to_string() errors must + // all resolve to "null" here, not bubble to the main loop's generic `b"[]"` fallback — + // `[]` is a valid DocumentSymbol/FoldingRange shape but breaks hover/definition (the + // renderer would build a "```typescript undefined```" tooltip or an empty Location[]). + fn feature_json(&self, uri: &str, label: &str, compute: F) -> String + where + F: FnOnce(&Document) -> Result, String>, + T: Serialize, + { let doc = match self.document(uri) { Ok(doc) => doc, Err(e) => { - eprintln!("[languageHost] hover error (unknown uri): {}", e); + eprintln!("[languageHost] {} error (unknown uri): {}", label, e); return "null".to_string(); } }; - match hover_at_position(&doc.language_id, &doc.content, line, column) { + match compute(doc) { Ok(Some(response)) => serde_json::to_string(&response).unwrap_or_else(|e| { - eprintln!("[languageHost] hover serialization error: {}", e); + eprintln!("[languageHost] {} serialization error: {}", label, e); "null".to_string() }), Ok(None) => "null".to_string(), Err(e) => { - eprintln!("[languageHost] hover error: {}", e); + eprintln!("[languageHost] {} error: {}", label, e); "null".to_string() } } } + + fn hover_json(&self, uri: &str, line: u32, column: u32) -> String { + self.feature_json(uri, "hover", |doc| { + hover_at_position(&doc.language_id, &doc.content, line, column) + }) + } + + fn definition_json(&self, uri: &str, line: u32, column: u32) -> String { + self.feature_json(uri, "definition", |doc| { + definition_at_position(&doc.language_id, &doc.content, uri, line, column) + }) + } } fn parse_source(language_id: &str, content: &str) -> Result { @@ -493,6 +533,123 @@ fn strip_jsdoc(combined: &str) -> String { .join("\n") } +// Phase 3: definition provider. Returns all declaration locations matching the identifier +// at the given position. File-local only: all locations share the same uri. +fn definition_at_position( + language_id: &str, + content: &str, + uri: &str, + line_1: u32, + 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, + }; + + // Get the identifier at the cursor position. + let leaf = match tree + .root_node() + .named_descendant_for_point_range(point, point) + { + Some(n) => n, + None => return Ok(None), + }; + + // Only trigger on identifier-like nodes (matches TS behavior). + if !is_definition_trigger_kind(leaf.kind()) { + return Ok(None); + } + + let identifier_text = node_text(leaf, content); + + // Scan the entire document for declarations whose name token matches. + // Caution: name-based, not scope-aware — returns every same-named declaration in the file + // (no shadowing/overload filtering), since Phase 3 is file-local only. + let mut locations = Vec::new(); + collect_definition_locations( + tree.root_node(), + content, + uri, + identifier_text, + &mut locations, + ); + + if locations.is_empty() { + Ok(None) + } else { + Ok(Some(DefinitionResponse { locations })) + } +} + +// Why recursive named-child walk (not TreeCursor siblings): declarations nest arbitrarily +// (methods inside classes, functions inside blocks), so we descend into named children the +// same way collect_symbols does — a single TreeCursor.goto_next() loop only visits siblings +// of one node and would miss nested declarations. +fn collect_definition_locations( + node: Node, + source: &str, + uri: &str, + identifier: &str, + out: &mut Vec, +) { + if is_definition_declaration_kind(node.kind()) { + if let Some(name_node) = node.child_by_field_name("name") { + if node_text(name_node, source) == identifier { + out.push(Location { + uri: uri.to_string(), + range: range_from_points(name_node.start_position(), name_node.end_position()), + }); + } + } + } + let count = node.named_child_count(); + for i in 0..count { + if let Some(child) = node.named_child(i) { + collect_definition_locations(child, source, uri, identifier, out); + } + } +} + +// Definition triggers on identifier references (variable_name, property_identifier, etc.). +fn is_definition_trigger_kind(kind: &str) -> bool { + matches!( + kind, + "identifier" + | "property_identifier" + | "variable_name" + | "type_identifier" + | "shorthand_property_identifier" + | "shorthand_property_identifier_pattern" + ) +} + +// Definition searches any declaration with a `name` field. Wider than hover: also includes +// enum_declaration. Note: lexical_declaration is intentionally absent — it has no `name` +// field, and the recursive walk in collect_definition_locations still reaches its +// variable_declarator children (which do carry the name). +fn is_definition_declaration_kind(kind: &str) -> bool { + matches!( + kind, + "function_declaration" + | "generator_function_declaration" + | "function_signature" + | "method_definition" + | "method_signature" + | "abstract_method_signature" + | "class_declaration" + | "abstract_class_declaration" + | "interface_declaration" + | "enum_declaration" + | "type_alias_declaration" + | "variable_declarator" + | "public_field_definition" + ) +} + // 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 8e1c8a3869d..fb829768f3f 100644 --- a/src/vs/workbench/services/languageHost/common/languageFeatures.ts +++ b/src/vs/workbench/services/languageHost/common/languageFeatures.ts @@ -10,9 +10,13 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { combinedDisposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { ITextModel } from '../../../../editor/common/model.js'; +import { URI } from '../../../../base/common/uri.js'; import { DocumentSymbol, DocumentSymbolProvider, + Definition, + DefinitionProvider, + Location, FoldingContext, FoldingRange, FoldingRangeProvider, @@ -45,6 +49,10 @@ interface HoverResponse { range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; } +interface DefinitionResponse { + locations: Array<{ uri: string; range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number } }>; +} + class CodermDocumentSymbolProvider implements DocumentSymbolProvider { readonly displayName = 'Coderm Language Host'; @@ -106,6 +114,29 @@ class CodermHoverProvider implements HoverProvider { } } +class CodermDefinitionProvider implements DefinitionProvider { + readonly displayName = 'Coderm Language Host'; + + constructor(private readonly languageHostService: ILanguageHostService) { } + + async provideDefinition(model: ITextModel, position: Position, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + try { + const json = await this.languageHostService.requestDefinition( + model.uri.toString(), + position.lineNumber, + position.column + ); + return parseDefinition(json); + } catch (err) { + console.error('[CodermDefinitionProvider] 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[] { @@ -145,11 +176,11 @@ function parseFoldingRanges(json: string): FoldingRange[] { return parseJsonArray(json).map((r): FoldingRange => ({ start: r.start, end: r.end })); } -function parseHover(json: string): Hover | undefined { - // Note: the host's hover "no result" sentinel is the literal string "null" (hover_json in - // rust/crates/language-host/src/main.rs), unlike documentSymbol/foldingRange which fall back - // to "[]". Treat both "null" and empty as no-hover so a null reply never renders as a - // "```typescript undefined```" tooltip. +// Note: the host's "no result" sentinel for hover/definition is the literal string "null" +// (see hover_json/definition_json in rust/crates/language-host/src/main.rs), unlike +// documentSymbol/foldingRange which fall back to "[]". Treat both "null" and empty as +// no-result so a null reply never renders as a "```typescript undefined```" tooltip. +function parseJsonObject(json: string): T | undefined { if (json === 'null' || json === '') { return undefined; } @@ -162,7 +193,14 @@ function parseHover(json: string): Hover | undefined { if (!raw || typeof raw !== 'object') { return undefined; } - const response = raw as HoverResponse; + return raw as T; +} + +function parseHover(json: string): Hover | undefined { + const response = parseJsonObject(json); + if (!response) { + return undefined; + } // Wrap signature in a ```typescript code block; render JSDoc as a second markdown string. // Why renderer-side shaping: keeps markdown construction out of Rust and matches the // built-in TS hover (appendCodeblock + documentation markdown). @@ -175,6 +213,17 @@ function parseHover(json: string): Hover | undefined { return { contents, range: response.range }; } +function parseDefinition(json: string): Definition | undefined { + const response = parseJsonObject(json); + if (!response) { + return undefined; + } + return response.locations.map((loc): Location => ({ + uri: URI.parse(loc.uri), + range: loc.range, + })); +} + export function registerLanguageFeatureProviders( languageHostService: ILanguageHostService, languages: string[], @@ -183,6 +232,7 @@ export function registerLanguageFeatureProviders( const documentSymbolProvider = new CodermDocumentSymbolProvider(languageHostService); const foldingRangeProvider = new CodermFoldingRangeProvider(languageHostService); const hoverProvider = new CodermHoverProvider(languageHostService); + const definitionProvider = new CodermDefinitionProvider(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. @@ -190,5 +240,6 @@ export function registerLanguageFeatureProviders( languageFeaturesService.documentSymbolProvider.register(languages, documentSymbolProvider), languageFeaturesService.foldingRangeProvider.register(languages, foldingRangeProvider), languageFeaturesService.hoverProvider.register(languages, hoverProvider), + languageFeaturesService.definitionProvider.register(languages, definitionProvider), ); } diff --git a/src/vs/workbench/services/languageHost/common/languageHost.ts b/src/vs/workbench/services/languageHost/common/languageHost.ts index a66037467e1..ab1f22e6f78 100644 --- a/src/vs/workbench/services/languageHost/common/languageHost.ts +++ b/src/vs/workbench/services/languageHost/common/languageHost.ts @@ -49,6 +49,12 @@ export interface ILanguageHostService { * Returns the host's JSON string (a Hover shape or null). */ requestHover(uri: string, line: number, column: number): Promise; + + /** + * Request definition information for a position in a synced document. + * Returns the host's JSON string (a DefinitionResponse shape or null). + */ + requestDefinition(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 fc20de64bf9..c043447285b 100644 --- a/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts +++ b/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts @@ -107,6 +107,10 @@ export class NativeLanguageHostService extends Disposable implements ILanguageHo return this.requestFeature({ type: 'hover', uri, line, column }); } + async requestDefinition(uri: string, line: number, column: number): Promise { + return this.requestFeature({ type: 'definition', uri, line, column }); + } + private async requestFeature(message: { type: string; uri: string; line?: number; column?: number }): Promise { if (!this.protocol) { throw new Error('Language Host not ready');