From c282805b5df7c2acf40a135a42f1362f06dfcfad Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Sat, 25 Jul 2026 23:23:44 +0900 Subject: [PATCH] feat: add Phase 2 hover provider + Phase 1.5 robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — tree-sitter-backed HoverProvider: Message::Hover { uri, line, column } -> HoverResponse { signature, documentation, range }. UTF-16 to UTF-8 column reconciliation for hover positions (renderer Position.column is UTF-16 code units; tree-sitter Point.column is a UTF-8 byte offset). Signature via source-slice of the declaration header (keeps async/generics/params/return-type verbatim); JSDoc collected from leading /** comment siblings. Name-token gate (hover fires only on the identifier, not the body); variable_declarator/public_field_definition require a type annotation. CodermHoverProvider registered for the configured languages; renderer wraps signature in a typescript MarkdownString and renders JSDoc as a second markdown string. Phase 1.5 — robustness: reject all pending requests on MessagePort close (no 30s timeout wait); onDidChangeLanguage re-sync (document/open re-sent with the new languageId). Fix: SymbolKind off-by-one — documentSymbol kind values now match VS Code's SymbolKind enum (Class=4, Method=5, Enum=9, Interface=10, Function=11, TypeParameter=25), not LSP's off-by-one numbers, so outline icons render correctly. Co-Authored-By: Claude --- README.ja.md | 4 +- README.md | 2 +- rust/crates/language-host/src/main.rs | 255 +++++++++++++++++- .../languageHost/common/documentSync.ts | 32 ++- .../languageHost/common/languageFeatures.ts | 65 +++++ .../languageHost/common/languageHost.ts | 6 + .../common/languageHostProtocol.ts | 17 ++ .../electron-browser/languageHostService.ts | 6 +- 8 files changed, 361 insertions(+), 26 deletions(-) diff --git a/README.ja.md b/README.ja.md index 94459e182ff..d992e76ac77 100644 --- a/README.ja.md +++ b/README.ja.md @@ -80,8 +80,8 @@ 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` | (実験的)ネイティブ Language Host プロセスを有効化(Phase 0: 自己テストのみ。言語機能は未実装) | -| `coderm.languageHost.languages` | `array` | `[]` | (実験的)ネイティブ Host で処理する言語 ID(予約) | +| `coderm.languageHost.enabled` | `boolean` | `false` | (実験的)ネイティブ(Rust)Language Host を有効化。設定した言語で tree-sitter ベースの documentSymbol・foldingRange・hover を提供(Phase 2) | +| `coderm.languageHost.languages` | `array` | `[]` | (実験的)ネイティブ Host が扱う言語 ID(例: "typescript", "tsx")。空の場合は機能無効 | --- diff --git a/README.md b/README.md index 7201bca909b..5e83af81ad8 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 and foldingRange for the configured languages (Phase 1) | +| `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.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 2ac00f46d67..f977d02878c 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 1: request-id multiplexer + document sync + tree-sitter features). +// Coderm Language Host (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. @@ -9,11 +9,16 @@ // - document/close: {type:"document/close", uri} // - documentSymbol: {type:"documentSymbol", uri} → [DocumentSymbol] // - foldingRange: {type:"foldingRange", uri} → [FoldingRange] +// - hover: {type:"hover", uri, line, column} → HoverResponse | null // -// Caution: tree-sitter positions are 0-indexed byte offsets; VS Code DocumentSymbol -// line/column are 1-indexed. Phase 1 converts with a simple +1 — columns coincide for -// ASCII but drift for non-ASCII identifiers (UTF-8 byte offset vs UTF-16 code unit). -// Reconciliation is TODO for Phase 2. +// Phase 2 additions: +// - hover: function/method/class/interface/type/typed-variable signatures with JSDoc +// - UTF-16 column reconciliation for hover positions (Phase 1.5) +// +// 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). use std::collections::HashMap; use std::io::{self, Read, Write}; @@ -49,6 +54,9 @@ enum Message { DocumentSymbol { uri: String }, #[serde(rename = "foldingRange")] FoldingRange { uri: String }, + // 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 }, } struct Document { @@ -82,6 +90,17 @@ struct FoldingRange { end: u32, } +// Phase 2 hover response. The renderer wraps `signature` in a ```typescript MarkdownString +// code block and renders `documentation` as a second MarkdownString (matching the built-in TS +// hover). Keeping markdown shaping on the renderer side avoids markdown pitfalls in Rust. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct HoverResponse { + signature: String, + documentation: String, + range: Range, // name-token range, so the highlight matches the hovered identifier +} + struct LanguageHost { documents: HashMap, } @@ -147,11 +166,41 @@ impl LanguageHost { serde_json::to_string(&ranges).map_err(|e| e.to_string())?, )) } + Message::Hover { uri, line, column } => Ok(Some(self.hover_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 { + let doc = match self.document(uri) { + Ok(doc) => doc, + Err(e) => { + eprintln!("[languageHost] hover error (unknown uri): {}", e); + return "null".to_string(); + } + }; + match hover_at_position(&doc.language_id, &doc.content, line, column) { + Ok(Some(response)) => serde_json::to_string(&response).unwrap_or_else(|e| { + eprintln!("[languageHost] hover serialization error: {}", e); + "null".to_string() + }), + Ok(None) => "null".to_string(), + Err(e) => { + eprintln!("[languageHost] hover error: {}", e); + "null".to_string() + } } } } fn parse_source(language_id: &str, content: &str) -> Result { + // Note: tree-sitter-typescript 0.23 exposes LANGUAGE_TYPESCRIPT / LANGUAGE_TSX as LanguageFn + // constants (NOT callable functions); `.into()` converts each to a Language. The 0.22 form is + // absent from crates.io, so 0.23 is the baseline (rust/crates/language-host/Cargo.toml). let language: Language = match language_id { "typescript" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), "tsx" => tree_sitter_typescript::LANGUAGE_TSX.into(), @@ -179,7 +228,7 @@ fn folding_ranges(language_id: &str, content: &str) -> Result, } // tree-sitter Point is 0-indexed; VS Code line/column are 1-indexed. See file header -// for the non-ASCII column caveat. +// for the non-ASCII column caveat (Phase 1 symbol/fold remain byte-column + 1). fn range_from_points(start: Point, end: Point) -> Range { Range { start_line_number: start.row as u32 + 1, @@ -209,14 +258,18 @@ fn collect_symbols(node: Node, source: &str, symbols: &mut Vec) } fn symbol_for_node(node: Node, source: &str) -> Option { - // SymbolKind numeric values mirror VS Code's SymbolKind enum. + // SymbolKind numeric values mirror VS Code's SymbolKind enum (languages.ts:1595). + // Why these exact numbers: languageFeatures.ts casts `kind` straight to SymbolKind via + // `as SymbolKind` (no LSP conversion layer), so the values must already be VS Code's enum + // (Class=4, Method=5, Enum=9, Interface=10, Function=11, TypeParameter=25) — NOT LSP's + // off-by-one numbers, or outline icons would render one slot off. let kind_num = match node.kind() { - "function_declaration" | "generator_function_declaration" | "function_signature" => 12, // Function - "class_declaration" | "abstract_class_declaration" => 5, // Class - "method_definition" | "method_signature" | "abstract_method_signature" => 6, // Method - "interface_declaration" => 11, // Interface - "enum_declaration" => 10, // Enum - "type_alias_declaration" => 26, // TypeParameter + "function_declaration" | "generator_function_declaration" | "function_signature" => 11, // Function + "class_declaration" | "abstract_class_declaration" => 4, // Class + "method_definition" | "method_signature" | "abstract_method_signature" => 5, // Method + "interface_declaration" => 10, // Interface + "enum_declaration" => 9, // Enum + "type_alias_declaration" => 25, // TypeParameter _ => return None, }; let name_node = node.child_by_field_name("name")?; @@ -264,6 +317,182 @@ fn is_foldable(kind: &str) -> bool { ) } +// Phase 2: hover provider. Returns signature + JSDoc for function/method/class/interface/ +// type/typed-variable declarations. Resolves the renderer's (line, UTF-16 column) to a +// tree-sitter Point, finds the deepest node, walks up to the enclosing declaration, and +// only emits a hover when the point is on the declaration's name token (matches TS behavior). +fn hover_at_position( + language_id: &str, + content: &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, + }; + + // Deepest named node at the point, then walk up to the enclosing declaration. + let leaf = match tree + .root_node() + .named_descendant_for_point_range(point, point) + { + Some(n) => n, + None => return Ok(None), + }; + let decl = match walk_up_to_declaration(leaf) { + Some(d) => d, + None => return Ok(None), + }; + + // Gate on the name token: hover only fires on the identifier, not on the body. This + // mirrors the built-in TS hover (hovering inside a function body returns nothing). + let name_node = match decl.child_by_field_name("name") { + Some(n) => n, + None => return Ok(None), + }; + if !point_in_node_range(point, name_node) { + return Ok(None); + } + + // variable_declarator / public_field_definition are only useful with a type annotation + // (`const x = 1` has nothing to say; `const x: number` does). + if matches!( + decl.kind(), + "variable_declarator" | "public_field_definition" + ) && decl.child_by_field_name("type").is_none() + { + return Ok(None); + } + + Ok(Some(HoverResponse { + signature: signature_slice(decl, content), + documentation: collect_jsdoc(decl, content), + range: range_from_points(name_node.start_position(), name_node.end_position()), + })) +} + +// Reconcile a renderer (row_0, column_1) to a tree-sitter UTF-8 byte column on that row. +// Why per-row walk: tree-sitter Point.column is a UTF-8 byte offset relative to the row, +// while the renderer's Position.column is 1-indexed UTF-16 code units. For non-ASCII +// (emoji/CJK) the two diverge, so we count UTF-16 units char-by-char until column_1 - 1. +fn utf16_column_to_utf8_byte(content: &str, row_0: usize, column_1: u32) -> Result { + let line_str = content + .lines() + .nth(row_0) + .ok_or_else(|| format!("row {} out of range", row_0))?; + let target = column_1.saturating_sub(1) as usize; // renderer column is 1-indexed + let mut utf16_seen = 0usize; + let mut byte_offset = 0usize; + for c in line_str.chars() { + if utf16_seen >= target { + break; + } + utf16_seen += c.len_utf16(); + byte_offset += c.len_utf8(); + } + Ok(byte_offset) +} + +fn walk_up_to_declaration<'a>(start: Node<'a>) -> Option> { + let mut cursor = Some(start); + while let Some(n) = cursor { + if is_hover_declaration_kind(n.kind()) { + return Some(n); + } + cursor = n.parent(); + } + None +} + +fn is_hover_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" + | "type_alias_declaration" + | "variable_declarator" + | "public_field_definition" + ) +} + +fn point_in_node_range(point: Point, node: Node) -> bool { + let s = node.start_position(); + let e = node.end_position(); + !(point.row < s.row + || point.row > e.row + || (point.row == s.row && point.column < s.column) + || (point.row == e.row && point.column >= e.column)) +} + +// Signature slice: source[decl.start_byte() .. body.start_byte()) for kinds with a body; +// whole node for type_alias_declaration / *_signature. Trim trailing whitespace. This keeps +// async/generics/params/return-type verbatim as the author wrote them (no reconstruction). +fn signature_slice(decl: Node, source: &str) -> String { + let end_byte = if decl.kind() == "type_alias_declaration" || decl.kind().ends_with("signature") + { + decl.end_byte() + } else { + decl.child_by_field_name("body") + .map(|b| b.start_byte()) + .unwrap_or(decl.end_byte()) + }; + source[decl.start_byte()..end_byte].trim_end().to_string() +} + +// Walk prev_sibling collecting consecutive `comment` nodes whose text starts with `/**`. +// Comments are named siblings (not children), so prev_sibling (not prev_named_sibling) is +// correct. Returns the stripped JSDoc text, or empty if no leading JSDoc. +fn collect_jsdoc(decl: Node, source: &str) -> String { + let mut blocks: Vec<&str> = Vec::new(); + let mut cursor = decl.prev_sibling(); + while let Some(c) = cursor { + let text = node_text(c, source); + if c.kind() != "comment" || !text.starts_with("/**") { + break; + } + blocks.push(text); + cursor = c.prev_sibling(); + } + if blocks.is_empty() { + return String::new(); + } + blocks.reverse(); + strip_jsdoc(&blocks.join("\n")) +} + +// Strip the `/** ... */` markers and per-line `* ` prefixes from a JSDoc block. +fn strip_jsdoc(combined: &str) -> String { + let trimmed = combined + .strip_prefix("/**") + .and_then(|s| s.strip_suffix("*/")) + .unwrap_or(combined); + trimmed + .lines() + .map(|line| { + let stripped = line.trim(); + match stripped.strip_prefix('*') { + // After stripping `*` and an optional leading space, re-trim to drop any + // trailing whitespace (e.g. "* foo " → "foo"). + Some(rest) => rest.strip_prefix(' ').unwrap_or(rest).trim(), + None => stripped, + } + }) + .filter(|l| !l.is_empty()) + .collect::>() + .join("\n") +} + // 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/documentSync.ts b/src/vs/workbench/services/languageHost/common/documentSync.ts index a2d418935dc..4cb08e87896 100644 --- a/src/vs/workbench/services/languageHost/common/documentSync.ts +++ b/src/vs/workbench/services/languageHost/common/documentSync.ts @@ -10,7 +10,7 @@ // sidesteps an incremental range-merge engine at the cost of re-sending the buffer. import { ITextModel } from '../../../../editor/common/model.js'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IDisposable, combinedDisposable } from '../../../../base/common/lifecycle.js'; export type DocumentSyncMessage = | { type: 'document/open'; uri: string; version: number; languageId: string; text: string } @@ -36,16 +36,19 @@ export class DocumentSyncManager { return; // already syncing } - this.sendMessage({ - type: 'document/open', - uri, - version: model.getVersionId(), - languageId: model.getLanguageId(), - text: model.getValue(), + this.sendOpen(model, uri); + + // Phase 1.5: watch for language changes and re-sync if needed + const languageDisposable = model.onDidChangeLanguage(() => { + if (this.isLanguageEnabled(model.getLanguageId())) { + this.sendOpen(model, uri); + } else { + this.unsyncDocument(uri); + } }); // TODO(Phase 2): debounce rapid edits. Phase 1 forwards each change immediately. - const disposable = model.onDidChangeContent(() => { + const contentDisposable = model.onDidChangeContent(() => { this.sendMessage({ type: 'document/change', uri, @@ -54,8 +57,19 @@ export class DocumentSyncManager { }); }); + this.disposables.set(uri, combinedDisposable(languageDisposable, contentDisposable)); + this.syncedDocuments.set(uri, model); - this.disposables.set(uri, disposable); + } + + private sendOpen(model: ITextModel, uri: string): void { + this.sendMessage({ + type: 'document/open', + uri, + version: model.getVersionId(), + languageId: model.getLanguageId(), + text: model.getValue(), + }); } unsyncDocument(uri: string): void { diff --git a/src/vs/workbench/services/languageHost/common/languageFeatures.ts b/src/vs/workbench/services/languageHost/common/languageFeatures.ts index 899b420f599..8e1c8a3869d 100644 --- a/src/vs/workbench/services/languageHost/common/languageFeatures.ts +++ b/src/vs/workbench/services/languageHost/common/languageFeatures.ts @@ -16,8 +16,12 @@ import { FoldingContext, FoldingRange, FoldingRangeProvider, + Hover, + HoverProvider, SymbolKind, } from '../../../../editor/common/languages.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Position } from '../../../../editor/common/core/position.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { ILanguageHostService } from './languageHost.js'; @@ -35,6 +39,12 @@ interface FoldingRangeResponse { end: number; } +interface HoverResponse { + signature: string; + documentation: string; + range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; +} + class CodermDocumentSymbolProvider implements DocumentSymbolProvider { readonly displayName = 'Coderm Language Host'; @@ -73,6 +83,29 @@ class CodermFoldingRangeProvider implements FoldingRangeProvider { } } +class CodermHoverProvider implements HoverProvider { + readonly displayName = 'Coderm Language Host'; + + constructor(private readonly languageHostService: ILanguageHostService) { } + + async provideHover(model: ITextModel, position: Position, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + try { + const json = await this.languageHostService.requestHover( + model.uri.toString(), + position.lineNumber, + position.column + ); + return parseHover(json); + } catch (err) { + console.error('[CodermHoverProvider] 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[] { @@ -112,6 +145,36 @@ 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. + if (json === 'null' || json === '') { + return undefined; + } + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + return undefined; + } + if (!raw || typeof raw !== 'object') { + return undefined; + } + const response = raw as HoverResponse; + // 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). + const contents: IMarkdownString[] = [ + new MarkdownString('```typescript\n' + response.signature + '\n```'), + ]; + if (response.documentation) { + contents.push(new MarkdownString(response.documentation)); + } + return { contents, range: response.range }; +} + export function registerLanguageFeatureProviders( languageHostService: ILanguageHostService, languages: string[], @@ -119,11 +182,13 @@ export function registerLanguageFeatureProviders( ): IDisposable { const documentSymbolProvider = new CodermDocumentSymbolProvider(languageHostService); const foldingRangeProvider = new CodermFoldingRangeProvider(languageHostService); + const hoverProvider = new CodermHoverProvider(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. return combinedDisposable( languageFeaturesService.documentSymbolProvider.register(languages, documentSymbolProvider), languageFeaturesService.foldingRangeProvider.register(languages, foldingRangeProvider), + languageFeaturesService.hoverProvider.register(languages, hoverProvider), ); } diff --git a/src/vs/workbench/services/languageHost/common/languageHost.ts b/src/vs/workbench/services/languageHost/common/languageHost.ts index a495a1fc8f1..a66037467e1 100644 --- a/src/vs/workbench/services/languageHost/common/languageHost.ts +++ b/src/vs/workbench/services/languageHost/common/languageHost.ts @@ -43,6 +43,12 @@ export interface ILanguageHostService { * (a FoldingRange[] shape). */ requestFoldingRange(uri: string): Promise; + + /** + * Request hover information for a position in a synced document. + * Returns the host's JSON string (a Hover shape or null). + */ + requestHover(uri: string, line: number, column: number): Promise; } // --- Coderm end --- diff --git a/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts b/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts index dd16210e9a5..5429df890e3 100644 --- a/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts +++ b/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts @@ -31,7 +31,11 @@ export class LanguageHostProtocol { constructor(private readonly port: MessagePort) { port.onmessage = (e: MessageEvent) => this.onMessage(e.data); + port.onmessageerror = (e: MessageEvent) => this.onMessageError(e); port.start(); + + // Phase 1.5: reject all pending requests when the port closes + port.addEventListener('close', () => this.onPortClose()); } private onMessage(data: unknown): void { @@ -65,6 +69,19 @@ export class LanguageHostProtocol { } } + private onMessageError(e: MessageEvent): void { + console.error('[LanguageHostProtocol] message error', e); + } + + private onPortClose(): void { + // Phase 1.5: reject all pending requests when the port closes + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('Language Host port closed')); + } + this.pending.clear(); + } + // Fire-and-forget: reqId=0, no response expected. notify(payload: Uint8Array): void { this.port.postMessage(this.frame(NOTIFY_REQUEST_ID, payload)); diff --git a/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts b/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts index 429178a940f..fc20de64bf9 100644 --- a/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts +++ b/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts @@ -103,7 +103,11 @@ export class NativeLanguageHostService extends Disposable implements ILanguageHo return this.requestFeature({ type: 'foldingRange', uri }); } - private async requestFeature(message: { type: string; uri: string }): Promise { + async requestHover(uri: string, line: number, column: number): Promise { + return this.requestFeature({ type: 'hover', 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'); }