diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 201e88b7d..76e16e6ef 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1126,6 +1126,8 @@ Reference extraction deduplicates only within the same indexed file and language TypeScript decorators emit `annotation` rows for the decorator name and must not hide the decorated declaration's type-position edges. For example, `constructor(@Inject() svc: Service)` records `Inject` as `annotation` and `Service` as `type_reference`, and `@Input() profile: UserProfile` records both the decorator and field type. +C# named-argument labels such as `overwrite:` are syntax, not type positions, and must not emit `type_reference` rows. The declaration-type scanner skips a leading single-colon label inside an argument fragment, including comma-terminated multiline argument lines, while preserving expression references, named `out` declaration types, explicitly typed lambda and anonymous-method parameters, and typed LINQ range variables in the argument value. Multiline property subpatterns likewise keep the type after their property label. Alias-qualified names (`Alias::Type`), statement and `case` labels, nullable types, and ternary expressions remain distinct colon-bearing constructs. + ### GPU and shader reference extraction CUDA, GLSL, HLSL, Metal, and WGSL use a request-scoped, stateless reference @@ -4279,6 +4281,8 @@ authoritative な判定と一貫した推論を区別し、複数言語の evide TypeScript decorator は decorator 名を `annotation` 行として出力し、decorated declaration の型位置エッジを隠してはならない。たとえば `constructor(@Inject() svc: Service)` は `Inject` を `annotation`、`Service` を `type_reference` として記録し、`@Input() profile: UserProfile` も decorator と field type の両方を記録する。 +C# の `overwrite:` のような named-argument label は構文であり、型位置ではないため `type_reference` 行を出力してはならない。declaration-type scanner は argument fragment の先頭にある単一 colon の label を、comma で終わる複数行 argument も含めて読み飛ばし、argument value 内の式参照、named `out` declaration の型、明示型 lambda / anonymous method の parameter、および型付き LINQ range variable を維持する。複数行 property subpattern でも property label 後の型を維持する。alias-qualified name(`Alias::Type`)、statement / `case` label、nullable type、ternary expression は別の colon 構文として扱う。 + ### GPU / shader の参照抽出 CUDA、GLSL、HLSL、Metal、WGSL は request ごとの stateless な参照 extractor を使う。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 7ab0a97ce..77c792f6b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -92,6 +92,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Use `AssertReferencesContainInContext(...)` when several reference names share the same kind and exact source context; keep direct predicates when context is only one part of a richer edge contract. Use `AssertReferencesDoNotContain(...)` for negative checks over one reference kind; retain direct predicates when the exclusion depends on container, context, line, or other metadata. `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` owns the common symbol-then-reference extraction setup for tests that need both lists; use it instead of repeating the two extractor calls when the fixture does not need a specialized path or workspace symbol setup, and discard the symbol tuple element with `_` instead of keeping an unused `symbols` local when the test only asserts references. + C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side, named-`out`, explicitly typed lambda/anonymous-method, and typed LINQ range-variable type references, property-subpattern types, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). Crystal, Groovy, Tcl, Prolog, and `ambiguous_pl` graph fixtures keep imports, parenthesized calls, conservative same-file command/predicate calls, caller containers, and keyword false-positive controls independently diagnosable; capability tests must keep their advertised symbol/reference/graph flags aligned with those extractor fixtures (#4746). Database status tests must also pin stale and current extractor-version stamps for those languages so pre-graph rows cannot report authoritative graph readiness. HDL graph coverage keeps Verilog, SystemVerilog, and VHDL in one coordinated extractor fixture for syntax-visible hierarchy/package/architecture edges, known same-file signal/type references, scope attribution, declaration/literal false-positive suppression, and comment masking. The paired `QueryCommandRunnerReferencesTests` fixture must persist and query one representative edge per HDL language so extractor-only assertions cannot hide database or CLI graph drift. `IndexCommandRunnerFullScanTests` must also clear the HDL graph contract and persisted edge, then prove that a normal full scan refreshes an unchanged HDL file before restoring readiness. @@ -982,6 +983,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 複数の reference name が同じ kind と完全一致 source context を共有する場合は `AssertReferencesContainInContext(...)` を使い、context がより詳細な edge contract の一部にすぎない場合は直接 predicate を維持します。 1つの reference kind に対する否定チェックには `AssertReferencesDoNotContain(...)` を使い、container、context、line など他の metadata に依存する除外は直接 predicate を維持します。 `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` は symbol 抽出から reference 抽出までの共通 setup を所有します。fixture が特殊な path や workspace symbol setup を必要としない場合は 2 つの extractor 呼び出しを繰り返さずこの helper を使い、reference だけを検証するテストでは未使用の `symbols` local を残さず symbol 側を `_` で捨ててください。 + C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側、named `out` declaration、明示型 lambda / anonymous method、および型付き LINQ range variable の type reference、property subpattern の型、ならびに alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 Crystal、Groovy、Tcl、Prolog、`ambiguous_pl` の graph fixture では、import、括弧付き call、同一ファイルに限定した保守的な command / predicate call、caller container、keyword の false-positive control を個別に診断可能な状態で維持し、capability test の symbol / reference / graph 広告を extractor fixture と一致させてください(#4746)。 さらに database status test でこれらの言語の古い extractor-version stamp と現行 stamp を固定し、graph 対応前の row が authoritative な graph readiness を報告できないことを検証してください。 HDL graph coverage では Verilog、SystemVerilog、VHDL を1つの連携した extractor fixture にまとめ、構文上確認できる hierarchy/package/architecture edge、同一ファイル内の既知 signal/type reference、scope attribution、declaration/literal の false-positive 抑止、comment masking を検証します。対になる `QueryCommandRunnerReferencesTests` fixture では各 HDL 言語の代表 edge を1件ずつ永続化して query し、extractor-only assertion が database または CLI graph のずれを隠せないようにします。さらに `IndexCommandRunnerFullScanTests` では HDL graph contract と永続化 edge を消去し、通常の full scan が未変更 HDL file を更新してから readiness を復元することを検証します。 diff --git a/changelog.d/unreleased/4833.fixed.md b/changelog.d/unreleased/4833.fixed.md new file mode 100644 index 000000000..f32d1f6f6 --- /dev/null +++ b/changelog.d/unreleased/4833.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 4833 +affected: + - src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs +--- + +## English + +- **C# named-argument labels no longer become type references (#4833; regressions #106 and #122)** — labels in positional, reordered, attribute, constructor, nested, and multiline argument lists are suppressed without hiding value-expression types (including named `out`, explicitly typed anonymous functions, and typed LINQ range variables) or property-subpattern types, and without changing other colon-bearing C# constructs. + +## 日本語 + +- **C# の named-argument label を型参照として扱わないようにしました (#4833、回帰 #106 / #122)** — positional argument との混在、並べ替え、attribute、constructor、nested / multiline な引数リストにある label を抑止しつつ、value 式(named `out`、明示型 anonymous function、型付き LINQ range variable を含む)と property subpattern の型参照、および C# のその他の colon 構文は維持します。 diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs index 35d401da7..83bbeb8f3 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs @@ -18,6 +18,22 @@ internal static void EmitDeclarationTypeReferences( Func resolveContainerForColumn, IReadOnlySet? ignoredSegments = null) { + if (language == "csharp" + && TryGetCSharpLeadingColonLabelValueStart(line, out var namedArgumentValueStart)) + { + EmitCSharpNamedArgumentValueTypeReferences( + line, + namedArgumentValueStart, + lineStartOffset: 0, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + } + if (TryFindCallableParameterList(line, language, out var callableNameStart, out var paramStart, out var paramEnd)) { if (TryGetCallableReturnTypeSpan(line, callableNameStart, language, out var typeStart, out var typeLength)) @@ -319,7 +335,8 @@ private static void EmitParameterTypeReferences( string context, int lineNumber, Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments = null) + IReadOnlySet? ignoredSegments = null, + int lineStartOffset = 0) { if (paramEnd <= paramStart) return; @@ -328,10 +345,27 @@ private static void EmitParameterTypeReferences( foreach (var (segmentStart, segmentLength) in SplitTopLevelCommaSpans(parameterList)) { var fragment = parameterList.Slice(segmentStart, segmentLength).ToString(); + if (language == "csharp" + && TryGetCSharpLeadingColonLabelValueStart(fragment, out var namedArgumentValueStart)) + { + EmitCSharpNamedArgumentValueTypeReferences( + fragment, + namedArgumentValueStart, + lineStartOffset + paramStart + segmentStart, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + continue; + } + if (!TryGetParameterTypeRelativeSpan(fragment, language, out var typeRelativeStart, out var typeRelativeLength)) continue; - int absoluteStart = paramStart + segmentStart + typeRelativeStart; + int absoluteStart = lineStartOffset + paramStart + segmentStart + typeRelativeStart; AddTypeExpressionSegmentsForLanguage( language, references, @@ -790,6 +824,11 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language if (IsDefinitelyNotTypeDeclarationLine(line, language)) return false; + int headStart = 0; + bool hasLeadingCSharpColonLabel = language == "csharp" + && line.AsSpan().TrimEnd().EndsWith(",") + && TryGetCSharpLeadingColonLabelValueStart(line, out headStart); + int firstParen = FindFirstTopLevelChar(line, '('); int firstTerminator = FindFirstTopLevelChar(line, ';'); int firstBrace = FindFirstTopLevelChar(line, '{'); @@ -805,7 +844,7 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language if (firstParen >= 0 && firstParen < boundary) return false; - var head = line.Substring(0, boundary); + var head = line.Substring(headStart, boundary - headStart); var tokens = GetTopLevelTokenSpans(head); if (tokens.Count < 2) return false; @@ -828,14 +867,212 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language if (first >= tokens.Count - 1) return false; + if (hasLeadingCSharpColonLabel && first != tokens.Count - 2) + return false; var declaredNameToken = head.Substring(tokens[^1].Start, tokens[^1].Length); if (!IsSimpleDeclarationIdentifier(language, declaredNameToken)) return false; - typeStart = tokens[first].Start; + int relativeTypeStart = tokens[first].Start; + typeStart = headStart + relativeTypeStart; int lastTypeToken = tokens.Count - 2; - typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; + typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - relativeTypeStart; + return true; + } + + private static bool TryGetCSharpLeadingColonLabelValueStart(string text, out int valueStart) + { + valueStart = 0; + int index = 0; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + if (index >= text.Length || !IsCSharpIdentifierStart(text[index])) + return false; + + if (text[index] == '@') + { + index++; + if (index >= text.Length || (text[index] != '_' && !char.IsLetter(text[index]))) + return false; + } + + index++; + while (index < text.Length && IsCSharpIdentifierPart(text[index])) + index++; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + + if (index >= text.Length + || text[index] != ':' + || (index + 1 < text.Length && text[index + 1] == ':')) + { + return false; + } + + index++; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + valueStart = index; + return true; + } + + private static void EmitCSharpNamedArgumentValueTypeReferences( + string text, + int valueStart, + int lineStartOffset, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments) + { + if (valueStart >= text.Length) + return; + + var value = text.Substring(valueStart); + var valueTokens = GetTopLevelTokenSpans(value); + if (valueTokens.Count >= 2 + && value.AsSpan(valueTokens[0].Start, valueTokens[0].Length).Equals("out", StringComparison.Ordinal) + && TryGetParameterTypeRelativeSpan(value, "csharp", out var outTypeStart, out var outTypeLength)) + { + AddTypeExpressionSegmentsForLanguage( + "csharp", + references, + seen, + fileId, + value.Substring(outTypeStart, outTypeLength), + lineStartOffset + valueStart + outTypeStart, + context, + lineNumber, + resolveContainerForColumn(lineStartOffset + valueStart + outTypeStart), + ignoredSegments); + } + + if (TryGetCSharpNamedArgumentAnonymousFunctionParameterList(value, out var paramStart, out var paramEnd)) + { + EmitParameterTypeReferences( + "csharp", + value, + paramStart, + paramEnd, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments, + lineStartOffset + valueStart); + } + + if (TryGetCSharpNamedArgumentQueryTypeSpan(value, out var queryTypeStart, out var queryTypeLength)) + { + AddTypeExpressionSegmentsForLanguage( + "csharp", + references, + seen, + fileId, + value.Substring(queryTypeStart, queryTypeLength), + lineStartOffset + valueStart + queryTypeStart, + context, + lineNumber, + resolveContainerForColumn(lineStartOffset + valueStart + queryTypeStart), + ignoredSegments); + } + } + + private static bool TryGetCSharpNamedArgumentAnonymousFunctionParameterList( + string value, + out int paramStart, + out int paramEnd) + { + paramStart = -1; + paramEnd = -1; + + int cursor = 0; + while (TrySkipCSharpKeyword(value, ref cursor, "static") + || TrySkipCSharpKeyword(value, ref cursor, "async")) + { + } + + if (TrySkipCSharpKeyword(value, ref cursor, "delegate")) + { + if (cursor >= value.Length || value[cursor] != '(') + return false; + } + else if (cursor >= value.Length || value[cursor] != '(') + { + return false; + } + + int closeParen = FindMatchingChar(value, cursor, '(', ')'); + if (closeParen < 0) + return false; + + int continuation = closeParen + 1; + while (continuation < value.Length && char.IsWhiteSpace(value[continuation])) + continuation++; + if (continuation + 1 >= value.Length + || value[continuation] != '=' + || value[continuation + 1] != '>') + { + var delegateStart = 0; + while (delegateStart < value.Length && char.IsWhiteSpace(value[delegateStart])) + delegateStart++; + while (TrySkipCSharpKeyword(value, ref delegateStart, "static") + || TrySkipCSharpKeyword(value, ref delegateStart, "async")) + { + } + if (!value.AsSpan(delegateStart).StartsWith("delegate", StringComparison.Ordinal)) + return false; + } + + paramStart = cursor + 1; + paramEnd = closeParen; + return true; + } + + private static bool TryGetCSharpNamedArgumentQueryTypeSpan( + string value, + out int typeStart, + out int typeLength) + { + typeStart = -1; + typeLength = 0; + + var tokens = GetTopLevelTokenSpans(value); + if (tokens.Count < 4 + || !value.AsSpan(tokens[0].Start, tokens[0].Length).Equals("from", StringComparison.Ordinal) + || !IsSimpleDeclarationIdentifier( + "csharp", + value.Substring(tokens[2].Start, tokens[2].Length)) + || !value.AsSpan(tokens[3].Start, tokens[3].Length).Equals("in", StringComparison.Ordinal)) + { + return false; + } + + typeStart = tokens[1].Start; + typeLength = tokens[1].Length; + return !value.AsSpan(typeStart, typeLength).Equals("var", StringComparison.Ordinal); + } + + private static bool TrySkipCSharpKeyword(string text, ref int index, string keyword) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + if (!text.AsSpan(index).StartsWith(keyword, StringComparison.Ordinal)) + return false; + + int end = index + keyword.Length; + if (end < text.Length && IsCSharpIdentifierPart(text[end])) + return false; + + index = end; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; return true; } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs new file mode 100644 index 000000000..29c94e04c --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs @@ -0,0 +1,208 @@ +using CodeIndex.Cli; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void CSharpNamedArgumentLabels_DoNotCreateExactReferencesOrDependencies_Issue4833() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_csharp_named_arguments_issue4833"); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Types.cs", + """ + namespace Fixture; + + public sealed class ExpressionPayload + { + } + + public sealed class PatternHolder + { + public object? Value { get; init; } + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Impostor.cs", + """ + namespace Fixture; + + public sealed class CollisionHolder + { + public bool overwrite { get; } = true; + public object? Value { get; } + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Consumer.cs", + """ + using System; + using System.Collections.Generic; + using System.Linq; + + namespace Fixture; + + public sealed class Consumer + { + public void Run(bool condition, IEnumerable source) + { + Sink(1, overwrite: true, payload: typeof(ExpressionPayload)); + Sink( + positional: 2, + payload: typeof(ExpressionPayload), + overwrite: condition ? true : false); + SinkOut(payload: out ExpressionPayload declaredPayload); + _ = source.Select(selector: (ExpressionPayload item) => item); + _ = source.Select(selector: delegate(ExpressionPayload item) { return item; }); + SinkQuery(query: from ExpressionPayload item in source select item, other: condition); + SinkQuery( + query: from ExpressionPayload item in source select item, + other: condition); + if (declaredPayload is PatternHolder + { + Value: ExpressionPayload propertyPayload, + }) + { + _ = propertyPayload; + } + } + + private static void Sink(int positional, bool overwrite, Type payload) + { + } + + private static void SinkOut(out ExpressionPayload payload) + { + payload = new(); + } + + private static void SinkQuery(object query, object other) + { + } + } + """); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--json", "--quiet"], + _jsonOptions)); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + var (labelExitCode, labelStdout, labelStderr) = CaptureConsole( + () => QueryCommandRunner.RunReferences( + [ + "overwrite", + "--db", dbPath, + "--json", + "--exact-name", + "--kind", "type_reference", + "--lang", "csharp", + ], + _jsonOptions)); + using var labelDocument = ParseJsonOutput(labelStdout); + + Assert.Equal(CommandExitCodes.Success, labelExitCode); + Assert.Equal(string.Empty, labelStderr); + Assert.Equal(0, labelDocument.RootElement.GetProperty("count").GetInt32()); + Assert.Empty(labelDocument.RootElement.GetProperty("references").EnumerateArray()); + + var (payloadExitCode, payloadStdout, payloadStderr) = CaptureConsole( + () => QueryCommandRunner.RunReferences( + [ + "ExpressionPayload", + "--db", dbPath, + "--json", + "--exact-name", + "--kind", "type_reference", + "--lang", "csharp", + ], + _jsonOptions)); + using var payloadDocument = ParseJsonOutput(payloadStdout); + var payloadReference = payloadDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, payloadExitCode); + Assert.Equal(string.Empty, payloadStderr); + Assert.Equal("resolved", payloadReference.GetProperty("resolution_state").GetString()); + Assert.Equal("src/Consumer.cs", payloadReference.GetProperty("path").GetString()); + + using (var connection = new SqliteConnection( + new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + }.ToString())) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM symbols + WHERE name = 'overwrite' + AND kind = 'property' + """; + Assert.Equal(1L, (long)command.ExecuteScalar()!); + + command.CommandText = """ + SELECT COUNT(*) + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'src/Consumer.cs' + AND reference.symbol_name = 'overwrite' + AND reference.reference_kind = 'type_reference' + """; + Assert.Equal(0L, (long)command.ExecuteScalar()!); + + command.CommandText = """ + SELECT COUNT(*) + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'src/Consumer.cs' + AND reference.symbol_name = 'ExpressionPayload' + AND reference.reference_kind = 'type_reference' + """; + Assert.Equal(10L, (long)command.ExecuteScalar()!); + + command.CommandText = """ + SELECT COUNT(*) + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'src/Consumer.cs' + AND reference.symbol_name IN ('payload', 'selector', 'query', 'other', 'Value') + AND reference.reference_kind = 'type_reference' + """; + Assert.Equal(0L, (long)command.ExecuteScalar()!); + } + + var (depsExitCode, depsStdout, depsStderr) = CaptureConsole( + () => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--lang", "csharp", "--limit", "100"], + _jsonOptions)); + using var depsDocument = ParseJsonOutput(depsStdout); + var dependencyEdges = depsDocument.RootElement.GetProperty("edges").EnumerateArray().ToArray(); + + Assert.Equal(CommandExitCodes.Success, depsExitCode); + Assert.Equal(string.Empty, depsStderr); + Assert.Contains( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/Consumer.cs" + && edge.GetProperty("target_path").GetString() == "src/Types.cs"); + Assert.DoesNotContain( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/Consumer.cs" + && edge.GetProperty("target_path").GetString() == "src/Impostor.cs"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +} diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index ce05c9f6b..051a1fdf2 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -7598,6 +7598,133 @@ private static bool ReferenceMatches( && (column is null || reference.Column == column.Value); } + [Fact] + public void Extract_CsharpNamedArgumentLabels_DoNotBecomeTypeReferences_Issue4833() + { + // Named-argument labels are parameter selectors, not type positions. Keep the + // expressions after each label visible to their dedicated reference scanners, + // including attributes, constructors, nested calls, and multiline argument lists. + // named argument label は parameter selector であり、型位置ではない。attribute、 + // constructor、nested call、multiline 引数でも label 後の式は各参照 scanner に残す。 + const string content = """ + extern alias Alias; + using System; + using System.Collections.Generic; + + public sealed class ProbeAttribute : Attribute + { + public ProbeAttribute(Type payload, bool overwrite = false) {} + } + + public sealed class ExpressionPayload {} + public sealed class PatternHolder + { + public object Value { get; init; } = new(); + } + + public sealed class ConstructorTarget + { + public ConstructorTarget(bool overwrite, Type payload) {} + } + + public sealed class Demo + { + private static void Invoke(int positional, bool overwrite, Type payload) {} + private static object InvokeAdapter(bool overwrite, Type payload) => new(); + private static void InvokeOut(out ExpressionPayload payload) => payload = new(); + private static void Outer(object inner) {} + private static void Consume(Func transform) {} + private static void ConsumeQuery(object query, object other) {} + + [Probe(typeof(ExpressionPayload), overwrite: true)] + public void Run(bool condition, object value, IEnumerable source) + { + Invoke(1, overwrite: true, payload: typeof(ExpressionPayload)); + Invoke(payload: typeof(ExpressionPayload), positional: 2, overwrite: false); + _ = new ConstructorTarget(overwrite: true, payload: typeof(ExpressionPayload)); + Outer(inner: InvokeAdapter(overwrite: true, payload: typeof(ExpressionPayload))); + InvokeOut(payload: out ExpressionPayload declaredPayload); + Consume(transform: (ExpressionPayload item) => item); + Consume(transform: delegate(ExpressionPayload item) { return item; }); + ConsumeQuery(query: from ExpressionPayload item in source select item, other: value); + ConsumeQuery( + query: from ExpressionPayload item in source select item, + other: value); + Invoke( + positional: 3, + payload: typeof(ExpressionPayload), + overwrite: condition ? true : false); + + retry: + if (value is ExpressionPayload matched) + goto done; + if (value is PatternHolder + { + Value: ExpressionPayload propertyPayload, + }) + { + _ = propertyPayload; + } + switch (value) + { + case ExpressionPayload casePayload: + goto done; + } + + ExpressionPayload? nullablePayload = null; + Alias::ExpressionPayload aliasQualified = default!; + if (condition) + goto retry; + + done: + _ = nullablePayload; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + var colonLabels = new HashSet( + ["overwrite", "payload", "positional", "inner", "transform", "query", "other", "Value"], + StringComparer.Ordinal); + + Assert.DoesNotContain( + references, + reference => reference.ReferenceKind == "type_reference" + && colonLabels.Contains(reference.SymbolName)); + Assert.True( + references.Count(reference => + reference.SymbolName == "ExpressionPayload" + && reference.ReferenceKind == "type_reference") >= 17); + var namedValueTypeLines = content + .Split('\n') + .Select((line, index) => (Line: line, Number: index + 1)) + .Where(item => item.Line.Contains("transform:", StringComparison.Ordinal) + || item.Line.Contains("query: from", StringComparison.Ordinal)) + .Select(item => item.Number) + .ToArray(); + Assert.Equal(4, namedValueTypeLines.Length); + Assert.All( + namedValueTypeLines, + expectedLine => Assert.Contains( + references, + reference => reference.SymbolName == "ExpressionPayload" + && reference.ReferenceKind == "type_reference" + && reference.Line == expectedLine)); + Assert.Contains( + references, + reference => reference.SymbolName == "ConstructorTarget" + && reference.ReferenceKind == "instantiate"); + Assert.Contains( + references, + reference => reference.SymbolName == "InvokeAdapter" + && reference.ReferenceKind == "call"); + Assert.DoesNotContain( + references, + reference => reference.SymbolName == "Alias" + && reference.ReferenceKind == "type_reference"); + } + #if NET8_0 [Fact] #else