Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ Do not add mutable static caches, shared `StringBuilder` instances, reused `Matc
| `file_module` | File-scoped module/package declarations | Namespace-like context symbol |
| `function` | Functions, methods, constructors, delegates, tasks, and callable bindings that do not have a narrower kind | Primary callable definition; participates in callers/callees through reference rows |
| `generator` | JavaScript/TypeScript generator declarations | Callable definition; participates in callers/callees through reference rows |
| `heading` | Markdown headings and language section markers such as C# regions, Python module docstrings, and JavaScript/TypeScript `@module` docblocks | Outline symbol; Markdown headings are definition targets for path-scoped fragment references |
| `heading` | Markdown headings and language section markers such as C# regions, Python module docstrings, and JavaScript/TypeScript `@module` docblocks | Outline symbol; Markdown headings are definition targets for path-scoped fragment references. Their 1-based inclusive ranges stop at the last physical source line, regardless of a trailing LF/CRLF, and omit body ranges when the heading body is empty. |
| `hook` | JavaScript/TypeScript React custom hook bindings | Callable-like search/filter symbol |
| `implements` | Razor `@implements` directives | Context/search symbol |
| `import` | Imports, using directives, aliases, and package includes | Search/filter symbol |
Expand Down Expand Up @@ -4218,7 +4218,7 @@ filter、downstream JSON consumer が同じ値を理解できるようにして
| `file_module` | file-scoped module / package declaration | Namespace-like context symbol |
| `function` | 関数、method、constructor、delegate、task、およびより狭い kind がない callable binding | Primary callable definition。reference row 経由で callers/callees に参加 |
| `generator` | JavaScript / TypeScript generator declaration | Callable definition。reference row 経由で callers/callees に参加 |
| `heading` | Markdown heading、C# region、Python module docstring、JavaScript / TypeScript `@module` docblock などの language section marker | Outline symbol。Markdown heading は path に限定した fragment reference の定義対象 |
| `heading` | Markdown heading、C# region、Python module docstring、JavaScript / TypeScript `@module` docblock などの language section marker | Outline symbol。Markdown heading は path に限定した fragment reference の定義対象。1始まりの包含 range は末尾の LF / CRLF の有無にかかわらず実在する最終 source 行で止まり、heading body が空なら body range を省略する |
| `hook` | JavaScript / TypeScript React custom hook binding | Callable-like search/filter symbol |
| `implements` | Razor `@implements` directive | Context/search symbol |
| `import` | import、using directive、alias、package include | Search/filter symbol |
Expand Down
2 changes: 2 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding
- `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs`
Pure or mostly pure behavior tests with in-memory inputs.
C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, inherited and qualified field-receiver persistence, extractor-contract reindexing, search declaration metadata, and LSP field-kind projection in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart.
Markdown heading-range coverage keeps LF/CRLF and terminated/non-terminated files in one extractor fixture, including empty, one-line, Setext, nested, final-body, empty-body, and large-file cases. Pair it with persisted outline/definition-body and LSP document-symbol assertions so inclusive source ranges and 0-based protocol projection cannot drift apart.
`DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`.
Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract.
Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, option-like source names such as `--db`, and file-URI database query parameters containing raw/encoded paths, percent-encoded sensitive keys, or path values with embedded sensitive assignments. Default-output assertions must reject the fixture's full absolute paths and secrets while preserving safe URI controls. Pair this with `status --config` coverage for default DB/data/log path and URI-query redaction, always-redacted secrets, and explicit `--show-paths`.
Expand Down Expand Up @@ -1026,6 +1027,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests"
- `ChunkSplitterTests.cs`、`SymbolExtractorTests.cs`、`ReferenceExtractorTests.cs`、`SearchSnippetFormatterTests.cs`、`DbPathResolverTests.cs`、`ExcerptRecoveryCommandFormatterTests.cs`、`ConsoleUiTests.cs`
インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。
C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、継承 / qualified field receiver の永続化、extractor contract による再 index、search declaration metadata、LSP field kind への投影を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。
Markdown heading range の coverage は LF / CRLF と終端改行あり / なしを1つの extractor fixture にまとめ、empty、1行、Setext、nested、final body、empty body、large file の case を含めます。包含 source range と0始まりの protocol 投影がずれないよう、永続化後の outline / definition body と LSP document symbol の assertion を対にしてください。
`DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。
search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。
recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、`--db` のように option と紛らわしい source 名、raw / encoded path、percent-encoded な機密 key、機密 assignment を内包する path 値を持つ file-URI database query parameter を含めます。既定出力に fixture の完全な絶対パスや secret が残らず、安全な URI control は維持されることを assertion にします。`status --config` の DB/data/log path と URI query の既定 redaction、mode に関係なく維持される secret redaction、明示的 `--show-paths` も対で検証してください。
Expand Down
22 changes: 22 additions & 0 deletions changelog.d/unreleased/4910.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
category: fixed
issues:
- 4910
affected:
- src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs
- src/CodeIndex/Indexer/Symbols/SymbolExtractor.Markup.cs
- tests/CodeIndex.Tests/SymbolExtractorTests.cs
- tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs
- tests/CodeIndex.Tests/LspServerTests.cs
- tests/CodeIndex.Tests/DatabaseTests.cs
- DEVELOPER_GUIDE.md
- TESTING_GUIDE.md
---

## English

- **Markdown ranges now stop at the last physical source line (#4910)** — trailing LF or CRLF terminators no longer create a phantom EOF line for root or nested headings or unclosed fenced code blocks, and symbols without body lines now omit empty body ranges so outline, definition excerpts, and LSP document symbols stay within the file.

## 日本語

- **Markdown の range が実在する最終 source 行で止まるようになりました (#4910)** — 末尾の LF / CRLF が root / nested heading や閉じていない fenced code block に架空の EOF 行を追加しなくなり、body 行を持たない symbol は空の body range を省略するため、outline、definition excerpt、LSP document symbol がファイル範囲内に収まります。
2 changes: 1 addition & 1 deletion src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static partial class SymbolExtractor
public const int ScientificNativeGraphContractVersion = 4;
public const int RepositoryMetadataContractVersion = 2;
public const int ApplicationManifestContractVersion = 3;
public const int MarkdownContractVersion = 3;
public const int MarkdownContractVersion = 4;
public const int DependencyLockContractVersion = 3;
public const int JsonContractVersion = 3;
public const int JsonLinesContractVersion = 3;
Expand Down
38 changes: 27 additions & 11 deletions src/CodeIndex/Indexer/Symbols/SymbolExtractor.Markup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -386,23 +386,28 @@ private static List<SymbolRecord> ExtractMarkdownSymbols(long fileId, string[] l
var fenceLength = 0;
var fenceSymbolIndex = -1;
var inHtmlComment = false;
var sourceLineCount = lines.Length > 0 && lines[^1].Length == 0
? lines.Length - 1
: lines.Length;

for (var i = 0; i < lines.Length; i++)
{
if (TryToggleMarkdownFence(lines[i], inFence, fenceChar, fenceLength, out var nextFenceChar, out var nextFenceLength, out var fenceInfo))
{
if (!inFence)
{
var bodyStartLine = i + 2;
var hasBodyAtEof = bodyStartLine <= sourceLineCount;
var codeSymbol = new SymbolRecord
{
FileId = fileId,
Kind = "code",
Name = NormalizeMarkdownFenceInfo(fenceInfo),
Line = i + 1,
StartLine = i + 1,
EndLine = lines.Length,
BodyStartLine = i + 2,
BodyEndLine = lines.Length,
EndLine = sourceLineCount,
BodyStartLine = hasBodyAtEof ? bodyStartLine : null,
BodyEndLine = hasBodyAtEof ? sourceLineCount : null,
Signature = lines[i].Trim(),
};

Expand Down Expand Up @@ -446,8 +451,7 @@ private static List<SymbolRecord> ExtractMarkdownSymbols(long fileId, string[] l
while (headingStack is { Count: > 0 } && headingStack.Peek().Level >= setextLevel)
{
var closedHeading = headingStack.Pop();
symbols![closedHeading.SymbolIndex].EndLine = i;
symbols[closedHeading.SymbolIndex].BodyEndLine = i;
CloseMarkdownHeading(symbols!, closedHeading.SymbolIndex, i);
}

var setextSymbol = new SymbolRecord
Expand All @@ -462,7 +466,7 @@ private static List<SymbolRecord> ExtractMarkdownSymbols(long fileId, string[] l
StartLine = i + 1,
EndLine = i + 2,
BodyStartLine = i + 3,
BodyEndLine = lines.Length,
BodyEndLine = sourceLineCount,
Signature = lines[i].TrimEnd(),
};

Expand All @@ -485,8 +489,7 @@ private static List<SymbolRecord> ExtractMarkdownSymbols(long fileId, string[] l
while (headingStack is { Count: > 0 } && headingStack.Peek().Level >= level)
{
var closedHeading = headingStack.Pop();
symbols![closedHeading.SymbolIndex].EndLine = i;
symbols[closedHeading.SymbolIndex].BodyEndLine = i;
CloseMarkdownHeading(symbols!, closedHeading.SymbolIndex, i);
}

var symbol = new SymbolRecord
Expand All @@ -501,7 +504,7 @@ private static List<SymbolRecord> ExtractMarkdownSymbols(long fileId, string[] l
StartLine = i + 1,
EndLine = i + 1,
BodyStartLine = i + 2,
BodyEndLine = lines.Length,
BodyEndLine = sourceLineCount,
Signature = lines[i].Trim(),
};

Expand All @@ -519,13 +522,26 @@ private static List<SymbolRecord> ExtractMarkdownSymbols(long fileId, string[] l
while (headingStack is { Count: > 0 })
{
var closedHeading = headingStack.Pop();
symbols![closedHeading.SymbolIndex].EndLine = lines.Length;
symbols[closedHeading.SymbolIndex].BodyEndLine = lines.Length;
CloseMarkdownHeading(symbols!, closedHeading.SymbolIndex, sourceLineCount);
}

return symbols ?? [];
}

private static void CloseMarkdownHeading(List<SymbolRecord> symbols, int symbolIndex, int endLine)
{
var symbol = symbols[symbolIndex];
symbol.EndLine = endLine;
if (symbol.BodyStartLine is int bodyStartLine && bodyStartLine <= endLine)
{
symbol.BodyEndLine = endLine;
return;
}

symbol.BodyStartLine = null;
symbol.BodyEndLine = null;
}

private static void AddMarkdownExplicitAnchorSymbols(
long fileId,
string line,
Expand Down
23 changes: 23 additions & 0 deletions tests/CodeIndex.Tests/DatabaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5748,6 +5748,29 @@ public void GetUnchangedFileId_ReturnsNullWhenLanguageExtractorVersionIsStale()
Assert.Null(id);
}

[Fact]
public void GetUnchangedFileId_InvalidatesPriorMarkdownHeadingRangeContract_Issue4910()
{
const string language = "markdown";
const int previousContractVersion = 3;
var modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var file = new FileRecord
{
Path = "docs/guide.md",
Lang = language,
Size = 50,
Lines = 5,
Modified = modified,
};
_writer.UpsertFile(file);
_writer.SetMeta(
DbContext.GetSymbolExtractorVersionMetaKey(language),
previousContractVersion.ToString(CultureInfo.InvariantCulture));

Assert.True(SymbolExtractor.MarkdownContractVersion > previousContractVersion);
Assert.Null(_writer.GetUnchangedFileId(file.Path, modified, language: language));
}

[Fact]
public void GetUnchangedFileId_InvalidatesPriorDependencyLockContract_Issue4845()
{
Expand Down
66 changes: 66 additions & 0 deletions tests/CodeIndex.Tests/LspServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3152,6 +3152,72 @@ public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols()
}
}

[Fact]
public void HandleMessage_DocumentSymbol_MarkdownRangeEndsOnLastPhysicalLine_Issue4910()
{
var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_markdown_range_4910");
try
{
var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
var sourcePath = Path.Combine(projectRoot, "guide.md");
const string source = "# Root\r\nroot body\r\n## Nested\r\nnested body\r\n### Empty\r\n";
File.WriteAllText(sourcePath, source);
TestProjectHelper.InsertIndexedFile(dbPath, "guide.md", "markdown", source);
using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath);
using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot);

var response = HandleInitializedMessage(
server,
CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 4910));

Assert.NotNull(response);
var root = Assert.Single(response!["result"]!.AsArray());
var nested = Assert.Single(root!["children"]!.AsArray());
var empty = Assert.Single(nested!["children"]!.AsArray());
foreach (var symbol in new[] { root, nested, empty })
{
Assert.Equal(4, symbol!["range"]!["end"]!["line"]!.GetValue<int>());
Assert.InRange(symbol["selectionRange"]!["end"]!["line"]!.GetValue<int>(), 0, 4);
}
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void HandleMessage_DocumentSymbol_UnclosedMarkdownFenceStaysNestedAtPhysicalEof_Issue4910()
{
var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_markdown_fence_range_4910");
try
{
var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
var sourcePath = Path.Combine(projectRoot, "fenced.md");
const string source = "# Root\r\n```csharp\r\ncode\r\n";
File.WriteAllText(sourcePath, source);
TestProjectHelper.InsertIndexedFile(dbPath, "fenced.md", "markdown", source);
using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath);
using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot);

var response = HandleInitializedMessage(
server,
CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 49101));

Assert.NotNull(response);
var root = Assert.Single(response!["result"]!.AsArray());
Assert.Equal("Root", root!["name"]!.GetValue<string>());
Assert.Equal(2, root["range"]!["end"]!["line"]!.GetValue<int>());
var code = Assert.Single(root["children"]!.AsArray());
Assert.Equal("csharp", code!["name"]!.GetValue<string>());
Assert.Equal(2, code["range"]!["end"]!["line"]!.GetValue<int>());
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void HandleMessage_NormalizedCSharpField_UsesLspFieldKinds_Issue4865()
{
Expand Down
Loading
Loading