diff --git a/README.md b/README.md index d0591576c16..7201bca909b 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ 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 Language Host process (Phase 0: runs a self-test only; no language features yet) | -| `coderm.languageHost.languages` | `array` | `[]` | _(experimental)_ Reserved for language IDs to handle in the native host | +| `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.languages` | `array` | `[]` | _(experimental)_ Language IDs handled by the native host (e.g. "typescript", "tsx"). Empty keeps the feature inert | --- diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 008852a7df9..df6ea0dfced 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,196 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "coderm-language-host" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tree-sitter", + "tree-sitter-typescript", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tree-sitter" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/crates/language-host/Cargo.toml b/rust/crates/language-host/Cargo.toml index 8189078eb4a..564d2410048 100644 --- a/rust/crates/language-host/Cargo.toml +++ b/rust/crates/language-host/Cargo.toml @@ -8,5 +8,12 @@ license.workspace = true name = "coderm-language-host" path = "src/main.rs" -# Phase 0: no dependencies. FlatBuffers will be added back in Phase 1 (main-process side). +# Phase 1: tree-sitter + tree-sitter-typescript for language features +# (documentSymbol, foldingRange). serde for JSON (de)serialization of protocol messages. +# Note: tree-sitter-typescript has no 0.22 release on crates.io; 0.23.x is the baseline that +# matches tree-sitter 0.23, so both crates are pinned to 0.23 here. [dependencies] +tree-sitter = "0.23" +tree-sitter-typescript = "0.23" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/crates/language-host/src/main.rs b/rust/crates/language-host/src/main.rs index 24d512fa44c..2ac00f46d67 100644 --- a/rust/crates/language-host/src/main.rs +++ b/rust/crates/language-host/src/main.rs @@ -1,38 +1,339 @@ -// Coderm Language Host (Phase 0: simple echo stub). +// Coderm Language Host (Phase 1: request-id multiplexer + document sync + tree-sitter features). // -// Wire format: [4 bytes LE length][raw payload]. Echoes the payload back verbatim. -// Why simple (no FlatBuffers yet): VS Code 1.122 renderer does not support static npm -// imports, so FlatBuffers cannot be used on the renderer side without deep build changes. -// FlatBuffers will be introduced in Phase 1, likely hosted on the main-process side -// (renderer <-> main over VSBuffer, main <-> Rust over FlatBuffers). The Rust FlatBuffers -// scaffolding (schemas/message.fbs) is kept for that future step. +// 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] +// +// 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. +use std::collections::HashMap; use std::io::{self, Read, Write}; +use serde::{Deserialize, Serialize}; +use tree_sitter::{Language, Node, Parser, Point}; + +#[derive(Deserialize)] +#[serde(tag = "type")] +enum Message { + // Why rename_all on the variant (not the enum): a container-level rename_all on an enum + // renames VARIANT names, not struct-variant fields. Field camelCase (languageId) must be set + // per-variant, or the TS camelCase payload fails to deserialize and silently kills the host + // process (every later request then times out). + #[serde(rename = "document/open", rename_all = "camelCase")] + DocumentOpen { + uri: String, + #[allow(dead_code)] + version: u32, // reserved for Phase 2 incremental sync + language_id: String, + text: String, + }, + #[serde(rename = "document/change", rename_all = "camelCase")] + DocumentChange { + uri: String, + #[allow(dead_code)] + version: u32, // reserved for Phase 2 incremental sync + text: String, + }, + #[serde(rename = "document/close")] + DocumentClose { uri: String }, + #[serde(rename = "documentSymbol")] + DocumentSymbol { uri: String }, + #[serde(rename = "foldingRange")] + FoldingRange { uri: String }, +} + +struct Document { + language_id: String, + content: String, +} + +// VS Code DocumentSymbol (subset). camelCase serialization matches the renderer's +// DocumentSymbol shape (startLineNumber, selectionRange, ...). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentSymbol { + name: String, + kind: u32, + range: Range, + selection_range: Range, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Range { + start_line_number: u32, + start_column: u32, + end_line_number: u32, + end_column: u32, +} + +#[derive(Serialize)] +struct FoldingRange { + start: u32, + end: u32, +} + +struct LanguageHost { + documents: HashMap, +} + +impl LanguageHost { + fn new() -> Self { + Self { + documents: HashMap::new(), + } + } + + fn document(&self, uri: &str) -> Result<&Document, String> { + self.documents + .get(uri) + .ok_or_else(|| format!("unknown document: {}", uri)) + } + + // Returns Some(json) for requests (caller emits a response frame) and None for + // notifications (no response). On handler error, callers emit an empty result for + // requests so the renderer does not wait until timeout. + fn handle_message(&mut self, message: Message) -> Result, String> { + match message { + Message::DocumentOpen { + uri, + language_id, + text, + .. + } => { + self.documents.insert( + uri, + Document { + language_id, + content: text, + }, + ); + Ok(None) + } + // Constraint: Phase 1 replaces the whole content on every change rather than + // applying incremental TextChange edits. This sidesteps a range-merge engine + // (and the row/column reconciliation it would force) at the cost of re-sending + // the full buffer from the renderer — an acceptable trade for Phase 1. + Message::DocumentChange { uri, text, .. } => { + if let Some(doc) = self.documents.get_mut(&uri) { + doc.content = text; + } + Ok(None) + } + Message::DocumentClose { uri } => { + self.documents.remove(&uri); + Ok(None) + } + Message::DocumentSymbol { uri } => { + let doc = self.document(&uri)?; + let symbols = document_symbols(&doc.language_id, &doc.content)?; + Ok(Some( + serde_json::to_string(&symbols).map_err(|e| e.to_string())?, + )) + } + Message::FoldingRange { uri } => { + let doc = self.document(&uri)?; + let ranges = folding_ranges(&doc.language_id, &doc.content)?; + Ok(Some( + serde_json::to_string(&ranges).map_err(|e| e.to_string())?, + )) + } + } + } +} + +fn parse_source(language_id: &str, content: &str) -> Result { + let language: Language = match language_id { + "typescript" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + "tsx" => tree_sitter_typescript::LANGUAGE_TSX.into(), + other => return Err(format!("unsupported language: {}", other)), + }; + let mut parser = Parser::new(); + parser.set_language(&language).map_err(|e| e.to_string())?; + parser + .parse(content, None) + .ok_or_else(|| "parse returned None".to_string()) +} + +fn document_symbols(language_id: &str, content: &str) -> Result, String> { + let tree = parse_source(language_id, content)?; + let mut symbols = Vec::new(); + collect_symbols(tree.root_node(), content, &mut symbols); + Ok(symbols) +} + +fn folding_ranges(language_id: &str, content: &str) -> Result, String> { + let tree = parse_source(language_id, content)?; + let mut ranges = Vec::new(); + collect_folds(tree.root_node(), &mut ranges); + Ok(ranges) +} + +// tree-sitter Point is 0-indexed; VS Code line/column are 1-indexed. See file header +// for the non-ASCII column caveat. +fn range_from_points(start: Point, end: Point) -> Range { + Range { + start_line_number: start.row as u32 + 1, + start_column: start.column as u32 + 1, + end_line_number: end.row as u32 + 1, + end_column: end.column as u32 + 1, + } +} + +fn node_text<'a>(node: Node, source: &'a str) -> &'a str { + // tree-sitter guarantees start_byte/end_byte land on UTF-8 boundaries. + &source[node.start_byte()..node.end_byte()] +} + +// Phase 1 emits a flat symbol list. VS Code re-hierarchizes by range overlap, so a flat +// list renders correctly in the Outline view. TODO(Phase 2): emit nested children. +fn collect_symbols(node: Node, source: &str, symbols: &mut Vec) { + if let Some(sym) = symbol_for_node(node, source) { + symbols.push(sym); + } + let count = node.named_child_count(); + for i in 0..count { + if let Some(child) = node.named_child(i) { + collect_symbols(child, source, symbols); + } + } +} + +fn symbol_for_node(node: Node, source: &str) -> Option { + // SymbolKind numeric values mirror VS Code's SymbolKind enum. + 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 + _ => return None, + }; + let name_node = node.child_by_field_name("name")?; + let name = node_text(name_node, source).to_string(); + Some(DocumentSymbol { + name, + kind: kind_num, + range: range_from_points(node.start_position(), node.end_position()), + selection_range: range_from_points(name_node.start_position(), name_node.end_position()), + }) +} + +fn collect_folds(node: Node, ranges: &mut Vec) { + if is_foldable(node.kind()) { + let start = node.start_position().row; + let end = node.end_position().row; + // Only foldable when the block spans more than one line. + if end > start { + ranges.push(FoldingRange { + start: start as u32 + 1, + end: end as u32 + 1, + }); + } + } + let count = node.named_child_count(); + for i in 0..count { + if let Some(child) = node.named_child(i) { + collect_folds(child, ranges); + } + } +} + +fn is_foldable(kind: &str) -> bool { + matches!( + kind, + "statement_block" + | "class_body" + | "interface_body" + | "enum_body" + | "switch_body" + | "object" + | "formal_parameters" + | "block" + | "named_imports" + ) +} + +// 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]; + header[0..4].copy_from_slice(&request_id.to_le_bytes()); + header[4..8].copy_from_slice(&(payload.len() as u32).to_le_bytes()); + io::stdout().write_all(&header)?; + io::stdout().write_all(payload)?; + io::stdout().flush() +} + fn main() -> io::Result<()> { - let mut buffer = vec![0u8; 65536]; + let mut host = LanguageHost::new(); + let mut buffer = vec![0u8; 1024 * 1024]; // 1 MiB max payload loop { - // Frame: [4 bytes LE length][payload] - let mut length_bytes = [0u8; 4]; - if io::stdin().read_exact(&mut length_bytes).is_err() { - break; // EOF (parent closed stdin) or read error + // Frame: [4 bytes LE request_id][4 bytes LE length][payload] + let mut header = [0u8; 8]; + if io::stdin().read_exact(&mut header).is_err() { + break; // EOF (parent closed stdin) or read error → exit cleanly } - let length = u32::from_le_bytes(length_bytes) as usize; + let request_id = u32::from_le_bytes([header[0], header[1], header[2], header[3]]); + let length = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize; + if length == 0 || length > buffer.len() { - // Drain the oversized payload to keep the stream framed; otherwise the next - // length read would consume payload bytes and silently desync the protocol. + // Drain the oversized/zero-length payload to keep the stream framed. io::copy(&mut io::stdin().take(length as u64), &mut io::sink())?; continue; } io::stdin().read_exact(&mut buffer[..length])?; - // Echo the payload back with the same length-prefix framing. - io::stdout().write_all(&length_bytes)?; - io::stdout().write_all(&buffer[..length])?; - io::stdout().flush()?; + let json_str = match std::str::from_utf8(&buffer[..length]) { + Ok(s) => s, + Err(e) => { + // Skip the malformed frame but keep the stream framed: read_exact already + // consumed exactly `length` bytes. Logging surfaces the cause instead of + // letting the host die and stall every pending request until its timeout. + eprintln!("[languageHost] UTF-8 error (frame skipped): {}", e); + continue; + } + }; + + let message: Message = match serde_json::from_str(json_str) { + Ok(m) => m, + Err(e) => { + eprintln!("[languageHost] JSON parse error (frame skipped): {}", e); + continue; + } + }; + + let handled = host.handle_message(message); + + // Notifications (request_id == 0) never receive a response frame, even on error — + // the renderer does not await them. Requests always receive a frame (real result or + // empty fallback) so the multiplexer's pending entry is never orphaned. + match handled { + Ok(Some(json)) => { + if request_id > 0 { + write_frame(request_id, json.as_bytes())?; + } + } + Ok(None) => {} + Err(e) => { + eprintln!("[languageHost] handler error: {}", e); + if request_id > 0 { + write_frame(request_id, b"[]")?; + } + } + } } Ok(()) diff --git a/src/vs/platform/languageHost/electron-main/languageHostServerService.ts b/src/vs/platform/languageHost/electron-main/languageHostServerService.ts index 050e6a0a94f..c79662e352d 100644 --- a/src/vs/platform/languageHost/electron-main/languageHostServerService.ts +++ b/src/vs/platform/languageHost/electron-main/languageHostServerService.ts @@ -5,7 +5,9 @@ // Coderm: electron-main ILanguageHostServerService implementation. Owns the Rust Language // Host child process and the stdio<->MessagePort byte relay. -// Wire format on stdio: [4 bytes LE length][payload]. +// Wire format on stdio: [4 bytes LE request_id][4 bytes LE length][payload]. +// The relay is a transparent 8-byte-frame pass-through: the renderer emits one frame per +// MessagePort message, and each Rust response frame is forwarded whole to the renderer. // Why a relay (not direct socket): Electron exposes no cross-process shared memory; the // tsserver Content-Length framing (see typescript-language-features) is a proven pattern. @@ -24,10 +26,25 @@ import { ILanguageHostStartResult, } from '../common/languageHostServer.js'; +// Node MessagePort passes the value directly; Electron MessagePortMain may wrap it in a +// MessageEvent { data }. Buffers may coincidentally carry a `data` field, so skip the +// unwrap for typed arrays and ArrayBuffers. +function unwrapPortMessage(e: unknown): unknown { + if ( + e && typeof e === 'object' && + Object.hasOwn(e, 'data') && + !(e instanceof Uint8Array) && + !(e instanceof ArrayBuffer) + ) { + return (e as { data: unknown }).data; + } + return e; +} + export class LanguageHostServerService extends Disposable implements ILanguageHostServerService { declare readonly _serviceBrand: undefined; - private static readonly _frameHeaderBytes = 4; + private static readonly _frameHeaderBytes = 8; // request_id (4) + length (4) private static _lastId = 0; private readonly _hosts = new Map(); @@ -98,44 +115,40 @@ export class LanguageHostServerService extends Disposable implements ILanguageHo } private _setupRelay(id: string, port: Electron.MessagePortMain, childProcess: ChildProcess): void { - // renderer -> Rust: prefix each payload with a 4-byte LE length header. - // Node MessagePort passes the value directly; Electron MessagePortMain may wrap it in - // a MessageEvent. Handle both shapes and log to confirm the path during Phase 0 bring-up. + // renderer -> Rust: forward the renderer's 8-byte-header frame ([reqId][length][payload]) + // unchanged. The renderer emits exactly one frame per postMessage and MessagePort + // preserves message boundaries, so no extra length-prefixing is needed on this leg. port.on('message', (e: unknown) => { - const raw: unknown = (e && typeof e === 'object' && Object.hasOwn(e as object, 'data') && !(e instanceof Uint8Array) && !(e instanceof ArrayBuffer)) - ? (e as { data: unknown }).data - : e; + const raw = unwrapPortMessage(e); const data: Uint8Array = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw as Uint8Array; if (!data || data.byteLength === undefined) { this._logService.trace(`[languageHost:${id}] relay: received non-buffer message`, e); return; } this._logService.trace(`[languageHost:${id}] relay: received ${data.byteLength} bytes from renderer`); - const header = Buffer.alloc(LanguageHostServerService._frameHeaderBytes); - header.writeUInt32LE(data.byteLength, 0); - childProcess.stdin?.write(header); childProcess.stdin?.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); }); port.start(); childProcess.stdin?.on('error', err => this._logService.error(`[languageHost:${id}] stdin error`, err)); - // Rust -> renderer: parse length-prefixed frames and forward each payload. + // Rust -> renderer: reassemble [reqId(4)][length(4)][payload] frames from the stdout + // stream and forward each whole frame. Forwarding the header (not just the payload) + // lets the renderer's multiplexer read reqId + payload in a single message. let readBuffer = Buffer.alloc(0); childProcess.stdout?.on('data', (chunk: Buffer) => { - this._logService.trace(`[languageHost:${id}] stdout: ${chunk.length} bytes`); readBuffer = Buffer.concat([readBuffer, chunk]); while (readBuffer.length >= LanguageHostServerService._frameHeaderBytes) { - const length = readBuffer.readUInt32LE(0); + const length = readBuffer.readUInt32LE(4); // skip request_id at [0..4] if (length === 0 || readBuffer.length < LanguageHostServerService._frameHeaderBytes + length) { break; // incomplete frame, wait for more } - const payload = readBuffer.subarray( - LanguageHostServerService._frameHeaderBytes, + const frame = readBuffer.subarray( + 0, LanguageHostServerService._frameHeaderBytes + length ); // Copy into a standalone Uint8Array so it survives the next concat/subarray. - port.postMessage(Uint8Array.from(payload)); + port.postMessage(Uint8Array.from(frame)); readBuffer = readBuffer.subarray(LanguageHostServerService._frameHeaderBytes + length); } }); diff --git a/src/vs/workbench/contrib/coderm/browser/languageHostConfiguration.ts b/src/vs/workbench/contrib/coderm/browser/languageHostConfiguration.ts index 0b803d7871f..4f596274321 100644 --- a/src/vs/workbench/contrib/coderm/browser/languageHostConfiguration.ts +++ b/src/vs/workbench/contrib/coderm/browser/languageHostConfiguration.ts @@ -3,17 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Coderm: Language Host configuration + Phase 0 self-test. Registers coderm.languageHost.* -// settings (default off so main stays inert) and runs a single echo round-trip when enabled. +// Coderm: Language Host configuration + Phase 1 feature registration. Registers +// coderm.languageHost.* settings (default off so main stays inert) and, when enabled with a +// non-empty language set, registers tree-sitter-backed documentSymbol/foldingRange providers +// and syncs open models to the native host. -import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { ILanguageHostService } from '../../../services/languageHost/common/languageHost.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { registerLanguageFeatureProviders } from '../../../services/languageHost/common/languageFeatures.js'; export const CodermLanguageHostEnabledSetting = 'coderm.languageHost.enabled'; @@ -26,33 +31,34 @@ Registry.as(ConfigurationExtensions.Configuration).regis [CodermLanguageHostEnabledSetting]: { type: 'boolean', // Note: default:false intentionally deviates from the project's "new settings - // default to enabled" convention (project CLAUDE.md, development rules). Phase 0 only - // validates the wire path; defaulting true would spawn the Rust host for every - // user before any language feature ships. Revisit once Phase 1 adds a user-facing - // capability. + // default to enabled" convention (project CLAUDE.md, development rules). The host + // spawns a Rust process and parses every open document of the configured languages; + // defaulting true would opt every user into that before the feature is proven. default: false, scope: ConfigurationScope.APPLICATION, description: localize('coderm.languageHost.enabled', - "Enable the native (Rust) Language Host. When enabled, language features for the configured languages are offloaded from the Extension Host to a separate native process to reduce memory and CPU usage."), + "Enable the native (Rust) Language Host. When enabled, documentSymbol and foldingRange for the configured languages are computed by tree-sitter in a separate native process."), }, 'coderm.languageHost.languages': { type: 'array', default: [], scope: ConfigurationScope.APPLICATION, description: localize('coderm.languageHost.languages', - "Language IDs handled by the native Language Host. Empty (default) keeps the feature inert; Phase 0 only validates the communication path."), + "Language IDs handled by the native Language Host (e.g. \"typescript\", \"tsx\"). Empty (default) keeps the feature inert."), items: { type: 'string' }, }, }, }); -class LanguageHostPhase0Contribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.coderm.languageHostPhase0'; +class LanguageHostPhase1Contribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.coderm.languageHostPhase1'; constructor( @ILanguageHostService languageHostService: ILanguageHostService, + @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, + @IModelService modelService: IModelService, @IConfigurationService configurationService: IConfigurationService, - @ILogService private readonly logService: ILogService, + @ILogService logService: ILogService, ) { super(); @@ -60,13 +66,32 @@ class LanguageHostPhase0Contribution extends Disposable implements IWorkbenchCon return; // feature off: main stays untouched } + const languages = configurationService.getValue('coderm.languageHost.languages') || []; + if (languages.length === 0) { + logService.info('[coderm.languageHost] no languages configured, feature inert'); + return; + } + languageHostService.whenReady() - .then(() => languageHostService.echo(new Uint8Array([1, 2, 3, 4, 5]))) - .then(result => this.logService.info(`[coderm.languageHost] phase 0 echo self-test OK: echoed ${result.byteLength} bytes`)) - .catch(err => this.logService.error('[coderm.languageHost] phase 0 echo self-test FAILED', err)); + .then(() => { + logService.info('[coderm.languageHost] starting Phase 1 (documentSymbol + foldingRange via tree-sitter)'); + + this._register(registerLanguageFeatureProviders(languageHostService, languages, languageFeaturesService)); + + // Sync every currently-open model, then track add/remove so the host buffer + // matches the renderer for the lifetime of the window. + for (const model of modelService.getModels()) { + languageHostService.syncDocument(model); + } + this._register(modelService.onModelAdded(model => languageHostService.syncDocument(model))); + this._register(modelService.onModelRemoved(model => languageHostService.unsyncDocument(model.uri.toString()))); + + logService.info(`[coderm.languageHost] active for languages: ${languages.join(', ')}`); + }) + .catch(err => logService.error('[coderm.languageHost] failed to start', err)); } } -registerWorkbenchContribution2(LanguageHostPhase0Contribution.ID, LanguageHostPhase0Contribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(LanguageHostPhase1Contribution.ID, LanguageHostPhase1Contribution, WorkbenchPhase.AfterRestored); // --- Coderm end --- diff --git a/src/vs/workbench/services/languageHost/common/documentSync.ts b/src/vs/workbench/services/languageHost/common/documentSync.ts new file mode 100644 index 00000000000..a2d418935dc --- /dev/null +++ b/src/vs/workbench/services/languageHost/common/documentSync.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Coderm: Document synchronization for the Language Host. +// Watches ITextModel lifecycle/content changes and forwards them to the Rust host as +// notifications (request_id = 0, no response). Phase 1 sends the full text on open AND on +// every change (full-text replace) — the Rust side overwrites its buffer each time. This +// 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'; + +export type DocumentSyncMessage = + | { type: 'document/open'; uri: string; version: number; languageId: string; text: string } + | { type: 'document/change'; uri: string; version: number; text: string } + | { type: 'document/close'; uri: string }; + +export class DocumentSyncManager { + private readonly syncedDocuments = new Map(); + private readonly disposables = new Map(); + + constructor( + private readonly sendMessage: (message: DocumentSyncMessage) => void, + private readonly isLanguageEnabled: (languageId: string) => boolean, + ) { } + + syncDocument(model: ITextModel): void { + if (!this.isLanguageEnabled(model.getLanguageId())) { + return; // not a language the host handles + } + + const uri = model.uri.toString(); + if (this.syncedDocuments.has(uri)) { + return; // already syncing + } + + this.sendMessage({ + type: 'document/open', + uri, + version: model.getVersionId(), + languageId: model.getLanguageId(), + text: model.getValue(), + }); + + // TODO(Phase 2): debounce rapid edits. Phase 1 forwards each change immediately. + const disposable = model.onDidChangeContent(() => { + this.sendMessage({ + type: 'document/change', + uri, + version: model.getVersionId(), + text: model.getValue(), + }); + }); + + this.syncedDocuments.set(uri, model); + this.disposables.set(uri, disposable); + } + + unsyncDocument(uri: string): void { + const disposable = this.disposables.get(uri); + if (disposable) { + disposable.dispose(); + this.disposables.delete(uri); + } + // Only notify close for documents we were actually syncing. + if (this.syncedDocuments.delete(uri)) { + this.sendMessage({ type: 'document/close', uri }); + } + } + + dispose(): void { + for (const disposable of this.disposables.values()) { + disposable.dispose(); + } + this.disposables.clear(); + this.syncedDocuments.clear(); + } +} diff --git a/src/vs/workbench/services/languageHost/common/languageFeatures.ts b/src/vs/workbench/services/languageHost/common/languageFeatures.ts new file mode 100644 index 00000000000..899b420f599 --- /dev/null +++ b/src/vs/workbench/services/languageHost/common/languageFeatures.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Coderm: Language feature providers backed by the native Language Host. Registers +// documentSymbol and foldingRange providers for the configured language set. Filtering is +// done via the language selector (the provider only runs for models whose language matches). + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { combinedDisposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { + DocumentSymbol, + DocumentSymbolProvider, + FoldingContext, + FoldingRange, + FoldingRangeProvider, + SymbolKind, +} from '../../../../editor/common/languages.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). +interface DocumentSymbolResponse { + name: string; + kind: number; + range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; + selectionRange: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; + children?: DocumentSymbolResponse[]; +} + +interface FoldingRangeResponse { + start: number; + end: number; +} + +class CodermDocumentSymbolProvider implements DocumentSymbolProvider { + readonly displayName = 'Coderm Language Host'; + + constructor(private readonly languageHostService: ILanguageHostService) { } + + async provideDocumentSymbols(model: ITextModel, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + try { + const json = await this.languageHostService.requestDocumentSymbol(model.uri.toString()); + return parseDocumentSymbols(json); + } catch (err) { + console.error('[CodermDocumentSymbolProvider] request failed', err); + return undefined; + } + } +} + +class CodermFoldingRangeProvider implements FoldingRangeProvider { + readonly displayName = 'Coderm Language Host'; + + constructor(private readonly languageHostService: ILanguageHostService) { } + + async provideFoldingRanges(model: ITextModel, _context: FoldingContext, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + try { + const json = await this.languageHostService.requestFoldingRange(model.uri.toString()); + return parseFoldingRanges(json); + } catch (err) { + console.error('[CodermFoldingRangeProvider] 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[] { + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + return []; + } + return Array.isArray(raw) ? raw as T[] : []; +} + +function parseDocumentSymbols(json: string): DocumentSymbol[] { + return parseJsonArray(json).map(toDocumentSymbol); +} + +function toDocumentSymbol(s: DocumentSymbolResponse): DocumentSymbol { + // DocumentSymbol/FoldingRange are interfaces here, so build plain objects that satisfy + // the shape rather than constructing a class instance. + const sym: DocumentSymbol = { + name: s.name, + // Note: detail/tags are required DocumentSymbol fields (languages.ts:1753-1755), but the + // Phase 1 Rust host does not compute them — defaulted empty until a later phase fills them. + detail: '', + kind: s.kind as SymbolKind, + tags: [], + range: s.range, + selectionRange: s.selectionRange, + }; + if (s.children && s.children.length > 0) { + sym.children = s.children.map(toDocumentSymbol); + } + return sym; +} + +function parseFoldingRanges(json: string): FoldingRange[] { + return parseJsonArray(json).map((r): FoldingRange => ({ start: r.start, end: r.end })); +} + +export function registerLanguageFeatureProviders( + languageHostService: ILanguageHostService, + languages: string[], + languageFeaturesService: ILanguageFeaturesService, +): IDisposable { + const documentSymbolProvider = new CodermDocumentSymbolProvider(languageHostService); + const foldingRangeProvider = new CodermFoldingRangeProvider(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), + ); +} diff --git a/src/vs/workbench/services/languageHost/common/languageHost.ts b/src/vs/workbench/services/languageHost/common/languageHost.ts index e64c9daae4c..a495a1fc8f1 100644 --- a/src/vs/workbench/services/languageHost/common/languageHost.ts +++ b/src/vs/workbench/services/languageHost/common/languageHost.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Coderm: Language Host service (renderer side). Phase 0 exposes an echo probe to validate -// the wire path (renderer -> main relay -> Rust). Phase 1+ adds document sync and language -// feature dispatch on the same MessagePort acquired at startup. +// Coderm: Language Host service (renderer side). Phase 1 adds document sync (notifications) +// and language feature dispatch (requests) on the same MessagePort acquired at startup. +// The Phase 0 raw echo probe is intentionally removed: the documentSymbol/foldingRange path +// now carries the wire-path validation that echo used to provide. import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ITextModel } from '../../../../editor/common/model.js'; export const ILanguageHostService = createDecorator('languageHostService'); @@ -15,16 +17,32 @@ export interface ILanguageHostService { readonly _serviceBrand: undefined; /** - * Resolves once the MessagePort to the native Language Host is established and the - * first handshake exchange has completed. + * Resolves once the MessagePort to the native Language Host is established. */ whenReady(): Promise; /** - * Phase 0 echo probe: sends `payload` to the native host and returns the echoed bytes. - * Throws if the host is not ready or the request times out / errors. + * Start syncing a document to the native host. Sends the full text initially, then + * full-text replaces on every content edit. No-op for languages outside the configured set. */ - echo(payload: Uint8Array): Promise; + syncDocument(model: ITextModel): void; + + /** + * Stop syncing a document (sends document/close). + */ + unsyncDocument(uri: string): void; + + /** + * Request the document symbol tree for a synced document. Returns the host's JSON string + * (a DocumentSymbol[] shape); the caller parses and shapes it. + */ + requestDocumentSymbol(uri: string): Promise; + + /** + * Request folding ranges for a synced document. Returns the host's JSON string + * (a FoldingRange[] shape). + */ + requestFoldingRange(uri: string): Promise; } // --- Coderm end --- diff --git a/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts b/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts index 4b78b1cba45..dd16210e9a5 100644 --- a/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts +++ b/src/vs/workbench/services/languageHost/common/languageHostProtocol.ts @@ -3,14 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Coderm: Language Host wire protocol (Phase 0: simple length-prefixed echo). -// Frame format: [4 bytes LE length][raw payload]. The Rust host echoes the payload back. +// Coderm: Language Host wire protocol (Phase 1: request-id multiplexer). +// Frame format: [4 bytes LE request_id][4 bytes LE length][payload]. +// The Rust host echoes the request_id on responses so concurrent requests can be demuxed. +// request_id == 0 → notification (fire-and-forget, no response). Used for document sync. +// request_id > 0 → request (response awaited). Used for language features. // FlatBuffers is intentionally NOT used yet: VS Code 1.122's renderer does not support static -// npm imports, so FlatBuffers will be introduced in Phase 1 on the main-process side. +// npm imports, so FlatBuffers will be introduced later on the main-process side. // rpcProtocol.ts (JSON+VSBuffer) remains untouched. -const FRAME_HEADER_BYTES = 4; -const REQUEST_TIMEOUT_MS = 5000; +const FRAME_HEADER_BYTES = 8; // request_id (4) + length (4) +const NOTIFY_REQUEST_ID = 0; // reserved id for notifications (no response) +const REQUEST_TIMEOUT_MS = 30000; // 30s for language feature requests interface PendingRequest { resolve: (data: Uint8Array) => void; @@ -18,14 +22,12 @@ interface PendingRequest { timer: ReturnType; } -// Constraint: only one in-flight request at a time (no multiplexing). `pending` -// holds exactly one request; a second `request()` before the first resolves will -// overwrite it and can misroute the response. Phase 0 only issues the single echo -// self-test, so this is safe here. Phase 1 must replace this with a request-id -// multiplexer once multiple request types are needed. +// Phase 1: request-id multiplexer. Multiple concurrent requests are supported. +// Each request has a unique ID, and responses are routed by matching the ID. export class LanguageHostProtocol { - private pending: PendingRequest | undefined; + private nextRequestId = 1; + private pending = new Map(); constructor(private readonly port: MessagePort) { port.onmessage = (e: MessageEvent) => this.onMessage(e.data); @@ -38,44 +40,67 @@ export class LanguageHostProtocol { return; } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const length = view.getUint32(0, true); + const requestId = view.getUint32(0, true); + const length = view.getUint32(4, true); if (bytes.byteLength < FRAME_HEADER_BYTES + length) { return; // incomplete frame } + // Notifications (request_id == 0) carry no response; ignore any inbound frame with + // that id rather than logging it as an unknown request. + if (requestId === NOTIFY_REQUEST_ID) { + return; + } + const payload = bytes.slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + length); - const pending = this.pending; + const pending = this.pending.get(requestId); if (pending) { - this.pending = undefined; + this.pending.delete(requestId); clearTimeout(pending.timer); pending.resolve(payload); + } else { + // Note: this is expected (not a protocol error) when a response lands after its + // request already timed out and the timer above cleared the pending entry. + console.warn(`[LanguageHostProtocol] received response for unknown request_id ${requestId}`); } } + // Fire-and-forget: reqId=0, no response expected. + notify(payload: Uint8Array): void { + this.port.postMessage(this.frame(NOTIFY_REQUEST_ID, payload)); + } + + // Request/response: unique reqId, response awaited (or timeout). request(payload: Uint8Array): Promise { - // Frame: [4 bytes LE length][payload] - const frame = new Uint8Array(FRAME_HEADER_BYTES + payload.byteLength); - new DataView(frame.buffer).setUint32(0, payload.byteLength, true); - frame.set(payload, FRAME_HEADER_BYTES); + const requestId = this.nextRequestId++; + this.port.postMessage(this.frame(requestId, payload)); return new Promise((resolve, reject) => { const timer = setTimeout(() => { - if (this.pending) { - this.pending = undefined; - reject(new Error(`Language Host request timed out after ${REQUEST_TIMEOUT_MS}ms`)); + const pending = this.pending.get(requestId); + if (pending) { + this.pending.delete(requestId); + reject(new Error(`Language Host request ${requestId} timed out after ${REQUEST_TIMEOUT_MS}ms`)); } }, REQUEST_TIMEOUT_MS); - this.pending = { resolve, reject, timer }; - - // Send the frame as a structured-clone copy (no transfer). Electron's renderer - // -> main MessagePort does not honor ArrayBuffer/typed-array transfer for the - // message payload (the main side receives { data: null }), so we copy. This is - // fine for Phase 0's small frames; revisit zero-copy via VSBuffer-style wrapping - // (as rpcProtocol does) once payloads grow in Phase 3+. - this.port.postMessage(frame); + this.pending.set(requestId, { resolve, reject, timer }); }); } + + private frame(requestId: number, payload: Uint8Array): Uint8Array { + // Frame: [4 bytes LE request_id][4 bytes LE length][payload] + const frame = new Uint8Array(FRAME_HEADER_BYTES + payload.byteLength); + const view = new DataView(frame.buffer); + view.setUint32(0, requestId, true); + view.setUint32(4, payload.byteLength, true); + frame.set(payload, FRAME_HEADER_BYTES); + + // Send the frame as a structured-clone copy (no transfer). Electron's renderer + // -> main MessagePort does not honor ArrayBuffer/typed-array transfer for the + // message payload (the main side receives { data: null }), so we copy. + return frame; + } } // --- 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 8472f7cc197..429178a940f 100644 --- a/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts +++ b/src/vs/workbench/services/languageHost/electron-browser/languageHostService.ts @@ -15,8 +15,11 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; import { ILanguageHostServerService, ipcLanguageHostServerChannelName } from '../../../../platform/languageHost/common/languageHostServer.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ITextModel } from '../../../../editor/common/model.js'; import { ILanguageHostService } from '../common/languageHost.js'; import { LanguageHostProtocol } from '../common/languageHostProtocol.js'; +import { DocumentSyncManager, DocumentSyncMessage } from '../common/documentSync.js'; // Channel name shared with the main process side (see languageHostServerService). // Constraint: the `coderm:` prefix is required, not cosmetic. The sandboxed preload's @@ -31,11 +34,14 @@ export class NativeLanguageHostService extends Disposable implements ILanguageHo private protocol: LanguageHostProtocol | undefined; private _whenReadyPromise: Promise | undefined; + private documentSyncManager: DocumentSyncManager | undefined; + private enabledLanguages: Set = new Set(); constructor( @ILanguageHostServerService private readonly languageHostServerService: ILanguageHostServerService, @INativeHostService private readonly nativeHostService: INativeHostService, @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); // Lazy: only start when a caller first awaits whenReady(), so the native host @@ -52,6 +58,10 @@ export class NativeLanguageHostService extends Disposable implements ILanguageHo private async start(): Promise { const nonce = generateUuid(); + // Load enabled languages from configuration. + const languages = this.configurationService.getValue('coderm.languageHost.languages') || []; + this.enabledLanguages = new Set(languages); + // Begin listening for the port the main process will post back on RESPONSE_CHANNEL, // then ask main (via the proxied server service) to spawn the Rust host and transfer it. const portPromise = acquirePort(undefined, RESPONSE_CHANNEL, nonce); @@ -61,13 +71,50 @@ export class NativeLanguageHostService extends Disposable implements ILanguageHo this.protocol = new LanguageHostProtocol(port); this.logService.info('[languageHost] connected to native host'); + + // Document sync uses notifications (fire-and-forget); feature requests use request(). + this.documentSyncManager = new DocumentSyncManager( + (message: DocumentSyncMessage) => this.sendDocumentSyncMessage(message), + (languageId: string) => this.enabledLanguages.has(languageId), + ); + } + + private sendDocumentSyncMessage(message: DocumentSyncMessage): void { + if (!this.protocol) { + return; + } + const json = JSON.stringify(message); + this.protocol.notify(new TextEncoder().encode(json)); + } + + syncDocument(model: ITextModel): void { + this.documentSyncManager?.syncDocument(model); } - async echo(payload: Uint8Array): Promise { + unsyncDocument(uri: string): void { + this.documentSyncManager?.unsyncDocument(uri); + } + + async requestDocumentSymbol(uri: string): Promise { + return this.requestFeature({ type: 'documentSymbol', uri }); + } + + async requestFoldingRange(uri: string): Promise { + return this.requestFeature({ type: 'foldingRange', uri }); + } + + private async requestFeature(message: { type: string; uri: string }): Promise { if (!this.protocol) { throw new Error('Language Host not ready'); } - return this.protocol.request(payload); + const json = JSON.stringify(message); + const response = await this.protocol.request(new TextEncoder().encode(json)); + return new TextDecoder().decode(response); + } + + override dispose(): void { + this.documentSyncManager?.dispose(); + super.dispose(); } }