From 6b93bc2083ffdecd82eb6044e90b1a5e1d83f564 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 15 Jun 2026 08:31:19 +0900 Subject: [PATCH 1/3] Fix ctags export summaries and filters (#3551) --- DEVELOPER_GUIDE.md | 11 + USER_GUIDE.md | 19 ++ changelog.d/unreleased/3551.fixed.md | 20 ++ src/CodeIndex/Cli/ConsoleUi.cs | 2 +- .../Cli/ExportImportCommandRunner.cs | 214 ++++++++++++++++-- src/CodeIndex/Cli/JsonOutputContracts.cs | 2 + .../ExportImportCommandRunnerTests.cs | 81 +++++++ 7 files changed, 331 insertions(+), 18 deletions(-) create mode 100644 changelog.d/unreleased/3551.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 8afe307e62..ac7cd5e1ff 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -236,6 +236,12 @@ When an error code is available, the first line is `Error []: `. CLI JSON output must be machine-clean: redirected stdout is written as UTF-8 without a BOM, and JSON-mode commands must not emit ANSI escape sequences even when `--color=always` or `CLICOLOR_FORCE=1` would color human output. Keep JSON-safe styling suppression close to shared formatting helpers such as `ConsoleUi.ColorizeKind` so future query output paths inherit the invariant. +`cdidx export ctags --json` follows the same contract: stdout contains only a +single JSON summary or structured error, while the tag file itself remains the +artifact. The summary includes resolved output/database paths, tag/emitted/ +skipped counts, filters, and advertised metadata field names so editor +integrations can validate filtered exports without parsing human output. + Interactive terminal controls are allowed only when stdout is not redirected or captured, terminal capability hints are present, and the environment has not opted out. Treat `TERM=dumb`, truthy `CI`, missing Unix terminal hints, `NO_COLOR`, and `CLICOLOR=0` as reasons to suppress ANSI/progress controls unless an explicitly human-facing override is documented for that control. ### C# / .NET integration @@ -2463,6 +2469,11 @@ CLI JSON output は機械処理向けにきれいでなければなりません `ConsoleUi.ColorizeKind` など共有 formatter の近くに置き、将来の query output path も同じ invariant を継承できるようにしてください。 +`cdidx export ctags --json` も同じ contract に従います。stdout は単一の JSON summary または +structured error だけを含み、tags file 自体は artifact として残します。summary には解決済みの +output / database path、tag / emitted / skipped counts、filters、metadata field names を含め、 +editor integration が human output を parse せず filtered export を検証できるようにします。 + interactive terminal control は stdout が redirected / captured されておらず、terminal capability hint があり、environment が opt out していない場合にだけ許可します。`TERM=dumb`、 truthy `CI`、Unix terminal hint の欠落、`NO_COLOR`、`CLICOLOR=0` は、明示的な human-facing diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0c8ed561ae..5263b576e3 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -350,8 +350,18 @@ instead of querying `cdidx` directly: ```bash cdidx export ctags --output tags cdidx export ctags --db .cdidx/codeindex.db --output .tags +cdidx export ctags --lang csharp --path src/ --exclude-tests --json ``` +`cdidx export ctags` accepts the same language and path filtering style used by +query commands: `--lang `, repeatable `--path `, repeatable +`--exclude-path `, and `--exclude-tests`. The default human mode keeps +writing the tags file and prints the output path. `--json` prints a machine +summary with `output_path`, `db_path`, `tag_count`, `emitted_count`, +`skipped_count`, `filters`, and `metadata_fields`. Tag lines keep the standard +`kind` and `line` fields and may also include indexed metadata such as +`language`, `container_kind`, `container`, and `visibility`. + Use `cdidx export ` to package the current `codeindex.db` with a manifest, and `cdidx import ` to restore it on another checkout or CI job: @@ -2805,8 +2815,17 @@ Editor が `cdidx` を直接 query するのではなく従来の ctags file を ```bash cdidx export ctags --output tags cdidx export ctags --db .cdidx/codeindex.db --output .tags +cdidx export ctags --lang csharp --path src/ --exclude-tests --json ``` +`cdidx export ctags` は query command と同じ language / path filter の形を受け付けます。 +`--lang `、繰り返し指定できる `--path ` / `--exclude-path `、 +`--exclude-tests` を使えます。既定の human mode は tags file を書き出し、output path を +表示します。`--json` は `output_path`、`db_path`、`tag_count`、`emitted_count`、 +`skipped_count`、`filters`、`metadata_fields` を含む機械処理向け summary を出力します。 +tag line は標準の `kind` / `line` fields を維持し、indexed metadata として +`language`、`container_kind`、`container`、`visibility` も含めることがあります。 + `cdidx export ` は現在の `codeindex.db` と manifest を archive 化します。 別 checkout や CI job では `cdidx import ` で復元できます。 diff --git a/changelog.d/unreleased/3551.fixed.md b/changelog.d/unreleased/3551.fixed.md new file mode 100644 index 0000000000..5f1555158f --- /dev/null +++ b/changelog.d/unreleased/3551.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3551 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **ctags export now reports filtered JSON summaries and richer metadata (#3551)** — `cdidx export ctags` now supports `--json`, `--lang`, repeated `--path` / `--exclude-path`, and `--exclude-tests`, while emitted tags include indexed metadata fields such as language, container, and visibility when available. + +## 日本語 + +- **ctags export が filtered JSON summary と richer metadata を出力できるようになりました (#3551)** — `cdidx export ctags` は `--json`、`--lang`、繰り返し指定できる `--path` / `--exclude-path`、`--exclude-tests` を受け付け、出力 tag には利用可能な場合に language、container、visibility などの indexed metadata fields も含めるようになりました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index bbe4672340..a76101a56c 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -116,7 +116,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("hotspots", "cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--limit ] [--offset ] [--format ] [--open-issues ] [--repo ]"), ("export", "cdidx export [--db ] [--json]"), - ("export", "cdidx export ctags [--output ] [--db ]"), + ("export", "cdidx export ctags [--output ] [--db ] [--json] [--lang ] [--path ] [--exclude-path ] [--exclude-tests]"), ("import", "cdidx import [--db ] [--prune-paths] [--dry-run|--check] [--json]"), ("languages", "cdidx languages [--db ] [--json] [--indexed-only] [--capability ]"), ("batch", "cdidx batch [--db ] # reads JSON string arrays from stdin, one query command per line; max 1,048,576 chars/line and 256 arguments"), diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index d08749f18d..d35f70087e 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -31,12 +31,14 @@ internal static class ExportImportCommandRunner private const string PhasePrunePaths = "prune_paths"; private const string PhaseReplaceDb = "replace_db"; private const string PhaseWriteArchive = "write_archive"; + private const string PhaseWriteCtags = "write_ctags"; private const string ImportUsage = "cdidx import [--db ] [--prune-paths] [--dry-run|--check] [--json]"; + private const string CtagsExportUsage = "cdidx export ctags [--output ] [--db ] [--json] [--lang ] [--path ] [--exclude-path ] [--exclude-tests]"; public static int RunExport(string[] args, JsonSerializerOptions jsonOptions, string appVersion) { if (args.Length > 0 && args[0] == "ctags") - return RunExportCtags(args[1..]); + return RunExportCtags(args[1..], jsonOptions); return RunExportArchive(args, jsonOptions, appVersion); } @@ -309,18 +311,34 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt } } - private static int RunExportCtags(string[] args) + private static int RunExportCtags(string[] args, JsonSerializerOptions jsonOptions) { var outputPath = "tags"; string? dbPath = null; + string? lang = null; + var pathPatterns = new List(); + var excludePathPatterns = new List(); + var excludeTests = false; + var wantsJson = Array.Exists(args, arg => arg == "--json"); for (var i = 0; i < args.Length; i++) { var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg == "--exclude-tests") + { + excludeTests = true; + continue; + } + if (TryReadValueOption(args, ref i, "--output", arg, out var outputValue, out var outputError)) { if (outputError != null) - return WriteError(outputError, "use `cdidx export ctags --output tags`.", "cdidx export ctags [--output ] [--db ]"); + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_output_requires_value", outputError, "use `cdidx export ctags --output tags`.", CtagsExportUsage); outputPath = outputValue!; continue; } @@ -328,12 +346,36 @@ private static int RunExportCtags(string[] args) if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) { if (dbError != null) - return WriteError(dbError, "use `cdidx export ctags --db `.", "cdidx export ctags [--output ] [--db ]"); + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_db_requires_value", dbError, "use `cdidx export ctags --db `.", CtagsExportUsage); dbPath = dbValue; continue; } - return WriteError($"unknown ctags export option `{arg}`.", "use `--output ` or `--db `.", "cdidx export ctags [--output ] [--db ]"); + if (TryReadValueOption(args, ref i, "--lang", arg, out var langValue, out var langError)) + { + if (langError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_lang_requires_value", langError, "pass a language name such as `csharp`, `cs`, or `python`.", CtagsExportUsage); + lang = DbReader.NormalizeQueryLanguage(langValue); + continue; + } + + if (TryReadValueOption(args, ref i, "--path", arg, out var pathValue, out var pathError)) + { + if (pathError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_path_requires_value", pathError, "pass a path substring or glob such as `src/` or `src/*.cs`.", CtagsExportUsage); + pathPatterns.Add(pathValue!); + continue; + } + + if (TryReadValueOption(args, ref i, "--exclude-path", arg, out var excludePathValue, out var excludePathError)) + { + if (excludePathError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_exclude_path_requires_value", excludePathError, "pass a path substring or glob to omit.", CtagsExportUsage); + excludePathPatterns.Add(excludePathValue!); + continue; + } + + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_unknown_option", $"unknown ctags export option `{arg}`.", "use `--output`, `--db`, `--json`, or filter flags.", CtagsExportUsage); } dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; @@ -342,12 +384,13 @@ private static int RunExportCtags(string[] args) var fullOutputPath = Path.GetFullPath(outputPath); if (IsDatabaseOrSqliteSidecarPath(fullOutputPath, fullSourceDbPath)) { - return WriteError("ctags output path must not be the source database or a SQLite sidecar.", "choose a separate tags path, for example `tags`.", "cdidx export ctags [--output ] [--db ]"); + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_output_overlaps_database", "ctags output path must not be the source database or a SQLite sidecar.", "choose a separate tags path, for example `tags`.", CtagsExportUsage); } if (!DbContext.TryValidateExistingCodeIndexDb(normalizedDbPath, out var validationMessage, out _)) - return WriteError(validationMessage, "run `cdidx index ` first or pass `--db `.", "cdidx export ctags [--output ] [--db ]"); + return WriteExportError(wantsJson, jsonOptions, PhaseSqliteValidate, "ctags_export_database_invalid", validationMessage, "run `cdidx index ` first or pass `--db `.", CtagsExportUsage); + var filters = new CtagsExportOptions(lang, pathPatterns.ToArray(), excludePathPatterns.ToArray(), excludeTests); try { using var db = new DbContext(normalizedDbPath); @@ -356,18 +399,14 @@ private static int RunExportCtags(string[] args) if (!string.IsNullOrWhiteSpace(outputDirectory)) Directory.CreateDirectory(outputDirectory); + var totalTagCount = CountCtagsSymbols(db.Connection, CtagsExportOptions.Unfiltered); + long emittedCount = 0; WriteCtagsFile(fullOutputPath, writer => { writer.WriteLine("!_TAG_FILE_FORMAT\t2\t/extended format/"); writer.WriteLine("!_TAG_FILE_SORTED\t1\t/0=unsorted, 1=sorted, 2=foldcase/"); - using var cmd = db.Connection.CreateCommand(); - cmd.CommandText = @" - SELECT s.name, f.path, COALESCE(s.start_line, s.line, 1), s.kind - FROM symbols s - JOIN files f ON s.file_id = f.id - WHERE s.name IS NOT NULL AND s.name != '' - ORDER BY s.name COLLATE NOCASE, f.path, COALESCE(s.start_line, s.line, 1)"; + using var cmd = CreateCtagsSymbolCommand(db.Connection, filters, countOnly: false); using var reader = cmd.ExecuteReader(); while (reader.Read()) { @@ -375,17 +414,135 @@ FROM symbols s var path = SanitizeCtagsField(reader.GetString(1)); var line = Math.Max(1, reader.GetInt32(2)); var kind = SanitizeCtagsField(reader.GetString(3)); - writer.WriteLine($"{name}\t{path}\t{line};\"\tkind:{kind}\tline:{line}"); + var tagLine = new StringBuilder() + .Append(name) + .Append('\t') + .Append(path) + .Append('\t') + .Append(line.ToString(CultureInfo.InvariantCulture)) + .Append(";\"\tkind:") + .Append(kind) + .Append("\tline:") + .Append(line.ToString(CultureInfo.InvariantCulture)); + AppendCtagsExtensionField(tagLine, "language", GetNullableString(reader, 4)); + AppendCtagsExtensionField(tagLine, "container_kind", GetNullableString(reader, 5)); + AppendCtagsExtensionField(tagLine, "container", GetNullableString(reader, 6)); + AppendCtagsExtensionField(tagLine, "visibility", GetNullableString(reader, 7)); + writer.WriteLine(tagLine.ToString()); + emittedCount++; } }); - Console.WriteLine($"Exported ctags to {fullOutputPath}"); + if (wantsJson) + { + var skippedCount = Math.Max(0, totalTagCount - emittedCount); + var result = new CtagsExportResult( + "1", + "success", + fullOutputPath, + fullSourceDbPath, + emittedCount, + emittedCount, + skippedCount, + new CtagsExportFilterResult(filters.Lang, filters.PathPatterns, filters.ExcludePathPatterns, filters.ExcludeTests), + ["kind", "line", "language", "container_kind", "container", "visibility"]); + Console.WriteLine(JsonSerializer.Serialize( + result, + CliJsonSerializerContextFactory.Create(jsonOptions).CtagsExportResult)); + } + else + { + Console.WriteLine($"Exported ctags to {fullOutputPath}"); + } return CommandExitCodes.Success; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SqliteException) { - return WriteError($"ctags export failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", "check the database and output paths.", "cdidx export ctags [--output ] [--db ]"); + return WriteExportError(wantsJson, jsonOptions, PhaseWriteCtags, "ctags_export_failed", $"ctags export failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", "check the database and output paths.", CtagsExportUsage); + } + } + + private static SqliteCommand CreateCtagsSymbolCommand(SqliteConnection connection, CtagsExportOptions filters, bool countOnly) + { + var cmd = connection.CreateCommand(); + var sql = countOnly + ? @" + SELECT COUNT(*) + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.name IS NOT NULL AND s.name != ''" + : @" + SELECT + s.name, + f.path, + COALESCE(s.start_line, s.line, 1), + s.kind, + f.lang, + s.container_kind, + s.container_name, + s.visibility + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.name IS NOT NULL AND s.name != ''"; + AppendCtagsFilters(ref sql, filters); + if (!countOnly) + sql += " ORDER BY s.name COLLATE NOCASE, f.path, COALESCE(s.start_line, s.line, 1)"; + cmd.CommandText = sql; + AddCtagsFilterParameters(cmd, filters); + return cmd; + } + + private static long CountCtagsSymbols(SqliteConnection connection, CtagsExportOptions filters) + { + using var cmd = CreateCtagsSymbolCommand(connection, filters, countOnly: true); + return Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); + } + + private static void AppendCtagsFilters(ref string sql, CtagsExportOptions filters) + { + if (!string.IsNullOrWhiteSpace(filters.Lang)) + sql += " AND f.lang = @lang"; + + if (filters.PathPatterns.Count > 0) + { + var ors = new List(filters.PathPatterns.Count); + for (var i = 0; i < filters.PathPatterns.Count; i++) + ors.Add($"f.path LIKE @pathPattern{i} ESCAPE '\\'"); + sql += " AND (" + string.Join(" OR ", ors) + ")"; } + + for (var i = 0; i < filters.ExcludePathPatterns.Count; i++) + sql += $" AND f.path NOT LIKE @excludePathPattern{i} ESCAPE '\\'"; + + if (filters.ExcludeTests) + sql += $" AND NOT {DbReader.TestPathCondition}"; + } + + private static void AddCtagsFilterParameters(SqliteCommand cmd, CtagsExportOptions filters) + { + if (!string.IsNullOrWhiteSpace(filters.Lang)) + cmd.Parameters.AddWithValue("@lang", filters.Lang); + + for (var i = 0; i < filters.PathPatterns.Count; i++) + cmd.Parameters.AddWithValue($"@pathPattern{i}", DbReader.BuildPathLikePattern(filters.PathPatterns[i])); + + for (var i = 0; i < filters.ExcludePathPatterns.Count; i++) + cmd.Parameters.AddWithValue($"@excludePathPattern{i}", DbReader.BuildPathLikePattern(filters.ExcludePathPatterns[i])); + } + + private static string? GetNullableString(SqliteDataReader reader, int ordinal) + => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + + private static void AppendCtagsExtensionField(StringBuilder builder, string name, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + builder + .Append('\t') + .Append(name) + .Append(':') + .Append(SanitizeCtagsField(value)); } private static ExportManifest BuildManifest(SqliteConnection connection, string appVersion) @@ -1237,6 +1394,29 @@ internal sealed record ImportDryRunResult( [property: JsonPropertyName("replacement_would_be_allowed")] bool ReplacementWouldBeAllowed, [property: JsonPropertyName("validation_phases")] IReadOnlyList ValidationPhases); internal sealed record ExportArchiveResult(string ApiVersion, string ArchivePath, string DbPath); + private sealed record CtagsExportOptions( + string? Lang, + IReadOnlyList PathPatterns, + IReadOnlyList ExcludePathPatterns, + bool ExcludeTests) + { + internal static CtagsExportOptions Unfiltered { get; } = new(null, [], [], false); + } + internal sealed record CtagsExportFilterResult( + [property: JsonPropertyName("lang")] string? Lang, + [property: JsonPropertyName("path")] IReadOnlyList PathPatterns, + [property: JsonPropertyName("exclude_path")] IReadOnlyList ExcludePathPatterns, + [property: JsonPropertyName("exclude_tests")] bool ExcludeTests); + internal sealed record CtagsExportResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("output_path")] string OutputPath, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("tag_count")] long TagCount, + [property: JsonPropertyName("emitted_count")] long EmittedCount, + [property: JsonPropertyName("skipped_count")] long SkippedCount, + [property: JsonPropertyName("filters")] CtagsExportFilterResult Filters, + [property: JsonPropertyName("metadata_fields")] IReadOnlyList MetadataFields); internal sealed record ImportResult( string ApiVersion, string DbPath, diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 525e70f1da..48033c457b 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -469,6 +469,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(DiffSummaryOnlyJsonResult))] [JsonSerializable(typeof(DiffSummaryJsonResult))] [JsonSerializable(typeof(ExactZeroHintResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.CtagsExportFilterResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.CtagsExportResult))] [JsonSerializable(typeof(ExportImportCommandRunner.ExportArchiveResult))] [JsonSerializable(typeof(ExportImportCommandRunner.ExportImportErrorResult))] [JsonSerializable(typeof(ExportImportCommandRunner.ExportManifest))] diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index 24e46c57d8..99e4606e96 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -205,6 +205,87 @@ public void RunExportCtags_MissingDatabaseDoesNotCreateDatabase_Issue3368() } } + [Fact] + public void RunExportCtags_JsonReportsFiltersAndMetadata_Issue3551() + { + var projectRoot = TestProjectHelper.CreateTempProject("ctags_json_filters"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App.cs", "csharp", "public class App { public void Run() {} }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "tests/AppTests.cs", "csharp", "public class AppTests { public void Run() {} }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Generated.cs", "csharp", "public class Generated { }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/tool.py", "python", "def run():\n pass\n"); + var outputPath = Path.Combine(projectRoot, "tags"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [ + "ctags", + "--db", + dbPath, + "--output", + outputPath, + "--json", + "--lang", + "csharp", + "--path", + "src/", + "--exclude-path", + "src/Generated*", + "--exclude-tests" + ], + jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(File.Exists(outputPath)); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.Equal("1", root.GetProperty("api_version").GetString()); + Assert.Equal("success", root.GetProperty("status").GetString()); + Assert.Equal(Path.GetFullPath(outputPath), root.GetProperty("output_path").GetString()); + Assert.Equal(Path.GetFullPath(dbPath), root.GetProperty("db_path").GetString()); + Assert.True(root.GetProperty("tag_count").GetInt64() > 0); + Assert.True(root.GetProperty("emitted_count").GetInt64() > 0); + Assert.Equal(root.GetProperty("tag_count").GetInt64(), root.GetProperty("emitted_count").GetInt64()); + Assert.True(root.GetProperty("skipped_count").GetInt64() > 0); + + var filters = root.GetProperty("filters"); + Assert.Equal("csharp", filters.GetProperty("lang").GetString()); + Assert.Equal("src/", filters.GetProperty("path")[0].GetString()); + Assert.Equal("src/Generated*", filters.GetProperty("exclude_path")[0].GetString()); + Assert.True(filters.GetProperty("exclude_tests").GetBoolean()); + Assert.Contains(root.GetProperty("metadata_fields").EnumerateArray(), field => field.GetString() == "language"); + + var tags = File.ReadAllText(outputPath); + Assert.Contains("App\tsrc/App.cs", tags); + Assert.Contains("language:csharp", tags); + Assert.DoesNotContain("AppTests", tags); + Assert.DoesNotContain("Generated", tags); + Assert.DoesNotContain("tool.py", tags); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunExportCtags_JsonUnknownOptionReturnsStructuredError_Issue3551() + { + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport(["ctags", "--json", "--bogus"], jsonOptions, "test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + AssertExportImportError(stdout, "export", "parse_args", "ctags_export_unknown_option"); + } + [Fact] public void IsDatabaseOrSqliteSidecarPath_UsesStampedCaseSensitivity_Issue3368() { From f6504226a8414d7b77edd79a65ca1f1d88ae4235 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 15 Jun 2026 09:01:53 +0900 Subject: [PATCH 2/3] Add LSP live sync providers (#3536) --- DEVELOPER_GUIDE.md | 13 + USER_GUIDE.md | 18 +- changelog.d/unreleased/3536.added.md | 18 + src/CodeIndex/Lsp/LspServer.cs | 571 +++++++++++++++++++++++- tests/CodeIndex.Tests/LspServerTests.cs | 252 ++++++++++- 5 files changed, 857 insertions(+), 15 deletions(-) create mode 100644 changelog.d/unreleased/3536.added.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ac7cd5e1ff..a03da44889 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -254,6 +254,13 @@ Query commands that accept path filters (`search`, `definition`, `references`, ` Editor integrations can request standard location shapes directly. `definition`, `references`, `search`, `find`, and `validate` accept `--format `; `lsp` emits LSP `Location` arrays, `qf` emits Vim quickfix lines, and `sarif` emits SARIF 2.1.0. `goto ` returns the single unambiguous definition as one LSP `Location`, while `goto --all ` returns all matching locations. +The `cdidx lsp` server advertises full text document synchronization and keeps +open document text in a bounded in-memory cache only. Position-based providers +must read that live cache before disk so unsaved editor buffers can identify the +requested token, but provider results remain conservative and index-backed: +return empty arrays or null when the database cannot answer safely instead of +inventing language-server analysis. + ### Extractor performance contract Symbol and reference extractors run during `cdidx index`, so language-specific @@ -2489,6 +2496,12 @@ path filter を受け付ける query コマンド(`search`, `definition`, `ref editor integration は標準的な location 形状を直接要求できる。`definition`、`references`、`search`、`find`、`validate` は `--format ` を受け付け、`lsp` は LSP `Location` 配列、`qf` は Vim quickfix 行、`sarif` は SARIF 2.1.0 を出力する。`goto ` は曖昧でない単一定義を 1 つの LSP `Location` として返し、`goto --all ` は一致する全 location を返す。 +`cdidx lsp` server は full text document synchronization を advertise し、open document text は +上限付きの in-memory cache にだけ保持する。position-based provider は未保存 editor buffer から +request token を特定できるよう disk より先に live cache を読む必要があるが、provider result は +保守的かつ index-backed のままにする。database が安全に答えられない場合は、language-server +analysis を作り上げず、空配列または null を返す。 + ### 抽出器の性能契約 symbol / reference extractor は `cdidx index` 中に実行されるため、言語別 helper は diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 5263b576e3..a61ef4d406 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2115,7 +2115,14 @@ server over stdio. It reuses the existing CodeIndex database and exposes `textDocument/definition`, `textDocument/declaration`, `textDocument/typeDefinition`, `textDocument/implementation`, and `textDocument/references` for editors that can launch an arbitrary LSP command -but do not speak MCP. +but do not speak MCP. It also advertises full `textDocument` sync and +conservative `hover`, `completion`, `documentHighlight`, `semanticTokens/full`, +`codeLens`, and `inlayHint` providers backed by indexed symbols and references +where available. +Open buffers sent through `textDocument/didOpen`, `textDocument/didChange`, and +`textDocument/didClose` are kept in a bounded in-memory cache. Position-based +requests read the live buffer first, so unsaved edits can drive token lookup +without writing back to the CodeIndex database. Incoming `textDocument.uri` values must be strings, must be absolute `file:` URIs, and are rejected before URI parsing when they exceed 4096 characters, matching the MCP resource URI limit and keeping error responses bounded. LSP @@ -4579,7 +4586,14 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて `initialize`、`workspace/symbol`、`textDocument/documentSymbol`、 `textDocument/definition`、`textDocument/declaration`、 `textDocument/typeDefinition`、`textDocument/implementation`、 -`textDocument/references` を公開します。 +`textDocument/references` を公開します。さらに full `textDocument` sync と、 +indexed symbols / references で答えられる範囲に限定した `hover`、`completion`、 +`documentHighlight`、`semanticTokens/full`、`codeLens`、`inlayHint` provider を +advertise します。 +`textDocument/didOpen`、`textDocument/didChange`、`textDocument/didClose` で送られた +open buffer は上限付きの in-memory cache に保持されます。position-based request は +live buffer を先に読むため、未保存の編集内容でも CodeIndex database に書き戻さず token lookup に +利用できます。 受信した `textDocument.uri` は string かつ absolute `file:` URI である必要があり、 4096 文字を超える場合は URI parse の前に拒否されます。これは MCP resource URI の上限と 揃えており、エラー応答が過大にならないようにします。 diff --git a/changelog.d/unreleased/3536.added.md b/changelog.d/unreleased/3536.added.md new file mode 100644 index 0000000000..ae5aaac8c0 --- /dev/null +++ b/changelog.d/unreleased/3536.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 3536 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **LSP now supports live document sync and richer editor providers (#3536)** — `cdidx lsp` now advertises full text document sync plus conservative hover, completion, document highlight, semantic tokens, code lens, and inlay hint providers backed by indexed symbols and references where available. + +## 日本語 + +- **LSP が live document sync と richer editor providers に対応しました (#3536)** — `cdidx lsp` は full text document sync と、indexed symbols / references で答えられる範囲に限定した hover、completion、document highlight、semantic tokens、code lens、inlay hint providers を advertise するようになりました。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index c8ac439d4d..0b0ad866b7 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -21,6 +21,7 @@ internal sealed class LspServer : IDisposable internal const int MaxLspHeaderCount = 64; internal const int MaxLspHeaderBytes = 64 * 1024; internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; + internal const int MaxLiveDocuments = 64; internal const int MaxTextDocumentUriChars = McpBoundedText.MaxResourceUriChars; internal const int MaxLspRequestIdRawBytes = 4 * 1024; internal const int MaxJsonDepth = 32; @@ -29,6 +30,10 @@ internal sealed class LspServer : IDisposable internal const int MaxDocumentSymbolDetailChars = 512; internal const int MaxDocumentSymbolResponseBytes = 512 * 1024; internal const int MaxPositionLineChars = 16 * 1024; + internal const int MaxCompletionItems = 100; + internal const int MaxCodeLensItems = 200; + internal const int MaxInlayHintItems = 200; + internal const int MaxSemanticTokenItems = 1000; internal const int MaxDocumentPathFallbackCandidates = 32; internal const int MaxUnknownMethodDiagnosticChars = 240; private const int JsonRpcInvalidParamsCode = -32602; @@ -49,6 +54,45 @@ internal sealed class LspServer : IDisposable private const string FailurePositionLineMissing = "position_line_missing"; private const string FailurePositionFileUnreadable = "position_file_unreadable"; private const string FailureNoTokenAtPosition = "no_token_at_position"; + private static readonly string[] SemanticTokenTypes = + [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator", + ]; + private static readonly string[] SemanticTokenModifiers = + [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ]; private static readonly JsonReaderOptions LspJsonReaderOptions = new() { MaxDepth = MaxJsonDepth, @@ -67,9 +111,12 @@ internal sealed class LspServer : IDisposable private bool _exitRequested; private bool _exitRequestedBeforeShutdown; private readonly List _workspaceFolders = []; + private readonly Dictionary _liveDocuments; + private readonly List _liveDocumentOrder = []; - private readonly record struct PositionTokenContext(string Token, string IndexedPath, string? WorkspaceRoot); + private readonly record struct PositionTokenContext(string Token, string IndexedPath, string? WorkspaceRoot, int Line, int StartCharacter, int EndCharacter); private readonly record struct DocumentSymbolNode(SymbolResult Symbol, JsonObject Item); + private readonly record struct IndexedDocumentContext(string DocumentPath, string ResolvedPath, string IndexedPath, string? WorkspaceRoot); public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) { @@ -78,6 +125,10 @@ public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOpti _jsonOptions = jsonOptions; _projectRoot = string.IsNullOrWhiteSpace(projectRoot) ? null : projectRoot; _pathStringComparison = PathCasing.ComparisonFor(_projectRoot ?? Environment.CurrentDirectory); + _liveDocuments = new Dictionary( + _pathStringComparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal); if (_projectRoot != null) _workspaceFolders.Add(Path.GetFullPath(_projectRoot)); } @@ -138,6 +189,9 @@ public int Run(Stream input, Stream output, CancellationToken cancellationToken) "shutdown" => HandleShutdown(id), "exit" => HandleExit(), "workspace/didChangeWorkspaceFolders" => HandleDidChangeWorkspaceFolders(root), + "textDocument/didOpen" => HandleDidOpenTextDocument(root), + "textDocument/didChange" => HandleDidChangeTextDocument(root), + "textDocument/didClose" => HandleDidCloseTextDocument(root), "workspace/symbol" => Result(id, WorkspaceSymbol(root)), "textDocument/documentSymbol" => Result(id, DocumentSymbol(root)), "textDocument/definition" => Result(id, Definition(root, "textDocument/definition")), @@ -145,6 +199,12 @@ public int Run(Stream input, Stream output, CancellationToken cancellationToken) "textDocument/typeDefinition" => Result(id, Definition(root, "textDocument/typeDefinition")), "textDocument/implementation" => Result(id, Definition(root, "textDocument/implementation")), "textDocument/references" => Result(id, References(root, "textDocument/references")), + "textDocument/hover" => Result(id, Hover(root, "textDocument/hover")), + "textDocument/completion" => Result(id, Completion(root, "textDocument/completion")), + "textDocument/documentHighlight" => Result(id, DocumentHighlight(root, "textDocument/documentHighlight")), + "textDocument/semanticTokens/full" => Result(id, SemanticTokensFull(root)), + "textDocument/codeLens" => Result(id, CodeLens(root)), + "textDocument/inlayHint" => Result(id, InlayHint(root)), _ => hasId ? Error(id, -32601, $"Method not found: {SanitizeUnknownMethod(method)}") : null, }; } @@ -316,6 +376,100 @@ private JsonObject HandleInitialize(JsonNode? id, JsonElement root) return null; } + private JsonObject? HandleDidOpenTextDocument(JsonElement root) + { + var uri = GetTextDocumentUri(root); + if (TryGet(root, out var textElement, "params", "textDocument", "text") && textElement.ValueKind == JsonValueKind.String) + SetLiveDocumentText(uri, textElement.GetString() ?? string.Empty); + return null; + } + + private JsonObject? HandleDidChangeTextDocument(JsonElement root) + { + var uri = GetTextDocumentUri(root); + if (!TryGet(root, out var changes, "params", "contentChanges") || changes.ValueKind != JsonValueKind.Array) + return null; + + string? latestText = null; + foreach (var change in changes.EnumerateArray()) + { + if (change.ValueKind == JsonValueKind.Object + && change.TryGetProperty("text", out var textElement) + && textElement.ValueKind == JsonValueKind.String) + { + latestText = textElement.GetString() ?? string.Empty; + } + } + + if (latestText != null) + SetLiveDocumentText(uri, latestText); + return null; + } + + private JsonObject? HandleDidCloseTextDocument(JsonElement root) + { + var uri = GetTextDocumentUri(root); + if (TryGetLiveDocumentKeyFromUri(uri, out var key)) + RemoveLiveDocument(key); + return null; + } + + private void SetLiveDocumentText(string uri, string text) + { + if (!TryGetLiveDocumentKeyFromUri(uri, out var key)) + return; + + if (Encoding.UTF8.GetByteCount(text) > MaxPositionDocumentBytes) + { + RemoveLiveDocument(key); + return; + } + + EnsureLiveDocumentCapacity(key); + _liveDocuments[key] = text; + } + + private void EnsureLiveDocumentCapacity(string key) + { + if (_liveDocuments.ContainsKey(key)) + return; + + while (_liveDocuments.Count >= MaxLiveDocuments && _liveDocumentOrder.Count > 0) + { + var oldestKey = _liveDocumentOrder[0]; + _liveDocumentOrder.RemoveAt(0); + _liveDocuments.Remove(oldestKey); + } + + if (_liveDocuments.Count >= MaxLiveDocuments) + { + _liveDocuments.Clear(); + _liveDocumentOrder.Clear(); + } + + _liveDocumentOrder.Add(key); + } + + private void RemoveLiveDocument(string key) + { + _liveDocuments.Remove(key); + _liveDocumentOrder.RemoveAll(existing => string.Equals(existing, key, _pathStringComparison)); + } + + private bool TryGetLiveDocumentKeyFromUri(string uri, out string key) + { + key = string.Empty; + try + { + key = Path.GetFullPath(UriToPath(uri)); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + private static Activity? StartLspRequestActivity(string method) { var activity = CodeIndexTelemetry.ActivitySource.StartActivity("lsp.request", ActivityKind.Server); @@ -336,7 +490,36 @@ private JsonObject HandleInitialize(JsonNode? id, JsonElement root) ["referencesProvider"] = true, ["documentSymbolProvider"] = true, ["workspaceSymbolProvider"] = true, - ["textDocumentSync"] = 0, + ["hoverProvider"] = true, + ["completionProvider"] = new JsonObject + { + ["resolveProvider"] = false, + ["triggerCharacters"] = new JsonArray(".", ":", "_"), + }, + ["documentHighlightProvider"] = true, + ["semanticTokensProvider"] = new JsonObject + { + ["legend"] = new JsonObject + { + ["tokenTypes"] = ToJsonStringArray(SemanticTokenTypes), + ["tokenModifiers"] = ToJsonStringArray(SemanticTokenModifiers), + }, + ["full"] = true, + ["range"] = false, + }, + ["codeLensProvider"] = new JsonObject + { + ["resolveProvider"] = false, + }, + ["inlayHintProvider"] = new JsonObject + { + ["resolveProvider"] = false, + }, + ["textDocumentSync"] = new JsonObject + { + ["openClose"] = true, + ["change"] = 1, + }, ["workspace"] = new JsonObject { ["workspaceFolders"] = new JsonObject @@ -353,6 +536,14 @@ private JsonObject HandleInitialize(JsonNode? id, JsonElement root) }, }; + private static JsonArray ToJsonStringArray(IEnumerable values) + { + var array = new JsonArray(); + foreach (var value in values) + array.Add(value); + return array; + } + private JsonArray WorkspaceSymbol(JsonElement root) { var query = GetString(root, "params", "query"); @@ -451,6 +642,267 @@ private JsonArray References(JsonElement root, string method) return array; } + private JsonNode? Hover(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return null; + } + + var definition = ResolveLspDefinitions(context).FirstOrDefault(); + if (definition == null) + return null; + + return new JsonObject + { + ["contents"] = new JsonObject + { + ["kind"] = "plaintext", + ["value"] = FormatHoverText(definition), + }, + ["range"] = ToRange(context.Line + 1, context.StartCharacter + 1, context.Line + 1, context.EndCharacter + 1), + }; + } + + private JsonObject Completion(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return CompletionList([]); + } + + var symbols = _reader.SearchSymbols(context.Token, MaxCompletionItems, pathPatterns: [context.IndexedPath]) + .Concat(_reader.SearchSymbols(context.Token, MaxCompletionItems)) + .DistinctBy(BuildCompletionIdentity) + .Take(MaxCompletionItems) + .ToList(); + var items = new JsonArray(); + for (var i = 0; i < symbols.Count; i++) + items.Add((JsonNode)ToCompletionItem(symbols[i], i)); + return CompletionList(items); + } + + private JsonArray DocumentHighlight(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return []; + } + + var array = new JsonArray(); + var seenRanges = new HashSet(StringComparer.Ordinal); + foreach (var definition in ResolveLspDefinitions(context).Where(definition => string.Equals(definition.Path, context.IndexedPath, StringComparison.Ordinal))) + AddDocumentHighlight(array, seenRanges, definition.StartLine, 1, definition.EndLine, 1); + + foreach (var reference in ResolveLspReferences(context).References.Where(reference => string.Equals(reference.Path, context.IndexedPath, StringComparison.Ordinal))) + { + var startColumn = Math.Max(reference.Column, 1); + AddDocumentHighlight(array, seenRanges, reference.Line, startColumn, reference.Line, startColumn + Math.Max(context.Token.Length, 1)); + } + + if (array.Count == 0) + AddDocumentHighlight(array, seenRanges, context.Line + 1, context.StartCharacter + 1, context.Line + 1, context.EndCharacter + 1); + return array; + } + + private JsonObject SemanticTokensFull(JsonElement root) + { + if (!TryResolveIndexedDocument(root, out var document)) + return new JsonObject { ["data"] = new JsonArray() }; + + var symbols = GetDocumentSymbols(document.IndexedPath, MaxSemanticTokenItems) + .Where(symbol => !string.IsNullOrWhiteSpace(symbol.Name)) + .Take(MaxSemanticTokenItems) + .Select(symbol => BuildSemanticToken(document, symbol)) + .Where(token => token.HasValue) + .Select(token => token!.Value) + .OrderBy(token => token.Line) + .ThenBy(token => token.StartCharacter) + .ToList(); + var data = new JsonArray(); + var previousLine = 0; + var previousStart = 0; + foreach (var token in symbols) + { + var deltaLine = token.Line - previousLine; + var deltaStart = deltaLine == 0 ? token.StartCharacter - previousStart : token.StartCharacter; + data.Add(deltaLine); + data.Add(deltaStart); + data.Add(token.Length); + data.Add(token.TokenType); + data.Add(token.TokenModifiers); + previousLine = token.Line; + previousStart = token.StartCharacter; + } + + return new JsonObject { ["data"] = data }; + } + + private JsonArray CodeLens(JsonElement root) + { + if (!TryResolveIndexedDocument(root, out var document)) + return []; + + var array = new JsonArray(); + foreach (var symbol in GetDocumentSymbols(document.IndexedPath, MaxCodeLensItems).Take(MaxCodeLensItems)) + array.Add((JsonNode)ToCodeLens(symbol)); + return array; + } + + private JsonArray InlayHint(JsonElement root) + { + if (!TryResolveIndexedDocument(root, out var document)) + return []; + + var array = new JsonArray(); + foreach (var symbol in GetDocumentSymbols(document.IndexedPath, MaxInlayHintItems) + .Where(symbol => !string.IsNullOrWhiteSpace(symbol.ReturnType)) + .Take(MaxInlayHintItems)) + { + array.Add((JsonNode)ToInlayHint(document, symbol)); + } + return array; + } + + private static JsonObject CompletionList(JsonArray items) => new() + { + ["isIncomplete"] = false, + ["items"] = items, + }; + + private static string BuildCompletionIdentity(SymbolResult symbol) + => string.Join('\0', symbol.Name, symbol.Kind, symbol.Path, symbol.Line.ToString(CultureInfo.InvariantCulture)); + + private static JsonObject ToCompletionItem(SymbolResult symbol, int index) => new() + { + ["label"] = symbol.Name, + ["kind"] = CompletionItemKind(symbol.Kind), + ["detail"] = FormatSymbolDetail(symbol), + ["sortText"] = index.ToString("D4", CultureInfo.InvariantCulture) + "_" + symbol.Name, + }; + + private static string FormatHoverText(SymbolResult symbol) + { + var builder = new StringBuilder(); + builder.Append(symbol.Kind).Append(' ').Append(symbol.Name); + if (!string.IsNullOrWhiteSpace(symbol.Signature)) + builder.AppendLine().Append(symbol.Signature); + builder.AppendLine().Append(symbol.Path).Append(':').Append(symbol.Line.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(symbol.ContainerName)) + builder.AppendLine().Append("container: ").Append(symbol.ContainerName); + if (!string.IsNullOrWhiteSpace(symbol.ReturnType)) + builder.AppendLine().Append("returns: ").Append(symbol.ReturnType); + return builder.ToString(); + } + + private static string FormatSymbolDetail(SymbolResult symbol) + { + var detail = string.IsNullOrWhiteSpace(symbol.Signature) + ? $"{symbol.Kind} {symbol.Path}:{symbol.Line.ToString(CultureInfo.InvariantCulture)}" + : symbol.Signature; + return detail.Length <= MaxDocumentSymbolDetailChars + ? detail + : detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; + } + + private static int CompletionItemKind(string kind) => kind switch + { + "class" => 7, + "function" or "test.method" => 3, + "property" => 10, + "enum" => 13, + "interface" => 8, + "namespace" => 9, + "struct" => 22, + _ => 6, + }; + + private static void AddDocumentHighlight(JsonArray array, HashSet seenRanges, int startLine, int startColumn, int endLine, int endColumn) + { + var key = string.Join('\0', startLine, startColumn, endLine, endColumn); + if (!seenRanges.Add(key)) + return; + + array.Add(new JsonObject + { + ["range"] = ToRange(startLine, startColumn, endLine, endColumn), + ["kind"] = 1, + }); + } + + private JsonObject ToCodeLens(SymbolResult symbol) => new() + { + ["range"] = ToRange(symbol.Line, 1, symbol.Line, 1), + ["command"] = new JsonObject + { + ["title"] = $"cdidx: {symbol.Kind}", + ["command"] = "cdidx.showSymbol", + ["arguments"] = new JsonArray(new JsonObject + { + ["name"] = symbol.Name, + ["kind"] = symbol.Kind, + ["path"] = symbol.Path, + ["line"] = symbol.Line, + }), + }, + }; + + private JsonObject ToInlayHint(IndexedDocumentContext document, SymbolResult symbol) + { + var startCharacter = FindSymbolStartCharacter(document, symbol); + return new JsonObject + { + ["position"] = ToPosition(symbol.Line, startCharacter + symbol.Name.Length + 1), + ["label"] = ": " + symbol.ReturnType, + ["kind"] = 1, + ["paddingLeft"] = true, + }; + } + + private readonly record struct SemanticToken(int Line, int StartCharacter, int Length, int TokenType, int TokenModifiers); + + private SemanticToken? BuildSemanticToken(IndexedDocumentContext document, SymbolResult symbol) + { + var line = Math.Max(symbol.Line, symbol.StartLine); + if (line <= 0) + return null; + + var startCharacter = FindSymbolStartCharacter(document, symbol); + var length = Math.Max(symbol.Name.Length, 1); + return new SemanticToken( + line - 1, + startCharacter, + length, + SemanticTokenType(symbol.Kind), + 1 << 1); + } + + private int FindSymbolStartCharacter(IndexedDocumentContext document, SymbolResult symbol) + { + var line = Math.Max(symbol.Line, symbol.StartLine); + if (line <= 0 || string.IsNullOrWhiteSpace(symbol.Name)) + return 0; + + return TryReadPositionLine(document.ResolvedPath, line - 1, out var sourceLine, out _) + ? Math.Max(0, sourceLine.IndexOf(symbol.Name, StringComparison.Ordinal)) + : 0; + } + + private static int SemanticTokenType(string kind) => kind switch + { + "namespace" => 0, + "class" => 2, + "enum" => 3, + "interface" => 4, + "struct" => 5, + "property" => 9, + "function" or "test.method" => 13, + _ => 8, + }; + private void AddLocation( JsonArray array, HashSet seenLocations, @@ -654,11 +1106,95 @@ private bool TryExtractPositionToken(JsonElement root, out PositionTokenContext return false; } - context = new PositionTokenContext(token, indexedPath, workspaceRoot); + var (startCharacter, endCharacter) = FindTokenRangeAtUtf16Position(sourceLine, character); + context = new PositionTokenContext(token, indexedPath, workspaceRoot, line, startCharacter, endCharacter); return true; } - private static bool TryReadPositionLine(string path, int targetLine, out string sourceLine, out string? failureReason) + private bool TryResolveIndexedDocument(JsonElement root, out IndexedDocumentContext context) + { + context = default; + var documentPath = GetDocumentPath(root); + if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath, out var workspaceRoot)) + return false; + + var indexedPath = ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath, workspaceRoot); + if (indexedPath == null) + return false; + + var indexedPathRoot = _projectRoot == null ? workspaceRoot : null; + if (!TryResolveIndexedFilePath(indexedPath, indexedPathRoot, out var indexedFullPath)) + return false; + + if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) + return false; + + context = new IndexedDocumentContext(documentPath, resolvedPath, indexedPath, workspaceRoot); + return true; + } + + private List GetDocumentSymbols(string indexedPath, int limit) + => _reader.SearchSymbols((string?)null, limit, pathPatterns: [indexedPath]) + .OrderBy(s => s.StartLine) + .ThenByDescending(s => s.EndLine) + .ThenBy(s => s.ContainerName == null ? 0 : 1) + .ThenBy(s => s.Name, StringComparer.Ordinal) + .ToList(); + + private bool TryReadPositionLine(string path, int targetLine, out string sourceLine, out string? failureReason) + { + if (_liveDocuments.TryGetValue(Path.GetFullPath(path), out var liveText)) + return TryReadPositionLineFromText(liveText, targetLine, out sourceLine, out failureReason); + + return TryReadPositionLineFromFile(path, targetLine, out sourceLine, out failureReason); + } + + private static bool TryReadPositionLineFromText(string text, int targetLine, out string sourceLine, out string? failureReason) + { + sourceLine = string.Empty; + failureReason = null; + if (targetLine < 0) + { + failureReason = FailureInvalidPosition; + return false; + } + + var currentLine = 0; + var lineStart = 0; + for (var i = 0; i <= text.Length; i++) + { + var atEnd = i == text.Length; + var isLineBreak = !atEnd && (text[i] == '\r' || text[i] == '\n'); + if (!atEnd && !isLineBreak) + continue; + + if (currentLine == targetLine) + { + var length = i - lineStart; + if (length > MaxPositionLineChars) + { + failureReason = FailurePositionLineTooLong; + return false; + } + + sourceLine = text.Substring(lineStart, length); + return true; + } + + if (atEnd) + break; + + if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n') + i++; + currentLine++; + lineStart = i + 1; + } + + failureReason = FailurePositionLineMissing; + return false; + } + + private static bool TryReadPositionLineFromFile(string path, int targetLine, out string sourceLine, out string? failureReason) { sourceLine = string.Empty; failureReason = null; @@ -763,6 +1299,27 @@ private static bool TryReadPositionLine(string path, int targetLine, out string return line[start..end].TrimStart('@'); } + private static (int Start, int End) FindTokenRangeAtUtf16Position(string line, int character) + { + if (character < 0) + return (0, 0); + 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 (Math.Max(0, Math.Min(character, line.Length)), Math.Max(0, Math.Min(character, line.Length))); + + var start = index; + while (start > 0 && IsTokenChar(line[start - 1])) + start--; + var end = index + 1; + while (end < line.Length && IsTokenChar(line[end])) + end++; + return (start, end); + } + private static bool IsTokenChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '@'; private bool MatchesDocumentPath(string indexedPath, string documentPath, string? projectRelativePath, string resolvedPath, string? workspaceRoot) @@ -986,6 +1543,12 @@ private static bool TryGetRelativePath(string root, string resolvedPath, out str }, }; + private static JsonObject ToPosition(int line, int column) => new() + { + ["line"] = Math.Max(line - 1, 0), + ["character"] = Math.Max(column - 1, 0), + }; + private static int SymbolKind(string kind) => kind switch { "class" => 5, diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index ecd9f24455..2d5cbd7ada 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -144,13 +144,23 @@ public void HandleMessage_Initialize_AdvertisesCoreCapabilities() 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"]!["declarationProvider"]!.GetValue()); - Assert.True(response["result"]!["capabilities"]!["typeDefinitionProvider"]!.GetValue()); - Assert.True(response["result"]!["capabilities"]!["implementationProvider"]!.GetValue()); - Assert.True(response["result"]!["capabilities"]!["documentSymbolProvider"]!.GetValue()); - Assert.True(response["result"]!["capabilities"]!["workspace"]!["workspaceFolders"]!["supported"]!.GetValue()); - Assert.True(response["result"]!["capabilities"]!["workspace"]!["workspaceFolders"]!["changeNotifications"]!.GetValue()); + var capabilities = response!["result"]!["capabilities"]!; + Assert.True(capabilities["definitionProvider"]!.GetValue()); + Assert.True(capabilities["declarationProvider"]!.GetValue()); + Assert.True(capabilities["typeDefinitionProvider"]!.GetValue()); + Assert.True(capabilities["implementationProvider"]!.GetValue()); + Assert.True(capabilities["documentSymbolProvider"]!.GetValue()); + Assert.True(capabilities["hoverProvider"]!.GetValue()); + Assert.True(capabilities["documentHighlightProvider"]!.GetValue()); + Assert.Equal(1, capabilities["textDocumentSync"]!["change"]!.GetValue()); + Assert.True(capabilities["textDocumentSync"]!["openClose"]!.GetValue()); + Assert.False(capabilities["completionProvider"]!["resolveProvider"]!.GetValue()); + Assert.False(capabilities["codeLensProvider"]!["resolveProvider"]!.GetValue()); + Assert.False(capabilities["inlayHintProvider"]!["resolveProvider"]!.GetValue()); + Assert.True(capabilities["semanticTokensProvider"]!["full"]!.GetValue()); + Assert.Contains(capabilities["semanticTokensProvider"]!["legend"]!["tokenTypes"]!.AsArray(), node => node!.GetValue() == "class"); + Assert.True(capabilities["workspace"]!["workspaceFolders"]!["supported"]!.GetValue()); + Assert.True(capabilities["workspace"]!["workspaceFolders"]!["changeNotifications"]!.GetValue()); Assert.Equal("cdidx", response["result"]!["serverInfo"]!["name"]!.GetValue()); } finally @@ -159,6 +169,171 @@ public void HandleMessage_Initialize_AdvertisesCoreCapabilities() } } + [Fact] + public void HandleMessage_LiveDocumentSync_UsesChangedBufferForPositionRequests_Issue3536() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_live_sync"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var diskSource = "class App { void Needle() { } void Call() { Missing(); } }\n"; + var liveSource = "class App { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, diskSource); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", diskSource); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + + Assert.Null(server.HandleMessage(CreateDidOpenRequest(sourcePath, diskSource, version: 1))); + Assert.Null(server.HandleMessage(CreateDidChangeRequest(sourcePath, liveSource, version: 2))); + var liveResponse = server.HandleMessage(CreateDefinitionRequest( + sourcePath, + 3536, + 0, + liveSource.LastIndexOf("Needle();", StringComparison.Ordinal))); + + Assert.NotNull(liveResponse); + Assert.NotEmpty(liveResponse!["result"]!.AsArray()); + + Assert.Null(server.HandleMessage(CreateDidCloseRequest(sourcePath))); + var closedResponse = server.HandleMessage(CreateDefinitionRequest( + sourcePath, + 35361, + 0, + liveSource.LastIndexOf("Needle();", StringComparison.Ordinal))); + + Assert.NotNull(closedResponse); + Assert.Empty(closedResponse!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_LiveDocumentSync_EvictsOldestBufferWhenCacheIsFull_Issue3536() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_live_sync_bound"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + string? firstPath = null; + string? firstLiveSource = null; + string? lastPath = null; + string? lastLiveSource = null; + var sources = new List<(string Path, string DiskSource, string LiveSource)>(); + for (var i = 0; i <= LspServer.MaxLiveDocuments; i++) + { + var sourcePath = Path.Combine(projectRoot, $"file{i}.cs"); + var needle = $"Needle{i}"; + var missing = $"Missing{i}"; + var diskSource = $"class App{i} {{ void {needle}() {{ }} void Call() {{ {missing}(); }} }}\n"; + var liveSource = $"class App{i} {{ void {needle}() {{ }} void Call() {{ {needle}(); }} }}\n"; + File.WriteAllText(sourcePath, diskSource); + TestProjectHelper.InsertIndexedFile(dbPath, $"file{i}.cs", "csharp", diskSource); + sources.Add((sourcePath, diskSource, liveSource)); + if (i == 0) + { + firstPath = sourcePath; + firstLiveSource = liveSource; + } + if (i == LspServer.MaxLiveDocuments) + { + lastPath = sourcePath; + lastLiveSource = liveSource; + } + } + + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + for (var i = 0; i < sources.Count; i++) + { + var source = sources[i]; + Assert.Null(server.HandleMessage(CreateDidOpenRequest(source.Path, source.DiskSource, version: i + 1))); + Assert.Null(server.HandleMessage(CreateDidChangeRequest(source.Path, source.LiveSource, version: i + 100))); + } + + Assert.NotNull(firstPath); + Assert.NotNull(firstLiveSource); + var evictedResponse = server.HandleMessage(CreateDefinitionRequest( + firstPath!, + 35368, + 0, + firstLiveSource!.LastIndexOf("Needle0();", StringComparison.Ordinal))); + + Assert.NotNull(evictedResponse); + Assert.Empty(evictedResponse!["result"]!.AsArray()); + + Assert.NotNull(lastPath); + Assert.NotNull(lastLiveSource); + var retainedResponse = server.HandleMessage(CreateDefinitionRequest( + lastPath!, + 35369, + 0, + lastLiveSource!.LastIndexOf($"Needle{LspServer.MaxLiveDocuments}();", StringComparison.Ordinal))); + + Assert.NotNull(retainedResponse); + Assert.NotEmpty(retainedResponse!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_RicherProviders_ReturnIndexBackedResponses_Issue3536() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_richer_providers"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = """ + public class App + { + public int Count() { return 1; } + public void Call() { Count(); } + } + """; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + MarkGraphReady(dbPath); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var countCallCharacter = CharacterOf(source, 3, "Count();"); + + var hover = server.HandleMessage(CreatePositionRequest("textDocument/hover", sourcePath, 35362, 3, countCallCharacter)); + Assert.NotNull(hover); + Assert.Contains("Count", hover!["result"]!["contents"]!["value"]!.GetValue(), StringComparison.Ordinal); + + var completion = server.HandleMessage(CreatePositionRequest("textDocument/completion", sourcePath, 35363, 3, countCallCharacter + 3)); + Assert.NotNull(completion); + Assert.Contains(completion!["result"]!["items"]!.AsArray(), item => item!["label"]!.GetValue() == "Count"); + + var highlights = server.HandleMessage(CreatePositionRequest("textDocument/documentHighlight", sourcePath, 35364, 3, countCallCharacter)); + Assert.NotNull(highlights); + Assert.NotEmpty(highlights!["result"]!.AsArray()); + + var semanticTokens = server.HandleMessage(CreateTextDocumentRequest("textDocument/semanticTokens/full", sourcePath, 35365)); + Assert.NotNull(semanticTokens); + Assert.NotEmpty(semanticTokens!["result"]!["data"]!.AsArray()); + + var codeLens = server.HandleMessage(CreateTextDocumentRequest("textDocument/codeLens", sourcePath, 35366)); + Assert.NotNull(codeLens); + Assert.NotEmpty(codeLens!["result"]!.AsArray()); + + var inlayHints = server.HandleMessage(CreateTextDocumentRequest("textDocument/inlayHint", sourcePath, 35367)); + Assert.NotNull(inlayHints); + Assert.NotEmpty(inlayHints!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_TooDeepJson_ReturnsParseError_Issue3021() { @@ -262,14 +437,14 @@ public void HandleMessage_UnknownMethod_PreservesSlashDelimitedMethodName_Issue3 { jsonrpc = "2.0", id = 1, - method = "textDocument/hover", + method = "textDocument/unknownHover", }); var response = server.HandleMessage(request); Assert.NotNull(response); Assert.Equal(-32601, response!["error"]!["code"]!.GetValue()); - Assert.Equal("Method not found: textDocument/hover", response["error"]!["message"]!.GetValue()); + Assert.Equal("Method not found: textDocument/unknownHover", response["error"]!["message"]!.GetValue()); } finally { @@ -1818,6 +1993,65 @@ private static string CreatePositionRequest(string method, string sourcePath, in }, }); + private static string CreateTextDocumentRequest(string method, string sourcePath, int id) => + JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id, + method, + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + private static string CreateDidOpenRequest(string sourcePath, string text, int version) => + JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + method = "textDocument/didOpen", + @params = new + { + textDocument = new + { + uri = new Uri(sourcePath).AbsoluteUri, + languageId = "csharp", + version, + text, + }, + }, + }); + + private static string CreateDidChangeRequest(string sourcePath, string text, int version) => + JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + method = "textDocument/didChange", + @params = new + { + textDocument = new + { + uri = new Uri(sourcePath).AbsoluteUri, + version, + }, + contentChanges = new[] + { + new { text }, + }, + }, + }); + + private static string CreateDidCloseRequest(string sourcePath) => + JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + method = "textDocument/didClose", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + private static string Frame(string payload) => $"Content-Length: {Encoding.UTF8.GetByteCount(payload)}\r\n\r\n{payload}"; From f9ea3d1e6abbe15ec13af981c78e134480a4b658 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 15 Jun 2026 09:08:36 +0900 Subject: [PATCH 3/3] Fix ctags JSON count summary (#3551) --- USER_GUIDE.md | 15 +++++++++------ src/CodeIndex/Cli/ExportImportCommandRunner.cs | 2 +- .../ExportImportCommandRunnerTests.cs | 4 +++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a61ef4d406..a4c8881c16 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -357,9 +357,10 @@ cdidx export ctags --lang csharp --path src/ --exclude-tests --json query commands: `--lang `, repeatable `--path `, repeatable `--exclude-path `, and `--exclude-tests`. The default human mode keeps writing the tags file and prints the output path. `--json` prints a machine -summary with `output_path`, `db_path`, `tag_count`, `emitted_count`, -`skipped_count`, `filters`, and `metadata_fields`. Tag lines keep the standard -`kind` and `line` fields and may also include indexed metadata such as +summary with `output_path`, `db_path`, total candidate `tag_count`, +`emitted_count`, `skipped_count`, `filters`, and `metadata_fields`; filtered +exports satisfy `tag_count == emitted_count + skipped_count`. Tag lines keep +the standard `kind` and `line` fields and may also include indexed metadata such as `language`, `container_kind`, `container`, and `visibility`. Use `cdidx export ` to package the current `codeindex.db` with a @@ -2828,9 +2829,11 @@ cdidx export ctags --lang csharp --path src/ --exclude-tests --json `cdidx export ctags` は query command と同じ language / path filter の形を受け付けます。 `--lang `、繰り返し指定できる `--path ` / `--exclude-path `、 `--exclude-tests` を使えます。既定の human mode は tags file を書き出し、output path を -表示します。`--json` は `output_path`、`db_path`、`tag_count`、`emitted_count`、 -`skipped_count`、`filters`、`metadata_fields` を含む機械処理向け summary を出力します。 -tag line は標準の `kind` / `line` fields を維持し、indexed metadata として +表示します。`--json` は `output_path`、`db_path`、総候補数の `tag_count`、 +`emitted_count`、`skipped_count`、`filters`、`metadata_fields` を含む機械処理向け +summary を出力します。filter 付き export では +`tag_count == emitted_count + skipped_count` になります。tag line は標準の +`kind` / `line` fields を維持し、indexed metadata として `language`、`container_kind`、`container`、`visibility` も含めることがあります。 `cdidx export ` は現在の `codeindex.db` と manifest を archive 化します。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index d35f70087e..147f3958c1 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -441,7 +441,7 @@ private static int RunExportCtags(string[] args, JsonSerializerOptions jsonOptio "success", fullOutputPath, fullSourceDbPath, - emittedCount, + totalTagCount, emittedCount, skippedCount, new CtagsExportFilterResult(filters.Lang, filters.PathPatterns, filters.ExcludePathPatterns, filters.ExcludeTests), diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index 99e4606e96..0b2d4a9e13 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -250,8 +250,10 @@ public void RunExportCtags_JsonReportsFiltersAndMetadata_Issue3551() Assert.Equal(Path.GetFullPath(dbPath), root.GetProperty("db_path").GetString()); Assert.True(root.GetProperty("tag_count").GetInt64() > 0); Assert.True(root.GetProperty("emitted_count").GetInt64() > 0); - Assert.Equal(root.GetProperty("tag_count").GetInt64(), root.GetProperty("emitted_count").GetInt64()); Assert.True(root.GetProperty("skipped_count").GetInt64() > 0); + Assert.Equal( + root.GetProperty("tag_count").GetInt64(), + root.GetProperty("emitted_count").GetInt64() + root.GetProperty("skipped_count").GetInt64()); var filters = root.GetProperty("filters"); Assert.Equal("csharp", filters.GetProperty("lang").GetString());