From dabb9440d7e04f7fcb37574586426663a20155bc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:08:37 +0900 Subject: [PATCH 1/6] Fix outline parent disambiguation (#2016) --- changelog.d/unreleased/2016.fixed.md | 16 ++++++++++ src/CodeIndex/Database/DbSymbolReader.cs | 17 +++++++++++ tests/CodeIndex.Tests/DbReaderTests.cs | 38 ++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 changelog.d/unreleased/2016.fixed.md diff --git a/changelog.d/unreleased/2016.fixed.md b/changelog.d/unreleased/2016.fixed.md new file mode 100644 index 0000000000..af483300fb --- /dev/null +++ b/changelog.d/unreleased/2016.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2016 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **Outline depth now disambiguates same-named containers by qualified path (#2016)** — nested symbols now attach to the matching qualified parent, preventing duplicate class names in different scopes from skewing outline depth. + +## 日本語 + +- **outline depth が同名 container を qualified path で区別するようになりました (#2016)** — nested symbol は一致する qualified parent に紐づくため、別 scope の同名 class が outline depth を誤らせなくなりました。 diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 22319c74e7..a269811605 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -1662,6 +1662,7 @@ private static int GetOutlineDepth(List symbols, int index, Dicti private static int FindOutlineContainerIndex(List symbols, int childIndex, string containerName, string? containerKind) { var child = symbols[childIndex]; + var expectedContainerPath = GetOutlineContainerPath(child); for (var i = childIndex - 1; i >= 0; i--) { var candidate = symbols[i]; @@ -1669,6 +1670,11 @@ private static int FindOutlineContainerIndex(List symbols, int ch continue; if (containerKind != null && !string.Equals(candidate.Kind, containerKind, StringComparison.Ordinal)) continue; + if (expectedContainerPath != null + && !string.Equals(candidate.Path, expectedContainerPath, StringComparison.Ordinal)) + { + continue; + } if (candidate.Line > child.Line) continue; if (IsOutlineContainerMatch(candidate, child.Line)) @@ -1678,6 +1684,17 @@ private static int FindOutlineContainerIndex(List symbols, int ch return -1; } + private static string? GetOutlineContainerPath(OutlineSymbol symbol) + { + if (string.IsNullOrWhiteSpace(symbol.Path) || string.IsNullOrWhiteSpace(symbol.Name)) + return null; + + var suffix = "." + symbol.Name; + return symbol.Path.EndsWith(suffix, StringComparison.Ordinal) + ? symbol.Path[..^suffix.Length] + : null; + } + private static bool IsOutlineContainerMatch(OutlineSymbol candidate, int childLine) { if (candidate.StartLine <= childLine && candidate.EndLine >= childLine) diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 31e3a9d96f..69630ecd31 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -12996,6 +12996,44 @@ public void Method() { } }); } + [Fact] + public void GetOutline_UsesQualifiedContainerPathForAmbiguousNames() + { + var fileId = _writer.UpsertFile(new FileRecord + { + Path = "src/ambiguous.cs", + Lang = "csharp", + Size = 300, + Lines = 20, + Modified = new DateTime(2025, 6, 2, 0, 0, 0, DateTimeKind.Utc), + }); + _writer.InsertChunks([new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 20, + Content = """ + class A { class Wrapper { } } + class B { class Wrapper { void Target() { } } } + """, + }]); + _writer.InsertSymbols([ + new SymbolRecord { FileId = fileId, Kind = "class", Name = "A", Line = 1, StartLine = 1, EndLine = 5, BodyStartLine = 1, BodyEndLine = 5, ContainerQualifiedName = null }, + new SymbolRecord { FileId = fileId, Kind = "class", Name = "Wrapper", Line = 2, StartLine = 2, EndLine = 4, BodyStartLine = 2, BodyEndLine = 4, ContainerKind = "class", ContainerName = "A", ContainerQualifiedName = "A" }, + new SymbolRecord { FileId = fileId, Kind = "class", Name = "B", Line = 6, StartLine = 6, EndLine = 15, BodyStartLine = 6, BodyEndLine = 15, ContainerQualifiedName = null }, + new SymbolRecord { FileId = fileId, Kind = "class", Name = "Wrapper", Line = 7, StartLine = 7, EndLine = 14, BodyStartLine = 7, BodyEndLine = 14, ContainerKind = "class", ContainerName = "B", ContainerQualifiedName = "B" }, + new SymbolRecord { FileId = fileId, Kind = "function", Name = "Target", Line = 8, StartLine = 8, EndLine = 8, ContainerKind = "class", ContainerName = "Wrapper", ContainerQualifiedName = "B.Wrapper" }, + ]); + + var outline = _reader.GetOutline("src/ambiguous.cs"); + + Assert.NotNull(outline); + var target = Assert.Single(outline!.Symbols.Where(symbol => symbol.Name == "Target")); + Assert.Equal("B.Wrapper.Target", target.Path); + Assert.Equal(2, target.Depth); + } + [Fact] public void GetOutline_ComputesDepthForFileScopedNamespace() { From cc0a713a3b30553d21ee60786a1f8e31398c8726 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:19:19 +0900 Subject: [PATCH 2/6] Index GraphQL input and union anchors (#1825) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/1825.fixed.md | 17 +++ .../Indexer/Symbols/SymbolExtractor.cs | 106 ++++++++++++++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 21 +++- 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/1825.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 3c3d3a7117..277c8ae771 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -225,12 +225,12 @@ Do not add mutable static caches, shared `StringBuilder` instances, reused `Matc | `operator` | C# operator overload and conversion operator declarations | Callable definition; participates in callers/callees through reference rows | | `object` | Object-literal/object container context used by nested extracted symbols | Container context | | `package` | Package declarations | Namespace-like context symbol | -| `property` | Properties and property-like fields | Definition target; not treated as a call edge by itself | +| `property` | Properties, property-like fields, and GraphQL input fields | Definition target; not treated as a call edge by itself | | `procedure` | Procedure declarations in languages such as Fortran | Callable definition | | `program` | Program block declarations in languages such as Fortran | Definition target and container | | `protocol` | Protocol declarations in languages that distinguish protocols from interfaces | Definition target and container | | `protocol_impl` | Elixir `defimpl` protocol implementation declarations | Definition target and container for implementation blocks | -| `reference` | Secondary extracted symbolic references, such as HTML classes or metadata keys | Search/filter symbol | +| `reference` | Secondary extracted symbolic references, such as HTML classes, metadata keys, or GraphQL union variants | Search/filter symbol | | `rule` | CSS/SCSS rule container context used by nested references | Container context | | `route` | Razor route directives | Context/search symbol | | `service` | Service declarations in IDL/protobuf-like languages | Definition target and container | diff --git a/changelog.d/unreleased/1825.fixed.md b/changelog.d/unreleased/1825.fixed.md new file mode 100644 index 0000000000..92bf260e5f --- /dev/null +++ b/changelog.d/unreleased/1825.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1825 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **GraphQL input fields and union variants are now indexed as anchors (#1825)** — `input` members are emitted as `property` symbols and `union` variants as `reference` symbols, including `extend` and multiline union declarations. + +## 日本語 + +- **GraphQL input field と union variant を anchor として index するようになりました (#1825)** — `input` member は `property` symbol、`union` variant は `reference` symbol として出力され、`extend` と複数行 union 宣言にも対応します。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 3c2b4f2b7b..f69ce58bec 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -15,6 +15,21 @@ public static partial class SymbolExtractor { public const int DefaultContractVersion = 1; public const int CSharpContractVersion = 2; + private static readonly Regex GraphQLInputBlockRegex = new( + @"^\s*(?:extend\s+)?input\s+(?\w+)[^{]*\{(?.*?)^\s*\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline | RegexOptions.Singleline); + private static readonly Regex GraphQLInputFieldRegex = new( + @"^\s*(?[_A-Za-z]\w*)\s*:", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline); + private static readonly Regex GraphQLUnionDeclarationRegex = new( + @"^\s*(?:extend\s+)?union\s+(?\w+)\s*=\s*(?.*)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GraphQLUnionVariantRegex = new( + @"\|?\s*(?[_A-Za-z]\w*)\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GraphQLDeclarationStartRegex = new( + @"^\s*(?:extend\s+)?(?:type|interface|input|enum|union|scalar|schema|query|mutation|subscription|fragment|directive)\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant); public static int GetContractVersion(string? lang) { @@ -4044,6 +4059,8 @@ public static List Extract(long fileId, string? lang, string conte ExtractSqlRoutineResultColumnSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols); ExtractSqlGeneratedColumnSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols); } + if (lang == "graphql") + ExtractGraphQLMemberSymbols(fileId, lines, symbols); if (IsRazorLanguage(originalLang) || IsRazorFilePath(filePath)) ExtractRazorDirectiveSymbols(fileId, lines, symbols); AssignContainers(symbols, lines, csharpLineStartStates); @@ -4150,6 +4167,95 @@ private static int GetLineNumberFromOffset(List lineStarts, int offset) return ~index; } + private static void ExtractGraphQLMemberSymbols(long fileId, string[] lines, List symbols) + { + var content = string.Join('\n', lines); + var lineStarts = BuildLineStarts(content); + foreach (Match inputMatch in GraphQLInputBlockRegex.Matches(content)) + { + var inputName = inputMatch.Groups["name"].Value; + var body = inputMatch.Groups["body"]; + foreach (Match fieldMatch in GraphQLInputFieldRegex.Matches(body.Value)) + { + var fieldGroup = fieldMatch.Groups["name"]; + var absoluteIndex = body.Index + fieldGroup.Index; + var lineNumber = GetLineNumberFromOffset(lineStarts, absoluteIndex); + AddSymbolRecord( + symbols, + null, + lineNumber, + new SymbolRecord + { + FileId = fileId, + Kind = "property", + Name = fieldGroup.Value, + Line = lineNumber, + StartLine = lineNumber, + StartColumn = absoluteIndex - lineStarts[lineNumber - 1], + EndLine = lineNumber, + Signature = lines[lineNumber - 1].Trim(), + ContainerKind = "class", + ContainerName = inputName, + }, + lines[lineNumber - 1]); + } + } + + for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) + { + var match = GraphQLUnionDeclarationRegex.Match(lines[lineIndex]); + if (!match.Success) + continue; + + var unionName = match.Groups["name"].Value; + AddGraphQLUnionVariantSymbols(fileId, lines, lineIndex, match.Groups["variants"].Value, match.Groups["variants"].Index, unionName, symbols); + for (var continuationIndex = lineIndex + 1; continuationIndex < lines.Length; continuationIndex++) + { + var continuation = lines[continuationIndex]; + if (string.IsNullOrWhiteSpace(continuation) || GraphQLDeclarationStartRegex.IsMatch(continuation)) + break; + + AddGraphQLUnionVariantSymbols(fileId, lines, continuationIndex, continuation, 0, unionName, symbols); + } + } + } + + private static void AddGraphQLUnionVariantSymbols( + long fileId, + string[] lines, + int lineIndex, + string variantText, + int baseColumn, + string unionName, + List symbols) + { + foreach (Match variantMatch in GraphQLUnionVariantRegex.Matches(variantText)) + { + var variantName = variantMatch.Groups["name"].Value; + if (variantName == "extend" || variantName == "union") + continue; + + AddSymbolRecord( + symbols, + null, + lineIndex + 1, + new SymbolRecord + { + FileId = fileId, + Kind = "reference", + Name = variantName, + Line = lineIndex + 1, + StartLine = lineIndex + 1, + StartColumn = baseColumn + variantMatch.Groups["name"].Index, + EndLine = lineIndex + 1, + Signature = lines[lineIndex].Trim(), + ContainerKind = "class", + ContainerName = unionName, + }, + lines[lineIndex]); + } + } + private static void ExtractSqlGeneratedColumnSymbols(long fileId, string[] lines, string[] structuralLines, List symbols) { var structuralContent = string.Join('\n', structuralLines); diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 0501d8fb48..b8eb1b7bfa 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -21065,8 +21065,11 @@ type User { input CreateUserInput { name: String! email: String! + roles: [Role!]! } + union SearchResult = User | Organization | Team + enum Role { ADMIN USER @@ -21084,6 +21087,13 @@ mutation CreateUser($input: CreateUserInput!) { Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "User"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "CreateUserInput"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "name" && s.ContainerName == "CreateUserInput"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "email" && s.ContainerName == "CreateUserInput"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "roles" && s.ContainerName == "CreateUserInput"); + Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "SearchResult"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "User" && s.ContainerName == "SearchResult"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Organization" && s.ContainerName == "SearchResult"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Team" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "enum" && s.Name == "Role"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "GetUser"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "CreateUser"); @@ -21127,13 +21137,17 @@ extend interface ExtendedNode { extend input ExtendedCreateUserInput { email: String + phone: String } extend enum ExtendedRole { GUEST } - extend union SearchResult = User | Organization + extend union SearchResult = + | User + | Organization + | Team extend scalar DateTime @@ -21156,8 +21170,13 @@ mutation CreateUser($input: CreateUserInput!) { Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "ExtendedUser"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "ExtendedNode"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "ExtendedCreateUserInput"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "email" && s.ContainerName == "ExtendedCreateUserInput"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "phone" && s.ContainerName == "ExtendedCreateUserInput"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "ExtendedRole"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "SearchResult"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "User" && s.ContainerName == "SearchResult"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Organization" && s.ContainerName == "SearchResult"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Team" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "DateTime"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "GetUser"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "CreateUser"); From 172fc15cae1c7ccb5ea740175143eb222c40a869 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:31:36 +0900 Subject: [PATCH 3/6] Surface outline section headings (#1636) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/1636.fixed.md | 17 ++++ .../Indexer/Symbols/SymbolExtractor.cs | 85 +++++++++++++++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 39 +++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/1636.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 277c8ae771..644c29d173 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -212,7 +212,7 @@ Do not add mutable static caches, shared `StringBuilder` instances, reused `Matc | `file_module` | File-scoped module/package declarations | Namespace-like context symbol | | `function` | Functions, methods, constructors, delegates, tasks, and callable bindings that do not have a narrower kind | Primary callable definition; participates in callers/callees through reference rows | | `generator` | JavaScript/TypeScript generator declarations | Callable definition; participates in callers/callees through reference rows | -| `heading` | Markdown headings | Outline symbol | +| `heading` | Markdown headings and language section markers such as C# regions, Python module docstrings, and JavaScript/TypeScript `@module` docblocks | Outline symbol | | `hook` | JavaScript/TypeScript React custom hook bindings | Callable-like search/filter symbol | | `implements` | Razor `@implements` directives | Context/search symbol | | `import` | Imports, using directives, aliases, and package includes | Search/filter symbol | diff --git a/changelog.d/unreleased/1636.fixed.md b/changelog.d/unreleased/1636.fixed.md new file mode 100644 index 0000000000..f82a1b52df --- /dev/null +++ b/changelog.d/unreleased/1636.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1636 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Outline now surfaces language section markers (#1636)** — C# `#region`, Python module docstrings, and JavaScript/TypeScript JSDoc `@module` markers are emitted as `heading` symbols for table-of-contents style outline views. + +## 日本語 + +- **outline が言語ごとの section marker を表示するようになりました (#1636)** — C# `#region`、Python module docstring、JavaScript/TypeScript JSDoc `@module` marker を `heading` symbol として出力し、目次型の outline で扱えるようにしました。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index f69ce58bec..d87820c7f2 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -30,6 +30,12 @@ public static partial class SymbolExtractor private static readonly Regex GraphQLDeclarationStartRegex = new( @"^\s*(?:extend\s+)?(?:type|interface|input|enum|union|scalar|schema|query|mutation|subscription|fragment|directive)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CSharpRegionRegex = new( + @"^\s*#region(?:\s+(?.*\S))?\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex JavaScriptTypeScriptModuleDocRegex = new( + @"@module(?:\s+(?[^\s*]+))?", + RegexOptions.Compiled | RegexOptions.CultureInvariant); public static int GetContractVersion(string? lang) { @@ -4061,6 +4067,8 @@ public static List Extract(long fileId, string? lang, string conte } if (lang == "graphql") ExtractGraphQLMemberSymbols(fileId, lines, symbols); + if (lang is "csharp" or "python" or "javascript" or "typescript") + ExtractSectionHeadingSymbols(fileId, lang, lines, symbols); if (IsRazorLanguage(originalLang) || IsRazorFilePath(filePath)) ExtractRazorDirectiveSymbols(fileId, lines, symbols); AssignContainers(symbols, lines, csharpLineStartStates); @@ -4256,6 +4264,83 @@ private static void AddGraphQLUnionVariantSymbols( } } + private static void ExtractSectionHeadingSymbols(long fileId, string lang, string[] lines, List symbols) + { + if (lang == "csharp") + { + for (var i = 0; i < lines.Length; i++) + { + var match = CSharpRegionRegex.Match(lines[i]); + if (!match.Success) + continue; + + AddHeadingSymbol(fileId, lines, symbols, i, match.Groups["name"].Value.Trim(), "#region"); + } + } + else if (lang == "python") + { + TryAddPythonModuleDocstringHeading(fileId, lines, symbols); + } + else + { + for (var i = 0; i < lines.Length; i++) + { + var match = JavaScriptTypeScriptModuleDocRegex.Match(lines[i]); + if (!match.Success) + continue; + + AddHeadingSymbol(fileId, lines, symbols, i, match.Groups["name"].Value.Trim(), "@module"); + } + } + } + + private static void TryAddPythonModuleDocstringHeading(long fileId, string[] lines, List symbols) + { + for (var i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + if (trimmed.Length == 0 || trimmed.StartsWith("#", StringComparison.Ordinal)) + continue; + + var quote = trimmed.StartsWith("\"\"\"", StringComparison.Ordinal) ? "\"\"\"" : + trimmed.StartsWith("'''", StringComparison.Ordinal) ? "'''" : null; + if (quote == null) + return; + + var name = trimmed[quote.Length..].Trim(); + if (name.EndsWith(quote, StringComparison.Ordinal)) + name = name[..^quote.Length].Trim(); + AddHeadingSymbol(fileId, lines, symbols, i, name, "module docstring"); + return; + } + } + + private static void AddHeadingSymbol( + long fileId, + string[] lines, + List symbols, + int lineIndex, + string name, + string fallbackName) + { + var lineNumber = lineIndex + 1; + AddSymbolRecord( + symbols, + null, + lineNumber, + new SymbolRecord + { + FileId = fileId, + Kind = "heading", + Name = string.IsNullOrWhiteSpace(name) ? fallbackName : name, + Line = lineNumber, + StartLine = lineNumber, + EndLine = lineNumber, + Signature = lines[lineIndex].Trim(), + }, + lines[lineIndex]); + } + private static void ExtractSqlGeneratedColumnSymbols(long fileId, string[] lines, string[] structuralLines, List symbols) { var structuralContent = string.Join('\n', structuralLines); diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index b8eb1b7bfa..8bae9f8f57 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -21182,6 +21182,45 @@ mutation CreateUser($input: CreateUserInput!) { Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "CreateUser"); } + [Fact] + public void Extract_CSharp_DetectsRegionHeadings() + { + var symbols = SymbolExtractor.Extract(1, "csharp", """ + public class Service + { + #region Validation + public void Check() { } + #endregion + } + """); + + Assert.Contains(symbols, s => s.Kind == "heading" && s.Name == "Validation"); + } + + [Fact] + public void Extract_Python_DetectsModuleDocstringHeading() + { + var content = "\"\"\"Payments API helpers.\"\"\"\n\n" + + "def charge():\n" + + " pass\n"; + var symbols = SymbolExtractor.Extract(1, "python", content); + + Assert.Contains(symbols, s => s.Kind == "heading" && s.Name == "Payments API helpers."); + } + + [Fact] + public void Extract_JavaScript_DetectsModuleDocHeading() + { + var symbols = SymbolExtractor.Extract(1, "javascript", """ + /** + * @module payments/service + */ + export function charge() {} + """); + + Assert.Contains(symbols, s => s.Kind == "heading" && s.Name == "payments/service"); + } + [Fact] public void Extract_Gradle_DetectsSymbols() { From 183a50ac670dcc1fd55f1124b44d4a21a2a813c6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 19:34:31 +0900 Subject: [PATCH 4/6] Ignore GraphQL union trivia (#1825) --- .../Indexer/Symbols/SymbolExtractor.cs | 17 +++++++++++++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 7 +++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index d87820c7f2..4142b3e348 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -4237,6 +4237,10 @@ private static void AddGraphQLUnionVariantSymbols( string unionName, List symbols) { + variantText = StripGraphQLUnionVariantTrivia(variantText); + if (string.IsNullOrWhiteSpace(variantText)) + return; + foreach (Match variantMatch in GraphQLUnionVariantRegex.Matches(variantText)) { var variantName = variantMatch.Groups["name"].Value; @@ -4264,6 +4268,19 @@ private static void AddGraphQLUnionVariantSymbols( } } + private static string StripGraphQLUnionVariantTrivia(string text) + { + var commentIndex = text.IndexOf('#', StringComparison.Ordinal); + if (commentIndex >= 0) + text = text[..commentIndex]; + + var directiveIndex = text.IndexOf('@', StringComparison.Ordinal); + if (directiveIndex >= 0) + text = text[..directiveIndex]; + + return text; + } + private static void ExtractSectionHeadingSymbols(long fileId, string lang, string[] lines, List symbols) { if (lang == "csharp") diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 8bae9f8f57..f1e04e7651 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -21068,7 +21068,7 @@ input CreateUserInput { roles: [Role!]! } - union SearchResult = User | Organization | Team + union SearchResult = User | Organization | Team @deprecated # returned by search enum Role { ADMIN @@ -21094,6 +21094,7 @@ mutation CreateUser($input: CreateUserInput!) { Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "User" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Organization" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Team" && s.ContainerName == "SearchResult"); + Assert.DoesNotContain(symbols, s => s.Kind == "reference" && s.Name is "deprecated" or "returned" or "search"); Assert.Contains(symbols, s => s.Kind == "enum" && s.Name == "Role"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "GetUser"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "CreateUser"); @@ -21146,8 +21147,9 @@ extend enum ExtendedRole { extend union SearchResult = | User + # organization result | Organization - | Team + | Team @deprecated(reason: "legacy") extend scalar DateTime @@ -21177,6 +21179,7 @@ mutation CreateUser($input: CreateUserInput!) { Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "User" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Organization" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Team" && s.ContainerName == "SearchResult"); + Assert.DoesNotContain(symbols, s => s.Kind == "reference" && s.Name is "organization" or "result" or "deprecated" or "reason" or "legacy"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "DateTime"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "GetUser"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "CreateUser"); From 851ab894bdb86e53aa61bf23f27291fef3447fe5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 19:40:39 +0900 Subject: [PATCH 5/6] Support GraphQL union directives (#1825) --- src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs | 2 +- tests/CodeIndex.Tests/SymbolExtractorTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 4142b3e348..9c6ac3edcc 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -22,7 +22,7 @@ public static partial class SymbolExtractor @"^\s*(?[_A-Za-z]\w*)\s*:", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline); private static readonly Regex GraphQLUnionDeclarationRegex = new( - @"^\s*(?:extend\s+)?union\s+(?\w+)\s*=\s*(?.*)$", + @"^\s*(?:extend\s+)?union\s+(?\w+)(?:\s+@\w+(?:\([^)]*\))?)*\s*=\s*(?.*)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex GraphQLUnionVariantRegex = new( @"\|?\s*(?[_A-Za-z]\w*)\b", diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index f1e04e7651..2a76a73d41 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -21068,7 +21068,7 @@ input CreateUserInput { roles: [Role!]! } - union SearchResult = User | Organization | Team @deprecated # returned by search + union SearchResult @deprecated(reason: "legacy") = User | Organization | Team @deprecated # returned by search enum Role { ADMIN @@ -21094,7 +21094,7 @@ mutation CreateUser($input: CreateUserInput!) { Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "User" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Organization" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Team" && s.ContainerName == "SearchResult"); - Assert.DoesNotContain(symbols, s => s.Kind == "reference" && s.Name is "deprecated" or "returned" or "search"); + Assert.DoesNotContain(symbols, s => s.Kind == "reference" && s.Name is "deprecated" or "reason" or "legacy" or "returned" or "search"); Assert.Contains(symbols, s => s.Kind == "enum" && s.Name == "Role"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "GetUser"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "CreateUser"); From 42af8269a03e288a460570cae853e34860d8afd6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 19:47:21 +0900 Subject: [PATCH 6/6] Handle multiline GraphQL union headers (#1825) --- .../Indexer/Symbols/SymbolExtractor.cs | 42 +++++++++++++++++-- tests/CodeIndex.Tests/SymbolExtractorTests.cs | 6 +++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 9c6ac3edcc..1bf55a6cc4 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -24,6 +24,9 @@ public static partial class SymbolExtractor private static readonly Regex GraphQLUnionDeclarationRegex = new( @"^\s*(?:extend\s+)?union\s+(?\w+)(?:\s+@\w+(?:\([^)]*\))?)*\s*=\s*(?.*)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GraphQLUnionHeaderRegex = new( + @"^\s*(?:extend\s+)?union\s+(?\w+)(?:\s+@\w+(?:\([^)]*\))?)*\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex GraphQLUnionVariantRegex = new( @"\|?\s*(?[_A-Za-z]\w*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -4212,18 +4215,49 @@ private static void ExtractGraphQLMemberSymbols(long fileId, string[] lines, Lis for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) { var match = GraphQLUnionDeclarationRegex.Match(lines[lineIndex]); - if (!match.Success) + if (match.Success) + { + var unionName = match.Groups["name"].Value; + AddGraphQLUnionVariantSymbols(fileId, lines, lineIndex, match.Groups["variants"].Value, match.Groups["variants"].Index, unionName, symbols); + for (var continuationIndex = lineIndex + 1; continuationIndex < lines.Length; continuationIndex++) + { + var continuation = lines[continuationIndex]; + if (string.IsNullOrWhiteSpace(continuation) || GraphQLDeclarationStartRegex.IsMatch(continuation)) + break; + + AddGraphQLUnionVariantSymbols(fileId, lines, continuationIndex, continuation, 0, unionName, symbols); + } + continue; + } - var unionName = match.Groups["name"].Value; - AddGraphQLUnionVariantSymbols(fileId, lines, lineIndex, match.Groups["variants"].Value, match.Groups["variants"].Index, unionName, symbols); + var headerMatch = GraphQLUnionHeaderRegex.Match(lines[lineIndex]); + if (!headerMatch.Success) + continue; + + var headerUnionName = headerMatch.Groups["name"].Value; for (var continuationIndex = lineIndex + 1; continuationIndex < lines.Length; continuationIndex++) { var continuation = lines[continuationIndex]; if (string.IsNullOrWhiteSpace(continuation) || GraphQLDeclarationStartRegex.IsMatch(continuation)) break; - AddGraphQLUnionVariantSymbols(fileId, lines, continuationIndex, continuation, 0, unionName, symbols); + var equalsIndex = continuation.IndexOf('=', StringComparison.Ordinal); + if (equalsIndex >= 0) + { + AddGraphQLUnionVariantSymbols(fileId, lines, continuationIndex, continuation[(equalsIndex + 1)..], equalsIndex + 1, headerUnionName, symbols); + for (var variantIndex = continuationIndex + 1; variantIndex < lines.Length; variantIndex++) + { + var variantContinuation = lines[variantIndex]; + if (string.IsNullOrWhiteSpace(variantContinuation) || GraphQLDeclarationStartRegex.IsMatch(variantContinuation)) + break; + + AddGraphQLUnionVariantSymbols(fileId, lines, variantIndex, variantContinuation, 0, headerUnionName, symbols); + } + + lineIndex = continuationIndex; + break; + } } } } diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 2a76a73d41..125bfe8419 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -21151,6 +21151,10 @@ extend enum ExtendedRole { | Organization | Team @deprecated(reason: "legacy") + union ExternalSearchResult @deprecated(reason: "legacy") + = User + | Organization + extend scalar DateTime query GetUser($id: ID!) { @@ -21180,6 +21184,8 @@ mutation CreateUser($input: CreateUserInput!) { Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Organization" && s.ContainerName == "SearchResult"); Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Team" && s.ContainerName == "SearchResult"); Assert.DoesNotContain(symbols, s => s.Kind == "reference" && s.Name is "organization" or "result" or "deprecated" or "reason" or "legacy"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "User" && s.ContainerName == "ExternalSearchResult"); + Assert.Contains(symbols, s => s.Kind == "reference" && s.Name == "Organization" && s.ContainerName == "ExternalSearchResult"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "DateTime"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "GetUser"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "CreateUser");