Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")。空の場合は機能無効 |

---
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---
Expand Down
181 changes: 169 additions & 12 deletions rust/crates/language-host/src/main.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Location>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Location {
uri: String,
range: Range,
}

struct LanguageHost {
documents: HashMap<String, Document>,
}
Expand Down Expand Up @@ -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<T, F>(&self, uri: &str, label: &str, compute: F) -> String
where
F: FnOnce(&Document) -> Result<Option<T>, 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<tree_sitter::Tree, String> {
Expand Down Expand Up @@ -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<Option<DefinitionResponse>, 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<Location>,
) {
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];
Expand Down
63 changes: 57 additions & 6 deletions src/vs/workbench/services/languageHost/common/languageFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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<Definition | undefined> {
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<T>(json: string): T[] {
Expand Down Expand Up @@ -145,11 +176,11 @@ function parseFoldingRanges(json: string): FoldingRange[] {
return parseJsonArray<FoldingRangeResponse>(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<T extends object>(json: string): T | undefined {
if (json === 'null' || json === '') {
return undefined;
}
Expand All @@ -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<HoverResponse>(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).
Expand All @@ -175,6 +213,17 @@ function parseHover(json: string): Hover | undefined {
return { contents, range: response.range };
}

function parseDefinition(json: string): Definition | undefined {
const response = parseJsonObject<DefinitionResponse>(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[],
Expand All @@ -183,12 +232,14 @@ 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.
return combinedDisposable(
languageFeaturesService.documentSymbolProvider.register(languages, documentSymbolProvider),
languageFeaturesService.foldingRangeProvider.register(languages, foldingRangeProvider),
languageFeaturesService.hoverProvider.register(languages, hoverProvider),
languageFeaturesService.definitionProvider.register(languages, definitionProvider),
);
}
6 changes: 6 additions & 0 deletions src/vs/workbench/services/languageHost/common/languageHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;

/**
* 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<string>;
}

// --- Coderm end ---
Loading
Loading