diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 49aba91fc..2124777b8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -494,6 +494,10 @@ Container ownership follows the same contract. If references repeatedly resolve an extracted declaration by name and source range, index candidates by name once and scan only that name's ordered range list. Preserve first-candidate behavior for duplicate names and keep the index local to one extraction call. +For C#, both symbol assignment and reference resolution prefer the narrowest +active callable range, including `test.method` and nested local functions, before +an enclosing type. Named lambdas without a complete body range attach to that +enclosing callable and do not become reference containers. Symbol container assignment keeps one reusable path buffer for the sorted per-symbol walk. Enumerate the active stack into that buffer and reverse it to outer-to-inner order; do not materialize both a stack array and a fresh path list @@ -3648,6 +3652,10 @@ container ownership にも同じ契約を適用する。reference が extracted source range で繰り返し解決する場合は、candidate を name ごとに一度だけ索引化し、その name の ordered range list だけを走査する。duplicate name の first-candidate behavior を維持し、index は 1 回の extraction call 内だけに保持する。 +C# では symbol assignment と reference resolution の両方が、enclosing type より先に、 +`test.method` や nested local function を含む最も狭い active callable range を選ぶ。 +完全な body range を持たない named lambda はその enclosing callable に所属し、 +reference container にはしない。 symbol の container assignment は、sort 済みの per-symbol walk で1つの path buffer を再利用する。 active stack を buffer へ列挙して outer-to-inner 順に反転し、深く nest した生成ファイルの member ごとに stack array と新しい path list の両方を実体化してはならない。 diff --git a/README.md b/README.md index 602c84fdb..8fb848a92 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ membership checks reuse file-local lookup sets instead of rescanning every extracted symbol for each call site. C# declaration-container resolution and GitHub Actions job ownership likewise use name-indexed candidates instead of a full container scan per reference. +For C#, symbol and reference ownership prefers the narrowest active callable, +including test methods and nested local functions, before the enclosing type. Dense Python import, GitHub Actions dependency, JSON path, and Fortran procedure lists are scanned in place instead of allocating temporary split arrays. Python PEP 695 aliases and `TypeAlias` / `NewType` declarations are persisted as @@ -550,6 +552,8 @@ reference extraction でも、C# property と Python import / class の反復 me call site ごとに全 extracted symbol を再走査せず、file-local な lookup set を再利用します。 C# declaration-container 解決と GitHub Actions の job ownership も同様に、reference ごとの 全 container 走査ではなく name-indexed candidate を使います。 +C# の symbol / reference ownership は、enclosing type より先に、test method や nested local +function を含む最も狭い active callable を選びます。 密な Python import、GitHub Actions dependency、JSON path、Fortran procedure list は、 一時的な split array を作らず入力上で直接走査します。 Python の PEP 695 alias と `TypeAlias` / `NewType` declaration は `typealias`、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 4bb74d20a..00eb30d65 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -79,6 +79,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `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. + C# callable-containment fixtures should cover block-bodied test methods, local and nested local functions, named lambdas, expression-bodied members, and nested types together, asserting both symbol parents and call-reference containers. 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. @@ -975,6 +976,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `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 の両方を観測可能にします。 + C# の callable containment fixture では、block body の test method、local / nested local function、named lambda、expression-bodied member、nested type を同居させ、symbol の親と call reference の container の両方を検証してください。 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/4840.fixed.md b/changelog.d/unreleased/4840.fixed.md new file mode 100644 index 000000000..b393245a1 --- /dev/null +++ b/changelog.d/unreleased/4840.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 4840 +affected: + - src/CodeIndex/Indexer/CallableContainerSelection.cs + - src/CodeIndex/Indexer/References + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs + - tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs + - tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- Preserve the innermost C# callable as the symbol and reference container for test methods, local functions, nested local functions, and named lambdas, while retaining the enclosing callable or type fallback for incomplete inner ranges. This restores the containment behavior expected by #233 and #4417. + +## 日本語 + +- C# の test method、local function、nested local function、named lambda について、symbol と reference の container に最内側の callable を保持し、不完全な内側 range では enclosing callable または type への fallback を維持しました。これにより #233 と #4417 で想定された containment を復元しました。 diff --git a/src/CodeIndex/Indexer/CallableContainerSelection.cs b/src/CodeIndex/Indexer/CallableContainerSelection.cs new file mode 100644 index 000000000..afbcdb5df --- /dev/null +++ b/src/CodeIndex/Indexer/CallableContainerSelection.cs @@ -0,0 +1,42 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static class CallableContainerSelection +{ + internal static bool IsCallableKind(string? kind) + => kind is "function" + or "test.method" + or "operator" + or "lambda" + or "async_function" + or "generator" + or "async_generator"; + + internal static int CompareInnermost( + SymbolRecord left, + int leftOriginalIndex, + SymbolRecord right, + int rightOriginalIndex, + bool preferCallable) + { + var compare = GetSpanLength(left).CompareTo(GetSpanLength(right)); + if (compare != 0) + return compare; + + if (preferCallable) + { + compare = GetCallableRank(left.Kind).CompareTo(GetCallableRank(right.Kind)); + if (compare != 0) + return compare; + } + + return leftOriginalIndex.CompareTo(rightOriginalIndex); + } + + internal static int GetSpanLength(SymbolRecord symbol) + => (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); + + private static int GetCallableRank(string? kind) + => IsCallableKind(kind) ? 0 : 1; +} diff --git a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs index 1a2c355c3..e1f995bf3 100644 --- a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs @@ -218,7 +218,10 @@ private static List ExtractFunctionalLanguageReferences(Referen var seen = CreateReferenceSeenSet(preparedInput.Lines.Length); var symbolsByLine = BuildFunctionalSymbolsByLine(request.Symbols, request.ReportDiagnostic); var containerResolver = new InnermostContainerResolver( - BuildReferenceContainerCandidates(request.Symbols, request.ReportDiagnostic)); + BuildReferenceContainerCandidates( + request.Language, + request.Symbols, + request.ReportDiagnostic)); var state = new FunctionalReferenceState(); for (var index = 0; index < preparedInput.Lines.Length; index++) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs index 4a1faa21e..bf5c4ea49 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs @@ -125,7 +125,7 @@ internal static bool IsSafetyCapDiagnosticKind(string kind) }; private static bool IsFunctionLikeSymbolKind(string kind) - => kind is "function" or "operator" or "lambda" or "async_function" or "generator" or "async_generator"; + => CallableContainerSelection.IsCallableKind(kind); private static readonly Dictionary> LanguageSpecificIgnoredCallNames = new(StringComparer.Ordinal) { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs index d15db5889..cf2ac87aa 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs @@ -56,24 +56,28 @@ internal static void AddTypeReferenceSegment( internal sealed class InnermostContainerResolver { private readonly IReadOnlyList candidates; - private readonly List<(SymbolRecord Symbol, int SpanLength, int OriginalIndex)>? candidatesByStart; + private readonly List<(SymbolRecord Symbol, int OriginalIndex)>? candidatesByStart; private SortedSet? activeContainers; private int nextCandidateIndex; private int currentLine; private int? cachedLine; private SymbolRecord? cachedContainer; + private readonly bool preferCallable; - internal InnermostContainerResolver(IReadOnlyList candidates) + internal InnermostContainerResolver( + IReadOnlyList candidates, + bool preferCallable = false) { this.candidates = candidates; + this.preferCallable = preferCallable; if (candidates.Count == 0) return; - candidatesByStart = new List<(SymbolRecord Symbol, int SpanLength, int OriginalIndex)>(candidates.Count); + candidatesByStart = new List<(SymbolRecord Symbol, int OriginalIndex)>(candidates.Count); for (var index = 0; index < candidates.Count; index++) { var symbol = candidates[index]; - candidatesByStart.Add((symbol, GetContainerSpanLength(symbol), index)); + candidatesByStart.Add((symbol, index)); } candidatesByStart.Sort(CompareCandidatesByStart); @@ -106,7 +110,10 @@ private void AdvanceTo(int lineNumber) && candidatesByStart[nextCandidateIndex].Symbol.BodyStartLine!.Value <= lineNumber) { var candidate = candidatesByStart[nextCandidateIndex]; - (activeContainers ??= []).Add(new ActiveContainer(candidate.Symbol, candidate.SpanLength, candidate.OriginalIndex)); + (activeContainers ??= []).Add(new ActiveContainer( + candidate.Symbol, + candidate.OriginalIndex, + preferCallable)); nextCandidateIndex++; } @@ -121,12 +128,9 @@ private void AdvanceTo(int lineNumber) return container; } - private static int GetContainerSpanLength(SymbolRecord symbol) => - (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); - private static int CompareCandidatesByStart( - (SymbolRecord Symbol, int SpanLength, int OriginalIndex) left, - (SymbolRecord Symbol, int SpanLength, int OriginalIndex) right) + (SymbolRecord Symbol, int OriginalIndex) left, + (SymbolRecord Symbol, int OriginalIndex) right) { var compare = left.Symbol.BodyStartLine!.Value.CompareTo(right.Symbol.BodyStartLine!.Value); if (compare != 0) @@ -136,23 +140,21 @@ private static int CompareCandidatesByStart( if (compare != 0) return compare; - compare = left.SpanLength.CompareTo(right.SpanLength); - if (compare != 0) - return compare; - return left.OriginalIndex.CompareTo(right.OriginalIndex); } - private readonly record struct ActiveContainer(SymbolRecord Symbol, int SpanLength, int OriginalIndex) : IComparable + private readonly record struct ActiveContainer( + SymbolRecord Symbol, + int OriginalIndex, + bool PreferCallable) : IComparable { public int CompareTo(ActiveContainer other) - { - var spanComparison = SpanLength.CompareTo(other.SpanLength); - if (spanComparison != 0) - return spanComparison; - - return OriginalIndex.CompareTo(other.OriginalIndex); - } + => CallableContainerSelection.CompareInnermost( + Symbol, + OriginalIndex, + other.Symbol, + other.OriginalIndex, + PreferCallable); } } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index 4e3f2c257..6f5881cf8 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -115,10 +115,13 @@ internal static List ExtractCore( ? BuildCobolCallableSymbols(symbols) : null; var containerCandidates = BuildReferenceContainerCandidates( + language, symbols, request.ReportDiagnostic); var containerResolver = - new InnermostContainerResolver(containerCandidates); + new InnermostContainerResolver( + containerCandidates, + preferCallable: language == "csharp"); if (language == "solidity") { return ExtractSolidityReferences( diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs index cda679460..b882a08fb 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs @@ -36,7 +36,7 @@ private sealed class CoreExtractionLookups private HashSet<(string Container, string Name)>? csharpProperties; private bool csharpPropertiesResolved; private Dictionary>? csharpContainerCandidatesByName; - private List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)>? recordPrimaryCtorRanges; + private List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container, SymbolRecord Owner)>? recordPrimaryCtorRanges; private bool recordPrimaryCtorRangesResolved; private ( IReadOnlyDictionary ByContainingType, @@ -254,7 +254,7 @@ internal IReadOnlyList GetEnclosingTypeCandidates() return powershellSplatAssignments!; } - internal List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)> GetRecordPrimaryCtorRanges() + internal List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container, SymbolRecord Owner)> GetRecordPrimaryCtorRanges() { if (!recordPrimaryCtorRangesResolved) { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs index 96f7eabb9..3a8c1fe61 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs @@ -240,25 +240,44 @@ private static CSharpMultiLineTypePatternState EmitCoreReferenceLines( { if (language == "csharp") { + SymbolRecord? primaryCtorOwner = null; foreach (var ( rangeStart, rangeStartColumn, rangeEnd, rangeEndColumn, - syntheticRecordCtor) in + syntheticRecordCtor, + owner) in lookups.GetRecordPrimaryCtorRanges()) { + if (ReferenceEquals(container, syntheticRecordCtor) + || (container?.Kind == "function" + && container.FileId == syntheticRecordCtor.FileId + && container.StartLine == syntheticRecordCtor.StartLine + && (container.StartLine < rangeEnd + || (container.StartColumn is int containerStartColumn + && containerStartColumn < rangeEndColumn)) + && string.Equals( + container.Name, + syntheticRecordCtor.Name, + StringComparison.Ordinal))) + { + primaryCtorOwner ??= owner; + } if (lineNumber < rangeStart || lineNumber > rangeEnd) continue; if (lineNumber == rangeStart && column < rangeStartColumn) { - continue; + return owner; } if (lineNumber == rangeEnd && column >= rangeEndColumn) continue; return syntheticRecordCtor; } + + if (primaryCtorOwner != null) + return primaryCtorOwner; } if (javaSameLineCtor != null) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs index 101f88a3f..69ff1adbf 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs @@ -301,6 +301,7 @@ private static StringComparer GetDefinitionNamesComparer(string language) : StringComparer.Ordinal; private static IReadOnlyList BuildReferenceContainerCandidates( + string language, IReadOnlyList symbols, Action? reportDiagnostic) => BuildBoundedContainerCandidates( @@ -311,7 +312,8 @@ private static IReadOnlyList BuildReferenceContainerCandidates( || symbol.Kind == "object" || symbol.Kind == "property" || symbol.Kind == "heading" || symbol.Kind == "class_hook"), "reference_container_candidate_budget_exceeded", "Reference container lookup retained the highest-priority bounded candidate set and skipped additional candidates.", - reportDiagnostic); + reportDiagnostic, + preferCallable: language == "csharp"); private static IReadOnlyList? BuildCSharpXmlDocAttachmentScopeCandidates( string language, @@ -444,7 +446,8 @@ private static IReadOnlyList BuildBoundedContainerCandidates( Func predicate, string diagnosticKind, string diagnosticMessage, - Action? reportDiagnostic) + Action? reportDiagnostic, + bool preferCallable = false) { var limit = GetSafetyLimits().MaxContainerCandidates; List? candidates = null; @@ -474,7 +477,8 @@ private static IReadOnlyList BuildBoundedContainerCandidates( if (candidates is not { Count: > 0 }) return Array.Empty(); - candidates.Sort(CompareReferenceContainerCandidateSortEntries); + candidates.Sort((left, right) => + CompareReferenceContainerCandidateSortEntries(left, right, preferCallable)); var sorted = new SymbolRecord[candidates.Count]; for (var index = 0; index < candidates.Count; index++) @@ -487,16 +491,17 @@ private static IReadOnlyList BuildBoundedContainerCandidates( private static int CompareReferenceContainerCandidateSortEntries( ReferenceContainerCandidateSortEntry left, - ReferenceContainerCandidateSortEntry right) - { - var compare = left.SpanLength.CompareTo(right.SpanLength); - return compare != 0 - ? compare - : left.OriginalIndex.CompareTo(right.OriginalIndex); - } + ReferenceContainerCandidateSortEntry right, + bool preferCallable) + => CallableContainerSelection.CompareInnermost( + left.Symbol, + left.OriginalIndex, + right.Symbol, + right.OriginalIndex, + preferCallable); private static int GetReferenceContainerCandidateSpanLength(SymbolRecord symbol) - => (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); + => CallableContainerSelection.GetSpanLength(symbol); private static void ReportReferenceLookupBudgetHit( Action? reportDiagnostic, diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs index 34a56f7dc..c297c77d8 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs @@ -118,7 +118,7 @@ private static void EmitMethodGroupReferences( /// 宣言ヘッダーの範囲(end line は終端 `;` / `{` のカラムまで)だけ合成 ctor に差し替えることで、 /// 同一行 braced body の呼び出しや後続メソッドは本来の container に残る。 /// - private static List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)> BuildCSharpPrimaryCtorContainers( + private static List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container, SymbolRecord Owner)> BuildCSharpPrimaryCtorContainers( string language, IReadOnlyList symbols, string[] structuralLines) @@ -126,7 +126,7 @@ private static void EmitMethodGroupReferences( if (language != "csharp") return []; - var ranges = new List<(int, int, int, int, SymbolRecord)>(4); + var ranges = new List<(int, int, int, int, SymbolRecord, SymbolRecord)>(4); foreach (var symbol in symbols) { // SymbolExtractor stores C# records as Kind=class and C# 12 structs as Kind=struct. @@ -180,7 +180,7 @@ private static void EmitMethodGroupReferences( Visibility = symbol.Visibility, }; - ranges.Add((symbol.StartLine, startColumn, headerEndLine, headerEndColumn, synthetic)); + ranges.Add((symbol.StartLine, startColumn, headerEndLine, headerEndColumn, synthetic, symbol)); } return ranges; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs index 660b1bf36..fa1cf7796 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs @@ -89,7 +89,8 @@ private static void AssignContainers( return; } - if (!ContainsContainerCandidates(symbols)) + var includeCallableContainers = getCSharpLineStartStates != null; + if (!ContainsContainerCandidates(symbols, includeCallableContainers)) { foreach (var symbol in symbols) AssignTopLevelFamilyKey(symbol); @@ -167,7 +168,7 @@ private static void AssignContainers( symbol.FamilyKey ??= BuildSelfFamilyKey(symbol, containerPath); - if (CanContainSymbols(symbol)) + if (CanContainSymbols(symbol, includeCallableContainers)) stack.Push(symbol); } } @@ -175,11 +176,13 @@ private static void AssignContainers( private static void AssignTopLevelFamilyKey(SymbolRecord symbol) => symbol.FamilyKey ??= BuildSelfFamilyKey(symbol, Array.Empty()); - private static bool ContainsContainerCandidates(IReadOnlyList symbols) + private static bool ContainsContainerCandidates( + IReadOnlyList symbols, + bool includeCallableContainers) { foreach (var symbol in symbols) { - if (CanContainSymbols(symbol)) + if (CanContainSymbols(symbol, includeCallableContainers)) return true; } @@ -357,8 +360,16 @@ private static bool TryGetObjCCategoryDisplayName(string objcDeclaration, string return true; } - private static bool CanContainSymbols(SymbolRecord symbol) + private static bool CanContainSymbols(SymbolRecord symbol, bool includeCallableContainers) { + if (includeCallableContainers + && CallableContainerSelection.IsCallableKind(symbol.Kind) + && symbol.BodyStartLine != null + && symbol.BodyEndLine != null) + { + return true; + } + if (symbol.Kind == "function" && symbol.ContainerKind == "enum" && symbol.BodyStartLine != null @@ -399,6 +410,16 @@ private static bool ContainsSymbol( && container.Signature.Contains(candidate.Signature, StringComparison.Ordinal); } + if (TryContainsCSharpCallableEndLineSymbol( + container, + candidate, + rawLines, + getCSharpLineStartStates, + out var containsCallableEndLineSymbol)) + { + return containsCallableEndLineSymbol; + } + if (candidate.StartLine >= container.BodyStartLine && candidate.StartLine <= container.BodyEndLine && candidate.StartLine > container.StartLine) @@ -409,6 +430,66 @@ private static bool ContainsSymbol( return IsInsideCSharpClosingBraceLineContainer(container, candidate, rawLines, getCSharpLineStartStates); } + private static bool TryContainsCSharpCallableEndLineSymbol( + SymbolRecord container, + SymbolRecord candidate, + string[]? rawLines, + Func? getCSharpLineStartStates, + out bool contains) + { + contains = false; + if (rawLines == null + || getCSharpLineStartStates == null + || !CallableContainerSelection.IsCallableKind(container.Kind) + || container.BodyEndLine == null + || candidate.Signature == null + || candidate.StartLine != container.BodyEndLine.Value + || candidate.StartLine <= container.StartLine) + { + return false; + } + + if (candidate.StartLine < container.EndLine) + { + contains = true; + return true; + } + + var lineIndex = candidate.StartLine - 1; + if (lineIndex < 0 || lineIndex >= rawLines.Length) + return false; + + var lineStartStates = getCSharpLineStartStates(); + if (lineIndex >= lineStartStates.Length) + return false; + + var candidateColumn = FindSignatureOccurrenceStartColumn( + rawLines[lineIndex], + candidate.Signature, + candidate.SameLineSignatureOccurrenceIndex ?? 0, + lineStartStates[lineIndex]); + if (candidateColumn < 0) + return false; + + if (container.Signature?.Contains("=>", StringComparison.Ordinal) == true) + { + var sanitizedLine = LexCSharpLine(rawLines[lineIndex], lineStartStates[lineIndex]).SanitizedLine; + var terminatorColumn = sanitizedLine.IndexOf(';'); + if (terminatorColumn < 0) + return false; + + contains = candidateColumn < terminatorColumn; + return true; + } + + var closingBraceColumn = FindCSharpClosingBraceColumnOnContainerEndLine(container, rawLines); + if (closingBraceColumn < 0) + return false; + + contains = candidateColumn < closingBraceColumn; + return true; + } + private static bool TryContainsCSharpSameLineSymbolByRawLine( SymbolRecord container, SymbolRecord candidate, diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index 051a1fdf2..3f6cbbe4b 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -583,6 +583,112 @@ public class Calc Assert.DoesNotContain(computeRefs, r => r.ContainerKind == "class"); } + [Fact] + public void Extract_CsharpCallableContainment_PrefersInnermostCallableRanges_Issue4840() + { + const string content = """ + public class Host + { + [Fact] + public void BlockTest() + { + TestCall(); + + void Local() + { + LocalCall(); + + void Nested() + { + NestedCall(); + } + } + + Func blockLambda = () => + { + LambdaCall(); + return 1; + }; + } + + public int ExpressionBody() => ExpressionCall(); + + public class Inner + { + public void NestedTypeMethod() + { + NestedTypeCall(); + } + } + } + """; + + var (symbols, references) = ExtractSymbolsAndReferences("csharp", content); + + Assert.Contains(symbols, symbol => + symbol.Kind == "function" + && symbol.Name == "Local" + && symbol.ContainerKind == "test.method" + && symbol.ContainerName == "BlockTest"); + Assert.Contains(symbols, symbol => + symbol.Kind == "function" + && symbol.Name == "Nested" + && symbol.ContainerKind == "function" + && symbol.ContainerName == "Local"); + Assert.Contains(symbols, symbol => + symbol.Kind == "lambda" + && symbol.Name == "blockLambda" + && symbol.ContainerKind == "test.method" + && symbol.ContainerName == "BlockTest"); + + AssertReferenceContainer("TestCall", "test.method", "BlockTest"); + AssertReferenceContainer("LocalCall", "function", "Local"); + AssertReferenceContainer("NestedCall", "function", "Nested"); + AssertReferenceContainer("LambdaCall", "test.method", "BlockTest"); + AssertReferenceContainer("ExpressionCall", "function", "ExpressionBody"); + AssertReferenceContainer("NestedTypeCall", "function", "NestedTypeMethod"); + + void AssertReferenceContainer(string symbolName, string containerKind, string containerName) + { + var reference = Assert.Single(references.Where(r => + r.SymbolName == symbolName + && r.ReferenceKind == "call")); + Assert.Equal(containerKind, reference.ContainerKind); + Assert.Equal(containerName, reference.ContainerName); + } + } + + [Fact] + public void Extract_CSharpMultilinePrimaryCtorAttribute_UsesDeclaredTypeContainer_Issue4840Review() + { + // A primary-constructor declaration can begin before its body range. Attribute calls on + // that declaration line still belong to the declared type, not the outer scope. + // primary constructor 宣言は本体範囲より前から始まり得る。宣言行の属性呼び出しも + // 外側スコープではなく、宣言された型に所属する。 + const string content = """ + [Attr(Helper.Get())] public record Child( + int Value) + : Parent(Value) + { + } + """; + + var (symbols, references) = ExtractSymbolsAndReferences("csharp", content); + + Assert.Contains(symbols, symbol => + symbol.Kind == "class" && symbol.Name == "Child"); + AssertReferenceContainer("Attr", "class", "Child"); + AssertReferenceContainer("Get", "class", "Child"); + AssertReferenceContainer("Parent", "function", "Child"); + + void AssertReferenceContainer(string symbolName, string containerKind, string containerName) + { + var reference = Assert.Single(references.Where(r => r.SymbolName == symbolName)); + Assert.Equal(containerKind, reference.ContainerKind); + Assert.Equal(containerName, reference.ContainerName); + } + } + [Fact] public void Extract_CsharpSameLineDefinitionCalls_KeepRecursiveAndDelegatedReferences() { @@ -6301,6 +6407,31 @@ public void Extract_CSharpPrimaryCtor_SameLineAttributeCallsStayOnClassContainer && r.ContainerKind == "function" && r.ContainerName == "Child"); } + [Fact] + public void Extract_CSharpPrimaryCtor_SameLineExplicitCtorCallStaysOnExplicitCtor_Issue4840Review() + { + // A real constructor after the primary-constructor header can share the type's name and + // physical line. It must not be mistaken for the synthetic primary constructor when + // resolving calls after the header-closing brace. + // primary constructor のヘッダー後にある実コンストラクタは型名と物理行を共有し得る。 + // ヘッダー終端より後の呼び出しでは、合成 primary constructor と誤認してはならない。 + const string content = """ + public class Parent(int value); + public record Child(int Value) : Parent(Value) { public Child() : this(0) { BodyCall(); } } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var parentRef = Assert.Single(references, r => r.SymbolName == "Parent"); + Assert.Equal("function", parentRef.ContainerKind); + Assert.Equal("Child", parentRef.ContainerName); + + var bodyCallRef = Assert.Single(references, r => r.SymbolName == "BodyCall"); + Assert.Equal("function", bodyCallRef.ContainerKind); + Assert.Equal("Child", bodyCallRef.ContainerName); + } + [Fact] public void Extract_CsharpAttribute_ClassifiedAsAttribute() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 6f539965b..97bd113a4 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -2738,6 +2738,14 @@ public void InnermostContainerResolver_ForwardScan_UpdatesAtNestedContainerBound Assert.Same(later, resolver.Find(65)); Assert.Same(method, resolver.Find(80)); Assert.Same(local, resolver.Find(22)); + + var sameRangeType = Container("same-range-type", "class", 110, 120); + var sameRangeTest = Container("same-range-test", "test.method", 110, 120); + var sameRangeResolver = new ReferenceExtractor.InnermostContainerResolver( + [sameRangeType, sameRangeTest], + preferCallable: true); + + Assert.Same(sameRangeTest, sameRangeResolver.Find(115)); } @@ -16361,9 +16369,33 @@ public IReadOnlyList Extract(long fileId, string source, Extrac } } + [Fact] + public void Extract_NonCSharpEqualSpanContainers_PreserveOriginalSelection_Issue4840Review() + { + // C# needs callable-first tie-breaking for overlapping line-only ranges, but applying that + // rule to compact JavaScript would assign every call on the line to the first method. + // C# 用の callable 優先規則をコンパクトな JavaScript に適用すると、同一行の全呼び出しが + // 最初のメソッドに誤帰属するため、非 C# では従来の安定順を維持する。 + const string content = "class C { a(){ foo(); } b(){ bar(); } }\n"; + var symbols = new List + { + Container("C", "class", 1, 1), + Container("a", "function", 1, 1), + Container("b", "function", 1, 1), + }; + + var references = ReferenceExtractor.Extract(1, "javascript", content, symbols); + + var bar = Assert.Single(references.Where(reference => + reference.SymbolName == "bar" && reference.ReferenceKind == "call")); + Assert.Equal("class", bar.ContainerKind); + Assert.Equal("C", bar.ContainerName); + } + private static SymbolRecord Container(string name, string kind, int startLine, int endLine) => new() { + FileId = 1, Name = name, Kind = kind, StartLine = startLine, diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 303698737..72952edc0 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -165,11 +165,114 @@ public void Run() """; var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "lambda" && s.Name == "transform"); - Assert.Contains(symbols, s => s.Kind == "lambda" && s.Name == "projector"); + Assert.Contains(symbols, s => + s.Kind == "lambda" + && s.Name == "transform" + && s.ContainerKind == "function" + && s.ContainerName == "Run" + && s.ContainerQualifiedName == "Worker.Run"); + Assert.Contains(symbols, s => + s.Kind == "lambda" + && s.Name == "projector" + && s.ContainerKind == "function" + && s.ContainerName == "Run" + && s.ContainerQualifiedName == "Worker.Run"); Assert.DoesNotContain(symbols, s => s.Kind == "function" && s.Name is "transform" or "projector"); } + [Fact] + public void Extract_CSharp_CallableContainersPreserveNestedDeclarations_Issue4840() + { + const string content = """ + public class Outer + { + public int ExpressionBody() => Source(); + + [Fact] + public void BlockTest() + { + void Local() + { + void Nested() => Sink(); + } + + Func blockLambda = () => + { + return Source(); + }; + } + + public class Inner + { + public void NestedTypeMethod() + { + } + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var expressionBody = Assert.Single(symbols.Where(s => s.Kind == "function" && s.Name == "ExpressionBody")); + Assert.Equal("class", expressionBody.ContainerKind); + Assert.Equal("Outer", expressionBody.ContainerName); + + var blockTest = Assert.Single(symbols.Where(s => s.Kind == "test.method" && s.Name == "BlockTest")); + Assert.Equal("class", blockTest.ContainerKind); + Assert.Equal("Outer", blockTest.ContainerName); + + var local = Assert.Single(symbols.Where(s => s.Kind == "function" && s.Name == "Local")); + Assert.Equal("test.method", local.ContainerKind); + Assert.Equal("BlockTest", local.ContainerName); + Assert.Equal("Outer.BlockTest", local.ContainerQualifiedName); + + var nested = Assert.Single(symbols.Where(s => s.Kind == "function" && s.Name == "Nested")); + Assert.Equal("function", nested.ContainerKind); + Assert.Equal("Local", nested.ContainerName); + Assert.Equal("Outer.BlockTest.Local", nested.ContainerQualifiedName); + + var blockLambda = Assert.Single(symbols.Where(s => s.Kind == "lambda" && s.Name == "blockLambda")); + Assert.Equal("test.method", blockLambda.ContainerKind); + Assert.Equal("BlockTest", blockLambda.ContainerName); + Assert.Equal("Outer.BlockTest", blockLambda.ContainerQualifiedName); + Assert.Null(blockLambda.BodyStartLine); + Assert.Null(blockLambda.BodyEndLine); + + var inner = Assert.Single(symbols.Where(s => s.Kind == "class" && s.Name == "Inner")); + Assert.Equal("class", inner.ContainerKind); + Assert.Equal("Outer", inner.ContainerName); + + var nestedTypeMethod = Assert.Single(symbols.Where(s => s.Kind == "function" && s.Name == "NestedTypeMethod")); + Assert.Equal("class", nestedTypeMethod.ContainerKind); + Assert.Equal("Inner", nestedTypeMethod.ContainerName); + } + + [Fact] + public void Extract_CSharpCallableWithShortenedBodyRange_ContainsEarlierLineLocalFunction_Issue4840Review() + { + // When the closing-brace line also starts an outer sibling, the callable body range can + // end on the previous line. A local declared there is still inside the callable. + // 閉じ波括弧の行で外側の兄弟宣言も始まる場合、callable の本体範囲は前の行までに + // 短縮され得る。その前行のローカル関数は引き続き callable 内に属する。 + const string content = """ + public class Host + { + public void Run() + { + void Local() {} + } public int Value { get; set; } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var local = Assert.Single(symbols.Where(symbol => + symbol.Kind == "function" && symbol.Name == "Local")); + Assert.Equal("function", local.ContainerKind); + Assert.Equal("Run", local.ContainerName); + Assert.Equal("Host.Run", local.ContainerQualifiedName); + } + [Fact] public void Extract_CSharp_DoesNotClassifyScopedMethodParametersAsProperties() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs b/tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs index 7ee1c822e..4c82a2e85 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorIssue4831Tests.cs @@ -192,22 +192,22 @@ public void Extract_CSharp_RejectsInvocationAndParameterContinuationsAsFunctions 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); + Assert.Equal("function", callbackLocal.ContainerKind); + Assert.Equal("Build", 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); + Assert.Equal("function", delegateLocal.ContainerKind); + Assert.Equal("Build", 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); + Assert.Equal("function", localCount.ContainerKind); + Assert.Equal("Build", localCount.ContainerName); var tryParser = Assert.Single(symbols.Where( symbol => symbol.Kind == "delegate" && symbol.Name == "TryParser"));