diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 19b71ab44..70ea753b8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -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 | @@ -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 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cadef907e..c8f25ac3f 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -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`. @@ -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` も対で検証してください。 diff --git a/changelog.d/unreleased/4910.fixed.md b/changelog.d/unreleased/4910.fixed.md new file mode 100644 index 000000000..0fd8474c8 --- /dev/null +++ b/changelog.d/unreleased/4910.fixed.md @@ -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 がファイル範囲内に収まります。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs index 76035a0c5..896da0c4f 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -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; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Markup.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Markup.cs index 9b7d8ae10..1158cc0c8 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Markup.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Markup.cs @@ -386,6 +386,9 @@ private static List 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++) { @@ -393,6 +396,8 @@ private static List ExtractMarkdownSymbols(long fileId, string[] l { if (!inFence) { + var bodyStartLine = i + 2; + var hasBodyAtEof = bodyStartLine <= sourceLineCount; var codeSymbol = new SymbolRecord { FileId = fileId, @@ -400,9 +405,9 @@ private static List ExtractMarkdownSymbols(long fileId, string[] l 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(), }; @@ -446,8 +451,7 @@ private static List 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 @@ -462,7 +466,7 @@ private static List ExtractMarkdownSymbols(long fileId, string[] l StartLine = i + 1, EndLine = i + 2, BodyStartLine = i + 3, - BodyEndLine = lines.Length, + BodyEndLine = sourceLineCount, Signature = lines[i].TrimEnd(), }; @@ -485,8 +489,7 @@ private static List 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 @@ -501,7 +504,7 @@ private static List ExtractMarkdownSymbols(long fileId, string[] l StartLine = i + 1, EndLine = i + 1, BodyStartLine = i + 2, - BodyEndLine = lines.Length, + BodyEndLine = sourceLineCount, Signature = lines[i].Trim(), }; @@ -519,13 +522,26 @@ private static List 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 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, diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index ca71deb4e..d1bf1eabd 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -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() { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 3a6c31888..640ffe564 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -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()); + Assert.InRange(symbol["selectionRange"]!["end"]!["line"]!.GetValue(), 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()); + Assert.Equal(2, root["range"]!["end"]!["line"]!.GetValue()); + var code = Assert.Single(root["children"]!.AsArray()); + Assert.Equal("csharp", code!["name"]!.GetValue()); + Assert.Equal(2, code["range"]!["end"]!["line"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_NormalizedCSharpField_UsesLspFieldKinds_Issue4865() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index d2d335a1e..a46e8c886 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -154,6 +154,65 @@ private static void AssertOutlineJsonSymbol( Assert.Equal(containerName, symbol.TryGetProperty("container_name", out var container) ? container.GetString() : null); } + [Fact] + public void RunOutlineAndDefinition_MarkdownHeadingRangesStayWithinFile_Issue4910() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_markdown_heading_ranges_4910"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + const string source = "# Root\r\nroot body\r\n## Nested\r\nnested body\r\n### Empty\r\n"; + TestProjectHelper.InsertIndexedFile(dbPath, "docs/guide.md", "markdown", source); + + var (outlineExitCode, outlineStdout, outlineStderr) = CaptureConsole(() => QueryCommandRunner.RunOutline( + ["docs/guide.md", "--db", dbPath, "--json", "--kind", "heading", "--outline-fields", "name,start_line,end_line,body_start_line,body_end_line"], + _jsonOptions)); + using var outlineDocument = ParseJsonOutput(outlineStdout); + var outline = outlineDocument.RootElement; + var headings = outline.GetProperty("symbols").EnumerateArray().ToList(); + var root = Assert.Single(headings.Where(symbol => symbol.GetProperty("name").GetString() == "Root")); + var empty = Assert.Single(headings.Where(symbol => symbol.GetProperty("name").GetString() == "Empty")); + + Assert.Equal(CommandExitCodes.Success, outlineExitCode); + Assert.Equal(string.Empty, outlineStderr); + Assert.Equal(5, outline.GetProperty("total_lines").GetInt32()); + Assert.All(headings, heading => Assert.InRange(heading.GetProperty("end_line").GetInt32(), 1, 5)); + Assert.Equal(5, root.GetProperty("end_line").GetInt32()); + Assert.Equal(5, root.GetProperty("body_end_line").GetInt32()); + Assert.Equal(JsonValueKind.Null, empty.GetProperty("body_start_line").ValueKind); + Assert.Equal(JsonValueKind.Null, empty.GetProperty("body_end_line").ValueKind); + + var (rootExitCode, rootStdout, rootStderr) = CaptureConsole(() => QueryCommandRunner.RunDefinition( + ["Root", "--db", dbPath, "--json", "--body", "--lang", "markdown", "--exact-name"], + _jsonOptions)); + using var rootDocument = ParseJsonOutput(rootStdout); + var definition = rootDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, rootExitCode); + Assert.Equal(string.Empty, rootStderr); + Assert.Equal(5, definition.GetProperty("end_line").GetInt32()); + Assert.Equal(2, definition.GetProperty("body_requested_start_line").GetInt32()); + Assert.Equal(5, definition.GetProperty("body_requested_end_line").GetInt32()); + Assert.Equal(5, definition.GetProperty("body_effective_end_line").GetInt32()); + Assert.Contains("### Empty", definition.GetProperty("body_content").GetString(), StringComparison.Ordinal); + + var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole(() => QueryCommandRunner.RunDefinition( + ["Empty", "--db", dbPath, "--json", "--body", "--lang", "markdown", "--exact-name"], + _jsonOptions)); + using var emptyDocument = ParseJsonOutput(emptyStdout); + var emptyDefinition = emptyDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, emptyExitCode); + Assert.Equal(string.Empty, emptyStderr); + Assert.Equal(5, emptyDefinition.GetProperty("end_line").GetInt32()); + Assert.False(emptyDefinition.TryGetProperty("body_requested_end_line", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunOutline_CompactJsonAndJsonLinesKeepParentFirstDepth_Issue4874() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 25eaa5a0b..5014144e5 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -6043,6 +6043,88 @@ Intro text. Assert.DoesNotContain(headings, s => s.Name == "Also ignored"); } + [Fact] + public void Extract_Markdown_HeadingRangesUsePhysicalLinesAndEmptyBodiesStayEmpty_Issue4910() + { + foreach (var newline in new[] { "\n", "\r\n" }) + { + foreach (var terminateFile in new[] { false, true }) + { + var content = string.Join( + newline, + "# Root", + "root body", + "## Nested", + "nested body", + "### Final", + "final body"); + if (terminateFile) + content += newline; + + var headings = SymbolExtractor.Extract(1, "markdown", content) + .Where(symbol => symbol.Kind == "heading") + .ToList(); + var root = Assert.Single(headings.Where(symbol => symbol.Name == "Root")); + var nested = Assert.Single(headings.Where(symbol => symbol.Name == "Nested")); + var final = Assert.Single(headings.Where(symbol => symbol.Name == "Final")); + + Assert.All(headings, heading => Assert.InRange(heading.EndLine, 1, 6)); + Assert.Equal((1, 6, 2, 6), (root.StartLine, root.EndLine, root.BodyStartLine, root.BodyEndLine)); + Assert.Equal((3, 6, 4, 6), (nested.StartLine, nested.EndLine, nested.BodyStartLine, nested.BodyEndLine)); + Assert.Equal((5, 6, 6, 6), (final.StartLine, final.EndLine, final.BodyStartLine, final.BodyEndLine)); + } + } + + Assert.Empty(SymbolExtractor.Extract(1, "markdown", string.Empty)); + foreach (var content in new[] { "# Only", "# Only\n", "# Only\r\n" }) + { + var only = Assert.Single(SymbolExtractor.Extract(1, "markdown", content)); + Assert.Equal(1, only.EndLine); + Assert.Null(only.BodyStartLine); + Assert.Null(only.BodyEndLine); + } + + foreach (var content in new[] { "Only\n====", "Only\n====\n", "Only\r\n====\r\n" }) + { + var only = Assert.Single(SymbolExtractor.Extract(1, "markdown", content)); + Assert.Equal(2, only.EndLine); + Assert.Null(only.BodyStartLine); + Assert.Null(only.BodyEndLine); + } + + var largeContent = "# Large\n" + string.Join('\n', Enumerable.Repeat("body", 12_647)) + "\n"; + var largeHeading = Assert.Single(SymbolExtractor.Extract(1, "markdown", largeContent)); + Assert.Equal(12_648, largeHeading.EndLine); + Assert.Equal(2, largeHeading.BodyStartLine); + Assert.Equal(12_648, largeHeading.BodyEndLine); + + foreach (var newline in new[] { "\n", "\r\n" }) + { + foreach (var terminateFile in new[] { false, true }) + { + var content = string.Join(newline, "# Root", "```csharp", "code"); + if (terminateFile) + content += newline; + + var symbols = SymbolExtractor.Extract(1, "markdown", content); + var heading = Assert.Single(symbols.Where(symbol => symbol.Kind == "heading")); + var code = Assert.Single(symbols.Where(symbol => symbol.Kind == "code")); + + Assert.Equal(3, heading.EndLine); + Assert.Equal(3, code.EndLine); + Assert.Equal(3, code.BodyStartLine); + Assert.Equal(3, code.BodyEndLine); + Assert.Equal("Root", code.ContainerName); + } + + var emptyFenceSymbols = SymbolExtractor.Extract(1, "markdown", $"# Root{newline}```csharp{newline}"); + var emptyFence = Assert.Single(emptyFenceSymbols.Where(symbol => symbol.Kind == "code")); + Assert.Equal(2, emptyFence.EndLine); + Assert.Null(emptyFence.BodyStartLine); + Assert.Null(emptyFence.BodyEndLine); + } + } + [Fact] public void Extract_Markdown_LinksAreReferencesNotDefinitionSymbols_Issues4448And4846() {