diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 7ab0a97ce..f170b588a 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -78,6 +78,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Console writer synchronization coverage yields between character writes instead of sleeping per character; use enough whole-line iterations to expose interleaving without adding wall-clock delay. - `SymbolExtractor*Tests.cs` and `ReferenceExtractor*Tests.cs` Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. + C# declaration-boundary regressions should pair a direct extractor fixture with a real-index `symbols --exact-name` query. Keep invocation and parameter continuations beside valid multi-line methods, constructors, delegates, and local functions so both false-positive rejection and declaration ranges remain observable. Repository-metadata coverage lives in `SymbolExtractorRepositoryMetadataTests.cs` and `ReferenceExtractorRepositoryMetadataTests.cs`; keep TOML, JSON Lines, ignore/attributes, EditorConfig, `.rules`, and application-manifest capability assertions coordinated with conservative local-path and malformed-record controls. Capability-regression fixtures that require an unsupported language use the explicit `text` placeholder or an ambiguity bucket; do not use a recognized repository-metadata format as the unsupported control. When moving repeated extractor scenarios out of a giant suite, keep the new partial file grouped by a readable domain such as language, build-file format, or protocol surface, and prefer small semantic assertion helpers over repeated raw substring or predicate assertions. @@ -968,6 +969,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" console writer synchronization coverageは文字writeごとのsleepではなくyieldを使い、wall-clock delayを追加せずinterleavingを露出できる十分なwhole-line iterationを維持してください。 - `SymbolExtractor*Tests.cs` と `ReferenceExtractor*Tests.cs` extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 + C# の declaration boundary に関する regression では、extractor を直接呼ぶ fixture と、実 index に対する `symbols --exact-name` query を組み合わせてください。呼び出し・parameter の continuation と、正当な複数行 method、constructor、delegate、local function を同居させ、false positive の拒否と宣言 range の両方を観測可能にします。 repository metadata の coverage は `SymbolExtractorRepositoryMetadataTests.cs` と `ReferenceExtractorRepositoryMetadataTests.cs` に置きます。TOML、JSON Lines、ignore / attributes、EditorConfig、`.rules`、application manifest の capability assertion を、保守的な local-path 抽出と malformed-record control に同期させてください。 未対応言語を必要とする capability regression fixture には明示的な `text` placeholder または ambiguity bucket を使い、認識済み repository metadata 形式を未対応 control に使わないでください。 巨大 suite から繰り返しの extractor シナリオを切り出す場合は、言語、build-file 形式、protocol surface など読みやすい領域ごとの partial file にまとめ、raw substring や predicate assertion の繰り返しより小さな semantic assertion helper を優先してください。 diff --git a/changelog.d/unreleased/4831.fixed.md b/changelog.d/unreleased/4831.fixed.md new file mode 100644 index 000000000..6e57dab61 --- /dev/null +++ b/changelog.d/unreleased/4831.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 4831 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs + - tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue4831Tests.cs + - TESTING_GUIDE.md +--- + +## English + +- **C# declaration scanning now rejects expression and parameter continuations (#4831)** — The method and constructor scanner now checks a column-aware declaration-start scope before emitting symbols, so multi-line LINQ or fluent-call arguments no longer create duplicate function definitions and `out` / `params` parameter continuations no longer become functions. Block-lambda statement baselines preserve valid nested local functions, while reconciled conditional-compilation branch-end snapshots and ignored preprocessor payloads keep real methods visible across `#if` alternatives, disabled incomplete text, and arbitrary `#region` text. The C# extractor contract version invalidates stale rows, and focused extractor plus real-index exact-name tests prevent the continuation regressions previously covered by #496 and #4413 from returning. + +## 日本語 + +- **C# の宣言走査で式と parameter の continuation を拒否するようにしました (#4831)** — method / constructor scanner は symbol を出力する前に列位置を考慮した declaration-start scope を確認するため、複数行の LINQ・fluent call 引数から重複 function 定義が生成されず、`out` / `params` の parameter continuation も function になりません。block lambda では statement baseline を設定して正当な入れ子 local function を維持し、conditional compilation では branch end ごとの snapshot を共通状態へreconcileし、preprocessor payload を無視することで、`#if` の代替分岐、無効化された未完了text、任意の `#region` text をまたいでも本物の method を維持します。C# extractor contract version で古い row を stale と判定し、extractor の focused test と実 index の exact-name test により #496 / #4413 で扱った continuation regression の再発を防ぎます。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs index a84c494bf..23451b9b2 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -5,7 +5,7 @@ public static partial class SymbolExtractor public const int DefaultContractVersion = 1; public const int ExpandedLanguageContractVersion = 2; public const int PythonContractVersion = 2; - public const int CSharpContractVersion = 6; + public const int CSharpContractVersion = 7; public const int DockerfileContractVersion = 2; public const int MakefileContractVersion = 2; public const int StyleAndXamlContractVersion = 2; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index 9a6449f7d..7921e911e 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -120,6 +120,7 @@ private static List ExtractCore( var getPrivateScopeColumns = scanInputs.GetPrivateScopeColumns; Func GetCSharpInsideTypeBody = scanInputs.GetCSharpInsideTypeBody; Func GetCSharpCallableParameterScope = scanInputs.GetCSharpCallableParameterScope; + Func GetCSharpDeclarationStartScope = scanInputs.GetCSharpDeclarationStartScope; var getCSharpSwitchExpressionLines = scanInputs.GetCSharpSwitchExpressionLines; var getCssQualifiedRuleAncestors = scanInputs.GetCssQualifiedRuleAncestors; var initialSymbolCapacity = EstimateSymbolListInitialCapacity(lines.Length); @@ -465,6 +466,28 @@ private static List ExtractCore( continue; } + if (lang == "csharp" + && pattern.Kind == "function" + && pattern.BodyStyle == BodyStyle.Brace + && !GetCSharpDeclarationStartScope().CanStartDeclarationAt(i, csharpGateRawStartColumn)) + { + // Method and constructor patterns are intentionally broad enough to + // recognize multi-line declarations. Do not let that merger begin from + // an invocation argument or callable-parameter continuation, even when + // a later declaration brace makes the combined text look method-shaped. + // The scope is column-aware so a real same-line sibling remains eligible. + // メソッドとコンストラクタのパターンは複数行宣言を認識するため意図的に + // 広い。後続の宣言 brace によって結合テキストがメソッド形に見えても、 + // 呼び出し引数や callable parameter の継続位置から開始してはならない。 + // 列単位の scope により、同一行の後続にある本物の sibling は維持する。 + // Fixes #4831; prevents regressions covered by #496 and #4413. + lineOffset = FindNextSameLineBraceStatementStart( + matchLine, + absoluteStartColumn + Math.Max(1, match.Length), + lang); + continue; + } + if (lang == "csharp" && pattern.Kind == "function" && HasCSharpTokenBeforeIndex(matchLine, "when", absoluteStartColumn + match.Groups["name"].Index)) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs index 72a336901..c763bcc77 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs @@ -13,6 +13,7 @@ private sealed class PatternScanInputs private JavaScriptScopePrivacyFlags[][]? _privateScopeColumns; private CSharpTypeBodyScope? _csharpInsideTypeBody; private CSharpCallableParameterScope? _csharpCallableParameterScope; + private CSharpDeclarationStartScope? _csharpDeclarationStartScope; private bool[]? _csharpSwitchExpressionLines; private bool _csharpSwitchExpressionLinesInitialized; private bool[]? _cssQualifiedRuleAncestors; @@ -119,6 +120,11 @@ public CSharpCallableParameterScope GetCSharpCallableParameterScope() => StructuralLines, GetCSharpInsideTypeBody()); + public CSharpDeclarationStartScope GetCSharpDeclarationStartScope() => + _csharpDeclarationStartScope ??= BuildCSharpDeclarationStartScope( + StructuralLines, + GetCSharpInsideTypeBody()); + private CSharpLexState[] BuildCSharpLineStartStates() => _csharpLineStartStates ??= SymbolExtractor.BuildCSharpLineStartStates(_lines); diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs index 77af442b3..d444b259c 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs @@ -227,6 +227,455 @@ private static bool HasCSharpTopLevelSemicolon(string text) return false; } + /// + /// Records positions where a C# declaration may start without inheriting an + /// unmatched expression delimiter. Block lambdas establish a nested statement + /// baseline so valid local functions inside callback bodies remain eligible. + /// C# 宣言が未閉じの式 delimiter を引き継がずに開始できる位置を記録する。 + /// block lambda は入れ子の statement baseline を作り、callback 本体内の正当な + /// local function を引き続き宣言候補として扱う。 + /// + private sealed class CSharpDeclarationStartScope + { + public static readonly CSharpDeclarationStartScope Empty = new(null, null); + + private readonly bool[]? _lineStartInsideExpressionContinuation; + private readonly List<(int Column, bool IsInsideExpressionContinuation)>?[]? _transitions; + + public CSharpDeclarationStartScope( + bool[]? lineStartInsideExpressionContinuation, + List<(int Column, bool IsInsideExpressionContinuation)>?[]? transitions) + { + _lineStartInsideExpressionContinuation = lineStartInsideExpressionContinuation; + _transitions = transitions; + } + + public bool CanStartDeclarationAt(int lineIndex, int column) + { + var isInsideExpressionContinuation = + _lineStartInsideExpressionContinuation?[lineIndex] ?? false; + var transitions = _transitions?[lineIndex]; + if (transitions == null) + return !isInsideExpressionContinuation; + + foreach (var (transitionColumn, transitionState) in transitions) + { + if (transitionColumn >= column) + break; + isInsideExpressionContinuation = transitionState; + } + + return !isInsideExpressionContinuation; + } + } + + private enum CSharpPreprocessorDirectiveKind + { + None, + If, + ElseIf, + Else, + EndIf, + Other, + } + + private readonly record struct CSharpDeclarationDelimiterState( + int ParenDepth, + int ParenBaseline, + bool[] ParenContributions, + (int ParenDepth, bool RestoresBaseline)[] BraceBaselines, + string StatementText); + + private sealed class CSharpConditionalBranchFrame + { + public CSharpConditionalBranchFrame(CSharpDeclarationDelimiterState startState) + { + StartState = startState; + } + + public CSharpDeclarationDelimiterState StartState { get; } + public List BranchEndStates { get; } = []; + public bool HasElse { get; set; } + } + + private static CSharpDeclarationStartScope BuildCSharpDeclarationStartScope( + string[] structuralLines, + CSharpTypeBodyScope typeBodyScope) + { + if (!LinesContain(structuralLines, '(')) + return CSharpDeclarationStartScope.Empty; + + bool[]? lineStartInsideExpressionContinuation = null; + List<(int Column, bool IsInsideExpressionContinuation)>?[]? transitions = null; + var statementBuffer = new StringBuilder(256); + var braceBaselines = new Stack<(int ParenDepth, bool RestoresBaseline)>(); + var parenContributions = new Stack(); + var conditionalBranchFrames = new Stack(); + var parenDepth = 0; + var parenBaseline = 0; + + for (var lineIndex = 0; lineIndex < structuralLines.Length; lineIndex++) + { + var isInsideExpressionContinuation = parenDepth > parenBaseline; + if (isInsideExpressionContinuation) + { + (lineStartInsideExpressionContinuation ??= + new bool[structuralLines.Length])[lineIndex] = true; + } + + var line = structuralLines[lineIndex]; + var directiveKind = GetCSharpPreprocessorDirectiveKind(line); + if (directiveKind != CSharpPreprocessorDirectiveKind.None) + { + switch (directiveKind) + { + case CSharpPreprocessorDirectiveKind.If: + conditionalBranchFrames.Push(new CSharpConditionalBranchFrame( + CaptureCSharpDeclarationDelimiterState( + parenDepth, + parenBaseline, + parenContributions, + braceBaselines, + statementBuffer))); + break; + case CSharpPreprocessorDirectiveKind.ElseIf: + case CSharpPreprocessorDirectiveKind.Else: + if (conditionalBranchFrames.TryPeek(out var branchFrame)) + { + branchFrame.BranchEndStates.Add( + CaptureCSharpDeclarationDelimiterState( + parenDepth, + parenBaseline, + parenContributions, + braceBaselines, + statementBuffer)); + if (directiveKind == CSharpPreprocessorDirectiveKind.Else) + branchFrame.HasElse = true; + RestoreCSharpDeclarationDelimiterState( + branchFrame.StartState, + ref parenDepth, + ref parenBaseline, + parenContributions, + braceBaselines, + statementBuffer); + } + break; + case CSharpPreprocessorDirectiveKind.EndIf: + if (conditionalBranchFrames.TryPop(out var completedFrame)) + { + completedFrame.BranchEndStates.Add( + CaptureCSharpDeclarationDelimiterState( + parenDepth, + parenBaseline, + parenContributions, + braceBaselines, + statementBuffer)); + if (!completedFrame.HasElse) + { + // With no `#else`, none of the conditional branches may be + // active, so the entry state is also a possible branch end. + // `#else` が無い場合は全 conditional branch が非採用になり得る + // ため、entry state も branch end 候補に含める。 + completedFrame.BranchEndStates.Add(completedFrame.StartState); + } + + var mergedState = MergeCSharpDeclarationDelimiterStates( + completedFrame.BranchEndStates, + completedFrame.StartState); + RestoreCSharpDeclarationDelimiterState( + mergedState, + ref parenDepth, + ref parenBaseline, + parenContributions, + braceBaselines, + statementBuffer); + } + break; + } + + // Preprocessor payloads are not C# expressions. In particular, arbitrary + // `#region` text may contain unmatched delimiters and conditional branches + // are mutually exclusive rather than one linear token stream. + // preprocessor payload は C# 式ではない。特に任意の `#region` text は + // 未閉じ delimiter を含み得て、conditional branch は一つの線形 token + // stream ではなく相互排他的である。 + statementBuffer.Append(' '); + continue; + } + + for (var column = 0; column < line.Length; column++) + { + var previousState = isInsideExpressionContinuation; + var ch = line[column]; + switch (ch) + { + case '(': + { + // A type-body attribute opener cannot contain a declaration. Exclude + // only that same-line `[Attr(` parenthesis from expression depth so + // incomplete-attribute recovery stays intact without letting unrelated + // square-bracket syntax disable continuation tracking later in the file. + // 型本体の attribute opener 内から宣言は始まらない。同一行の + // `[Attr(` 括弧だけを expression depth から除外し、未完了 attribute + // の回復を維持しつつ、後続の無関係な角括弧構文で追跡を無効化しない。 + var contributesToExpressionDepth = + !IsCSharpTypeBodyAttributeArgumentOpeningParen( + line, + lineIndex, + column, + typeBodyScope); + parenContributions.Push(contributesToExpressionDepth); + if (contributesToExpressionDepth) + parenDepth++; + statementBuffer.Append(ch); + break; + } + case ')': + if (parenContributions.Count > 0 + && parenContributions.Pop() + && parenDepth > 0) + { + parenDepth--; + } + statementBuffer.Append(ch); + break; + case '[': + statementBuffer.Append(ch); + break; + case ']': + statementBuffer.Append(ch); + break; + case '{': + { + var establishesStatementBaseline = + IsCSharpAnonymousCallableBlock(statementBuffer); + braceBaselines.Push(( + parenBaseline, + establishesStatementBaseline)); + if (establishesStatementBaseline) + parenBaseline = parenDepth; + statementBuffer.Clear(); + break; + } + case '}': + if (braceBaselines.Count > 0) + { + var priorBaseline = braceBaselines.Pop(); + if (priorBaseline.RestoresBaseline) + parenBaseline = priorBaseline.ParenDepth; + } + statementBuffer.Clear(); + break; + case ';': + statementBuffer.Clear(); + break; + default: + statementBuffer.Append(ch); + break; + } + + isInsideExpressionContinuation = parenDepth > parenBaseline; + if (isInsideExpressionContinuation != previousState) + { + var transitionsByLine = transitions ??= + new List<(int, bool)>?[structuralLines.Length]; + (transitionsByLine[lineIndex] ??= []).Add(( + column, + isInsideExpressionContinuation)); + } + } + + statementBuffer.Append(' '); + } + + return new CSharpDeclarationStartScope( + lineStartInsideExpressionContinuation, + transitions); + } + + private static CSharpPreprocessorDirectiveKind GetCSharpPreprocessorDirectiveKind(string line) + { + var cursor = SkipWhitespace(line, 0); + if (cursor >= line.Length || line[cursor] != '#') + return CSharpPreprocessorDirectiveKind.None; + + cursor = SkipWhitespace(line, cursor + 1); + var directiveStart = cursor; + while (cursor < line.Length && char.IsLetter(line[cursor])) + cursor++; + + var directive = line[directiveStart..cursor]; + return directive switch + { + "if" => CSharpPreprocessorDirectiveKind.If, + "elif" => CSharpPreprocessorDirectiveKind.ElseIf, + "else" => CSharpPreprocessorDirectiveKind.Else, + "endif" => CSharpPreprocessorDirectiveKind.EndIf, + _ => CSharpPreprocessorDirectiveKind.Other, + }; + } + + private static CSharpDeclarationDelimiterState CaptureCSharpDeclarationDelimiterState( + int parenDepth, + int parenBaseline, + Stack parenContributions, + Stack<(int ParenDepth, bool RestoresBaseline)> braceBaselines, + StringBuilder statementBuffer) => + new( + parenDepth, + parenBaseline, + parenContributions.ToArray(), + braceBaselines.ToArray(), + statementBuffer.ToString()); + + private static CSharpDeclarationDelimiterState MergeCSharpDeclarationDelimiterStates( + IReadOnlyList states, + CSharpDeclarationDelimiterState fallback) + { + if (states.Count == 0) + return fallback; + + var parenContributions = GetCommonCSharpDeclarationStackBottom( + states.Select(state => state.ParenContributions).ToArray()); + var braceBaselines = GetCommonCSharpDeclarationStackBottom( + states.Select(state => state.BraceBaselines).ToArray()); + var parenDepth = parenContributions.Count(contributes => contributes); + var parenBaseline = states.All(state => state.ParenBaseline == states[0].ParenBaseline) + ? Math.Min(states[0].ParenBaseline, parenDepth) + : Math.Min(fallback.ParenBaseline, parenDepth); + var statementText = states.All( + state => string.Equals( + state.StatementText, + states[0].StatementText, + StringComparison.Ordinal)) + ? states[0].StatementText + : states.All(state => IsCSharpAnonymousCallableBlock(state.StatementText)) + ? "=>" + : fallback.StatementText; + + return new CSharpDeclarationDelimiterState( + parenDepth, + parenBaseline, + parenContributions, + braceBaselines, + statementText); + } + + private static T[] GetCommonCSharpDeclarationStackBottom(T[][] topFirstStacks) + { + if (topFirstStacks.Length == 0) + return []; + + var first = topFirstStacks[0]; + var commonCount = 0; + var maxCommonCount = topFirstStacks.Min(stack => stack.Length); + while (commonCount < maxCommonCount) + { + var firstItem = first[first.Length - commonCount - 1]; + if (topFirstStacks.Skip(1).Any( + stack => !EqualityComparer.Default.Equals( + firstItem, + stack[stack.Length - commonCount - 1]))) + { + break; + } + + commonCount++; + } + + return commonCount == 0 ? [] : first[^commonCount..]; + } + + private static void RestoreCSharpDeclarationDelimiterState( + CSharpDeclarationDelimiterState state, + ref int parenDepth, + ref int parenBaseline, + Stack parenContributions, + Stack<(int ParenDepth, bool RestoresBaseline)> braceBaselines, + StringBuilder statementBuffer) + { + parenDepth = state.ParenDepth; + parenBaseline = state.ParenBaseline; + RestoreCSharpDeclarationDelimiterStack( + parenContributions, + state.ParenContributions); + RestoreCSharpDeclarationDelimiterStack( + braceBaselines, + state.BraceBaselines); + statementBuffer.Clear(); + statementBuffer.Append(state.StatementText); + } + + private static void RestoreCSharpDeclarationDelimiterStack( + Stack destination, + T[] topFirstItems) + { + destination.Clear(); + for (var index = topFirstItems.Length - 1; index >= 0; index--) + destination.Push(topFirstItems[index]); + } + + private static bool IsCSharpTypeBodyAttributeArgumentOpeningParen( + string line, + int lineIndex, + int column, + CSharpTypeBodyScope typeBodyScope) + { + if (!typeBodyScope.IsInsideTypeBodyAt(lineIndex, column)) + return false; + + var cursor = SkipWhitespace(line, 0); + while (cursor < column && line[cursor] == '[') + { + var closeBracket = line.IndexOf(']', cursor + 1, column - cursor - 1); + if (closeBracket < 0) + return true; + cursor = SkipWhitespace(line, closeBracket + 1); + } + + return false; + } + + private static bool IsCSharpAnonymousCallableBlock(StringBuilder statementBuffer) + { + return IsCSharpAnonymousCallableBlock(statementBuffer.ToString()); + } + + private static bool IsCSharpAnonymousCallableBlock(string statementText) + { + var text = statementText.TrimEnd(); + if (text.EndsWith("=>", StringComparison.Ordinal)) + return true; + + const string delegateKeyword = "delegate"; + var delegateIndex = text.LastIndexOf(delegateKeyword, StringComparison.Ordinal); + if (delegateIndex < 0 + || (delegateIndex > 0 + && (text[delegateIndex - 1] == '@' + || text[delegateIndex - 1] == '_' + || char.IsLetterOrDigit(text[delegateIndex - 1])))) + { + return false; + } + + var suffix = text[(delegateIndex + delegateKeyword.Length)..].Trim(); + if (suffix.Length == 0) + return true; + if (suffix[0] != '(' || suffix[^1] != ')') + return false; + + var depth = 0; + foreach (var ch in suffix) + { + if (ch == '(') + depth++; + else if (ch == ')' && --depth < 0) + return false; + } + + return depth == 0; + } + private sealed class CSharpCallableParameterScope { public static readonly CSharpCallableParameterScope Empty = new(null, null); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4831Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4831Tests.cs new file mode 100644 index 000000000..40a32b69a --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4831Tests.cs @@ -0,0 +1,74 @@ +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void RunSymbols_CSharpExactNameRejectsDeclarationContinuationPhantoms_Issue4831() + { + var projectRoot = TestProjectHelper.CreateTempProject( + "cdidx_symbols_csharp_declaration_continuation_issue4831"); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Fixture.cs", + Issue4831CSharpFixture.Source); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--json", "--quiet"], + _jsonOptions)); + var (definitionExitCode, definitionStdout, definitionStderr) = CaptureConsole( + () => QueryCommandRunner.RunSymbols( + [ + "GetIndexedLanguageCount", + "--db", dbPath, + "--json", + "--exact-name", + "--lang", "csharp", + ], + _jsonOptions)); + var (outExitCode, outStdout, outStderr) = CaptureConsole( + () => QueryCommandRunner.RunSymbols( + ["out", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp"], + _jsonOptions)); + var (paramsExitCode, paramsStdout, paramsStderr) = CaptureConsole( + () => QueryCommandRunner.RunSymbols( + ["params", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp"], + _jsonOptions)); + + var definitionRows = ParseJsonLines(definitionStdout); + + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + Assert.Equal(CommandExitCodes.Success, definitionExitCode); + Assert.Equal(string.Empty, definitionStderr); + Assert.Equal(CommandExitCodes.Success, outExitCode); + Assert.Equal(string.Empty, outStderr); + Assert.Equal(CommandExitCodes.Success, paramsExitCode); + Assert.Equal(string.Empty, paramsStderr); + + var definition = Assert.Single(definitionRows); + Assert.Equal( + "function", + definition.RootElement.GetProperty("kind").GetString()); + Assert.Equal( + 70, + definition.RootElement.GetProperty("start_line").GetInt32()); + Assert.Equal( + 75, + definition.RootElement.GetProperty("end_line").GetInt32()); + Assert.Equal( + "Scanner", + definition.RootElement.GetProperty("container_name").GetString()); + Assert.Equal(string.Empty, outStdout); + Assert.Equal(string.Empty, paramsStdout); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +} diff --git a/tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs b/tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs new file mode 100644 index 000000000..7ee1c822e --- /dev/null +++ b/tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs @@ -0,0 +1,262 @@ +using CodeIndex.Indexer; + +namespace CodeIndex.Tests; + +internal static class Issue4831CSharpFixture +{ + internal const string Source = """ + using System; + using System.Collections.Generic; + using System.Linq; + + namespace Issue4831Fixture; + + public delegate bool TryParser( + string text, + out int value); + + public sealed class Scanner + { + public Scanner( + string name, + in int seed) + { + } + + public IReadOnlyList Build( + IReadOnlyDictionary counts, + IEnumerable languages, + ref int total, + out int matched, + in bool include, + params string[] filters) + { + var entries = languages.Select(language => new LanguageEntry( + language.ExactNames.OrderBy(value => value, StringComparer.Ordinal).ToList(), + language.Prefixes.OrderBy(value => value, StringComparer.Ordinal).ToList(), + language.Patterns.OrderBy(value => value, StringComparer.Ordinal).ToList(), + language.Symbols, + GetIndexedLanguageCount(counts, language.Name))).ToList(); + + Use( + () => + { + static int CallbackLocal( + int value) + { + return value; + } + + return CallbackLocal(1); + }); + + Use( + delegate + { + static int DelegateLocal( + int value) + { + return value; + } + + return DelegateLocal(2); + }); + + static long LocalCount( + IReadOnlyDictionary localCounts, + string language) + { + return localCounts.TryGetValue(language, out var count) ? count : 0; + } + + matched = entries.Count; + total += matched; + return entries; + } + + private static long GetIndexedLanguageCount( + IReadOnlyDictionary counts, + string language) + { + return counts.TryGetValue(language, out var count) ? count : 0; + } + + private static void Use( + Func callback) + { + _ = callback(); + } + + private static bool TryRecover( + string[] lines, + int startLine, + int endLine, + out (int Line, int Column) recoveredPosition) + { + recoveredPosition = default; + return true; + } + } + + public sealed record LanguageInfo( + string Name, + IReadOnlyList ExactNames, + IReadOnlyList Prefixes, + IReadOnlyList Patterns, + bool Symbols); + + public sealed record LanguageEntry( + IReadOnlyList ExactNames, + IReadOnlyList Prefixes, + IReadOnlyList Patterns, + bool Symbols, + long Count); + """; + + internal const string PreprocessorSource = """ + public sealed class ConditionalScanner + { + #if FEATURE + public void FeatureMethod( + #else + public void FallbackMethod( + #endif + int value, + out int result) + { + result = value; + } + + #region Handlers ( + public void InsideRegion() + { + } + #endregion + + public void AfterRegion() + { + } + } + + public sealed class DisabledBranchScanner + { + #if true + #else + Broken( + #endif + public void AfterInactiveElse() + { + } + + #if false + Broken( + #endif + public void AfterConditionalWithoutElse() + { + } + } + """; +} + +public partial class SymbolExtractorTests +{ + [Fact] + public void Extract_CSharp_RejectsInvocationAndParameterContinuationsAsFunctions_Issue4831() + { + var symbols = SymbolExtractor.Extract(1, "csharp", Issue4831CSharpFixture.Source); + + var indexedCount = Assert.Single(symbols.Where( + symbol => symbol.Kind == "function" + && symbol.Name == "GetIndexedLanguageCount")); + Assert.Equal(70, indexedCount.StartLine); + Assert.Equal(75, indexedCount.EndLine); + Assert.Equal(73, indexedCount.BodyStartLine); + Assert.Equal(75, indexedCount.BodyEndLine); + Assert.Equal("class", indexedCount.ContainerKind); + Assert.Equal("Scanner", indexedCount.ContainerName); + + var constructor = Assert.Single(symbols.Where( + symbol => symbol.Kind == "function" && symbol.Name == "Scanner")); + Assert.Equal((13, 17), (constructor.StartLine, constructor.EndLine)); + Assert.Equal("class", constructor.ContainerKind); + Assert.Equal("Scanner", constructor.ContainerName); + + var build = Assert.Single(symbols.Where( + symbol => symbol.Kind == "function" && symbol.Name == "Build")); + Assert.Equal((19, 68), (build.StartLine, build.EndLine)); + Assert.Equal((26, 68), (build.BodyStartLine, build.BodyEndLine)); + Assert.Equal("class", build.ContainerKind); + Assert.Equal("Scanner", build.ContainerName); + + var callbackLocal = Assert.Single(symbols.Where( + symbol => symbol.Kind == "function" && symbol.Name == "CallbackLocal")); + Assert.Equal((37, 41), (callbackLocal.StartLine, callbackLocal.EndLine)); + Assert.Equal((39, 41), (callbackLocal.BodyStartLine, callbackLocal.BodyEndLine)); + Assert.Equal("class", callbackLocal.ContainerKind); + Assert.Equal("Scanner", callbackLocal.ContainerName); + + var delegateLocal = Assert.Single(symbols.Where( + symbol => symbol.Kind == "function" && symbol.Name == "DelegateLocal")); + Assert.Equal((49, 53), (delegateLocal.StartLine, delegateLocal.EndLine)); + Assert.Equal((51, 53), (delegateLocal.BodyStartLine, delegateLocal.BodyEndLine)); + Assert.Equal("class", delegateLocal.ContainerKind); + Assert.Equal("Scanner", delegateLocal.ContainerName); + + var localCount = Assert.Single(symbols.Where( + symbol => symbol.Kind == "function" && symbol.Name == "LocalCount")); + Assert.Equal((58, 63), (localCount.StartLine, localCount.EndLine)); + Assert.Equal((61, 63), (localCount.BodyStartLine, localCount.BodyEndLine)); + Assert.Equal("class", localCount.ContainerKind); + Assert.Equal("Scanner", localCount.ContainerName); + + var tryParser = Assert.Single(symbols.Where( + symbol => symbol.Kind == "delegate" && symbol.Name == "TryParser")); + Assert.Equal((7, 7), (tryParser.StartLine, tryParser.EndLine)); + Assert.Equal("namespace", tryParser.ContainerKind); + Assert.Equal("Issue4831Fixture", tryParser.ContainerName); + + var tryRecover = Assert.Single(symbols.Where( + symbol => symbol.Kind == "function" && symbol.Name == "TryRecover")); + Assert.Equal((83, 91), (tryRecover.StartLine, tryRecover.EndLine)); + Assert.Equal((88, 91), (tryRecover.BodyStartLine, tryRecover.BodyEndLine)); + Assert.Equal("class", tryRecover.ContainerKind); + Assert.Equal("Scanner", tryRecover.ContainerName); + + Assert.DoesNotContain( + symbols, + symbol => symbol.Kind == "function" + && symbol.Name is "out" or "ref" or "in" or "params"); + } + + [Fact] + public void Extract_CSharp_DeclarationScopeHandlesPreprocessorBranchesAndPayloads_Issue4831() + { + var symbols = SymbolExtractor.Extract( + 1, + "csharp", + Issue4831CSharpFixture.PreprocessorSource); + + Assert.Contains( + symbols, + symbol => symbol.Kind == "function" && symbol.Name == "FeatureMethod"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "function" && symbol.Name == "FallbackMethod"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "function" && symbol.Name == "InsideRegion"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "function" && symbol.Name == "AfterRegion"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "function" && symbol.Name == "AfterInactiveElse"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "function" + && symbol.Name == "AfterConditionalWithoutElse"); + Assert.DoesNotContain( + symbols, + symbol => symbol.Kind == "function" && symbol.Name == "out"); + } +}