diff --git a/changelog.d/unreleased/3894.fixed.md b/changelog.d/unreleased/3894.fixed.md new file mode 100644 index 0000000000..a5a499904d --- /dev/null +++ b/changelog.d/unreleased/3894.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3894 +affected: + - src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs + - src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs + - tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +--- + +## English + +- **C# reference extraction now suppresses common qualified member-call overmatches (#3894)** — `Path.Combine`, `Math.Max`, `string.Join`, `JsonElement.GetString`, and similar receiver-qualified common calls no longer appear as references to unrelated same-name local functions, while direct and `this.` local calls remain indexed. + +## 日本語 + +- **C# 参照抽出で一般的な qualified member call の過剰一致を抑制しました (#3894)** — `Path.Combine`、`Math.Max`、`string.Join`、`JsonElement.GetString` などの receiver 付き共通呼び出しが、同名の無関係なローカル関数への参照として扱われなくなりました。一方で direct call と `this.` 経由のローカル呼び出しは引き続き index されます。 diff --git a/changelog.d/unreleased/3912.fixed.md b/changelog.d/unreleased/3912.fixed.md new file mode 100644 index 0000000000..ef31bc34fe --- /dev/null +++ b/changelog.d/unreleased/3912.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3912 +affected: + - tests/CodeIndex.Tests/SymbolExtractorTests.cs +--- + +## English + +- **C# symbol range regression coverage now locks small method spans (#3912)** — regression tests cover C# methods near local functions, lambdas, generic helpers, and later partial-class members so `definition`, `goto`, `symbols --sort size`, and `outline` keep using tight method end lines instead of class-level EOF ranges. + +## 日本語 + +- **小さな C# method の symbol range を固定する回帰テストを追加しました (#3912)** — local function、lambda、generic helper、後続の partial class member の近くにある C# method をカバーし、`definition`、`goto`、`symbols --sort size`、`outline` が class 末尾まで伸びた range ではなく正しい method 終端を使い続けるようにしました。 diff --git a/changelog.d/unreleased/3915.fixed.md b/changelog.d/unreleased/3915.fixed.md new file mode 100644 index 0000000000..990b1c3105 --- /dev/null +++ b/changelog.d/unreleased/3915.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3915 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbSymbolReader.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +--- + +## English + +- **`inspect --path --line` now returns exact or enclosing symbols (#3915)** — file-line inspect resolves a symbol on the requested definition line first, then the smallest enclosing symbol for body lines, so JSON results include `definitions`, `nearby_symbols`, and body-capable symbol context instead of only a source excerpt. + +## 日本語 + +- **`inspect --path --line` が exact または enclosing symbol を返すようになりました (#3915)** — file-line inspect は指定行が定義行ならその symbol を優先し、body 行では最小の enclosing symbol を解決するため、JSON 結果が source excerpt だけでなく `definitions`、`nearby_symbols`、body 取得可能な symbol context を含むようになりました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index c5e824adef..fe8f66456a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -6029,19 +6029,33 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions var inspectQuery = pathLineInspectMode ? $"{inspectPath}:{options.StartLine!.Value}" : options.Query!; - var analysis = reader.AnalyzeSymbol( - inspectQuery, - inspectLimit, - options.Lang, - options.IncludeBody, - options.PathPatterns, - options.ExcludePaths, - options.ExcludeTests, - exact, - options.MaxLineWidth, - options.BodyStartLine, - options.BodyLines, - kind: options.Kind); + var analysis = pathLineInspectMode + ? reader.AnalyzeFileLine( + inspectPath!, + options.StartLine!.Value, + inspectLimit, + options.Lang, + options.IncludeBody, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + options.MaxLineWidth, + options.BodyStartLine, + options.BodyLines, + kind: options.Kind) + : reader.AnalyzeSymbol( + inspectQuery, + inspectLimit, + options.Lang, + options.IncludeBody, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + exact, + options.MaxLineWidth, + options.BodyStartLine, + options.BodyLines, + kind: options.Kind); var sourceExcerpt = BuildInspectSourceExcerpt(reader, options, analysis, inspectPath); var sqlGraphSignal = NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests), diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 52eb3e6ca9..9a07dcb4fe 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.RegularExpressions; using CodeIndex.Indexer; @@ -1048,120 +1049,131 @@ public List GetDefinitions(string query, int limit = 20, strin foreach (var symbol in symbols) { - var definitionExcerpt = GetExcerpt(symbol.Path, symbol.StartLine, symbol.EndLine); - if (definitionExcerpt == null) - continue; + var definition = BuildDefinitionResult(symbol, includeBody, bodyStartLine, bodyLineCount); + if (definition != null) + results.Add(definition); + } - string? bodyContent = null; - int? bodyContentStartLine = null; - int? bodyContentEndLine = null; - int? bodyContentNextStartLine = null; - var bodyContentTruncated = false; - int? bodyRequestedStartLine = null; - int? bodyRequestedEndLine = null; - int? bodyEffectiveStartLine = null; - int? bodyEffectiveEndLine = null; - var bodyContentTruncationReasons = new List(); - ExcerptRecoveryHint? bodyContentRecovery = null; - if (includeBody && symbol.BodyStartLine != null && symbol.BodyEndLine != null) + return results; + } + + private DefinitionResult? BuildDefinitionResult( + SymbolResult symbol, + bool includeBody, + int? bodyStartLine = null, + int? bodyLineCount = null) + { + var definitionExcerpt = GetExcerpt(symbol.Path, symbol.StartLine, symbol.EndLine); + if (definitionExcerpt == null) + return null; + + string? bodyContent = null; + int? bodyContentStartLine = null; + int? bodyContentEndLine = null; + int? bodyContentNextStartLine = null; + var bodyContentTruncated = false; + int? bodyRequestedStartLine = null; + int? bodyRequestedEndLine = null; + int? bodyEffectiveStartLine = null; + int? bodyEffectiveEndLine = null; + var bodyContentTruncationReasons = new List(); + ExcerptRecoveryHint? bodyContentRecovery = null; + if (includeBody && symbol.BodyStartLine != null && symbol.BodyEndLine != null) + { + var requestedBodyLines = Math.Clamp( + bodyLineCount ?? DefinitionBodyMaxLines, + 1, + DefinitionBodyMaxRequestedLines); + var effectiveBodyStartLine = Math.Clamp( + bodyStartLine ?? symbol.BodyStartLine.Value, + symbol.BodyStartLine.Value, + symbol.BodyEndLine.Value); + var cappedBodyEndLine = Math.Min( + symbol.BodyEndLine.Value, + effectiveBodyStartLine + requestedBodyLines - 1); + var bodyExcerpt = GetExcerpt(symbol.Path, effectiveBodyStartLine, cappedBodyEndLine); + if (bodyExcerpt != null) { - var requestedBodyLines = Math.Clamp( - bodyLineCount ?? DefinitionBodyMaxLines, - 1, - DefinitionBodyMaxRequestedLines); - var effectiveBodyStartLine = Math.Clamp( - bodyStartLine ?? symbol.BodyStartLine.Value, - symbol.BodyStartLine.Value, - symbol.BodyEndLine.Value); - var cappedBodyEndLine = Math.Min( - symbol.BodyEndLine.Value, - effectiveBodyStartLine + requestedBodyLines - 1); - var bodyExcerpt = GetExcerpt(symbol.Path, effectiveBodyStartLine, cappedBodyEndLine); - if (bodyExcerpt != null) + bodyRequestedStartLine = symbol.BodyStartLine.Value; + bodyRequestedEndLine = symbol.BodyEndLine.Value; + bodyEffectiveStartLine = bodyExcerpt.StartLine; + bodyEffectiveEndLine = bodyExcerpt.EndLine; + bodyContent = bodyExcerpt.Content; + bodyContentStartLine = bodyExcerpt.StartLine; + bodyContentEndLine = bodyExcerpt.EndLine; + bodyContentTruncationReasons.AddRange(bodyExcerpt.ContentTruncationReasons); + bodyContentRecovery = bodyExcerpt.ContentRecovery; + if (cappedBodyEndLine < symbol.BodyEndLine.Value) + { + bodyContentTruncated = true; + AddBodyContentTruncationReason(bodyContentTruncationReasons, "body_line_cap"); + var recoveryStartLine = cappedBodyEndLine + 1; + var recoveryEndLine = Math.Min(symbol.BodyEndLine.Value, recoveryStartLine + DefinitionBodyMaxLines - 1); + bodyContentNextStartLine = recoveryStartLine; + bodyContentRecovery ??= FileExcerptResult.CreateRecoveryHint(symbol.Path, recoveryStartLine, recoveryEndLine); + } + bodyContentTruncated |= bodyExcerpt.ContentTruncated; + var byteClamp = ClampDefinitionBodyBytes(bodyContent); + bodyContent = byteClamp.Content; + if (byteClamp.Truncated) { - bodyRequestedStartLine = symbol.BodyStartLine.Value; - bodyRequestedEndLine = symbol.BodyEndLine.Value; - bodyEffectiveStartLine = bodyExcerpt.StartLine; - bodyEffectiveEndLine = bodyExcerpt.EndLine; - bodyContent = bodyExcerpt.Content; - bodyContentStartLine = bodyExcerpt.StartLine; - bodyContentEndLine = bodyExcerpt.EndLine; - bodyContentTruncationReasons.AddRange(bodyExcerpt.ContentTruncationReasons); - bodyContentRecovery = bodyExcerpt.ContentRecovery; - if (cappedBodyEndLine < symbol.BodyEndLine.Value) + bodyContentTruncated = true; + AddBodyContentTruncationReason(bodyContentTruncationReasons, "body_byte_cap"); + if (byteClamp.ReturnedLineCount > 0) { - bodyContentTruncated = true; - AddBodyContentTruncationReason(bodyContentTruncationReasons, "body_line_cap"); - var recoveryStartLine = cappedBodyEndLine + 1; - var recoveryEndLine = Math.Min(symbol.BodyEndLine.Value, recoveryStartLine + DefinitionBodyMaxLines - 1); - bodyContentNextStartLine = recoveryStartLine; - bodyContentRecovery ??= FileExcerptResult.CreateRecoveryHint(symbol.Path, recoveryStartLine, recoveryEndLine); + bodyContentEndLine = Math.Min( + bodyExcerpt.EndLine, + bodyExcerpt.StartLine + byteClamp.ReturnedLineCount - 1); + var nextStartLine = bodyContentEndLine.Value + 1; + if (nextStartLine <= symbol.BodyEndLine.Value) + bodyContentNextStartLine = nextStartLine; } - bodyContentTruncated |= bodyExcerpt.ContentTruncated; - var byteClamp = ClampDefinitionBodyBytes(bodyContent); - bodyContent = byteClamp.Content; - if (byteClamp.Truncated) + else { - bodyContentTruncated = true; - AddBodyContentTruncationReason(bodyContentTruncationReasons, "body_byte_cap"); - if (byteClamp.ReturnedLineCount > 0) - { - bodyContentEndLine = Math.Min( - bodyExcerpt.EndLine, - bodyExcerpt.StartLine + byteClamp.ReturnedLineCount - 1); - var nextStartLine = bodyContentEndLine.Value + 1; - if (nextStartLine <= symbol.BodyEndLine.Value) - bodyContentNextStartLine = nextStartLine; - } - else - { - bodyContentEndLine = bodyExcerpt.StartLine; - var nextStartLine = bodyExcerpt.StartLine + 1; - if (nextStartLine <= symbol.BodyEndLine.Value) - bodyContentNextStartLine = nextStartLine; - } - bodyContentRecovery = FileExcerptResult.CreateRecoveryHint(symbol.Path, bodyExcerpt.StartLine, bodyExcerpt.EndLine); + bodyContentEndLine = bodyExcerpt.StartLine; + var nextStartLine = bodyExcerpt.StartLine + 1; + if (nextStartLine <= symbol.BodyEndLine.Value) + bodyContentNextStartLine = nextStartLine; } + bodyContentRecovery = FileExcerptResult.CreateRecoveryHint(symbol.Path, bodyExcerpt.StartLine, bodyExcerpt.EndLine); } } - - results.Add(new DefinitionResult - { - Path = symbol.Path, - Lang = symbol.Lang, - Kind = symbol.Kind, - SubKind = symbol.SubKind, - Name = symbol.Name, - Line = symbol.Line, - StartLine = symbol.StartLine, - EndLine = symbol.EndLine, - BodyStartLine = symbol.BodyStartLine, - BodyEndLine = symbol.BodyEndLine, - Signature = symbol.Signature, - ContainerKind = symbol.ContainerKind, - ContainerName = symbol.ContainerName, - Visibility = symbol.Visibility, - ReturnType = symbol.ReturnType, - Disambiguator = BuildDefinitionDisambiguator(symbol), - Content = definitionExcerpt.Content, - BodyContent = bodyContent, - BodyContentStartLine = bodyContentStartLine, - BodyContentEndLine = bodyContentEndLine, - BodyContentNextStartLine = bodyContentNextStartLine, - BodyContentTruncated = bodyContentTruncated, - BodyRequestedStartLine = bodyRequestedStartLine, - BodyRequestedEndLine = bodyRequestedEndLine, - BodyEffectiveStartLine = bodyEffectiveStartLine, - BodyEffectiveEndLine = bodyEffectiveEndLine, - BodyContentTruncationReasons = bodyContentTruncationReasons.Count > 0 ? bodyContentTruncationReasons : null, - BodyContentRecovery = bodyContentRecovery, - Complexity = bodyContent != null && !bodyContentTruncated - ? SymbolExtractor.EstimateComplexity(bodyContent) - : null, - }); } - return results; + return new DefinitionResult + { + Path = symbol.Path, + Lang = symbol.Lang, + Kind = symbol.Kind, + SubKind = symbol.SubKind, + Name = symbol.Name, + Line = symbol.Line, + StartLine = symbol.StartLine, + EndLine = symbol.EndLine, + BodyStartLine = symbol.BodyStartLine, + BodyEndLine = symbol.BodyEndLine, + Signature = symbol.Signature, + ContainerKind = symbol.ContainerKind, + ContainerName = symbol.ContainerName, + Visibility = symbol.Visibility, + ReturnType = symbol.ReturnType, + Disambiguator = BuildDefinitionDisambiguator(symbol), + Content = definitionExcerpt.Content, + BodyContent = bodyContent, + BodyContentStartLine = bodyContentStartLine, + BodyContentEndLine = bodyContentEndLine, + BodyContentNextStartLine = bodyContentNextStartLine, + BodyContentTruncated = bodyContentTruncated, + BodyRequestedStartLine = bodyRequestedStartLine, + BodyRequestedEndLine = bodyRequestedEndLine, + BodyEffectiveStartLine = bodyEffectiveStartLine, + BodyEffectiveEndLine = bodyEffectiveEndLine, + BodyContentTruncationReasons = bodyContentTruncationReasons.Count > 0 ? bodyContentTruncationReasons : null, + BodyContentRecovery = bodyContentRecovery, + Complexity = bodyContent != null && !bodyContentTruncated + ? SymbolExtractor.EstimateComplexity(bodyContent) + : null, + }; } private static void AddBodyContentTruncationReason(List reasons, string reason) @@ -1689,6 +1701,160 @@ FROM symbols s return results; } + public SymbolAnalysisResult AnalyzeFileLine( + string path, + int line, + int limit = 10, + string? lang = null, + bool includeBody = false, + IReadOnlyList? pathPatterns = null, + IReadOnlyList? excludePathPatterns = null, + bool excludeTests = false, + int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, + int? bodyStartLine = null, + int? bodyLineCount = null, + string? kind = null) + { + lang = DbReader.NormalizeQueryLanguage(lang); + using var txn = _conn.BeginTransaction(deferred: true); + + var query = $"{path}:{line.ToString(CultureInfo.InvariantCulture)}"; + var file = GetFileByPath(path); + var freshness = GetWorkspaceFreshness(); + var graphLanguage = lang ?? file?.Lang; + List symbolsAtLine = file == null + ? [] + : GetSymbolsAtLine(path, line, Math.Max(limit, 1), kind, lang); + var primarySymbol = symbolsAtLine.FirstOrDefault(); + var primaryLineDefinition = primarySymbol == null + ? null + : BuildDefinitionResult(primarySymbol, includeBody, bodyStartLine, bodyLineCount); + List definitions = primaryLineDefinition == null ? [] : [primaryLineDefinition]; + var primaryDefinition = definitions.FirstOrDefault(); + var hasSupportedGraphDefinition = primaryDefinition != null + && ReferenceExtractor.SupportsSymbolGraph(primaryDefinition.Lang, primaryDefinition.Kind, primaryDefinition.ContainerKind) == true; + var baseGraphSupported = graphLanguage == null + ? (bool?)null + : ReferenceExtractor.SupportsLanguage(graphLanguage); + var graphSupportReason = ReferenceExtractor.BuildGraphSupportReasonWithUnsupportedEnumMemberGap( + graphLanguage, + baseGraphSupported, + hasUnsupportedEnumMember: false, + hasSupportedGraphDefinition); + var references = primaryDefinition != null + ? SearchReferences(primaryDefinition.Name, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, maxLineWidth) + : []; + var callers = primaryDefinition != null + ? GetCallers(primaryDefinition.Name, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true) + : []; + var callees = primaryDefinition != null + ? GetCallees(primaryDefinition.Name, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true) + : []; + var nearbySymbols = file != null + ? GetNearbySymbols( + path, + line, + Math.Min(limit, 10), + primaryDefinition?.Name, + primaryDefinition?.StartLine) + : []; + ApplyQueryOutputSignatureLimits(definitions); + ApplyQueryOutputSignatureLimits(nearbySymbols); + + var result = new SymbolAnalysisResult + { + Query = query, + File = file, + WorkspaceIndexedAt = freshness.IndexedAt, + WorkspaceLatestModified = freshness.LatestModified, + GraphLanguage = graphLanguage, + GraphSupported = baseGraphSupported, + GraphSupportReason = graphSupportReason, + Definitions = definitions, + NearbySymbols = nearbySymbols, + References = references, + Callers = callers, + Callees = callees, + GraphTableAvailable = _hasReferencesTable, + }; + txn.Commit(); + return result; + } + + private List GetSymbolsAtLine(string path, int line, int limit, string? kind, string? lang) + { + using var cmd = _conn.CreateCommand(); + + var startLineSql = GetSymbolColumnSql("start_line", "s.line"); + var endLineSql = GetSymbolColumnSql("end_line", "s.line"); + var bodyStartLineSql = GetSymbolColumnSql("body_start_line"); + var bodyEndLineSql = GetSymbolColumnSql("body_end_line"); + var sql = $@" + SELECT f.path, f.lang, s.kind, s.name, s.line, + {startLineSql} AS start_line, + {endLineSql} AS end_line, + {bodyStartLineSql} AS body_start_line, + {bodyEndLineSql} AS body_end_line, + {GetSymbolColumnSql("signature")} AS signature, + {GetSymbolColumnSql("container_kind")} AS container_kind, + {GetSymbolColumnSql("container_name")} AS container_name, + {GetSymbolColumnSql("visibility")} AS visibility, + {GetSymbolColumnSql("return_type")} AS return_type + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE f.path = @path + AND @line BETWEEN {startLineSql} AND {endLineSql}"; + if (kind != null) + sql += " AND s.kind = @kind"; + if (lang != null) + sql += " AND f.lang = @lang"; + sql += $@" + ORDER BY + CASE WHEN {startLineSql} = @line THEN 0 ELSE 1 END, + ({endLineSql} - {startLineSql}), + CASE WHEN {bodyStartLineSql} IS NOT NULL + AND {bodyEndLineSql} IS NOT NULL + AND @line BETWEEN {bodyStartLineSql} AND {bodyEndLineSql} + THEN 0 ELSE 1 END, + {startLineSql} DESC + LIMIT @limit"; + + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@path", path); + cmd.Parameters.AddWithValue("@line", line); + cmd.Parameters.AddWithValue("@limit", limit); + if (kind != null) + cmd.Parameters.AddWithValue("@kind", kind); + if (lang != null) + cmd.Parameters.AddWithValue("@lang", lang); + + var results = new List(); + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + results.Add(ReadSymbolResult(reader)); + + return results; + } + + private static SymbolResult ReadSymbolResult(SqliteDataReader reader) + => new() + { + Path = reader.GetString(0), + Lang = GetNullableString(reader, 1), + Kind = reader.GetString(2), + Name = reader.GetString(3), + Line = reader.GetInt32(4), + StartLine = GetInt32OrFallback(reader, 5, 4), + EndLine = GetInt32OrFallback(reader, 6, 4), + BodyStartLine = GetNullableInt32(reader, 7), + BodyEndLine = GetNullableInt32(reader, 8), + Signature = GetNullableString(reader, 9), + ContainerKind = GetNullableString(reader, 10), + ContainerName = GetNullableString(reader, 11), + Visibility = GetNullableString(reader, 12), + ReturnType = GetNullableString(reader, 13), + }; + /// /// Bundle definition, graph, and local file context for one symbol query. /// 単一シンボルクエリ向けに、定義・グラフ・ローカル文脈をまとめて返す。 diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs index 9ab6176929..12defd633d 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs @@ -28,6 +28,34 @@ internal static class CSharpReferenceExtractor private static readonly Regex StaticMemberQualifierRegex = new( @"(?(?:global::)?@?[A-Z_][\p{L}\p{Nd}_]*(?:\.@?[A-Z_][\p{L}\p{Nd}_]*)*)\s*\.\s*@?[\p{L}_][\p{L}\p{Nd}_]*", RegexOptions.Compiled); + private static readonly HashSet CommonQualifiedMemberCallNames = new(StringComparer.Ordinal) + { + "Add", + "Append", + "Combine", + "Contains", + "Convert", + "Create", + "Equals", + "GetString", + "GetValue", + "GetValues", + "Join", + "Max", + "Min", + "Parse", + "Read", + "Replace", + "Resolve", + "Select", + "ToArray", + "ToDictionary", + "ToList", + "ToString", + "TryParse", + "Where", + "Write", + }; public static void EmitCtorChainReferences( string preparedLine, @@ -243,6 +271,18 @@ public static void EmitDocCrefReferences( public static bool IsPatternHeadCallSite(string[] preparedLines, int lineIndex, string preparedLine, int nameIndex) => ReferenceExtractor.IsCSharpPatternHeadCallSite(preparedLines, lineIndex, preparedLine, nameIndex); + public static bool ShouldSuppressQualifiedCommonMemberCall(string preparedLine, string normalizedName, int nameIndex) + { + if (!CommonQualifiedMemberCallNames.Contains(normalizedName)) + return false; + + if (!TryGetImmediateMemberAccessQualifier(preparedLine, nameIndex, out var qualifier)) + return false; + + return !string.Equals(qualifier, "this", StringComparison.Ordinal) + && !string.Equals(qualifier, "base", StringComparison.Ordinal); + } + public static void EmitStaticMemberQualifierReferences( string preparedLine, IReadOnlyList<(int start, int end)>? csharpAttrRangesOnLine, @@ -492,6 +532,40 @@ private static string NormalizeCSharpIdentifier(string identifier) => ? identifier[1..] : identifier; + private static bool TryGetImmediateMemberAccessQualifier(string preparedLine, int nameIndex, out string qualifier) + { + qualifier = string.Empty; + var cursor = nameIndex - 1; + while (cursor >= 0 && char.IsWhiteSpace(preparedLine[cursor])) + cursor--; + + if (cursor < 0 || preparedLine[cursor] != '.') + return false; + + cursor--; + while (cursor >= 0 && char.IsWhiteSpace(preparedLine[cursor])) + cursor--; + + if (cursor < 0) + return true; + + var end = cursor + 1; + while (cursor >= 0) + { + var ch = preparedLine[cursor]; + if (char.IsLetterOrDigit(ch) || ch == '_' || ch == '@') + { + cursor--; + continue; + } + + break; + } + + qualifier = NormalizeCSharpIdentifier(preparedLine[(cursor + 1)..end]); + return true; + } + public static void EmitQualifiedEnumMemberReferences( string preparedLine, IReadOnlyDictionary> enumMemberLookup, diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs index 6d462894fd..add94e8144 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs @@ -2031,6 +2031,11 @@ bool TryAddCallLikeReference(string name, int callIndex) AddReference(references, seen, fileId, normalizedName, callIndex, "instantiate", context, lineNumber, callContainer, language); return true; } + if (language == "csharp" + && CSharpReferenceExtractor.ShouldSuppressQualifiedCommonMemberCall(preparedLine, normalizedName, callIndex)) + { + return false; + } if (IsIgnoredCallName(language, name)) { if (!(language == "scala" && string.Equals(name, "foreach", StringComparison.Ordinal))) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index e45b2443d2..cbb44d807e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -9,6 +9,61 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { + [Theory] + [InlineData(3, "Current", "property", 3, 3)] + [InlineData(4, "Run", "function", 4, 7)] + [InlineData(6, "Run", "function", 4, 7)] + public void RunInspect_PathLineJson_ReturnsExactOrEnclosingSymbol_Issue3915( + int line, + string expectedName, + string expectedKind, + int expectedStartLine, + int expectedEndLine) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_path_line_symbol"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/InspectTarget.cs", + "csharp", + """ + public class InspectTarget + { + public int Current => 1; + public void Run() + { + Helper(); + } + private void Helper() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["--path", "src/InspectTarget.cs", "--line", line.ToString(CultureInfo.InvariantCulture), "--db", dbPath, "--json", "--limit", "3"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var definition = root.GetProperty("definitions").EnumerateArray().First(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal($"src/InspectTarget.cs:{line.ToString(CultureInfo.InvariantCulture)}", root.GetProperty("query").GetString()); + Assert.Equal(expectedName, definition.GetProperty("name").GetString()); + Assert.Equal(expectedKind, definition.GetProperty("kind").GetString()); + Assert.Equal(expectedStartLine, definition.GetProperty("start_line").GetInt32()); + Assert.Equal(expectedEndLine, definition.GetProperty("end_line").GetInt32()); + Assert.NotEmpty(root.GetProperty("nearby_symbols").EnumerateArray()); + Assert.Equal(line, root.GetProperty("source_excerpt").GetProperty("requested_start_line").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunInspect_StrictNotFoundReturnsNotFoundForEmptyAnalysis_Issue1425() { @@ -403,7 +458,11 @@ public int Compute() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); Assert.Equal("src/Target.cs:5", root.GetProperty("query").GetString()); - Assert.Empty(root.GetProperty("definitions").EnumerateArray()); + var definition = Assert.Single(root.GetProperty("definitions").EnumerateArray()); + Assert.Equal("Compute", definition.GetProperty("name").GetString()); + Assert.Equal("function", definition.GetProperty("kind").GetString()); + Assert.Equal(3, definition.GetProperty("start_line").GetInt32()); + Assert.Equal(6, definition.GetProperty("end_line").GetInt32()); Assert.Equal("src/Target.cs", sourceExcerpt.GetProperty("path").GetString()); Assert.Equal(5, sourceExcerpt.GetProperty("start_line").GetInt32()); Assert.Equal(5, sourceExcerpt.GetProperty("end_line").GetInt32()); diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index ed542c24ca..642d32e699 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -411,6 +411,46 @@ public int FibBlock(int n) }); } + [Fact] + public void Extract_CsharpCommonQualifiedMemberCalls_DoNotOvermatchLocalFunctions_Issue3894() + { + const string content = """ + using System; + using System.IO; + using System.Text.Json; + + public class WorkerProcessCleanupDiagnostics + { + private string Combine(string left, string right) => left + right; + + public void TryKill(JsonElement element, string value) + { + Combine("a", "b"); + thisCombine(); + var path = Path.Combine("a", "b"); + var max = Math.Max(1, 2); + var joined = string.Join(",", new[] { "a", "b" }); + var text = element.GetString(); + value.Replace("a", "b"); + this.Combine("c", "d"); + } + + private static void thisCombine() { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var combineCalls = references + .Where(reference => reference.SymbolName == "Combine" && reference.ReferenceKind == "call") + .ToList(); + + Assert.Equal(2, combineCalls.Count); + Assert.All(combineCalls, reference => Assert.Equal("TryKill", reference.ContainerName)); + Assert.DoesNotContain(references, reference => reference.SymbolName is "Max" or "Join" or "GetString" or "Replace"); + } + [Fact] public void Extract_CsharpMethodGroups_TrackDelegateHandoffs() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index f19250dcf4..d6b7163bbf 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -148,6 +148,63 @@ private static bool Second() Assert.DoesNotContain("Second", first.Signature, StringComparison.Ordinal); } + [Fact] + public void Extract_CSharp_SmallMethodsNearLocalFunctionsKeepTightRanges_Issue3912() + { + const string content = """ + using System; + using System.Collections.Generic; + + internal static partial class QueryLike + { + private static bool TryWriteFormattedLocations(IEnumerable locations) + { + static int Normalize(int value) => value < 0 ? 0 : value; + Func transform = value => Normalize(value); + foreach (var location in locations) + { + WriteCompactLocations([transform(location)]); + } + + return true; + } + + private static void WriteCompactLocations(IEnumerable locations) + { + foreach (var location in locations) + { + Console.WriteLine(location); + } + } + + private static void WriteDependencyJsonGraph(IReadOnlyList edges) + where T : notnull + { + foreach (var edge in edges) + { + Console.WriteLine(edge); + } + } + + private static void Later() + { + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var compact = Assert.Single(symbols.Where(symbol => symbol.Kind == "function" && symbol.Name == "WriteCompactLocations")); + var graph = Assert.Single(symbols.Where(symbol => symbol.Kind == "function" && symbol.Name == "WriteDependencyJsonGraph")); + var later = Assert.Single(symbols.Where(symbol => symbol.Kind == "function" && symbol.Name == "Later")); + + Assert.Equal(18, compact.StartLine); + Assert.Equal(24, compact.EndLine); + Assert.Equal(19, compact.BodyStartLine); + Assert.Equal(24, compact.BodyEndLine); + Assert.True(compact.EndLine < graph.StartLine); + Assert.True(graph.EndLine < later.StartLine); + } + [Fact] public void Extract_BuiltInSymbolRegexes_AdversarialLongLinesDoNotThrow() {