diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 670cef7b6..0f8387f53 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1940,6 +1940,17 @@ The CLI normalizes exact-mode aliases and then rejects raw `--fts` combined with `--exact`, `--exact-substring`, or `--token-boundary` before database dispatch. This keeps query context and replay output on one matching model. +Search-result symbol attribution uses the primary match line and column when +the index provides declaration coordinates. C# positional-record properties +store the span from the component's first attribute or type token through its +identifier, so record keywords, record type names, base arguments, and body +members remain attributed to their actual enclosing symbols even when they +share a physical line with a positional component. Legacy rows without column +metadata retain the line-based fallback. Primary-match selection mirrors the +displayed focus line. Exact-source names are normalized with each result's +language when the query does not specify one, and raw source-column maps are +created only for lines whose escaped names actually change during normalization. + When you run: ```sql SELECT f.path, c.start_line, c.content @@ -5790,6 +5801,14 @@ CLI は exact-mode alias を正規化してから、raw `--fts` と `--exact`、 拒否します。これにより query context と replay output は 1 つの一致モデルだけを 保持します。 +検索結果の囲みシンボルは、index が宣言座標を持つ場合、主要一致の行と列を使って +判定します。C# の位置 record property は、component の最初の attribute または型 token から +識別子までの範囲を保持します。これにより、同じ物理行に位置 component があっても、 +record keyword、record 型名、base 引数、body member は実際の囲みシンボルに帰属します。 +列 metadata がない旧 row では行ベースの fallback を維持します。主要一致の選択は表示される +focus 行と一致します。query が言語を指定しない場合、exact-source 名は各結果の言語で正規化し、 +raw source の列 map は escaped name が正規化で実際に変わる行にだけ作成します。 + 以下のクエリを実行すると: ```sql SELECT f.path, c.start_line, c.content diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 90fb66537..9a67bc14e 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -551,6 +551,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result - Argument-bearing parameter attributes share one method fixture for inline and line-broken declaration layouts. - C# parameter and argument-list modifier coverage keeps multiline declaration and call sites for `out` / `ref` / `in` / `params` / `this` / `scoped`, multiple modifier fragments on one continuation line, final `)`-closing fragments, nested lambda arguments, `out var`, following generic types, ref returns, and ref structs in one extractor fixture. The indexed reader fixture separately proves raw `type_reference` queries exclude modifiers while resolved following-type edges remain graph-queryable. - C# generic outline display coverage stays in one `DbReaderTests` fixture that shares extraction and reader setup across generic/non-generic overloads, extension/local/explicit-interface methods, nested generic tuple/nullable/array parameter types, constraints, overload-significant modifiers, qualified/concrete-name collisions, escaped and Unicode identifiers, and delimiters inside attribute/default literals. Keep the paired moved-line fixture byte-identical except for leading newlines, assert canonical names/paths remain unchanged, and retain one manually persisted incomplete-signature row for the old-index `Name@line` fallback (#4916). +- C# positional-record search attribution coverage pairs extractor coordinate assertions with one indexed reader fixture and one CLI fixture. Keep record keywords and type names, attributes and component types, base arguments, body members, same-line ordinary properties, multiline `record struct` components, normalized verbatim-identifier columns, language-unspecified exact grouping, multi-hit focus selection, focus line/column output, and symbol grouping in the shared contract; legacy symbols without component columns must retain line-based fallback behavior. When C# comment padding changes to preserve offsets, assert that Java/Kotlin record-component comments retain their established signatures (#5095). - Direct and `global::` static type qualifiers share one C# fixture while retaining per-container reference assertions. - Static qualifiers in using statements and field access share one consumer fixture and extraction pass. - Namespace-qualified and Pascal-cased instance-member chains share one qualifier fixture with a rightmost static type reference, so positive and negative qualifier outcomes are checked after one parse. @@ -1193,6 +1194,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" C# の declaration boundary に関する regression では、extractor を直接呼ぶ fixture と、実 index に対する `symbols --exact-name` query を組み合わせてください。呼び出し・parameter の continuation と、正当な複数行 method、constructor、delegate、local function を同居させ、false positive の拒否と宣言 range の両方を観測可能にします。 C# の明示的 interface identity coverage では、method、property、event、indexer の extractor assertion と、永続化後の修飾 / 非修飾の完全一致 query、fold rewrite 検証、inspect / outline、LSP の definition / reference scope を組み合わせます。複数および継承 interface、generic arity、同名 public member に加え、明示的 interface qualifier と誤認してはならない修飾 return type を含めてください。 C# generic outline 表示の coverage は、generic / 非 generic overload、extension / local / explicit-interface method、nested generic の tuple / nullable / array parameter type、constraint、overload を区別する modifier、修飾型 / 具体型名との衝突、escaped / Unicode identifier、attribute / 既定値 literal 内の区切り文字を、抽出と reader setup を共有する1つの `DbReaderTests` fixture に維持します。line 移動を検証する対の fixture は先頭改行以外を同一にし、canonical name / path が不変であることを assertion に含め、旧 index の `Name@line` fallback 用に不完全 signature を手動永続化した row も1件残してください(#4916)。 + C# の位置 record に対する検索帰属 coverage は、extractor の座標 assertion、1つの indexed reader fixture、1つの CLI fixture を組み合わせます。record keyword と型名、attribute と component 型、base 引数、body member、同一行の通常 property、複数行 `record struct` component、正規化された verbatim identifier の列、言語未指定の exact grouping、multi-hit の focus 選択、focus の行・列出力、symbol grouping を共通契約に維持します。component 列を持たない旧 symbol では行ベースの fallback を維持してください。C# の座標維持のために comment padding を変更する場合、Java/Kotlin の record component comment が既存 signature を維持することも assertion してください(#5095)。 C# の callable containment fixture では、block body の test method、local / nested local function、named lambda、expression-bodied member、nested type を同居させ、symbol の親と call reference の container の両方を検証してください。 C# switch expression が返す lambda の coverage では、`ReferenceExtractorCSharpTests` の直接 regression と `IndexCommandRunnerFullScanTests` の `--rebuild` graph fixture を組み合わせます。expression / block、parenthesized、multiline、nested switch、async、qualified / unqualified、non-lambda の各 arm を同居させ、raw call row、enclosing container、正確な location、callers / callees、完全な graph readiness、次行に arrow がある guarded / nested / contextual-keyword-designation pattern を含む positional pattern / enum の負例を検証してください(#5085)。 repository metadata の coverage は `SymbolExtractorRepositoryMetadataTests.cs` と `ReferenceExtractorRepositoryMetadataTests.cs` に置きます。TOML、JSON Lines、ignore / attributes、EditorConfig、`.rules`、application manifest の capability assertion を、保守的な local-path 抽出と malformed-record control に同期させてください。 diff --git a/changelog.d/unreleased/5095.fixed.md b/changelog.d/unreleased/5095.fixed.md new file mode 100644 index 000000000..a7da35b5e --- /dev/null +++ b/changelog.d/unreleased/5095.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 5095 +affected: + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs + - tests/CodeIndex.Tests/DbReaderSearchTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Search results now attribute C# positional records by line and column (#5095)** — record keywords and type names stay attached to the record type, positional components attach to their generated properties, and base arguments, body members, multiline `record struct` components, language-inferred normalized identifiers, multi-hit focus coordinates, ordinary same-line properties, and symbol grouping retain their precise owners. + +## 日本語 + +- **C# の位置 record 検索が行と列に基づいて帰属するようになりました (#5095)** — record keyword と型名は record 型に帰属し続け、位置 component は対応する合成 property に帰属します。base 引数、body member、複数行 `record struct` component、結果の言語から推定して正規化した identifier、multi-hit の focus 座標、同一行の通常 property、symbol grouping も正確な所有者を維持します。 diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index b5f88b977..87cd458d4 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -151,6 +151,12 @@ public List Search(string query, int limit = 20, string? lang = nu var hasContextRanking = resultRanking == SearchResultRanking.CredentialContext; var hasCandidatePostProcessing = hasGuardFilters || tokenBoundary || hasContextRanking; var searchMatchLineContext = SearchMatchLineContext.Create(query, lang, exactSearch); + var searchPrimaryMatchContext = SearchPrimaryMatchContext.Create( + query, + normalizedQuery, + rawQuery, + exactSearch, + lang); var exactLiteralBoost = !exactSearch && !rawQuery && ShouldBoostExactLiteralSearch(query); var guardedRequestedLimit = Math.Max(0, guardRequestedLimit ?? limit); var guardedCandidateLimit = hasGuardFilters @@ -364,7 +370,7 @@ FROM fts_chunks limit: candidatePostProcessingLimit); } - AttachSearchEnclosingSymbols(pagedResults, searchMatchLineContext); + AttachSearchEnclosingSymbols(pagedResults, searchPrimaryMatchContext); return pagedResults; } @@ -756,12 +762,16 @@ private List RankCredentialContextSearchResults( result, index, lineScore.Score, - lineScore.MatchLine); + lineScore.MatchLine, + lineScore.MatchColumn); }) .ToList(); AttachSearchEnclosingSymbols(candidates .Where(candidate => candidate.MatchLine.HasValue) - .Select(candidate => new SearchEnclosingSymbolRequest(candidate.Result, candidate.MatchLine!.Value)) + .Select(candidate => new SearchEnclosingSymbolRequest( + candidate.Result, + candidate.MatchLine!.Value, + candidate.MatchColumn)) .ToList()); return candidates @@ -779,6 +789,7 @@ private static SearchCredentialContextLineScore ScoreCredentialContextLines( if (matches.Count == 0) return new SearchCredentialContextLineScore( ContainsStructuralTokenMarker(result.Content) ? -800 : -600, + null, null); return matches @@ -792,7 +803,8 @@ private static SearchCredentialContextLineScore ScoreCredentialContextLines( .ThenBy(candidate => candidate.Index) .Select(candidate => new SearchCredentialContextLineScore( candidate.Score, - candidate.Match.LineNumber)) + candidate.Match.LineNumber, + candidate.Match.Column)) .First(); } @@ -801,6 +813,7 @@ private static int ScoreCredentialContextLine( SearchPrimaryMatchContext matchContext, SearchPrimaryMatch match) { + var prepared = matchContext.ForResult(result); var facet = SearchMatchClassifier.Classify( result.Path, result.Lang, @@ -820,14 +833,14 @@ private static int ScoreCredentialContextLine( _ => 0, }; - var tightlyCoupled = matchContext.Terms.Length <= 1 || - HasTightlyCoupledCredentialTerms(match.Text, matchContext.Terms); + var tightlyCoupled = prepared.Terms.Length <= 1 || + HasTightlyCoupledCredentialTerms(match.Text, prepared.Terms); score += tightlyCoupled ? 160 : -100; if (ContainsRegexDefinitionSyntax(match.Text)) score -= 360; if (ContainsRelevantStructuralTokenMarker( match.Text, - matchContext.Terms)) + prepared.Terms)) score -= 420; if (ContainsCredentialUseSyntax(match.Text)) score += 60; @@ -1045,13 +1058,17 @@ private sealed record SearchContextRankingCandidate( SearchResult Result, int OriginalIndex, int LineScore, - int? MatchLine); + int? MatchLine, + int? MatchColumn); private sealed record SearchCredentialContextLineScore( int Score, - int? MatchLine); + int? MatchLine, + int? MatchColumn); - private void AttachSearchEnclosingSymbols(IReadOnlyList results, SearchMatchLineContext matchLineContext) + private void AttachSearchEnclosingSymbols( + IReadOnlyList results, + SearchPrimaryMatchContext primaryMatchContext) { var requests = new List(); foreach (var result in results) @@ -1059,9 +1076,9 @@ private void AttachSearchEnclosingSymbols(IReadOnlyList results, S if (!string.IsNullOrWhiteSpace(result.EnclosingSymbolKind)) continue; - var matchLine = GetFirstSearchMatchLine(result, matchLineContext); - if (matchLine.HasValue) - requests.Add(new SearchEnclosingSymbolRequest(result, matchLine.Value)); + var primaryMatch = FindPrimarySearchMatchLines(result, primaryMatchContext).FirstOrDefault(); + if (primaryMatch != null) + requests.Add(new SearchEnclosingSymbolRequest(result, primaryMatch.LineNumber, primaryMatch.Column)); } AttachSearchEnclosingSymbols(requests); @@ -1070,17 +1087,26 @@ private void AttachSearchEnclosingSymbols(IReadOnlyList results, S private void AttachSearchEnclosingSymbols(IReadOnlyList requests) { var startLineSql = GetSymbolColumnSql("start_line", "s.line", "s"); + var startColumnSql = GetSymbolColumnSql("start_column", "NULL", "s"); var endLineSql = GetSymbolColumnSql("end_line", "s.line", "s"); + var bodyStartLineSql = GetSymbolColumnSql("body_start_line", "NULL", "s"); + var identifierStartColumnSql = GetSymbolColumnSql("identifier_start_column", "NULL", "s"); + var signatureSql = GetSymbolColumnSql("signature", "NULL", "s"); var containerNameSql = GetSymbolColumnSql("container_name", symbolAlias: "s"); var returnTypeSql = GetSymbolColumnSql("return_type", symbolAlias: "s"); + var declarationStartColumnSql = $@"CASE + WHEN {startColumnSql} IS NULL THEN NULL + WHEN {signatureSql} IS NULL OR instr({signatureSql}, s.name) <= 0 THEN {startColumnSql} + ELSE max(0, {startColumnSql} - instr({signatureSql}, s.name) + 1) + END"; for (var offset = 0; offset < requests.Count; offset += SearchEnclosingSymbolBatchSize) { var batch = requests.Skip(offset).Take(SearchEnclosingSymbolBatchSize).ToList(); using var cmd = _conn.CreateCommand(); var requestedValues = string.Join(", ", Enumerable.Range(0, batch.Count) - .Select(index => $"(@symbolPath{index}, @symbolLine{index}, {index})")); + .Select(index => $"(@symbolPath{index}, @symbolLine{index}, @symbolColumn{index}, {index})")); cmd.CommandText = $@" - WITH requested(path, match_line, request_index) AS ( + WITH requested(path, match_line, match_column, request_index) AS ( VALUES {requestedValues} ), ranked AS ( @@ -1115,6 +1141,18 @@ FROM requested LEFT JOIN symbols s ON s.file_id = f.id AND {startLineSql} <= requested.match_line AND {endLineSql} >= requested.match_line + AND (requested.match_column IS NULL + OR s.line <> requested.match_line + OR {declarationStartColumnSql} IS NULL + OR {declarationStartColumnSql} <= requested.match_column - 1) + AND (requested.match_column IS NULL + OR s.kind <> 'property' + OR {bodyStartLineSql} IS NOT NULL + OR {identifierStartColumnSql} IS NULL + OR (({startLineSql} < requested.match_line + OR COALESCE({startColumnSql}, {identifierStartColumnSql}) <= requested.match_column - 1) + AND ({endLineSql} > requested.match_line + OR {identifierStartColumnSql} + length(s.name) > requested.match_column - 1))) ) SELECT request_index, symbol_name, @@ -1131,6 +1169,7 @@ AND symbol_name IS NOT NULL { SqliteCommandPolicy.Add(cmd, $"@symbolPath{index}", batch[index].Result.Path); SqliteCommandPolicy.Add(cmd, $"@symbolLine{index}", batch[index].MatchLine); + SqliteCommandPolicy.Add(cmd, $"@symbolColumn{index}", batch[index].MatchColumn); } using var reader = cmd.ExecuteTrackedReader(); @@ -1147,31 +1186,7 @@ AND symbol_name IS NOT NULL } } - private sealed record SearchEnclosingSymbolRequest(SearchResult Result, int MatchLine); - - private static int? GetFirstSearchMatchLine(SearchResult result, SearchMatchLineContext context) - { - var prepared = context.ForResult(result); - int? firstTokenMatchLine = null; - - foreach (var (lineIndex, text) in EnumerateContentLines(result.Content)) - { - var line = prepared.NormalizeLine(text); - if (!string.IsNullOrWhiteSpace(prepared.NormalizedQuery) && - line.Contains(prepared.NormalizedQuery, prepared.Comparison)) - { - return result.StartLine + lineIndex; - } - - if (firstTokenMatchLine.HasValue || prepared.Tokens.Length == 0) - continue; - - if (prepared.Tokens.Any(token => line.Contains(token, prepared.Comparison))) - firstTokenMatchLine = result.StartLine + lineIndex; - } - - return firstTokenMatchLine; - } + private sealed record SearchEnclosingSymbolRequest(SearchResult Result, int MatchLine, int? MatchColumn); private sealed class SearchMatchLineContext { @@ -1568,7 +1583,7 @@ private List FilterSearchResultByGuards( { guardWindow = Math.Clamp(guardWindow, 0, MaxSearchGuardWindow); var filtered = new List(); - foreach (var primaryMatch in FindPrimarySearchMatchLines(result, primaryMatchContext)) + foreach (var primaryMatch in FindPrimarySearchMatchLines(result, primaryMatchContext, requireAllTerms: true)) { var guardEvidence = new List(); var guardChecks = new List(guardFilters.Count); @@ -1612,9 +1627,13 @@ private List FilterSearchResultByGuards( return filtered; } - private static List FindPrimarySearchMatchLines(SearchResult result, SearchPrimaryMatchContext context) + private static List FindPrimarySearchMatchLines( + SearchResult result, + SearchPrimaryMatchContext context, + bool requireAllTerms = false) { - if (context.Terms.Length == 0) + var prepared = context.ForResult(result); + if (prepared.Terms.Length == 0) { foreach (var (lineIndex, text) in EnumerateContentLines(result.Content)) return [new SearchPrimaryMatch(result.StartLine + lineIndex, text, 1, 1)]; @@ -1622,37 +1641,139 @@ private static List FindPrimarySearchMatchLines(SearchResult return [new SearchPrimaryMatch(result.StartLine, string.Empty, 1, 1)]; } - var normalizeCSharp = context.ShouldNormalizeCSharp(result); - var matches = new List(); + var preferredMatches = new List(); + var fallbackMatches = new List(); foreach (var (lineIndex, text) in EnumerateContentLines(result.Content)) { - var line = normalizeCSharp ? CSharpVerbatimNameNormalizer.Normalize(text) : text; - var lineMatches = context.RequireAllTermsOnLine - ? context.Terms.All(term => line.Contains(term, context.Comparison)) - : context.Terms.Any(term => line.Contains(term, context.Comparison)); - if (lineMatches && TryFindPrimaryMatchSpan(text, line, context, out var column, out var length)) - matches.Add(new SearchPrimaryMatch(result.StartLine + lineIndex, text, column, length)); + var line = ExactSourceSearchNormalizer.Normalize(text, prepared.Lang); + int[]? rawIndexMap = null; + if (!ReferenceEquals(line, text)) + line = ExactSourceSearchNormalizer.Normalize(text, prepared.Lang, out rawIndexMap); + + if (TryFindPrimaryMatchSpan( + text, + line, + rawIndexMap, + prepared.PreferredTerms, + prepared.Comparison, + out var preferredColumn, + out var preferredLength)) + { + preferredMatches.Add(new SearchPrimaryMatch( + result.StartLine + lineIndex, + text, + preferredColumn, + preferredLength)); + } + + int fallbackColumn; + int fallbackLength; + var fallbackMatched = requireAllTerms + ? TryFindAllPrimaryMatchTerms( + text, + line, + rawIndexMap, + prepared.Terms, + prepared.Comparison, + out fallbackColumn, + out fallbackLength) + : TryFindPrimaryMatchSpan( + text, + line, + rawIndexMap, + prepared.Terms, + prepared.Comparison, + out fallbackColumn, + out fallbackLength); + if (fallbackMatched) + { + fallbackMatches.Add(new SearchPrimaryMatch( + result.StartLine + lineIndex, + text, + fallbackColumn, + fallbackLength)); + } } - return matches; + return preferredMatches.Count > 0 ? preferredMatches : fallbackMatches; } private sealed record SearchPrimaryMatch(int LineNumber, string Text, int Column, int Length); - private static bool TryFindPrimaryMatchSpan(string text, string normalizedLine, SearchPrimaryMatchContext context, out int column, out int length) + private static bool TryFindPrimaryMatchSpan( + string text, + string normalizedLine, + int[]? rawIndexMap, + IReadOnlyList terms, + StringComparison comparison, + out int column, + out int length) { var bestIndex = int.MaxValue; var bestLength = 0; - foreach (var term in context.Terms) + foreach (var term in terms) + { + if (!TryFindPrimaryMatchTerm( + text, + normalizedLine, + rawIndexMap, + term, + comparison, + out var rawIndex, + out var rawLength)) + continue; + + if (rawIndex >= bestIndex) + continue; + + bestIndex = rawIndex; + bestLength = rawLength; + } + + if (bestIndex == int.MaxValue) { - var index = text.IndexOf(term, context.Comparison); - if (index < 0) - index = normalizedLine.IndexOf(term, context.Comparison); - if (index < 0 || index >= bestIndex) + column = 1; + length = 1; + return false; + } + + column = bestIndex + 1; + length = Math.Max(1, Math.Min(bestLength, Math.Max(1, text.Length - bestIndex))); + return true; + } + + private static bool TryFindAllPrimaryMatchTerms( + string text, + string normalizedLine, + int[]? rawIndexMap, + IReadOnlyList terms, + StringComparison comparison, + out int column, + out int length) + { + var bestIndex = int.MaxValue; + var bestLength = 0; + foreach (var term in terms) + { + if (!TryFindPrimaryMatchTerm( + text, + normalizedLine, + rawIndexMap, + term, + comparison, + out var rawIndex, + out var rawLength)) + { + column = 1; + length = 1; + return false; + } + + if (rawIndex >= bestIndex) continue; - bestIndex = index; - bestLength = term.Length; + bestIndex = rawIndex; + bestLength = rawLength; } if (bestIndex == int.MaxValue) @@ -1667,41 +1788,155 @@ private static bool TryFindPrimaryMatchSpan(string text, string normalizedLine, return true; } - private sealed record SearchPrimaryMatchContext( - string[] Terms, - bool RawQuery, - bool Exact, - string? QueryLang) + private static bool TryFindPrimaryMatchTerm( + string text, + string normalizedLine, + int[]? rawIndexMap, + string term, + StringComparison comparison, + out int rawIndex, + out int rawLength) { - public StringComparison Comparison => Exact ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + rawIndex = -1; + rawLength = 0; + if (term.Length == 0) + return false; + + rawIndex = text.IndexOf(term, comparison); + rawLength = term.Length; + if (rawIndex >= 0) + return true; - public bool RequireAllTermsOnLine => !RawQuery && !Exact && Terms.Length > 1; + var normalizedIndex = normalizedLine.IndexOf(term, comparison); + if (normalizedIndex < 0) + return false; + + rawIndex = normalizedIndex; + if (rawIndexMap != null + && TryMapNormalizedSearchSpan(rawIndexMap, normalizedIndex, term.Length, out var mappedIndex, out var mappedLength)) + { + rawIndex = mappedIndex; + rawLength = mappedLength; + } + + return true; + } + + private static bool TryMapNormalizedSearchSpan( + int[] rawIndexMap, + int normalizedStart, + int normalizedLength, + out int rawStart, + out int rawLength) + { + rawStart = 0; + rawLength = 0; + if (normalizedStart < 0 || normalizedLength <= 0) + return false; + + var normalizedEnd = normalizedStart + normalizedLength - 1; + if (normalizedEnd < normalizedStart || normalizedEnd >= rawIndexMap.Length) + return false; + + rawStart = rawIndexMap[normalizedStart]; + var rawEnd = rawIndexMap[normalizedEnd]; + if (rawStart < 0 || rawEnd < rawStart) + return false; + + rawLength = rawEnd - rawStart + 1; + return true; + } + + private sealed class SearchPrimaryMatchContext + { + private readonly string _query; + private readonly string _normalizedQuery; + private readonly bool _rawQuery; + private readonly bool _exact; + private readonly string? _queryLang; + private readonly Dictionary _termsByLang = new(StringComparer.OrdinalIgnoreCase); + + private SearchPrimaryMatchContext( + string query, + string normalizedQuery, + bool rawQuery, + bool exact, + string? queryLang) + { + _query = query; + _normalizedQuery = normalizedQuery; + _rawQuery = rawQuery; + _exact = exact; + _queryLang = queryLang; + } public static SearchPrimaryMatchContext Create(string query, string normalizedQuery, bool rawQuery, bool exact, string? queryLang) - => new(BuildPrimarySearchMatchTerms(query, normalizedQuery, rawQuery, exact), rawQuery, exact, queryLang); + => new(query, normalizedQuery, rawQuery, exact, queryLang); - public string? GetEffectiveLang(SearchResult result) => QueryLang ?? result.Lang; + public string? GetEffectiveLang(SearchResult result) => _queryLang ?? result.Lang; - public bool ShouldNormalizeCSharp(SearchResult result) - => string.Equals(GetEffectiveLang(result), "csharp", StringComparison.OrdinalIgnoreCase); + public SearchPrimaryMatchTerms ForResult(SearchResult result) + { + var lang = GetEffectiveLang(result); + var key = lang ?? string.Empty; + if (_termsByLang.TryGetValue(key, out var prepared)) + return prepared; + + var normalizedQuery = _rawQuery + ? _normalizedQuery + : NormalizeLiteralSearchQuery(_query, lang); + var preferredQuery = NormalizeGuardSearchTerm( + _rawQuery + ? _query + : ExactSourceSearchNormalizer.Normalize(normalizedQuery, lang)); + prepared = new SearchPrimaryMatchTerms( + preferredQuery.Length == 0 ? [] : [preferredQuery], + BuildPrimarySearchMatchTerms(_query, normalizedQuery, _rawQuery, _exact, lang), + _exact ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase, + lang); + _termsByLang[key] = prepared; + return prepared; + } } - private static string[] BuildPrimarySearchMatchTerms(string query, string normalizedQuery, bool rawQuery, bool exact) + private sealed record SearchPrimaryMatchTerms( + string[] PreferredTerms, + string[] Terms, + StringComparison Comparison, + string? Lang); + + private static string[] BuildPrimarySearchMatchTerms( + string query, + string normalizedQuery, + bool rawQuery, + bool exact, + string? lang) { IEnumerable rawTerms = !exact && !rawQuery ? SplitLiteralSearchTokens(normalizedQuery) : [rawQuery ? query.Trim() : normalizedQuery.Trim()]; - var terms = rawTerms.Select(NormalizeGuardSearchTerm).ToList(); + var terms = rawTerms + .Select(NormalizeGuardSearchTerm) + .Where(term => !IsPrimarySearchMatchOperator(term)) + .Select(term => rawQuery ? term : ExactSourceSearchNormalizer.Normalize(term, lang)) + .ToList(); if (!exact && rawQuery) terms.AddRange(GetSearchCoverageTokens(normalizedQuery, rawQuery)); return terms .Select(NormalizeGuardSearchTerm) .Where(term => term.Length > 0) + .Where(term => !IsPrimarySearchMatchOperator(term)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } + private static bool IsPrimarySearchMatchOperator(string term) + => term.Equals("AND", StringComparison.OrdinalIgnoreCase) + || term.Equals("OR", StringComparison.OrdinalIgnoreCase) + || term.Equals("NOT", StringComparison.OrdinalIgnoreCase) + || term.Equals("NEAR", StringComparison.OrdinalIgnoreCase); + private sealed record SearchGuardEvaluation( int WindowStartLine, int WindowEndLine, diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs index 2c6f25f5a..be732111f 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -6,7 +6,7 @@ public static partial class SymbolExtractor public const int ExpandedLanguageContractVersion = 2; public const int YamlContractVersion = 3; public const int PythonContractVersion = 2; - public const int CSharpContractVersion = 10; + public const int CSharpContractVersion = 11; public const int DockerfileContractVersion = 2; public const int MakefileContractVersion = 2; public const int StyleAndXamlContractVersion = 2; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs index 571912771..91ee87421 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs @@ -9,11 +9,15 @@ private readonly record struct RecordPrimaryComponent( string Type, string Signature, int Line, - string? Visibility = null); + string? Visibility = null, + int? StartLine = null, + int? StartColumn = null, + int? IdentifierStartColumn = null); private readonly record struct RecordPrimaryComponentSlice( string Text, - int Line); + int Line, + int StartIndex); private readonly record struct PendingRecordPrimaryComponents( long FileId, diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs index 638605f0b..fe832de7c 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs @@ -145,8 +145,10 @@ private static void MaterializeRecordPrimaryComponentSymbols( Kind = "property", Name = component.Name, Line = component.Line, - StartLine = component.Line, + StartLine = component.StartLine ?? component.Line, + StartColumn = component.StartColumn, EndLine = component.Line, + IdentifierStartColumn = component.IdentifierStartColumn, Signature = component.Signature, ContainerKind = pending.Kind, ContainerName = pending.RecordName, @@ -223,12 +225,42 @@ private static bool TryGetRecordPrimaryComponents( var declarationLineSpanEnd = declarationTerminatorIndex >= 0 ? declarationTerminatorIndex + 1 : parameterCloseIndex + 1; declarationEndLine = declarationLineIndex + 1 + declaration[..declarationLineSpanEnd].Count(ch => ch == '\n'); - var rawParameterList = StripRecordComponentComments(declaration[(parameterOpenIndex + 1)..parameterCloseIndex]); + var parameterListStartIndex = parameterOpenIndex + 1; + var rawParameterList = StripRecordComponentComments( + declaration[parameterListStartIndex..parameterCloseIndex], + preserveOffsets: lang == "csharp"); + var parameterListFirstLine = lang == "csharp" + ? declarationLineIndex + 1 + declaration[..parameterListStartIndex].Count(ch => ch == '\n') + : declarationLineIndex + 1; components = []; - foreach (var rawComponent in SplitTopLevelRecordPrimaryComponents(rawParameterList, declarationLineIndex + 1)) + foreach (var rawComponent in SplitTopLevelRecordPrimaryComponents(rawParameterList, parameterListFirstLine)) { if (TryParseRecordPrimaryComponent(lang, rawComponent, out var component)) + { + if (lang == "csharp" + && TryGetCSharpRecordPrimaryComponentCoordinates( + declaration, + declarationLineIndex, + declarationStartColumn, + parameterListStartIndex, + rawComponent, + component.Name, + out var startLine, + out var startColumn, + out var identifierLine, + out var identifierStartColumn)) + { + component = component with + { + Line = identifierLine, + StartLine = startLine, + StartColumn = startColumn, + IdentifierStartColumn = identifierStartColumn, + }; + } + components.Add(component); + } } return true; @@ -651,6 +683,7 @@ private static IEnumerable SplitTopLevelRecordPrima var escapeNext = false; var currentLineNumber = firstLineNumber; var componentLineNumber = firstLineNumber; + var componentStartIndex = 0; var componentHasToken = false; for (int index = 0; index < parameterList.Length; index++) @@ -660,6 +693,7 @@ private static IEnumerable SplitTopLevelRecordPrima { componentHasToken = true; componentLineNumber = currentLineNumber; + componentStartIndex = index; } if (escapeNext) @@ -744,7 +778,7 @@ private static IEnumerable SplitTopLevelRecordPrima case ',' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: var component = builder.ToString().Trim(); if (component.Length > 0) - yield return new RecordPrimaryComponentSlice(component, componentLineNumber); + yield return new RecordPrimaryComponentSlice(component, componentLineNumber, componentStartIndex); builder.Clear(); componentHasToken = false; componentLineNumber = currentLineNumber; @@ -759,10 +793,10 @@ private static IEnumerable SplitTopLevelRecordPrima var trailingComponent = builder.ToString().Trim(); if (trailingComponent.Length > 0) - yield return new RecordPrimaryComponentSlice(trailingComponent, componentLineNumber); + yield return new RecordPrimaryComponentSlice(trailingComponent, componentLineNumber, componentStartIndex); } - private static string StripRecordComponentComments(string text) + private static string StripRecordComponentComments(string text, bool preserveOffsets) { var builder = new StringBuilder(text.Length); var inLineComment = false; @@ -783,6 +817,10 @@ private static string StripRecordComponentComments(string text) inLineComment = false; builder.Append(ch); } + else if (preserveOffsets) + { + builder.Append(' '); + } continue; } @@ -792,13 +830,17 @@ private static string StripRecordComponentComments(string text) if (ch == '*' && next == '/') { inBlockComment = false; + builder.Append(preserveOffsets ? " " : " "); i++; - builder.Append(' '); } else if (ch == '\n') { builder.Append(ch); } + else if (preserveOffsets) + { + builder.Append(' '); + } continue; } @@ -847,6 +889,8 @@ private static string StripRecordComponentComments(string text) if (ch == '/' && next == '/') { inLineComment = true; + if (preserveOffsets) + builder.Append(" "); i++; continue; } @@ -854,6 +898,8 @@ private static string StripRecordComponentComments(string text) if (ch == '/' && next == '*') { inBlockComment = true; + if (preserveOffsets) + builder.Append(" "); i++; continue; } @@ -864,6 +910,72 @@ private static string StripRecordComponentComments(string text) return builder.ToString(); } + private static bool TryGetCSharpRecordPrimaryComponentCoordinates( + string declaration, + int declarationLineIndex, + int declarationStartColumn, + int parameterListStartIndex, + RecordPrimaryComponentSlice rawComponent, + string componentName, + out int startLine, + out int startColumn, + out int identifierLine, + out int identifierStartColumn) + { + startLine = 0; + startColumn = 0; + identifierLine = 0; + identifierStartColumn = 0; + + var componentWithoutDefault = TrimAfterTopLevelEquals(rawComponent.Text); + var nameMatch = Regex.Match( + componentWithoutDefault, + @"(?@?" + Regex.Escape(componentName) + @")\s*$", + RegexOptions.CultureInvariant); + if (!nameMatch.Success) + return false; + + var componentStartIndex = parameterListStartIndex + rawComponent.StartIndex; + var identifierIndex = componentStartIndex + nameMatch.Groups["name"].Index; + if (declaration[identifierIndex] == '@') + identifierIndex++; + + (startLine, startColumn) = GetRecordPrimaryComponentSourcePosition( + declaration, + declarationLineIndex, + declarationStartColumn, + componentStartIndex); + (identifierLine, identifierStartColumn) = GetRecordPrimaryComponentSourcePosition( + declaration, + declarationLineIndex, + declarationStartColumn, + identifierIndex); + return true; + } + + private static (int Line, int Column) GetRecordPrimaryComponentSourcePosition( + string declaration, + int declarationLineIndex, + int declarationStartColumn, + int declarationIndex) + { + var line = declarationLineIndex + 1; + var lastNewlineIndex = -1; + for (var index = 0; index < declarationIndex; index++) + { + if (declaration[index] != '\n') + continue; + + line++; + lastNewlineIndex = index; + } + + var column = lastNewlineIndex < 0 + ? declarationStartColumn + declarationIndex + : declarationIndex - lastNewlineIndex - 1; + return (line, column); + } + private static bool TryParseRecordPrimaryComponent(string lang, RecordPrimaryComponentSlice rawComponent, out RecordPrimaryComponent component) { component = default; diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index e29518afe..204a62822 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -10,6 +10,120 @@ namespace CodeIndex.Tests; public partial class DbReaderTests { + [Fact] + public void Search_UsesColumnsToAttributeCSharpPositionalRecordMatches_Issue5095() + { + InsertIndexedFile( + "src/Records.cs", + "csharp", + """ + public record Base(string Key); + public record Person([property: Obsolete] string First, string Last) : Base("base-marker") + { + public string Ordinary { get; init; } = "ordinary-marker"; + public string Describe() => "body-marker"; + } + + public readonly record struct Point( + int X, + int Y); + + public record Generic<@T>(string @Value); + """); + + AssertEnclosing("record Person", "Person", "class"); + AssertEnclosing("Person(", "Person", "class"); + AssertEnclosing("Obsolete", "First", "property"); + AssertEnclosing("string First", "First", "property"); + AssertEnclosing("string Last", "Last", "property"); + AssertEnclosing("base-marker", "Person", "class"); + AssertEnclosing("ordinary-marker", "Ordinary", "property"); + AssertEnclosing("body-marker", "Describe", "function"); + AssertEnclosing("record struct Point", "Point", "struct"); + AssertEnclosing("int X", "X", "property"); + AssertEnclosing("int Y", "Y", "property"); + AssertEnclosing("string Value", "Value", "property"); + AssertEnclosing("@Value", "Value", "property"); + + InsertIndexedFile( + "src/InlineRecord.cs", + "csharp", + "public record Inline(string Nick) { public string Ordinary { get; init; } = \"inline-ordinary-marker\"; }"); + AssertEnclosing("record Inline", "Inline", "class"); + AssertEnclosing("string Nick", "Nick", "property"); + AssertEnclosing("inline-ordinary-marker", "Ordinary", "property"); + + InsertIndexedFile( + "src/MultiHit.cs", + "csharp", + """ + public class FocusMismatch + { + public void First() + { + var alphaOnlyMarker = 1; + } + + public void Second() + { + var alphaOnlyMarker = betaOnlyMarker; + } + } + """); + var multiHit = Assert.Single(_reader.Search( + "alphaOnlyMarker betaOnlyMarker", + lang: "csharp", + pathPatterns: ["src/MultiHit.cs"])); + Assert.Equal("First", multiHit.EnclosingSymbolName); + Assert.Equal("function", multiHit.EnclosingSymbolKind); + + void AssertEnclosing(string query, string expectedName, string expectedKind) + { + var result = Assert.Single(_reader.Search(query, exact: true)); + Assert.Equal(expectedName, result.EnclosingSymbolName); + Assert.Equal(expectedKind, result.EnclosingSymbolKind); + } + } + + [Fact] + public void Search_ExactAttributionNormalizesUsingResultLanguage() + { + InsertIndexedFile( + "src/JavaNames.java", + "java", + """ + public final class JavaNames { + public void run() { + int \u0056alue = 1; + } + } + """); + InsertIndexedFile( + "src/KotlinNames.kt", + "kotlin", + """ + class KotlinNames { + fun run() { + val `when` = 1 + } + } + """); + + var java = Assert.Single(_reader.Search( + "Value", + exact: true, + pathPatterns: ["src/JavaNames.java"])); + Assert.Equal("run", java.EnclosingSymbolName); + Assert.Equal("function", java.EnclosingSymbolKind); + + var kotlin = Assert.Single(_reader.Search( + "when", + exact: true, + pathPatterns: ["src/KotlinNames.kt"])); + Assert.Equal("run", kotlin.EnclosingSymbolName); + Assert.Equal("function", kotlin.EnclosingSymbolKind); + } + [Fact] public void Search_ExplicitPrefixMatchesLatinDiacriticToken() { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index bfff6c87b..331df9209 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -6087,7 +6087,7 @@ FROM symbol_references SymbolExtractor.CSharpContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), versionCmd.ExecuteScalar() as string); - Assert.Equal(10, SymbolExtractor.CSharpContractVersion); + Assert.Equal(11, SymbolExtractor.CSharpContractVersion); } finally { @@ -6353,7 +6353,7 @@ ORDER BY symbol_name SymbolExtractor.CSharpContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), versionCmd.ExecuteScalar() as string); - Assert.Equal(10, SymbolExtractor.CSharpContractVersion); + Assert.Equal(11, SymbolExtractor.CSharpContractVersion); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 8520037f8..e624577a2 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1864,6 +1864,125 @@ public void Execute() } } + [Fact] + public void RunSearch_CSharpPositionalRecordFocusAndGroupingUsePreciseSymbol_Issue5095() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_record_component_5095"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Records.cs", + "csharp", + """ + public record Person([property: Obsolete] string First, string Last); + public record Generic<@T>(string @Value); + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/MultiHit.cs", + "csharp", + """ + public class FocusMismatch + { + public void First() + { + var alphaOnlyMarker = 1; + } + + public void Second() + { + var alphaOnlyMarker = betaOnlyMarker; + } + } + """); + + var keyword = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["record Person", "--db", dbPath, "--exact-substring", "--json=array"], + _jsonOptions)); + var component = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["string First", "--db", dbPath, "--exact-substring", "--json=array"], + _jsonOptions)); + var grouped = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["string First", "--db", dbPath, "--exact-substring", "--group-by", "symbol", "--count", "--json"], + _jsonOptions)); + var normalizedColumn = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["string Value", "--db", dbPath, "--exact-substring", "--path", "src/Records.cs", "--json=array"], + _jsonOptions)); + var normalizedGrouping = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["@Value", "--db", dbPath, "--exact-substring", "--path", "src/Records.cs", "--group-by", "symbol", "--count", "--json"], + _jsonOptions)); + var multiHit = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["alphaOnlyMarker betaOnlyMarker", "--db", dbPath, "--path", "src/MultiHit.cs", "--json=array"], + _jsonOptions)); + var multiHitGrouped = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["alphaOnlyMarker betaOnlyMarker", "--db", dbPath, "--path", "src/MultiHit.cs", "--group-by", "symbol", "--count", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, keyword.Result); + Assert.Equal(string.Empty, keyword.Stderr); + using var keywordDocument = ParseJsonOutput(keyword.Stdout); + var keywordRow = Assert.Single(keywordDocument.RootElement.EnumerateArray()); + Assert.Equal(1, keywordRow.GetProperty("focus_line").GetInt32()); + Assert.Equal(8, keywordRow.GetProperty("focus_column").GetInt32()); + Assert.Equal("Person", keywordRow.GetProperty("enclosing_symbol_name").GetString()); + Assert.Equal("class", keywordRow.GetProperty("enclosing_symbol_kind").GetString()); + + Assert.Equal(CommandExitCodes.Success, component.Result); + Assert.Equal(string.Empty, component.Stderr); + using var componentDocument = ParseJsonOutput(component.Stdout); + var componentRow = Assert.Single(componentDocument.RootElement.EnumerateArray()); + Assert.Equal(1, componentRow.GetProperty("focus_line").GetInt32()); + Assert.Equal(43, componentRow.GetProperty("focus_column").GetInt32()); + Assert.Equal("First", componentRow.GetProperty("enclosing_symbol_name").GetString()); + Assert.Equal("property", componentRow.GetProperty("enclosing_symbol_kind").GetString()); + + Assert.Equal(CommandExitCodes.Success, grouped.Result); + Assert.Equal(string.Empty, grouped.Stderr); + using var groupedDocument = ParseJsonOutput(grouped.Stdout); + var group = Assert.Single(groupedDocument.RootElement.GetProperty("groups").EnumerateArray()); + Assert.Equal("src/Records.cs", group.GetProperty("file").GetString()); + Assert.Equal("First", group.GetProperty("symbol_name").GetString()); + Assert.Equal("property", group.GetProperty("symbol_kind").GetString()); + + Assert.Equal(CommandExitCodes.Success, normalizedColumn.Result); + Assert.Equal(string.Empty, normalizedColumn.Stderr); + using var normalizedColumnDocument = ParseJsonOutput(normalizedColumn.Stdout); + var normalizedColumnRow = Assert.Single(normalizedColumnDocument.RootElement.EnumerateArray()); + Assert.Equal(2, normalizedColumnRow.GetProperty("focus_line").GetInt32()); + Assert.Equal(27, normalizedColumnRow.GetProperty("focus_column").GetInt32()); + Assert.Equal("Value", normalizedColumnRow.GetProperty("enclosing_symbol_name").GetString()); + Assert.Equal("property", normalizedColumnRow.GetProperty("enclosing_symbol_kind").GetString()); + + Assert.Equal(CommandExitCodes.Success, normalizedGrouping.Result); + Assert.Equal(string.Empty, normalizedGrouping.Stderr); + using var normalizedGroupingDocument = ParseJsonOutput(normalizedGrouping.Stdout); + var normalizedGroup = Assert.Single(normalizedGroupingDocument.RootElement.GetProperty("groups").EnumerateArray()); + Assert.Equal("Value", normalizedGroup.GetProperty("symbol_name").GetString()); + Assert.Equal("property", normalizedGroup.GetProperty("symbol_kind").GetString()); + + Assert.Equal(CommandExitCodes.Success, multiHit.Result); + Assert.Equal(string.Empty, multiHit.Stderr); + using var multiHitDocument = ParseJsonOutput(multiHit.Stdout); + var multiHitRow = Assert.Single(multiHitDocument.RootElement.EnumerateArray()); + Assert.Equal(5, multiHitRow.GetProperty("focus_line").GetInt32()); + Assert.Equal("First", multiHitRow.GetProperty("enclosing_symbol_name").GetString()); + Assert.Equal("function", multiHitRow.GetProperty("enclosing_symbol_kind").GetString()); + + Assert.Equal(CommandExitCodes.Success, multiHitGrouped.Result); + Assert.Equal(string.Empty, multiHitGrouped.Stderr); + using var multiHitGroupedDocument = ParseJsonOutput(multiHitGrouped.Stdout); + var multiHitGroup = Assert.Single(multiHitGroupedDocument.RootElement.GetProperty("groups").EnumerateArray()); + Assert.Equal("First", multiHitGroup.GetProperty("symbol_name").GetString()); + Assert.Equal("function", multiHitGroup.GetProperty("symbol_kind").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_NullableRecipeGroupByReturnTypeCountsEnclosingReturnTypes_Issue4301() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 602a01356..15dce3fc0 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -3178,9 +3178,17 @@ public record Person( var name = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "Name" && s.ContainerName == "Person")); Assert.Equal(3, name.Line); + Assert.Equal(2, name.StartLine); + Assert.Equal(4, name.StartColumn); + Assert.Equal(11, name.IdentifierStartColumn); + Assert.Equal(3, name.EndLine); var age = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "Age" && s.ContainerName == "Person")); Assert.Equal(5, age.Line); + Assert.Equal(4, age.StartLine); + Assert.Equal(4, age.StartColumn); + Assert.Equal(8, age.IdentifierStartColumn); + Assert.Equal(5, age.EndLine); } [Fact] diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 6004eb52b..b5deb6980 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -6489,6 +6489,8 @@ public record Point( // the next component must still parse int y ) {} + + public record InlineComment(String/*marker*/name) {} """; var symbols = SymbolExtractor.Extract(1, "java", content); @@ -6504,6 +6506,11 @@ int y var pointY = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "y" && s.ContainerName == "Point")); Assert.Equal(40, pointY.Line); + + var inlineCommentName = Assert.Single(symbols.Where(s => + s.Kind == "property" && s.Name == "name" && s.ContainerName == "InlineComment")); + Assert.Equal("String", inlineCommentName.ReturnType); + Assert.Equal("String name", inlineCommentName.Signature); } #if NET8_0 @@ -7281,7 +7288,7 @@ public void Extract_Kotlin_DetectsFunctionsAndClasses() { // Kotlin: class, fun / Kotlin: クラス、関数 var content = """ - data class Config(val name: String) + data class Config(val name: String, val items: List) fun one() = 1 fun process(input: String): String { return input.trim() @@ -7292,6 +7299,9 @@ fun three() = 3 Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "Config"); Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "name" && s.ContainerKind == "class" && s.ContainerName == "Config"); + var items = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "items" && s.ContainerName == "Config")); + Assert.Equal("List< String>", items.ReturnType); + Assert.Equal("items: List< String>", items.Signature); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "one" && s.StartLine == 2 && s.EndLine == 2 && s.BodyStartLine == null && s.BodyEndLine == null); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "process" && s.StartLine == 3 && s.EndLine == 5 && s.BodyStartLine == 3 && s.BodyEndLine == 5); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "three" && s.StartLine == 6 && s.EndLine == 6 && s.BodyStartLine == null && s.BodyEndLine == null);