From 15fe783066308e5b7f136ea2c91850c7a5080af7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 14:25:57 +0900 Subject: [PATCH 1/2] Fix C# reference extraction slowdown --- DEVELOPER_GUIDE.md | 40 ++++++ ...-reference-extraction-performance.fixed.md | 24 ++++ .../CSharpReferenceExtractor.Support.cs | 66 ++++++---- ...CSharpReferenceExtractor.ValueReceivers.cs | 116 ++++++++++++------ .../ReferenceExtractorCSharpTests.cs | 73 +++++++++++ 5 files changed, 258 insertions(+), 61 deletions(-) create mode 100644 changelog.d/unreleased/+csharp-reference-extraction-performance.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 90ee84cac9..9bd5dc2bdf 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -240,6 +240,28 @@ 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. +### Extractor performance contract + +Symbol and reference extractors run during `cdidx index`, so language-specific +helpers must assume they will see generated files, very large methods, and +thousands of declarations or references in one syntactic scope. Avoid helper +shapes that rescan the same body, line range, or accumulated result list once +per candidate. If scope or delimiter information is needed for many candidates, +precompute the ranges once per file, function, or block and reuse that structure +for the per-candidate lookup. + +Duplicate detection in hot extraction loops should use a `HashSet` or another +constant-time structure keyed by the full emitted record identity. Do not add +`List.Any(...)`, `List.Contains(...)`, nested regex scans, or repeated string +joins to loops that can run once per local variable, parameter, call site, type +reference, or pattern match in a large generated file. + +The C# value-receiver path is the reference example: local receiver scopes are +derived from precomputed block spans for the containing function, and duplicate +receiver records are tracked with a hash set. Regressions in this area should +have a focused correctness test for the scoping rule and a large-fixture runaway +guard that would fail before users see multi-hour indexing stalls. + ### Extractor concurrency contract `SymbolExtractor` and `ReferenceExtractor` must be safe to call concurrently for different files or repeated calls on the same file content. Shared `Regex` instances and static lookup tables are initialized once by the CLR and treated as immutable after type initialization. Per-extraction state belongs in local variables, method parameters, caller-owned collections, or language-specific state objects created for that extraction call. @@ -2436,6 +2458,24 @@ 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 を返す。 +### 抽出器の性能契約 + +symbol / reference extractor は `cdidx index` 中に実行されるため、言語別 helper は +生成ファイル、非常に大きなメソッド、1 つの構文スコープ内に数千個の宣言や参照がある入力を +前提にする。候補ごとに同じ本文、行範囲、蓄積済み結果リストを再走査する helper 形状は避ける。 +多数の候補に対して scope や delimiter 情報が必要な場合は、file / function / block 単位で +範囲情報を一度だけ事前計算し、候補ごとの lookup でその構造を再利用する。 + +hot な抽出ループでの重複検出には、出力 record の完全な identity を key にした `HashSet` などの +定数時間構造を使う。大きな生成ファイルで local variable、parameter、call site、type reference、 +pattern match ごとに実行され得るループへ、`List.Any(...)`、`List.Contains(...)`、nested regex scan、 +繰り返しの string join を追加してはならない。 + +C# の value receiver 経路を参照例とする。local receiver の scope は containing function 用に +事前計算した block span から導出し、重複 receiver record は hash set で追跡する。この領域の +regression には、scope rule の focused correctness test と、ユーザーが multi-hour indexing stall を +見る前に失敗する大規模 fixture の runaway guard を追加する。 + ### 抽出器の並行実行契約 `SymbolExtractor` と `ReferenceExtractor` は、異なるファイルへの並行呼び出しや、同じファイル内容に対する繰り返し呼び出しでも安全でなければならない。共有される `Regex` インスタンスや static な lookup table は CLR が一度だけ初期化し、型初期化後は immutable として扱う。抽出ごとの状態は、ローカル変数、メソッド引数、呼び出し元が所有するコレクション、またはその抽出呼び出し用に生成した言語固有の state object に持たせる。 diff --git a/changelog.d/unreleased/+csharp-reference-extraction-performance.fixed.md b/changelog.d/unreleased/+csharp-reference-extraction-performance.fixed.md new file mode 100644 index 0000000000..e7c550a129 --- /dev/null +++ b/changelog.d/unreleased/+csharp-reference-extraction-performance.fixed.md @@ -0,0 +1,24 @@ +--- +category: fixed +affected: + - DEVELOPER_GUIDE.md + - src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs + - src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs + - tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +--- + +## English + +- **Fixed runaway C# reference extraction on very large methods.** Large C# repositories could spend an excessive amount of time in the `references` phase when a file contained a very large method, especially generated code or hand-written methods with thousands of local variables. In affected cases, indexing that previously finished in tens of minutes could continue for hours while making little progress through C# reference extraction. +- The slowdown came from value receiver tracking. For every local value receiver, the extractor rescanned the method body to find the innermost block end and then performed a linear duplicate check against the receivers already collected for the function. Methods with many locals therefore paid the same body scan and growing duplicate check repeatedly, creating super-linear behavior. +- C# reference extraction now builds block scope spans once per function body and reuses them when assigning local receiver scopes. It also uses a hash set for duplicate receiver detection. This keeps receiver collection practical for very large methods while preserving block-scoped behavior for locals that shadow enum or type names. +- Added regression coverage for block-scoped local receiver behavior and a large-method runaway guard so future changes catch this class of performance regression earlier. +- Documented the extractor performance contract in the developer guide so future language-specific extractor changes avoid per-candidate body rescans and linear duplicate checks in hot paths. + +## 日本語 + +- **非常に大きなメソッドで C# 参照抽出が暴走する問題を修正しました。** 大型の C# リポジトリで、巨大な生成コードや数千個規模のローカル変数を持つ手書きメソッドが含まれている場合、インデックス作成が `references` フェーズで極端に長く止まることがありました。影響を受けるケースでは、以前は数十分で終わっていたインデックス作成が、C# の参照抽出中に何時間も進みにくくなることがありました。 +- 原因は value receiver 追跡でした。各ローカル value receiver ごとに、最内側ブロックの終端を求めるためメソッド本文を再走査し、その後で関数内に集め済みの receiver に対して線形の重複チェックを行っていました。ローカル変数が多いメソッドでは、同じ本文走査と増え続ける重複チェックを何度も支払うため、super-linear な挙動になっていました。 +- C# 参照抽出では、関数本文ごとのブロックスコープ範囲を一度だけ構築し、ローカル receiver のスコープ判定で再利用するようにしました。また、receiver の重複検出にはハッシュセットを使うようにしました。これにより、巨大なメソッドでも実用的な時間で receiver を収集しつつ、enum や type 名を隠すブロックスコープ付きローカルの扱いは維持されます。 +- ブロックスコープ付きローカル receiver の挙動と、大きなメソッドでの暴走を検出する回帰テストを追加しました。 +- 開発者ガイドに extractor の性能契約を記載し、今後の言語別 extractor 変更で hot path に候補ごとの本文再走査や線形重複チェックを入れないようにしました。 diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs index b52efac45b..d2190da02c 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs @@ -465,13 +465,15 @@ private static Dictionary> Buil continue; var names = new List(); + var seenNames = new HashSet(); if (symbol.BodyStartLine != null && symbol.BodyEndLine != null) { var start = Math.Max(symbol.BodyStartLine.Value - 1, 0); var end = Math.Min(symbol.BodyEndLine.Value - 1, structuralLines.Count - 1); + var blockScopes = BuildCSharpBlockScopes(structuralLines, start, end); var bodyText = string.Join("\n", structuralLines.Skip(start).Take(end - start + 1)); if (symbol.Kind == "function") - AddCSharpParameterNames(names, symbol.Signature, symbol.BodyStartLine.Value, 0, symbol.BodyEndLine.Value, int.MaxValue); + AddCSharpParameterNames(names, symbol.Signature, symbol.BodyStartLine.Value, 0, symbol.BodyEndLine.Value, int.MaxValue, seenNames); for (var i = start; i <= end; i++) { foreach (Match match in CSharpLocalValueNameRegex.Matches(structuralLines[i])) @@ -480,8 +482,9 @@ private static Dictionary> Buil NormalizeCSharpIdentifier(match.Groups["name"].Value), i + 1, match.Index, - FindInnermostCSharpBlockEndLine(structuralLines, start, end, i, match.Index), - int.MaxValue); + FindInnermostCSharpBlockEndLine(blockScopes, end + 1, i, match.Index), + int.MaxValue, + seenNames); foreach (Match match in CSharpForeachValueNameRegex.Matches(structuralLines[i])) { var scopeEnd = FindFollowingCSharpEmbeddedStatementEndPosition(structuralLines, end, i, match.Index); @@ -491,7 +494,8 @@ private static Dictionary> Buil i + 1, match.Index, scopeEnd.Line, - scopeEnd.Column); + scopeEnd.Column, + seenNames); } foreach (Match match in CSharpQueryRangeValueNameRegex.Matches(structuralLines[i])) { @@ -509,7 +513,8 @@ private static Dictionary> Buil i + 1, match.Index, scopeEnd.Line, - scopeEnd.Column); + scopeEnd.Column, + seenNames); } foreach (Match match in CSharpDeclarationPatternValueNameRegex.Matches(structuralLines[i])) { @@ -522,7 +527,8 @@ private static Dictionary> Buil i + 1, match.Index, scopeEnd.Line, - scopeEnd.Column); + scopeEnd.Column, + seenNames); } foreach (Match match in CSharpCaseDeclarationPatternValueNameRegex.Matches(structuralLines[i])) { @@ -535,10 +541,11 @@ private static Dictionary> Buil i + 1, match.Index, scopeEnd.Line, - scopeEnd.Column); + scopeEnd.Column, + seenNames); } foreach (Match match in CSharpOutValueNameRegex.Matches(structuralLines[i])) - AddCSharpFunctionValueReceiverName(names, NormalizeCSharpIdentifier(match.Groups["name"].Value), i + 1, match.Index, symbol.BodyEndLine.Value, int.MaxValue); + AddCSharpFunctionValueReceiverName(names, NormalizeCSharpIdentifier(match.Groups["name"].Value), i + 1, match.Index, symbol.BodyEndLine.Value, int.MaxValue, seenNames); foreach (Match match in CSharpCatchValueNameRegex.Matches(structuralLines[i])) { var scopeEnd = FindFollowingCSharpEmbeddedStatementEndPosition(structuralLines, end, i, match.Index); @@ -548,7 +555,8 @@ private static Dictionary> Buil i + 1, match.Index, scopeEnd.Line, - scopeEnd.Column); + scopeEnd.Column, + seenNames); } foreach (Match match in CSharpUsingStatementValueNameRegex.Matches(structuralLines[i])) { @@ -559,7 +567,8 @@ private static Dictionary> Buil i + 1, match.Index, scopeEnd.Line, - scopeEnd.Column); + scopeEnd.Column, + seenNames); } foreach (Match match in CSharpFixedValueNameRegex.Matches(structuralLines[i])) { @@ -570,16 +579,18 @@ private static Dictionary> Buil i + 1, match.Index, scopeEnd.Line, - scopeEnd.Column); + scopeEnd.Column, + seenNames); } } - AddCSharpRecursivePatternValueReceiverNames(names, bodyText, structuralLines, start, end); + AddCSharpRecursivePatternValueReceiverNames(names, bodyText, structuralLines, start, end, seenNames); AddCSharpLambdaParameterNames( names, bodyText, start + 1, - symbol.BodyEndLine.Value); + symbol.BodyEndLine.Value, + seenNames); } if (names.Count > 0) @@ -1746,7 +1757,14 @@ private static bool IsWithinCSharpScope(CSharpFunctionValueReceiverNameRecord re || (lineNumber == record.ScopeEndLine && column < record.ScopeEndColumn); } - private static void AddCSharpParameterNames(List names, string? signature, int scopeStartLine, int scopeStartColumn, int scopeEndLine, int scopeEndColumn) + private static void AddCSharpParameterNames( + List names, + string? signature, + int scopeStartLine, + int scopeStartColumn, + int scopeEndLine, + int scopeEndColumn, + HashSet? seenNames = null) { if (string.IsNullOrWhiteSpace(signature)) return; @@ -1760,7 +1778,7 @@ private static void AddCSharpParameterNames(List names, string bodyText, int startLineNumber, int scopeEndLine) + private static void AddCSharpLambdaParameterNames( + List names, + string bodyText, + int startLineNumber, + int scopeEndLine, + HashSet? seenNames = null) { if (string.IsNullOrWhiteSpace(bodyText)) return; @@ -1857,7 +1880,7 @@ private static void AddCSharpLambdaParameterNames(List structuralLines, int bodyStartIndex, - int bodyEndIndex) + int bodyEndIndex, + HashSet? seenNames = null) { if (string.IsNullOrWhiteSpace(bodyText)) return; @@ -1880,7 +1904,7 @@ private static void AddCSharpRecursivePatternValueReceiverNames( if (pattern.ArrowIndex >= 0) { var scopeEnd = FindCSharpArrowExpressionScopeEndPosition(bodyText, pattern.ArrowIndex, startLineNumber, bodyEndIndex + 1); - AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column); + AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column, seenNames); continue; } @@ -1889,14 +1913,14 @@ private static void AddCSharpRecursivePatternValueReceiverNames( if (!TryFindCSharpSwitchCaseScopeEndPosition(structuralLines, bodyEndIndex, declarationLineIndex, position.Column, out var scopeEnd)) continue; - AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column); + AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column, seenNames); continue; } if (!TryFindCSharpDeclarationPatternScopeEndPosition(structuralLines, bodyStartIndex, bodyEndIndex, declarationLineIndex, position.Column, out var declarationScopeEnd)) continue; - AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, declarationScopeEnd.Line, declarationScopeEnd.Column); + AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, declarationScopeEnd.Line, declarationScopeEnd.Column, seenNames); } } diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs index 2e1fc3ff3e..5dccde47c5 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs @@ -7,12 +7,15 @@ namespace CodeIndex.Indexer; public static partial class ReferenceExtractor { + private readonly record struct CSharpBlockScope(int StartLineIndex, int StartColumn, int EndLineIndex, int EndColumn); + private static void AddCSharpLambdaParametersBeforeArrow( List names, string bodyText, int arrowIndex, int startLineNumber, - CSharpLineColumn scopeEnd) + CSharpLineColumn scopeEnd, + HashSet? seenNames = null) { var leftIndex = SkipWhitespaceBackward(bodyText, arrowIndex - 1); if (leftIndex < 0) @@ -29,7 +32,7 @@ private static void AddCSharpLambdaParametersBeforeArrow( foreach (var segment in SplitTopLevelCSharpParameterSegments(parameters)) { if (TryExtractTrailingCSharpParameterName(segment, out var parameterName)) - AddCSharpFunctionValueReceiverName(names, parameterName, scopeStart.Line, scopeStart.Column, scopeEnd.Line, scopeEnd.Column); + AddCSharpFunctionValueReceiverName(names, parameterName, scopeStart.Line, scopeStart.Column, scopeEnd.Line, scopeEnd.Column, seenNames); } return; @@ -53,23 +56,32 @@ private static void AddCSharpLambdaParametersBeforeArrow( || (TryReadPreviousIdentifierToken(bodyText, prefixIndex, out var previousToken) && string.Equals(previousToken, "return", StringComparison.Ordinal))) { - AddCSharpFunctionValueReceiverName(names, parameter, declarationLine, identifierStart - GetLineStartOffset(bodyText, arrowIndex), scopeEnd.Line, scopeEnd.Column); + AddCSharpFunctionValueReceiverName(names, parameter, declarationLine, identifierStart - GetLineStartOffset(bodyText, arrowIndex), scopeEnd.Line, scopeEnd.Column, seenNames); } } - private static void AddCSharpFunctionValueReceiverName(List names, string name, int scopeStartLine, int scopeStartColumn, int scopeEndLine, int scopeEndColumn) + private static void AddCSharpFunctionValueReceiverName( + List names, + string name, + int scopeStartLine, + int scopeStartColumn, + int scopeEndLine, + int scopeEndColumn, + HashSet? seenNames = null) { if (string.IsNullOrWhiteSpace(name)) return; - if (names.Any(record => - record.ScopeStartLine == scopeStartLine - && record.ScopeStartColumn == scopeStartColumn - && record.ScopeEndLine == scopeEndLine - && record.ScopeEndColumn == scopeEndColumn - && string.Equals(record.Name, name, StringComparison.Ordinal))) + + var record = new CSharpFunctionValueReceiverNameRecord(name, scopeStartLine, scopeStartColumn, scopeEndLine, scopeEndColumn); + if (seenNames != null) + { + if (seenNames.Add(record)) + names.Add(record); return; + } - names.Add(new CSharpFunctionValueReceiverNameRecord(name, scopeStartLine, scopeStartColumn, scopeEndLine, scopeEndColumn)); + if (!names.Contains(record)) + names.Add(record); } private static int GetLineNumberFromOffset(string text, int offset, int startLineNumber) @@ -85,51 +97,75 @@ private static int GetLineNumberFromOffset(string text, int offset, int startLin return lineNumber; } - private static int FindInnermostCSharpBlockEndLine( + private static List BuildCSharpBlockScopes( IReadOnlyList structuralLines, int bodyStartIndex, - int bodyEndIndex, - int declarationLineIndex, - int declarationColumn) + int bodyEndIndex) { - var depth = 0; + var blockScopes = new List(); + var stack = new Stack<(int LineIndex, int Column)>(); for (var lineIndex = bodyStartIndex; lineIndex <= bodyEndIndex; lineIndex++) { var line = structuralLines[lineIndex]; - var limit = lineIndex == declarationLineIndex ? Math.Min(declarationColumn, line.Length) : line.Length; - for (var column = 0; column < limit; column++) + for (var column = 0; column < line.Length; column++) { if (line[column] == '{') - depth++; - else if (line[column] == '}' && depth > 0) - depth--; + { + stack.Push((lineIndex, column)); + } + else if (line[column] == '}' && stack.Count > 0) + { + var start = stack.Pop(); + blockScopes.Add(new CSharpBlockScope(start.LineIndex, start.Column, lineIndex, column)); + } } + } - if (lineIndex != declarationLineIndex) + return blockScopes; + } + + private static int FindInnermostCSharpBlockEndLine( + IReadOnlyList blockScopes, + int fallbackEndLine, + int declarationLineIndex, + int declarationColumn) + { + CSharpBlockScope? bestScope = null; + foreach (var scope in blockScopes) + { + if (!ContainsCSharpBlockScope(scope, declarationLineIndex, declarationColumn)) continue; - var declarationDepth = depth; - for (var scanLine = declarationLineIndex; scanLine <= bodyEndIndex; scanLine++) + if (bestScope == null || IsNarrowerCSharpBlockScope(scope, bestScope.Value)) { - var scan = structuralLines[scanLine]; - var scanStart = scanLine == declarationLineIndex ? declarationColumn : 0; - for (var column = scanStart; column < scan.Length; column++) - { - if (scan[column] == '{') - depth++; - else if (scan[column] == '}' && depth > 0) - { - depth--; - if (depth < declarationDepth) - return scanLine + 1; - } - } + bestScope = scope; } - - break; } - return bodyEndIndex + 1; + return bestScope?.EndLineIndex + 1 ?? fallbackEndLine; + } + + private static bool ContainsCSharpBlockScope(CSharpBlockScope scope, int lineIndex, int column) + { + var startsBefore = lineIndex > scope.StartLineIndex + || (lineIndex == scope.StartLineIndex && column > scope.StartColumn); + if (!startsBefore) + return false; + + return lineIndex < scope.EndLineIndex + || (lineIndex == scope.EndLineIndex && column < scope.EndColumn); + } + + private static bool IsNarrowerCSharpBlockScope(CSharpBlockScope candidate, CSharpBlockScope current) + { + var candidateLineSpan = candidate.EndLineIndex - candidate.StartLineIndex; + var currentLineSpan = current.EndLineIndex - current.StartLineIndex; + if (candidateLineSpan != currentLineSpan) + return candidateLineSpan < currentLineSpan; + + var candidateColumnSpan = candidate.EndColumn - candidate.StartColumn; + var currentColumnSpan = current.EndColumn - current.StartColumn; + return candidateColumnSpan < currentColumnSpan; } private static CSharpLineColumn FindFollowingCSharpEmbeddedStatementEndPosition( diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index fc92662e87..ed542c24ca 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -9664,4 +9664,77 @@ void Run() r.SymbolName == "seed" && r.ReferenceKind == "capture"); } + + [Fact] + public void Extract_CsharpQualifiedEnumMemberAccess_WithBlockScopedLocalNamedLikeEnum_DoesNotLeakPastBlock() + { + const string content = """ + namespace Demo; + + public enum Status + { + Ready + } + + public sealed class Holder + { + public int Ready { get; set; } + } + + public sealed class Uses + { + public void Run() + { + { + var Status = new Holder(); + _ = Status.Ready; + } + + _ = Status.Ready; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var readyRefs = references + .Where(reference => reference.SymbolName == "Ready" && reference.ReferenceKind == "call") + .ToList(); + + var readyRef = Assert.Single(readyRefs); + Assert.Equal("Run", readyRef.ContainerName); + } + + [Fact] + public void Extract_CSharpLargeMethodWithManyLocals_CompletesWithinPracticalBudget() + { + var builder = new StringBuilder(); + builder.AppendLine("class Demo"); + builder.AppendLine("{"); + builder.AppendLine(" int Run(int input)"); + builder.AppendLine(" {"); + builder.AppendLine(" var result = input;"); + for (var i = 0; i < 10_000; i++) + { + builder.Append(" var value").Append(i).Append(" = result + ").Append(i).AppendLine(";"); + builder.Append(" result += value").Append(i).AppendLine(";"); + } + builder.AppendLine(" return Helper(result);"); + builder.AppendLine(" }"); + builder.AppendLine(" int Helper(int value) => value;"); + builder.AppendLine("}"); + var content = builder.ToString(); + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var stopwatch = Stopwatch.StartNew(); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + stopwatch.Stop(); + + Assert.Contains(references, reference => reference.SymbolName == "Helper" && reference.ReferenceKind == "call"); + var runawayBudget = TimeSpan.FromSeconds(15); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large C# method reference extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } } From c03043f734daee4a72e0a6949552306aea8e9a78 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 15:57:17 +0900 Subject: [PATCH 2/2] Improve extractor hot path dedupe --- ...ncsharp-symbol-dedupe-performance.fixed.md | 33 +++ .../Languages/SwiftReferenceExtractor.cs | 17 +- .../Languages/TypeScriptReferenceExtractor.cs | 17 +- .../Indexer/Symbols/SymbolExtractor.Cpp.cs | 22 +- .../Symbols/SymbolExtractor.Dockerfile.cs | 5 +- .../Indexer/Symbols/SymbolExtractor.Go.cs | 8 +- .../Indexer/Symbols/SymbolExtractor.Java.cs | 9 +- ...olExtractor.JavaScriptTypeScriptSupport.cs | 244 +++++++++--------- .../Indexer/Symbols/SymbolExtractor.Rust.cs | 63 +++-- .../Indexer/Symbols/SymbolExtractor.Shell.cs | 36 ++- .../Indexer/Symbols/SymbolExtractor.cs | 83 +++++- .../ReferenceExtractorRustSwiftTests.cs | 30 +++ .../ReferenceExtractorTests.cs | 30 +++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 239 +++++++++++++++++ 14 files changed, 613 insertions(+), 223 deletions(-) create mode 100644 changelog.d/unreleased/+noncsharp-symbol-dedupe-performance.fixed.md diff --git a/changelog.d/unreleased/+noncsharp-symbol-dedupe-performance.fixed.md b/changelog.d/unreleased/+noncsharp-symbol-dedupe-performance.fixed.md new file mode 100644 index 0000000000..4f69a6a031 --- /dev/null +++ b/changelog.d/unreleased/+noncsharp-symbol-dedupe-performance.fixed.md @@ -0,0 +1,33 @@ +--- +category: fixed +affected: + - src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs + - src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Cpp.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Dockerfile.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Go.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Java.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.JavaScriptTypeScriptSupport.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Rust.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Shell.cs + - tests/CodeIndex.Tests/ReferenceExtractorRustSwiftTests.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs +--- + +## English + +- **Reduced symbol extraction overhead for large non-C# generated files.** Rust `use` expansion, Shell alias expansion, Go grouped declarations, Java/Kotlin primary-constructor or record components, C++ same-line class members, Dockerfile named stage chains, and JavaScript/TypeScript exported surfaces and object/class scan-target collection now avoid candidate-by-candidate scans over growing lists. +- The shared symbol-line identity cache preserves the existing duplicate key of file, line, kind, and name for language paths such as Rust, Shell, Go, and JavaScript/TypeScript synthetic class emission. Java/Kotlin record component materialization now tracks existing component names per parent record, Java compact constructor synthesis reuses the already filtered same-line symbols, C++ same-line class-member backfill tracks member names per container, Dockerfile extraction tracks stage names as the file is scanned, and JavaScript/TypeScript export/object-literal supplement passes keep per-file or per-container name sets plus scan-target identity sets. +- Swift and TypeScript reference extraction also now checks existing type-reference rows through the shared reference dedupe key instead of scanning the accumulated reference list while expanding typealias / type-alias targets. +- These changes avoid theoretical super-linear hot paths in generated files with thousands of declarations, imports, aliases, constructor components, object-literal properties, export variables, object literal targets, or class expression targets while keeping emitted symbols and duplicate semantics unchanged. +- Added large-fixture runaway guards for Rust, Shell, Go, Java, Kotlin, C++, Dockerfile, JavaScript, TypeScript, and Swift so future extractor changes catch this class of non-C# performance regression earlier. + +## 日本語 + +- **大きな C# 以外の生成ファイルに対する symbol 抽出のオーバーヘッドを減らしました。** Rust の `use` 展開、Shell の alias 展開、Go の grouped declaration、Java/Kotlin の primary constructor / record component、C++ の same-line class member、Dockerfile の named stage chain、JavaScript/TypeScript の exported surface と object/class scan-target collection で、候補ごとに増え続ける list を走査しないようにしました。 +- 共通の symbol-line identity cache は、Rust、Shell、Go、JavaScript/TypeScript の synthetic class emission などの経路で従来どおり file、line、kind、name を重複キーとして使います。Java/Kotlin の record component materialization は親 record ごとの component name set を使い、Java compact constructor synthesis は同一行に絞り込んだ既存 symbol を再利用し、C++ の same-line class member 補完は container ごとの member name set を使い、Dockerfile 抽出はファイル走査中に stage name を追跡し、JavaScript/TypeScript の export / object literal 補完はファイル単位または container 単位の name set と scan-target identity set を使うようにしました。 +- Swift と TypeScript の reference 抽出でも、typealias / type alias target 展開時に accumulated reference list を走査するのではなく、共通の reference dedupe key で既存の type-reference 行を確認するようにしました。 +- これにより、数千個の declaration、import、alias、constructor component、object literal property、export variable、object literal target、class expression target を含む生成ファイルで理論上発生しうる super-linear な hot path を避けます。出力される symbol と重複判定の意味は変えていません。 +- Rust、Shell、Go、Java、Kotlin、C++、Dockerfile、JavaScript、TypeScript、Swift に対する大規模 fixture の runaway guard を追加し、今後の extractor 変更で同種の C# 以外の性能 regression を早く検出できるようにしました。 diff --git a/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs index 17cc54aa22..20de07e43c 100644 --- a/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs @@ -146,12 +146,15 @@ public static void EmitAliasTargetReferences( if (!HasIdentifierBoundaries(preparedLine, index, alias.Length)) continue; var column = index + 1; - if (!references.Any(reference => - reference.FileId == fileId - && reference.Line == lineNumber - && reference.Column == column - && reference.ReferenceKind == "type_reference" - && string.Equals(reference.SymbolName, alias, StringComparison.Ordinal))) + var container = resolveContainerForColumn(index); + if (!seen.Contains(ReferenceExtractor.BuildReferenceDedupeKey( + fileId, + "swift", + lineNumber, + column, + "type_reference", + alias, + container))) { continue; } @@ -169,7 +172,7 @@ public static void EmitAliasTargetReferences( fileId, context, lineNumber, - resolveContainerForColumn(index), + container, binding.Value.TypeParameters); } } diff --git a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs index c593391978..1bd7af42c8 100644 --- a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs @@ -293,12 +293,15 @@ public static void EmitAliasTargetReferences( if (!HasIdentifierBoundaries(preparedLine, index, alias.Length)) continue; var column = index + 1; - if (!references.Any(reference => - reference.FileId == fileId - && reference.Line == lineNumber - && reference.Column == column - && reference.ReferenceKind == "type_reference" - && string.Equals(reference.SymbolName, alias, StringComparison.Ordinal))) + var container = resolveContainerForColumn(index); + if (!seen.Contains(ReferenceExtractor.BuildReferenceDedupeKey( + fileId, + "typescript", + lineNumber, + column, + "type_reference", + alias, + container))) { continue; } @@ -316,7 +319,7 @@ public static void EmitAliasTargetReferences( fileId, context, lineNumber, - resolveContainerForColumn(index), + container, binding.Value.TypeParameters); } } diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Cpp.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Cpp.cs index e2ad9cf9c7..d655f7c1cb 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Cpp.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Cpp.cs @@ -35,6 +35,14 @@ symbol.Kind is "class" or "struct" if (body.Length == 0) continue; + var existingMemberNames = symbols + .Where(symbol => + symbol.Kind == "function" + && symbol.Line == classSymbol.StartLine + && symbol.ContainerKind == classSymbol.Kind + && symbol.ContainerName == classSymbol.Name) + .Select(symbol => symbol.Name) + .ToHashSet(StringComparer.Ordinal); var segments = body.Split(';'); var searchStart = 0; foreach (var segment in segments) @@ -53,7 +61,7 @@ symbol.Kind is "class" or "struct" continue; } - if (TryAddCppSameLineClassMemberSymbol(fileId, classSymbol, trimmedSegment, lineIndex + 1, symbols)) + if (TryAddCppSameLineClassMemberSymbol(fileId, classSymbol, trimmedSegment, lineIndex + 1, symbols, existingMemberNames)) searchStart = segmentStart + trimmedSegment.Length + 1; else searchStart = segmentStart + trimmedSegment.Length + 1; @@ -66,7 +74,8 @@ private static bool TryAddCppSameLineClassMemberSymbol( SymbolRecord classSymbol, string segment, int lineNumber, - List symbols) + List symbols, + HashSet existingMemberNames) { foreach (var pattern in PatternCache["cpp"]) { @@ -83,15 +92,8 @@ private static bool TryAddCppSameLineClassMemberSymbol( if (string.IsNullOrWhiteSpace(name)) continue; - if (symbols.Any(symbol => - symbol.Kind == "function" - && symbol.Line == lineNumber - && symbol.Name == name - && symbol.ContainerKind == classSymbol.Kind - && symbol.ContainerName == classSymbol.Name)) - { + if (!existingMemberNames.Add(name)) return true; - } symbols.Add(new SymbolRecord { diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Dockerfile.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Dockerfile.cs index 6dd9f131a5..ac313a28a5 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Dockerfile.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Dockerfile.cs @@ -368,14 +368,15 @@ private static void AddDockerfileNamedStageBaseImageSymbol( long fileId, string line, int lineNumber, - List symbols) + List symbols, + HashSet stageNames) { var match = DockerfileNamedFromImageRegex.Match(line); if (!match.Success) return; var name = match.Groups["name"].Value; - if (symbols.Any(symbol => symbol.Kind == "stage" && symbol.Name == name)) + if (stageNames.Contains(name)) return; AddSymbolRecord( diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Go.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Go.cs index 7b0d5bb484..ad4f0a1bc8 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Go.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Go.cs @@ -728,13 +728,7 @@ private static bool TryAddGoStructEmbeddedTypeSymbol( } private static bool HasGoSymbol(List symbols, long fileId, int lineNumber, string kind, string name) - { - return symbols.Any(symbol => - symbol.FileId == fileId - && symbol.Line == lineNumber - && symbol.Kind == kind - && symbol.Name == name); - } + => HasSymbolLineIdentity(symbols, fileId, lineNumber, kind, name); private static void AssignGoMethodReceiverContainers(List symbols) { diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Java.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Java.cs index ca6d35e8b5..e223e310cf 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Java.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Java.cs @@ -346,16 +346,19 @@ private static void ExtractJavaCompactConstructors(long fileId, string[] rawLine && (symbol.ContainerName == null || symbol.ContainerName == recordSymbol.Name) && (symbol.ContainerKind == null || symbol.ContainerKind == "class")) .ToList(); + var hasCompactConstructorSymbol = false; foreach (var existingSymbol in existingSymbols) { if (LooksLikeJavaCompactConstructorSymbol(existingSymbol, recordSymbol.Name)) + { + hasCompactConstructorSymbol = true; continue; + } + symbols.Remove(existingSymbol); } - if (!symbols.Any(symbol => LooksLikeJavaCompactConstructorSymbol(symbol, recordSymbol.Name) - && symbol.FileId == fileId - && symbol.StartLine == i + 1)) + if (!hasCompactConstructorSymbol) { symbols.Add(new SymbolRecord { diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.JavaScriptTypeScriptSupport.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.JavaScriptTypeScriptSupport.cs index 2a3a499f2b..2662e25548 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.JavaScriptTypeScriptSupport.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.JavaScriptTypeScriptSupport.cs @@ -39,6 +39,7 @@ private static List CollectJavaScriptTypeScriptObject JavaScriptScopePrivacyFlags[][] privateScopeColumns) { var targets = new List(); + var targetIdentities = new HashSet<(int StartIndex, int ScanStartIndex, int ScanEndExclusive, string ContainerName)>(); var lexState = new JavaScriptLexState(); for (int i = 0; i < lines.Length; i++) { @@ -122,13 +123,9 @@ private static List CollectJavaScriptTypeScriptObject containerName: containerName, isExported: isExported); - if (!targets.Any(t => t.StartIndex == candidate.StartIndex - && t.ScanStartIndex == candidate.ScanStartIndex - && t.ScanEndExclusive == candidate.ScanEndExclusive - && t.ContainerName == candidate.ContainerName)) - { + var targetIdentity = (candidate.StartIndex, candidate.ScanStartIndex, candidate.ScanEndExclusive, candidate.ContainerName); + if (targetIdentities.Add(targetIdentity)) targets.Add(candidate); - } } return targets @@ -1861,6 +1858,8 @@ private static void ExtractJavaScriptTypeScriptQualifiedAssignments( { var sanitizedLines = BuildJavaScriptTypeScriptSanitizedLines(lines); var syntheticClassTargets = new List(); + var symbolLineIdentities = BuildSymbolLineIdentities(symbols); + var targetIdentities = new HashSet<(int StartIndex, int StartColumn, int ScanStartIndex, int ScanEndExclusive, int FirstLineScanOffset, string ContainerKind, string ContainerName)>(); for (int i = 0; i < lines.Length; i++) { @@ -1952,6 +1951,8 @@ private static void ExtractJavaScriptTypeScriptQualifiedAssignments( lines, symbols, syntheticClassTargets, + symbolLineIdentities, + targetIdentities, i, absoluteMatchIndex, classTokenLineIndex, @@ -2646,6 +2647,11 @@ private static void ExtractJavaScriptTypeScriptExportedVariableSymbols( List symbols, JavaScriptScopePrivacyFlags[][] privateScopeColumns) { + var exportedSymbolNames = symbols + .Where(symbol => symbol.Visibility == "export") + .Select(symbol => symbol.Name) + .ToHashSet(StringComparer.Ordinal); + for (int i = 0; i < sanitizedLines.Length; i++) { var sanitizedLine = sanitizedLines[i]; @@ -2688,7 +2694,7 @@ private static void ExtractJavaScriptTypeScriptExportedVariableSymbols( endColumn); foreach (var variableName in variableNames) { - if (symbols.Any(s => s.Visibility == "export" && s.Name == variableName.Name)) + if (!exportedSymbolNames.Add(variableName.Name)) continue; AddSymbolRecord( @@ -4691,6 +4697,12 @@ private static void ExtractJavaScriptTypeScriptExportedObjectLiteralProperties( var parenDepth = 0; var bracketDepth = 0; var skippingPropertyValue = false; + var existingContainerSymbolNames = symbols + .Where(symbol => + symbol.ContainerKind == "object" + && symbol.ContainerName == target.ContainerName) + .Select(symbol => symbol.Name) + .ToHashSet(StringComparer.Ordinal); for (int lineIndex = target.ScanStartIndex; lineIndex < target.ScanEndExclusive; lineIndex++) { @@ -4772,32 +4784,15 @@ private static void ExtractJavaScriptTypeScriptExportedObjectLiteralProperties( if (propertyMatch.Success) { var propertyName = propertyMatch.Groups["name"].Value; - var hasExistingContainerSymbol = symbols.Any(s => - s.Name == propertyName - && s.ContainerKind == "object" - && s.ContainerName == target.ContainerName); - if (!hasExistingContainerSymbol) - { - AddSymbolRecord( - symbols, - cssSeenSymbols: null, - lineIndex + 1, - new SymbolRecord - { - FileId = fileId, - Kind = "property", - Name = propertyName, - Line = lineIndex + 1, - StartLine = lineIndex + 1, - StartColumn = scanColumn + propertyMatch.Index, - EndLine = lineIndex + 1, - Signature = rawLines[lineIndex].Trim(), - ContainerKind = "object", - ContainerName = target.ContainerName, - Visibility = "export", - }, - rawLines[lineIndex]); - } + AddJavaScriptTypeScriptExportedObjectLiteralPropertySymbol( + fileId, + rawLines, + symbols, + existingContainerSymbolNames, + target.ContainerName, + propertyName, + lineIndex, + scanColumn + propertyMatch.Index); scanColumn += propertyMatch.Length; skippingPropertyValue = true; @@ -4811,32 +4806,15 @@ private static void ExtractJavaScriptTypeScriptExportedObjectLiteralProperties( out var literalPropertyName, out var literalValueStartColumn)) { - var hasExistingContainerSymbol = symbols.Any(s => - s.Name == literalPropertyName - && s.ContainerKind == "object" - && s.ContainerName == target.ContainerName); - if (!hasExistingContainerSymbol) - { - AddSymbolRecord( - symbols, - cssSeenSymbols: null, - lineIndex + 1, - new SymbolRecord - { - FileId = fileId, - Kind = "property", - Name = literalPropertyName, - Line = lineIndex + 1, - StartLine = lineIndex + 1, - StartColumn = scanColumn, - EndLine = lineIndex + 1, - Signature = rawLines[lineIndex].Trim(), - ContainerKind = "object", - ContainerName = target.ContainerName, - Visibility = "export", - }, - rawLines[lineIndex]); - } + AddJavaScriptTypeScriptExportedObjectLiteralPropertySymbol( + fileId, + rawLines, + symbols, + existingContainerSymbolNames, + target.ContainerName, + literalPropertyName, + lineIndex, + scanColumn); scanColumn = literalValueStartColumn; skippingPropertyValue = true; @@ -4850,32 +4828,15 @@ private static void ExtractJavaScriptTypeScriptExportedObjectLiteralProperties( out var computedLiteralPropertyName, out var computedLiteralValueStartColumn)) { - var hasExistingContainerSymbol = symbols.Any(s => - s.Name == computedLiteralPropertyName - && s.ContainerKind == "object" - && s.ContainerName == target.ContainerName); - if (!hasExistingContainerSymbol) - { - AddSymbolRecord( - symbols, - cssSeenSymbols: null, - lineIndex + 1, - new SymbolRecord - { - FileId = fileId, - Kind = "property", - Name = computedLiteralPropertyName, - Line = lineIndex + 1, - StartLine = lineIndex + 1, - StartColumn = scanColumn, - EndLine = lineIndex + 1, - Signature = rawLines[lineIndex].Trim(), - ContainerKind = "object", - ContainerName = target.ContainerName, - Visibility = "export", - }, - rawLines[lineIndex]); - } + AddJavaScriptTypeScriptExportedObjectLiteralPropertySymbol( + fileId, + rawLines, + symbols, + existingContainerSymbolNames, + target.ContainerName, + computedLiteralPropertyName, + lineIndex, + scanColumn); scanColumn = computedLiteralValueStartColumn; skippingPropertyValue = true; @@ -4892,32 +4853,15 @@ private static void ExtractJavaScriptTypeScriptExportedObjectLiteralProperties( if (shorthandMatch.Success) { var propertyName = shorthandMatch.Groups["name"].Value; - var hasExistingContainerSymbol = symbols.Any(s => - s.Name == propertyName - && s.ContainerKind == "object" - && s.ContainerName == target.ContainerName); - if (!hasExistingContainerSymbol) - { - AddSymbolRecord( - symbols, - cssSeenSymbols: null, - lineIndex + 1, - new SymbolRecord - { - FileId = fileId, - Kind = "property", - Name = propertyName, - Line = lineIndex + 1, - StartLine = lineIndex + 1, - StartColumn = scanColumn + shorthandMatch.Index, - EndLine = lineIndex + 1, - Signature = rawLines[lineIndex].Trim(), - ContainerKind = "object", - ContainerName = target.ContainerName, - Visibility = "export", - }, - rawLines[lineIndex]); - } + AddJavaScriptTypeScriptExportedObjectLiteralPropertySymbol( + fileId, + rawLines, + symbols, + existingContainerSymbolNames, + target.ContainerName, + propertyName, + lineIndex, + scanColumn + shorthandMatch.Index); scanColumn += shorthandMatch.Length; continue; @@ -4955,6 +4899,40 @@ private static void ExtractJavaScriptTypeScriptExportedObjectLiteralProperties( } } + private static void AddJavaScriptTypeScriptExportedObjectLiteralPropertySymbol( + long fileId, + string[] rawLines, + List symbols, + HashSet existingContainerSymbolNames, + string containerName, + string propertyName, + int lineIndex, + int startColumn) + { + if (propertyName.Length == 0 || !existingContainerSymbolNames.Add(propertyName)) + return; + + AddSymbolRecord( + symbols, + cssSeenSymbols: null, + lineIndex + 1, + new SymbolRecord + { + FileId = fileId, + Kind = "property", + Name = propertyName, + Line = lineIndex + 1, + StartLine = lineIndex + 1, + StartColumn = startColumn, + EndLine = lineIndex + 1, + Signature = rawLines[lineIndex].Trim(), + ContainerKind = "object", + ContainerName = containerName, + Visibility = "export", + }, + rawLines[lineIndex]); + } + private static bool TryReadJavaScriptTypeScriptLiteralObjectLiteralKeyName( string sanitizedLine, string rawLine, @@ -6327,6 +6305,8 @@ private static List GetJavaScriptTypeScriptExistingCl private static List CollectJavaScriptTypeScriptSyntheticClassScanTargets(long fileId, string lang, string[] lines, List symbols, JavaScriptScopePrivacyFlags[][] privateScopeColumns) { var targets = new List(); + var symbolLineIdentities = BuildSymbolLineIdentities(symbols); + var targetIdentities = new HashSet<(int StartIndex, int StartColumn, int ScanStartIndex, int ScanEndExclusive, int FirstLineScanOffset, string ContainerKind, string ContainerName)>(); var lexState = new JavaScriptLexState(); for (int i = 0; i < lines.Length; i++) { @@ -6336,7 +6316,7 @@ private static List CollectJavaScriptTypeScriptSynthe var lineOffset = FindNextJavaScriptTypeScriptStatementStart(sanitizedLine, 0); while (lineOffset >= 0 && lineOffset < sanitizedLine.Length) { - TryAddJavaScriptTypeScriptSyntheticClassTarget(fileId, lang, lines, symbols, targets, i, lineOffset, sanitizedLine, privateScopeColumns); + TryAddJavaScriptTypeScriptSyntheticClassTarget(fileId, lang, lines, symbols, targets, symbolLineIdentities, targetIdentities, i, lineOffset, sanitizedLine, privateScopeColumns); lineOffset = FindNextJavaScriptTypeScriptStatementStart(sanitizedLine, lineOffset + 1); } } @@ -7484,6 +7464,8 @@ private static void TryAddJavaScriptTypeScriptSyntheticClassTarget( string[] lines, List symbols, List targets, + HashSet symbolLineIdentities, + HashSet<(int StartIndex, int StartColumn, int ScanStartIndex, int ScanEndExclusive, int FirstLineScanOffset, string ContainerKind, string ContainerName)> targetIdentities, int startIndex, int startColumn, string sanitizedLine, @@ -7517,6 +7499,8 @@ private static void TryAddJavaScriptTypeScriptSyntheticClassTarget( lines, symbols, targets, + symbolLineIdentities, + targetIdentities, startIndex, startColumn + anonymousDefaultMatch.Index, classTokenLineIndex, @@ -7558,6 +7542,8 @@ private static void TryAddJavaScriptTypeScriptSyntheticClassTarget( lines, symbols, targets, + symbolLineIdentities, + targetIdentities, startIndex, startColumn + exportEqualsMatch.Index, exportEqualsClassTokenLineIndex, @@ -7609,6 +7595,8 @@ private static void TryAddJavaScriptTypeScriptSyntheticClassTarget( lines, symbols, targets, + symbolLineIdentities, + targetIdentities, startIndex, startColumn + classExpressionBindingMatch.Index, classExpressionTokenLineIndex, @@ -7629,6 +7617,8 @@ private static void AddJavaScriptTypeScriptSyntheticClassTarget( string[] lines, List symbols, List targets, + HashSet symbolLineIdentities, + HashSet<(int StartIndex, int StartColumn, int ScanStartIndex, int ScanEndExclusive, int FirstLineScanOffset, string ContainerKind, string ContainerName)> targetIdentities, int declarationStartIndex, int declarationStartColumn, int classTokenLineIndex, @@ -7640,10 +7630,9 @@ private static void AddJavaScriptTypeScriptSyntheticClassTarget( if (bodyStartLine == null || bodyEndLine == null) return; - var existingClass = symbols.FirstOrDefault(s => s.Kind == "class" && s.Line == declarationStartIndex + 1 && s.Name == containerName); - if (existingClass == null) + if (!HasSymbolLineIdentity(symbolLineIdentities, fileId, declarationStartIndex + 1, "class", containerName)) { - symbols.Add(new SymbolRecord + var symbol = new SymbolRecord { FileId = fileId, Kind = "class", @@ -7655,20 +7644,22 @@ private static void AddJavaScriptTypeScriptSyntheticClassTarget( BodyEndLine = bodyEndLine, Signature = BuildJavaScriptTypeScriptSyntheticClassSignature(lines, declarationStartIndex, declarationStartColumn, classTokenLineIndex, classTokenStartColumn, bodyStartLine, bodyEndLine, lang), Visibility = visibility, - }); + }; + symbols.Add(symbol); + RecordSymbolLineIdentity(symbolLineIdentities, symbol); } var candidate = CreateJavaScriptClassScanTarget(lines, lang, classTokenLineIndex, classTokenStartColumn, bodyStartLine, bodyEndLine, "class", containerName); - if (!targets.Any(t => t.StartIndex == candidate.StartIndex - && t.StartColumn == candidate.StartColumn - && t.ScanStartIndex == candidate.ScanStartIndex - && t.ScanEndExclusive == candidate.ScanEndExclusive - && t.FirstLineScanOffset == candidate.FirstLineScanOffset - && t.ContainerKind == candidate.ContainerKind - && t.ContainerName == candidate.ContainerName)) - { + var targetIdentity = ( + candidate.StartIndex, + candidate.StartColumn, + candidate.ScanStartIndex, + candidate.ScanEndExclusive, + candidate.FirstLineScanOffset, + candidate.ContainerKind, + candidate.ContainerName); + if (targetIdentities.Add(targetIdentity)) targets.Add(candidate); - } } private static string BuildJavaScriptTypeScriptSyntheticClassSignature( @@ -7938,6 +7929,9 @@ private static void ExtractJavaScriptTypeScriptBareMethodsInClass( } } } + + column = valueStartColumn; + continue; } } diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Rust.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Rust.cs index 53f56c1b62..415aed5923 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Rust.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Rust.cs @@ -10,6 +10,7 @@ public static partial class SymbolExtractor { private static void ExtractRustUseSymbols(long fileId, string[] lines, List symbols) { + var symbolLineIdentities = BuildSymbolLineIdentities(symbols); for (var i = 0; i < lines.Length; i++) { if (!TryReadRustUseStatement(lines, i, out var statement, out var lineStarts, out var endLineIndex)) @@ -44,25 +45,27 @@ private static void ExtractRustUseSymbols(long fileId, string[] lines, List symbols) { + var symbolLineIdentities = BuildSymbolLineIdentities(symbols); for (var i = 0; i < lines.Length; i++) { if (!TryReadRustImplStatement(lines, i, out var statement, out var lineStarts, out var endLineIndex)) @@ -90,25 +94,27 @@ private static void ExtractRustMultilineImplSymbols(long fileId, string[] lines, continue; var position = GetRustStatementPosition(match.Groups["name"].Index, lineStarts, i + 1); - if (HasRustSymbol(symbols, fileId, position.Line, "class", name)) + if (HasSymbolLineIdentity(symbolLineIdentities, fileId, position.Line, "class", name)) continue; + var symbol = new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = name, + Line = position.Line, + StartLine = position.Line, + StartColumn = position.Column, + EndLine = position.Line, + Signature = statement.Trim(), + }; AddSymbolRecord( symbols, cssSeenSymbols: null, position.Line, - new SymbolRecord - { - FileId = fileId, - Kind = "class", - Name = name, - Line = position.Line, - StartLine = position.Line, - StartColumn = position.Column, - EndLine = position.Line, - Signature = statement.Trim(), - }, + symbol, lines[position.Line - 1]); + RecordSymbolLineIdentity(symbolLineIdentities, symbol); } } @@ -448,13 +454,4 @@ private static RustAsKeywordSpan FindTopLevelAsKeyword(string text) return new RustAsKeywordSpan(-1, 0); } - private static bool HasRustSymbol(List symbols, long fileId, int lineNumber, string kind, string name) - { - return symbols.Any(symbol => - symbol.FileId == fileId - && symbol.Line == lineNumber - && symbol.Kind == kind - && symbol.Name == name); - } - } diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Shell.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Shell.cs index 145e812faf..87d24e1a8d 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Shell.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Shell.cs @@ -10,6 +10,7 @@ public static partial class SymbolExtractor { private static void ExpandShellAliasSymbols(long fileId, string[] lines, List symbols) { + var symbolLineIdentities = BuildSymbolLineIdentities(symbols); for (var i = 0; i < lines.Length; i++) { var line = lines[i]; @@ -24,25 +25,27 @@ private static void ExpandShellAliasSymbols(long fileId, string[] lines, List tokenStart; } - private static bool HasShellAliasSymbol(List symbols, long fileId, int lineNumber, string name) - { - return symbols.Any(symbol => - symbol.FileId == fileId - && symbol.Line == lineNumber - && symbol.Kind == "alias" - && string.Equals(symbol.Name, name, StringComparison.Ordinal)); - } - private static bool IsShellAliasName(string name) => Regex.IsMatch(name, @"^[A-Za-z_][A-Za-z0-9_-]*$", RegexOptions.CultureInvariant); diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index d2c2840327..2d79542bb4 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -2387,6 +2387,9 @@ public static List Extract(long fileId, string? lang, string conte var cssSeenSymbols = lang == "css" ? new HashSet(StringComparer.Ordinal) : null; + var dockerfileStageNames = lang == "dockerfile" + ? new HashSet(StringComparer.Ordinal) + : null; var csharpSuppressedContinuationUntil = -1; var goImportBlock = false; @@ -2418,7 +2421,7 @@ public static List Extract(long fileId, string? lang, string conte AddDockerfileAdditionalLabelSymbols(fileId, line, i + 1, symbols); AddDockerfileAdditionalExposeSymbols(fileId, line, i + 1, symbols); AddDockerfileAdditionalVolumeSymbols(fileId, line, i + 1, symbols); - AddDockerfileNamedStageBaseImageSymbol(fileId, line, i + 1, symbols); + AddDockerfileNamedStageBaseImageSymbol(fileId, line, i + 1, symbols, dockerfileStageNames!); AddDockerfileShellSymbol(fileId, line, i + 1, symbols); AddDockerfileCopyDestinationSymbol(fileId, line, i + 1, symbols); AddDockerfileAddDestinationSymbol(fileId, line, i + 1, symbols); @@ -3616,6 +3619,9 @@ public static List Extract(long fileId, string? lang, string conte }, line); + if (dockerfileStageNames != null && kind == "stage") + dockerfileStageNames.Add(name); + if (lang == "objc" && pattern.Kind == "class" && TryGetObjCCategoryDisplayName(patternMatchLine[absoluteStartColumn..], name, out var categoryDisplayName)) @@ -5493,6 +5499,65 @@ public SymbolRecordIdentity(SymbolRecord symbol) } } + private readonly record struct SymbolLineIdentity(long FileId, int Line, string Kind, string Name); + + private static readonly ConditionalWeakTable, SymbolLineIdentityState> SymbolLineIdentityStates = new(); + + private sealed class SymbolLineIdentityState + { + private readonly HashSet _identities = []; + private int _knownCount; + + public bool Contains(List symbols, SymbolLineIdentity identity) + { + Sync(symbols); + return _identities.Contains(identity); + } + + private void Sync(List symbols) + { + if (_knownCount > symbols.Count) + { + _identities.Clear(); + _knownCount = 0; + } + + for (; _knownCount < symbols.Count; _knownCount++) + _identities.Add(GetSymbolLineIdentity(symbols[_knownCount])); + } + } + + private static HashSet BuildSymbolLineIdentities(IEnumerable symbols) + { + var identities = new HashSet(); + foreach (var symbol in symbols) + identities.Add(GetSymbolLineIdentity(symbol)); + return identities; + } + + private static SymbolLineIdentity GetSymbolLineIdentity(SymbolRecord symbol) + => new(symbol.FileId, symbol.Line, symbol.Kind, symbol.Name); + + private static bool HasSymbolLineIdentity( + HashSet identities, + long fileId, + int lineNumber, + string kind, + string name) + => identities.Contains(new SymbolLineIdentity(fileId, lineNumber, kind, name)); + + private static bool HasSymbolLineIdentity( + List symbols, + long fileId, + int lineNumber, + string kind, + string name) + => SymbolLineIdentityStates.GetValue(symbols, _ => new SymbolLineIdentityState()) + .Contains(symbols, new SymbolLineIdentity(fileId, lineNumber, kind, name)); + + private static void RecordSymbolLineIdentity(HashSet identities, SymbolRecord symbol) + => identities.Add(GetSymbolLineIdentity(symbol)); + private readonly record struct SameLineSignatureKey(int Line, int StartLine, string Signature); private static bool TryAddRPacmanPackageLoaderSymbols( long fileId, @@ -7958,19 +8023,21 @@ private static void MaterializeRecordPrimaryComponentSymbols( if (parentSymbol == null) continue; - foreach (var component in pending.Components) - { - if (symbols.Any(symbol => + var existingComponentNames = symbols + .Where(symbol => symbol.FileId == pending.FileId && symbol.Kind == "property" - && symbol.Name == component.Name && symbol.ContainerKind == pending.Kind && symbol.ContainerName == pending.RecordName && symbol.StartLine >= parentSymbol.StartLine - && symbol.EndLine <= parentSymbol.EndLine)) - { + && symbol.EndLine <= parentSymbol.EndLine) + .Select(symbol => symbol.Name) + .ToHashSet(StringComparer.Ordinal); + + foreach (var component in pending.Components) + { + if (!existingComponentNames.Add(component.Name)) continue; - } symbols.Add(new SymbolRecord { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorRustSwiftTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorRustSwiftTests.cs index 4eeec6f33a..9b2eee5640 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorRustSwiftTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorRustSwiftTests.cs @@ -353,6 +353,36 @@ func get(_ value: Any) -> Any { value } Assert.Equal(8, expanded[0].Column); } + [Fact] + public void Extract_SwiftLargeTypealiasUseSet_CompletesWithinPracticalBudget() + { + var uses = string.Join('\n', Enumerable.Range(0, 5_000).Select(index => $"let v{index}: MyAlias = value")); + var content = $$""" + class SomeType {} + typealias MyAlias = SomeType + {{uses}} + """; + + var symbols = SymbolExtractor.Extract(1, "swift", content); + + var stopwatch = Stopwatch.StartNew(); + var references = ReferenceExtractor.Extract(1, "swift", content, symbols); + stopwatch.Stop(); + + Assert.Contains(references, reference => + reference.SymbolName == "SomeType" + && reference.ReferenceKind == "type_reference" + && reference.Context == "let v0: MyAlias = value"); + Assert.Contains(references, reference => + reference.SymbolName == "SomeType" + && reference.ReferenceKind == "type_reference" + && reference.Context == "let v4999: MyAlias = value"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large Swift typealias reference extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_SwiftGenericTypealiasHeritage_DoesNotEmitTypeParameterAsTarget() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 552b0f9a80..d79a6e052b 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -2626,6 +2626,36 @@ class SomeType {} Assert.Equal(10, expanded[0].Column); } + [Fact] + public void Extract_TypeScriptLargeTypeAliasUseSet_CompletesWithinPracticalBudget() + { + var uses = string.Join('\n', Enumerable.Range(0, 5_000).Select(index => $"let v{index}: MyAlias = value;")); + var content = $$""" + class SomeType {} + type MyAlias = SomeType; + {{uses}} + """; + + var symbols = SymbolExtractor.Extract(1, "typescript", content); + + var stopwatch = Stopwatch.StartNew(); + var references = ReferenceExtractor.Extract(1, "typescript", content, symbols); + stopwatch.Stop(); + + Assert.Contains(references, reference => + reference.SymbolName == "SomeType" + && reference.ReferenceKind == "type_reference" + && reference.Context == "let v0: MyAlias = value;"); + Assert.Contains(references, reference => + reference.SymbolName == "SomeType" + && reference.ReferenceKind == "type_reference" + && reference.Context == "let v4999: MyAlias = value;"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large TypeScript type alias reference extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_TypeScriptTypeAliasWithGenericDefault_EmitsUnderlyingTypeReference() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index c1f957fd49..72467a3900 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -2232,6 +2232,23 @@ export const Assert.DoesNotContain(symbols, s => s.Kind == "property" && s.Name == "skipped" && s.Visibility == "export"); } + [Fact] + public void Extract_TypeScriptLargeExportedVariables_CompletesWithinPracticalBudget() + { + var lines = string.Join('\n', Enumerable.Range(0, 5_000).Select(i => $"export const value{i} = {i};")); + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "typescript", lines); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "value0" && s.Visibility == "export"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "value4999" && s.Visibility == "export"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large TypeScript exported variable extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_TypeScript_DetectsDeclareExportedVariableSurfaceSymbols() { @@ -6676,6 +6693,25 @@ function setup() { Assert.Contains(symbols, s => s.Kind == "alias" && s.Name == "G"); } + [Fact] + public void Extract_ShellLargeAliasSet_CompletesWithinPracticalBudget() + { + var builder = new StringBuilder(); + for (var i = 0; i < 5_000; i++) + builder.Append("alias a").Append(i).Append("='echo ").Append(i).AppendLine("'"); + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "shell", builder.ToString()); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "alias" && s.Name == "a0"); + Assert.Contains(symbols, s => s.Kind == "alias" && s.Name == "a4999"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large shell alias extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_SQL_DetectsCreateStatements() { @@ -8688,6 +8724,25 @@ public void Extract_Rust_UseAliasHandlesTokenBoundariesAndPathsContainingAs() Assert.Contains("Result", imports); } + [Fact] + public void Extract_RustLargeUseSet_CompletesWithinPracticalBudget() + { + var builder = new StringBuilder(); + for (var i = 0; i < 8_000; i++) + builder.Append("use crate::generated::Item").Append(i).AppendLine(";"); + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "rust", builder.ToString()); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "Item0"); + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "Item7999"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large Rust use extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_Rust_MapsImplBlocksToImplementingType() { @@ -8808,6 +8863,43 @@ public void Extract_Go_DetectsTypeAliasAndConst() Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "GlobalConfig"); } + [Fact] + public void Extract_GoLargeGroupedDeclarations_CompletesWithinPracticalBudget() + { + var typeLines = string.Join('\n', Enumerable.Range(0, 2_000).Select(i => $" Type{i} struct {{ Embedded{i} }}")); + var constLines = string.Join('\n', Enumerable.Range(0, 2_000).Select(i => $" Const{i} = {i}")); + var varLines = string.Join('\n', Enumerable.Range(0, 2_000).Select(i => $" Var{i} Config")); + var content = $$""" + package generated + + type ( + {{typeLines}} + ) + + const ( + {{constLines}} + ) + + var ( + {{varLines}} + ) + """; + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "go", content); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "struct" && s.Name == "Type0"); + Assert.Contains(symbols, s => s.Kind == "struct" && s.Name == "Type1999"); + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "Embedded1999"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Const0"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Var1999"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large Go grouped declaration extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_Rust_DetectsFunctionsAndStructs() { @@ -9203,6 +9295,32 @@ int y Assert.Equal(40, pointY.Line); } + [Fact] + public void Extract_JavaLargeRecordPrimaryComponents_CompletesWithinPracticalBudget() + { + var componentLines = string.Join('\n', Enumerable.Range(0, 2_000).Select(i => $" int p{i},")); + var content = $$""" + package com.example; + + public record Huge( + {{componentLines}} + int tail + ) {} + """; + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "java", content); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "p0" && s.ContainerName == "Huge"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "p1999" && s.ContainerName == "Huge"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "tail" && s.ContainerName == "Huge"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large Java record component extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_Java_DetectsRecordPrimaryComponentsWithSpacedGenericTypes() { @@ -9909,6 +10027,29 @@ fun three() = 3 Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "three" && s.StartLine == 6 && s.EndLine == 6 && s.BodyStartLine == null && s.BodyEndLine == null); } + [Fact] + public void Extract_KotlinLargePrimaryConstructorComponents_CompletesWithinPracticalBudget() + { + var components = string.Join(", ", Enumerable.Range(0, 2_000).Select(i => $"val p{i}: String")); + var content = $""" + package generated + + data class Huge({components}, val tail: String) + """; + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "p0" && s.ContainerName == "Huge"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "p1999" && s.ContainerName == "Huge"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "tail" && s.ContainerName == "Huge"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large Kotlin primary constructor extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_Kotlin_DistinguishesValueClassesAndInlineReifiedFunctions() { @@ -10578,6 +10719,24 @@ public void Extract_Cpp_DetectsClassAndNamespace() Assert.Contains("decltype(foo(42))", value.ReturnType); } + [Fact] + public void Extract_CppLargeSameLineClassBody_CompletesWithinPracticalBudget() + { + var members = string.Join(' ', Enumerable.Range(0, 2_000).Select(i => $"int method{i}();")); + var content = $"class Big {{ {members} }};"; + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "cpp", content); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "method0" && s.ContainerName == "Big"); + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "method1999" && s.ContainerName == "Big"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large C++ same-line class body extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_CppTemplateSpecializations_DistinguishesDeclarationSites() { @@ -15191,6 +15350,29 @@ public void Extract_Dockerfile_DetectsStages() Assert.Equal(6, symbols.Count); } + [Fact] + public void Extract_DockerfileLargeNamedStageChain_CompletesWithinPracticalBudget() + { + var builder = new StringBuilder(); + builder.AppendLine("FROM alpine AS stage0"); + for (var i = 1; i < 2_000; i++) + builder.Append("FROM stage").Append(i - 1).Append(" AS stage").Append(i).AppendLine(); + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "dockerfile", builder.ToString()); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "stage" && s.Name == "stage0"); + Assert.Contains(symbols, s => s.Kind == "stage" && s.Name == "stage1999"); + Assert.Contains(symbols, s => s.Kind == "base_image" && s.Name == "alpine"); + Assert.DoesNotContain(symbols, s => s.Kind == "base_image" && s.Name == "stage0"); + Assert.DoesNotContain(symbols, s => s.Kind == "base_image" && s.Name == "stage1998"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large Dockerfile named stage extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_Dockerfile_DetectsLowercaseInstructionsAndEnvSymbols() { @@ -16751,6 +16933,63 @@ public void Extract_JavaScript_DetectsModuleExportsObjectLiteralMembers() Assert.Contains(symbols, s => s.Kind == "generator" && s.Name == "gen" && s.ContainerKind == "object"); } + [Fact] + public void Extract_JavaScriptLargeExportedObjectLiteralProperties_CompletesWithinPracticalBudget() + { + var properties = string.Join(", ", Enumerable.Range(0, 3_000).Select(i => $"p{i}: {i}")); + var content = $"export default {{ {properties} }};"; + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "javascript", content); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "p0" && s.ContainerKind == "object" && s.ContainerName == "default"); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "p2999" && s.ContainerKind == "object" && s.ContainerName == "default"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large JavaScript exported object literal extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + + [Fact] + public void Extract_JavaScriptLargeObjectLiteralTargets_CompletesWithinPracticalBudget() + { + var builder = new StringBuilder(); + for (var i = 0; i < 2_000; i++) + builder.Append("export const obj").Append(i).Append(" = { run").Append(i).AppendLine("() { return 1; } };"); + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "javascript", builder.ToString()); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "run0" && s.ContainerKind == "object" && s.ContainerName == "obj0"); + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "run1999" && s.ContainerKind == "object" && s.ContainerName == "obj1999"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large JavaScript object literal target extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + + [Fact] + public void Extract_TypeScriptLargeClassExpressionTargets_CompletesWithinPracticalBudget() + { + var builder = new StringBuilder(); + for (var i = 0; i < 2_000; i++) + builder.Append("export const C").Append(i).Append(" = class { method").Append(i).AppendLine("(): number { return 1; } };"); + + var stopwatch = Stopwatch.StartNew(); + var symbols = SymbolExtractor.Extract(1, "typescript", builder.ToString()); + stopwatch.Stop(); + + Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "C0"); + Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "C1999"); + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "method1999" && s.ContainerKind == "class" && s.ContainerName == "C1999"); + var runawayBudget = TimeSpan.FromSeconds(10); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large TypeScript class expression target extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + [Fact] public void Extract_TypeScript_DoesNotEmitObjectLiteralMembersInBlockScopeOrNonExportedNamespace() {