diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71d3941115..b2fe448234 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -903,7 +903,7 @@ jobs: cat > "$tap_dir/Formula/codeindex.rb" <--project <name|path> filters for indexing and queries, plus `--solution ` when a workspace has multiple solution files. | -| MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, logging, a structured `ping` health result, HTTP `GET /healthz`, opt-in HTTP `/events` keep-alive notifications, a compatibility server-side `notifications/initialized` ready signal on stdio or HTTP `/events` streams, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. Tool schemas reject unknown arguments with `-32602`, advertise `x-stability`, and use snake_case structured JSON keys to match the CLI JSON contract. | +| MCP/LSP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, logging, a structured `ping` health result, HTTP `GET /healthz`, opt-in HTTP `/events` keep-alive notifications, a compatibility server-side `notifications/initialized` ready signal on stdio or HTTP `/events` streams, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. Tool schemas reject unknown arguments with `-32602`, advertise `x-stability`, and use snake_case structured JSON keys to match the CLI JSON contract. LSP mode exposes `initialize`, `workspace/symbol`, `textDocument/documentSymbol`, `textDocument/definition`, and `textDocument/references` over stdio for editors that do not speak MCP. | | Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. | | Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` still wins. | | DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. | @@ -275,12 +276,12 @@ details. ![License](https://img.shields.io/badge/License-FSL--1.1--ALv2-orange) ![SQLite](https://img.shields.io/badge/SQLite-FTS5-003B57?logo=sqlite&logoColor=white) -**ローカルリポジトリ向けの CLI コードインデックスと MCP 検索です。** +**ローカルリポジトリ向けの CLI コードインデックス、MCP 検索、LSP editor lookup です。** -`cdidx` はコマンドラインのコードインデクサー兼 MCP サーバーで、リポジトリの -ローカル SQLite index を作成します。人間と AI エージェントは、同じツリーを -何度も読み直さずに、高速な全文検索、シンボル、依存関係、inspect クエリを -実行できます。 +`cdidx` はコマンドラインのコードインデクサー、MCP サーバー、read-only LSP shim で、 +リポジトリのローカル SQLite index を作成します。人間、AI エージェント、 +LSP-native editor は、同じツリーを何度も読み直さずに、高速な全文検索、 +シンボル、依存関係、inspect クエリを実行できます。 ## なぜ cdidx なのか @@ -319,6 +320,7 @@ cdidx definition UserService cdidx search "Handle" --project MyApp cdidx validate cdidx mcp +cdidx lsp --db .cdidx/codeindex.db ``` カスタム言語の開発ループは out-of-tree で回せます。拡張子 alias は @@ -386,11 +388,11 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し | 分野 | 内容 | |---|---| -| 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。 | +| 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。`cdidx lsp --db .cdidx/codeindex.db` は LSP-native editor 向けの read-only stdio Language Server Protocol shim を起動します。 | | definition / impact 診断 | `definition --json` は C# overload、partial type、extension receiver を区別できる場合に `disambiguator` を返します。`impact --json` と MCP `impact_analysis` は 0 件時の経路判断用に `impact_failure_chain` と `suggestion_type` を返し、`impact --strict` は解決または graph の前提条件が満たされない場合に非 0 で終了します。 | | 順位と filter | public/exported なシンボル一致を protected、internal、private より優先します。従来順は `--no-visibility-rank`、可視性の include / exclude は `symbols`、`definition`、`unused`、`hotspots` の `--visibility` / `--exclude-visibility` で指定できます。query 既定値は `CDIDX_DEFAULT_LIMIT`、`CDIDX_DEFAULT_SNIPPET_LINES`、`CDIDX_DEFAULT_MAX_LINE_WIDTH` で調整でき、明示 CLI flag が常に優先されます。 | | project scope | `.sln` / `.csproj` を使った --project <name|path> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution ` を指定します。 | -| MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、logging、構造化された `ping` health result、HTTP `GET /healthz`、opt-in の HTTP `/events` keep-alive notification、stdio または HTTP `/events` stream 上の互換性用 server-side `notifications/initialized` ready signal、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。Tool schema は未知の引数を `-32602` で拒否し、`x-stability` を公開し、CLI JSON contract と一致する snake_case の structured JSON key を使います。 | +| MCP/LSP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、logging、構造化された `ping` health result、HTTP `GET /healthz`、opt-in の HTTP `/events` keep-alive notification、stdio または HTTP `/events` stream 上の互換性用 server-side `notifications/initialized` ready signal、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。Tool schema は未知の引数を `-32602` で拒否し、`x-stability` を公開し、CLI JSON contract と一致する snake_case の structured JSON key を使います。LSP mode は MCP 非対応 editor 向けに `initialize`、`workspace/symbol`、`textDocument/documentSymbol`、`textDocument/definition`、`textDocument/references` を stdio で公開します。 | | freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 | | storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | | DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。成功した writer 実行は WAL を `TRUNCATE` checkpoint します。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`cdidx db schema` は on-disk schema を出力し、`cdidx db prune --dry-run|--apply` は orphaned DB rows を検査・削除します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f8dd2c5f72..1bb4b6d332 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -28,6 +28,7 @@ cdidx find "guard" --path src/Auth.cs cdidx deps --path src/ # File-level dependency graph cdidx suggestions list # Review local AI feedback history cdidx mcp # Start MCP server for AI tools +cdidx lsp --db .cdidx/codeindex.db # Start read-only LSP server for editors ``` 78 languages supported. 24 registered MCP tools. Incremental updates. Zero config. @@ -1734,6 +1735,14 @@ If the refs are not known, use `cdidx ./myproject --json` and verify with `cdidx cdidx includes a built-in **MCP (Model Context Protocol) server**. MCP is a standard protocol that lets AI coding tools communicate with external programs. When you run `cdidx mcp`, cdidx starts listening on stdin/stdout — your AI tool sends search requests as JSON, and cdidx returns results instantly from the pre-built index. +### LSP Server (for LSP-native editors) + +`cdidx lsp --db .cdidx/codeindex.db` starts a read-only Language Server Protocol +server over stdio. It reuses the existing CodeIndex database and exposes +`initialize`, `workspace/symbol`, `textDocument/documentSymbol`, +`textDocument/definition`, and `textDocument/references` for editors that can +launch an arbitrary LSP command but do not speak MCP. + Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. Capped MCP result tools report `truncated` and `more_available` in `structuredContent` when more rows exist than the requested `limit`, so clients can avoid treating a capped page as exhaustive. @@ -2071,6 +2080,7 @@ cdidx find "guard" --path src/Auth.cs cdidx deps --path src/ # ファイル間依存グラフ cdidx suggestions list # ローカルのAIフィードバック履歴を確認 cdidx mcp # AIツール向けMCPサーバー起動 +cdidx lsp --db .cdidx/codeindex.db # editor向けread-only LSPサーバー起動 ``` 78言語対応。24 MCPツール。インクリメンタル更新。設定不要。 @@ -3767,6 +3777,14 @@ ref が分からない場合は `cdidx ./myproject --json` を使い、`cdidx st cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれています。MCPは、AIコーディングツールが外部プログラムと通信するための標準プロトコルです。`cdidx mcp` を実行すると、cdidxがstdin/stdoutで待機し、AIツールからの検索リクエストをJSONで受け取り、構築済みインデックスから即座に結果を返します。 +### LSP サーバー(LSP-native editor 向け) + +`cdidx lsp --db .cdidx/codeindex.db` は read-only の Language Server Protocol +サーバーを stdio で起動します。既存の CodeIndex database を再利用し、 +任意の LSP command を起動できるが MCP には対応していない editor 向けに +`initialize`、`workspace/symbol`、`textDocument/documentSymbol`、 +`textDocument/definition`、`textDocument/references` を公開します。 + ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 上限付きの MCP result tool は、要求した `limit` より多くの行がある場合に `structuredContent` へ `truncated` と `more_available` を返します。これにより、クライアントは上限で切られたページを網羅的な結果として扱わずに済みます。 diff --git a/changelog.d/unreleased/1650.added.md b/changelog.d/unreleased/1650.added.md new file mode 100644 index 0000000000..a8e5702cff --- /dev/null +++ b/changelog.d/unreleased/1650.added.md @@ -0,0 +1,24 @@ +--- +category: added +issues: + - 1650 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Lsp/LspServer.cs + - src/CodeIndex/CodeIndex.csproj + - tests/CodeIndex.Tests/LspServerTests.cs + - README.md + - USER_GUIDE.md + - INTEGRATION_POLICY.md + - docs/NUGET_README.md + - .github/workflows/release.yml +--- + +## English + +- **`cdidx lsp` starts a read-only Language Server Protocol shim (#1650)** — LSP-native editors can now launch `cdidx lsp --db .cdidx/codeindex.db` over stdio for workspace symbols, document symbols, definitions, and references without needing an MCP adapter. + +## 日本語 + +- **`cdidx lsp` が read-only の Language Server Protocol shim を起動できるようになりました (#1650)** — LSP-native editor は MCP adapter なしで `cdidx lsp --db .cdidx/codeindex.db` を stdio 起動し、workspace symbol、document symbol、definition、reference を取得できます。 diff --git a/docs/NUGET_README.md b/docs/NUGET_README.md index 9406c019ea..5476b0d24f 100644 --- a/docs/NUGET_README.md +++ b/docs/NUGET_README.md @@ -1,8 +1,9 @@ # cdidx -`cdidx` is a .NET global tool for local code indexing, CLI search, and MCP -workflows. It builds a local SQLite index so humans and AI agents can query a -repository without repeatedly rescanning the same tree. +`cdidx` is a .NET global tool for local code indexing, CLI search, MCP +workflows, and read-only LSP editor lookup. It builds a local SQLite index so +humans, AI agents, and editors can query a repository without repeatedly +rescanning the same tree. ## Install or update @@ -19,6 +20,7 @@ cdidx status --check --json cdidx search "handleRequest" cdidx definition UserService cdidx mcp +cdidx lsp --db .cdidx/codeindex.db ``` ## Documentation @@ -36,5 +38,6 @@ absolute GitHub URLs. | Security Policy | https://github.com/Widthdom/CodeIndex/blob/{{RELEASE_REF}}/SECURITY.md | | Changelog | https://github.com/Widthdom/CodeIndex/blob/{{RELEASE_REF}}/CHANGELOG.md | -`cdidx` is distributed as a CLI and MCP server only. The NuGet package is a -global tool package and does not provide a public library or SDK API. +`cdidx` is distributed as a CLI, MCP server, and LSP shim only. The NuGet +package is a global tool package and does not provide a public library or SDK +API. diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index cee7629406..e2215d8d52 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -106,6 +106,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("languages", "cdidx languages [--json]"), ("batch", "cdidx batch [--db ] # reads JSON string arrays from stdin, one query command per line"), ("mcp", "cdidx mcp [--db ]"), + ("lsp", "cdidx lsp [--db ]"), ("completions", "cdidx completions "), ("--completions", "cdidx --completions "), ("upgrade", "cdidx upgrade [--check-only]"), @@ -742,6 +743,7 @@ public static void PrintUsageBrief(bool showBanner = true) Console.WriteLine(" hotspots Find high-impact symbols; duplicate-name families may fall back conservatively"); Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); + Console.WriteLine(" lsp Start LSP server over stdio (for LSP-native editors)"); Console.WriteLine(" completions Generate shell completions for bash, zsh, fish, or PowerShell"); Console.WriteLine(); Console.WriteLine("Run `cdidx --help-all` for every command and option, `cdidx --help-flags` for shared flags, or `cdidx --help` for one command."); @@ -852,6 +854,7 @@ private static void PrintCommandSummary() Console.WriteLine(" languages List supported languages and their capabilities"); Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); + Console.WriteLine(" lsp Start LSP server over stdio (for LSP-native editors)"); Console.WriteLine(" completions Generate shell completions for bash, zsh, fish, or PowerShell"); Console.WriteLine(" license Show licensing, trademark, and commercial-use summary"); } diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index d046db6307..3ebbc8b2ac 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -7,6 +7,8 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Lsp; using CodeIndex.Mcp; using Microsoft.Data.Sqlite; @@ -205,6 +207,14 @@ internal static int Run( return mcpExitCode; } + if (args[0] is "lsp" or "--lsp") + { + var lspExitCode = RunLsp(args[1..], appVersion, jsonOptions); + GlobalToolLog.Info($"command_complete exit_code={lspExitCode} command=lsp"); + EmitCommandMetric("lsp", args, commandStartTimestamp, commandStopwatch, lspExitCode); + return lspExitCode; + } + var commandName = args[0]; var subArgs = args[1..]; Func? queryRunner = commandName switch @@ -1470,6 +1480,84 @@ internal static void EmitCommandMetric(string tool, string[] args, DateTimeOffse private const string DefaultMcpHttpListen = "127.0.0.1:38080"; private const string McpHttpTokenEnvVar = "CDIDX_MCP_HTTP_TOKEN"; + private static int RunLsp(string[] cmdArgs, string appVersion, JsonSerializerOptions jsonOptions) + { + var options = QueryCommandRunner.ParseArgs(cmdArgs, jsonDefault: true); + if (options.ParseError != null) + { + Console.Error.WriteLine(options.ParseError); + PrintLspUsage(); + return CommandExitCodes.UsageError; + } + + for (var i = 0; i < cmdArgs.Length; i++) + { + if (cmdArgs[i].StartsWith("--db=", StringComparison.Ordinal)) + continue; + if (cmdArgs[i] == "--db") + { + i++; + continue; + } + + Console.Error.WriteLine($"Error: {cmdArgs[i]} is not supported for lsp."); + Console.Error.WriteLine("Hint: use `--db ` to point at a specific index."); + PrintLspUsage(); + return CommandExitCodes.UsageError; + } + + try + { + if (string.IsNullOrWhiteSpace(options.DbPath)) + { + Console.Error.WriteLine("Error: database path could not be resolved."); + PrintLspUsage(); + return CommandExitCodes.UsageError; + } + + if (!options.DbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + && !File.Exists(LongPath.EnsureWindowsPrefix(options.DbPath))) + { + var resolvedPath = Path.GetFullPath(options.DbPath); + Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {resolvedPath}"); + Console.Error.WriteLine("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun `cdidx lsp`."); + return CommandExitCodes.DatabaseError; + } + + using var db = new DbContext(options.DbPath); + if (!db.TryValidateIsCodeIndexDb(out var validationReason)) + { + Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: invalid CodeIndex database: {validationReason}"); + return CommandExitCodes.DatabaseError; + } + + db.TryMigrateForRead(); + using var server = new LspServer(new DbReader(db), appVersion, jsonOptions, db.GetMetaString(DbContext.IndexedProjectRootMetaKey)); + server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput()); + return CommandExitCodes.Success; + } + catch (OperationCanceledException) + { + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.CancelledBySignal; + } + catch (Exception ex) + { + GlobalToolLog.Error("lsp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); + Console.Error.WriteLine($"Error: LSP server failed ({ex.GetType().Name}: {ex.Message})."); + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.DatabaseError; + } + } + + private static void PrintLspUsage() + { + Console.Error.WriteLine("Usage: cdidx lsp [--db ]"); + Console.Error.WriteLine("Runs a read-only Language Server Protocol server over stdio using an existing CodeIndex database."); + } + private static int RunMcp(string[] cmdArgs, string appVersion) { // Strip audit-log opt-in flags first so the strict mcp parser below does not see them diff --git a/src/CodeIndex/CodeIndex.csproj b/src/CodeIndex/CodeIndex.csproj index dd1c07b45c..54fad62576 100644 --- a/src/CodeIndex/CodeIndex.csproj +++ b/src/CodeIndex/CodeIndex.csproj @@ -28,8 +28,8 @@ $([System.IO.File]::ReadAllText('$(VersionJsonPath)')) $([System.Text.RegularExpressions.Regex]::Match('$(VersionJsonContent)', '"version"\s*:\s*"([^"]+)"').Groups[1].Value) Widthdom - AI-native local code index for CLI and MCP workflows. Index source code into SQLite, search with FTS5, and return structured results for humans and agents. Distributed as a .NET global tool — CLI and MCP server only; no public library / SDK API is provided (see INTEGRATION_POLICY.md). / CLIとMCPワークフロー向けのAIネイティブなローカルコードインデックス。ソースコードをSQLiteにインデックスし、FTS5で検索し、人間とAI向けに構造化された結果を返す。.NET グローバルツールとして配布され、CLI と MCP サーバーのみを提供します。公開ライブラリ/SDK API は提供しません(INTEGRATION_POLICY.md 参照)。 - code-search;indexer;sqlite;fts5;ai;mcp;developer-tools;source-available;fair-source + AI-native local code index for CLI, MCP, and LSP workflows. Index source code into SQLite, search with FTS5, and return structured results for humans, agents, and editors. Distributed as a .NET global tool — CLI, MCP server, and LSP shim only; no public library / SDK API is provided (see INTEGRATION_POLICY.md). / CLI、MCP、LSPワークフロー向けのAIネイティブなローカルコードインデックス。ソースコードをSQLiteにインデックスし、FTS5で検索し、人間、AI、editor向けに構造化された結果を返す。.NET グローバルツールとして配布され、CLI、MCP サーバー、LSP shim のみを提供します。公開ライブラリ/SDK API は提供しません(INTEGRATION_POLICY.md 参照)。 + code-search;indexer;sqlite;fts5;ai;mcp;lsp;developer-tools;source-available;fair-source LICENSE true Copyright 2026 Widthdom. Source-available under FSL-1.1-ALv2 with an Apache-2.0 future license; see LICENSE. diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs new file mode 100644 index 0000000000..c633e61afc --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -0,0 +1,395 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Models; + +namespace CodeIndex.Lsp; + +internal sealed class LspServer : IDisposable +{ + private const int DefaultLimit = 50; + private readonly DbReader _reader; + private readonly string _version; + private readonly JsonSerializerOptions _jsonOptions; + private readonly string? _projectRoot; + private bool _shutdownRequested; + + public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) + { + _reader = reader; + _version = version; + _jsonOptions = jsonOptions; + _projectRoot = string.IsNullOrWhiteSpace(projectRoot) ? null : projectRoot; + } + + public void Run(Stream input, Stream output) + { + while (TryReadMessage(input, out var payload)) + { + var response = HandleMessage(payload); + if (response != null) + WriteMessage(output, response.ToJsonString(_jsonOptions)); + } + } + + internal JsonObject? HandleMessage(string payload) + { + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + var method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() : null; + var hasId = root.TryGetProperty("id", out var idElement); + JsonNode? id = hasId ? JsonNode.Parse(idElement.GetRawText()) : null; + + if (method == null) + return hasId ? Error(id, -32600, "Invalid Request") : null; + + try + { + return method switch + { + "initialize" => Result(id, BuildInitializeResult()), + "initialized" => null, + "shutdown" => HandleShutdown(id), + "exit" => null, + "workspace/symbol" => Result(id, WorkspaceSymbol(root)), + "textDocument/documentSymbol" => Result(id, DocumentSymbol(root)), + "textDocument/definition" => Result(id, Definition(root)), + "textDocument/references" => Result(id, References(root)), + _ => hasId ? Error(id, -32601, $"Method not found: {method}") : null, + }; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or JsonException or IOException) + { + return hasId ? Error(id, -32602, ex.Message) : null; + } + } + + private JsonObject HandleShutdown(JsonNode? id) + { + _shutdownRequested = true; + return Result(id, null); + } + + private JsonObject BuildInitializeResult() => new() + { + ["capabilities"] = new JsonObject + { + ["definitionProvider"] = true, + ["referencesProvider"] = true, + ["documentSymbolProvider"] = true, + ["workspaceSymbolProvider"] = true, + ["textDocumentSync"] = 0, + }, + ["serverInfo"] = new JsonObject + { + ["name"] = "cdidx", + ["version"] = _version, + }, + }; + + private JsonArray WorkspaceSymbol(JsonElement root) + { + var query = GetString(root, "params", "query"); + var symbols = _reader.SearchSymbols(query, DefaultLimit); + var array = new JsonArray(); + foreach (var symbol in symbols) + array.Add(ToWorkspaceSymbol(symbol)); + return array; + } + + private JsonArray DocumentSymbol(JsonElement root) + { + var path = GetDocumentPath(root); + var indexedPath = ResolveIndexedPath(path); + if (indexedPath == null) + return []; + + var symbols = _reader.SearchSymbols((string?)null, 1000, pathPatterns: [indexedPath]); + var array = new JsonArray(); + foreach (var symbol in symbols.OrderBy(s => s.StartLine).ThenBy(s => s.Name, StringComparer.Ordinal)) + array.Add(ToDocumentSymbol(symbol)); + return array; + } + + private JsonArray Definition(JsonElement root) + { + var query = ExtractPositionToken(root); + if (string.IsNullOrWhiteSpace(query)) + return []; + + var definitions = _reader.GetDefinitions(query, DefaultLimit, exact: true); + var array = new JsonArray(); + foreach (var definition in definitions) + array.Add(ToLocation(definition.Path, definition.StartLine, 1, definition.EndLine, 1)); + return array; + } + + private JsonArray References(JsonElement root) + { + var query = ExtractPositionToken(root); + if (string.IsNullOrWhiteSpace(query)) + return []; + + var analysis = _reader.AnalyzeSymbol(query, DefaultLimit, exact: true); + var array = new JsonArray(); + foreach (var reference in analysis.References) + array.Add(ToLocation(reference.Path, reference.Line, Math.Max(reference.Column, 1), reference.Line, Math.Max(reference.Column, 1) + Math.Max(query.Length, 1))); + return array; + } + + private string? ExtractPositionToken(JsonElement root) + { + var path = GetDocumentPath(root); + var line = GetInt32(root, "params", "position", "line"); + var character = GetInt32(root, "params", "position", "character"); + if (line < 0 || character < 0) + return null; + + var resolved = Path.IsPathRooted(path) ? path : Path.GetFullPath(path); + if (!File.Exists(resolved)) + return null; + + var lines = File.ReadAllLines(resolved); + if (line >= lines.Length) + return null; + + return ExtractTokenAtUtf16Position(lines[line], character); + } + + internal static string? ExtractTokenAtUtf16Position(string line, int character) + { + if (character < 0) + return null; + var index = Math.Min(character, line.Length); + while (index > 0 && index == line.Length) + index--; + if (index < line.Length && !IsTokenChar(line[index]) && index > 0 && IsTokenChar(line[index - 1])) + index--; + if (index >= line.Length || !IsTokenChar(line[index])) + return null; + + var start = index; + while (start > 0 && IsTokenChar(line[start - 1])) + start--; + var end = index + 1; + while (end < line.Length && IsTokenChar(line[end])) + end++; + return line[start..end].TrimStart('@'); + } + + private static bool IsTokenChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '@'; + + private static bool MatchesDocumentPath(string indexedPath, string documentPath) + { + if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) + return true; + + var normalizedIndexed = indexedPath.Replace('\\', '/'); + var normalizedDocument = documentPath.Replace('\\', '/'); + return normalizedDocument.EndsWith("/" + normalizedIndexed, StringComparison.Ordinal); + } + + private string? ResolveIndexedPath(string documentPath) + { + var fileName = Path.GetFileName(documentPath); + var files = _reader.ListFiles(fileName, 1000); + var matches = files + .Where(file => MatchesDocumentPath(file.Path, documentPath)) + .Take(2) + .ToList(); + return matches.Count == 1 ? matches[0].Path : null; + } + + private JsonObject ToWorkspaceSymbol(SymbolResult symbol) => new() + { + ["name"] = symbol.Name, + ["kind"] = SymbolKind(symbol.Kind), + ["location"] = ToLocation(symbol.Path, symbol.StartLine, 1, symbol.EndLine, 1), + ["containerName"] = symbol.ContainerName, + }; + + private static JsonObject ToDocumentSymbol(SymbolResult symbol) => new() + { + ["name"] = symbol.Name, + ["kind"] = SymbolKind(symbol.Kind), + ["range"] = ToRange(symbol.StartLine, 1, symbol.EndLine, 1), + ["selectionRange"] = ToRange(symbol.Line, 1, symbol.Line, 1), + ["detail"] = symbol.Signature, + }; + + private JsonObject ToLocation(string path, int startLine, int startColumn, int endLine, int endColumn) => new() + { + ["uri"] = PathToUri(path, _projectRoot), + ["range"] = ToRange(startLine, startColumn, endLine, endColumn), + }; + + private static JsonObject ToRange(int startLine, int startColumn, int endLine, int endColumn) => new() + { + ["start"] = new JsonObject + { + ["line"] = Math.Max(startLine - 1, 0), + ["character"] = Math.Max(startColumn - 1, 0), + }, + ["end"] = new JsonObject + { + ["line"] = Math.Max(endLine - 1, 0), + ["character"] = Math.Max(endColumn - 1, 0), + }, + }; + + private static int SymbolKind(string kind) => kind switch + { + "class" => 5, + "function" or "test.method" => 12, + "property" => 7, + "enum" => 10, + "interface" => 11, + "namespace" => 3, + "struct" => 23, + _ => 13, + }; + + private static string GetDocumentPath(JsonElement root) + { + var uri = GetString(root, "params", "textDocument", "uri"); + if (string.IsNullOrWhiteSpace(uri)) + throw new ArgumentException("textDocument.uri is required."); + return UriToPath(uri); + } + + private static string? GetString(JsonElement root, params string[] path) + { + if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.String) + return null; + return value.GetString(); + } + + private static int GetInt32(JsonElement root, params string[] path) + { + if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var result)) + return -1; + return result; + } + + private static bool TryGet(JsonElement root, out JsonElement value, params string[] path) + { + value = root; + foreach (var segment in path) + { + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(segment, out value)) + return false; + } + return true; + } + + internal static string PathToUri(string path, string? projectRoot = null) + { + var fullPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(path, projectRoot ?? Environment.CurrentDirectory); + return new Uri(fullPath).AbsoluteUri; + } + + internal static string UriToPath(string uri) + { + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) || !parsed.IsFile) + return uri; + return parsed.LocalPath; + } + + private static JsonObject Result(JsonNode? id, JsonNode? result) => new() + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = result, + }; + + private static JsonObject Error(JsonNode? id, int code, string message) => new() + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["error"] = new JsonObject + { + ["code"] = code, + ["message"] = message, + }, + }; + + internal static bool TryReadMessage(Stream input, out string payload) + { + payload = string.Empty; + var contentLength = -1; + while (true) + { + var line = ReadAsciiLine(input); + if (line == null) + return false; + if (line.Length == 0) + break; + var colon = line.IndexOf(':'); + if (colon <= 0) + continue; + var name = line[..colon].Trim(); + var value = line[(colon + 1)..].Trim(); + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) + && int.TryParse(value, out var parsed)) + { + contentLength = parsed; + } + } + + if (contentLength < 0) + return false; + + var buffer = ArrayPool.Shared.Rent(contentLength); + try + { + var offset = 0; + while (offset < contentLength) + { + var read = input.Read(buffer, offset, contentLength - offset); + if (read == 0) + return false; + offset += read; + } + payload = Encoding.UTF8.GetString(buffer, 0, contentLength); + return true; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + internal static void WriteMessage(Stream output, string payload) + { + var body = Encoding.UTF8.GetBytes(payload); + var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + output.Write(header); + output.Write(body); + output.Flush(); + } + + private static string? ReadAsciiLine(Stream input) + { + var bytes = new List(); + while (true) + { + var value = input.ReadByte(); + if (value < 0) + return bytes.Count == 0 ? null : Encoding.ASCII.GetString(bytes.ToArray()); + if (value == '\n') + break; + if (value != '\r') + bytes.Add((byte)value); + } + return Encoding.ASCII.GetString(bytes.ToArray()); + } + + public void Dispose() + { + _ = _shutdownRequested; + } +} diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs new file mode 100644 index 0000000000..a547fc694b --- /dev/null +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -0,0 +1,170 @@ +using System.Text; +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Lsp; + +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public class LspServerTests +{ + [Fact] + public void ExtractTokenAtUtf16Position_ReturnsIdentifierUnderCursor() + { + Assert.Equal("Needle", LspServer.ExtractTokenAtUtf16Position("var value = Needle.Call();", 14)); + Assert.Equal("Needle", LspServer.ExtractTokenAtUtf16Position("var value = Needle.Call();", 18)); + } + + [Fact] + public void TryReadMessage_ReadsContentLengthFramedPayload() + { + const string payload = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"; + var bytes = Encoding.UTF8.GetBytes($"Content-Length: {Encoding.UTF8.GetByteCount(payload)}\r\n\r\n{payload}"); + using var stream = new MemoryStream(bytes); + + Assert.True(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(payload, actual); + } + + [Fact] + public void HandleMessage_Initialize_AdvertisesCoreCapabilities() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_initialize"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + + var response = server.HandleMessage("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); + + Assert.NotNull(response); + Assert.True(response!["result"]!["capabilities"]!["definitionProvider"]!.GetValue()); + Assert.True(response["result"]!["capabilities"]!["documentSymbolProvider"]!.GetValue()); + Assert.Equal("cdidx", response["result"]!["serverInfo"]!["name"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + File.WriteAllText(sourcePath, "class App { void Needle() { } }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", File.ReadAllText(sourcePath)); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 2, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var symbols = response!["result"]!.AsArray(); + Assert.Contains(symbols, symbol => symbol?["name"]?.GetValue() == "App"); + Assert.Contains(symbols, symbol => symbol?["name"]?.GetValue() == "Needle"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_DocumentSymbol_ResolvesDuplicateBasenamesByRelativePath() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_duplicate"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var srcPath = Path.Combine(projectRoot, "src", "app.cs"); + var testPath = Path.Combine(projectRoot, "tests", "app.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(srcPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(testPath)!); + File.WriteAllText(srcPath, "class SrcApp { }\n"); + File.WriteAllText(testPath, "class TestApp { }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", File.ReadAllText(srcPath)); + TestProjectHelper.InsertIndexedFile(dbPath, "tests/app.cs", "csharp", File.ReadAllText(testPath)); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 22, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(testPath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var names = response!["result"]! + .AsArray() + .Select(symbol => symbol?["name"]?.GetValue()) + .ToArray(); + Assert.Contains("TestApp", names); + Assert.DoesNotContain("SrcApp", names); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3, + method = "textDocument/definition", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + position = new { line = 0, character = source.IndexOf("Needle();", StringComparison.Ordinal) }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var locations = response!["result"]!.AsArray(); + Assert.NotEmpty(locations); + Assert.Equal(new Uri(sourcePath).AbsoluteUri, locations[0]!["uri"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +}