diff --git a/README.md b/README.md index d553f20..bfe82d2 100644 --- a/README.md +++ b/README.md @@ -25,33 +25,50 @@ dotnet add package TinyPreprocessor ## Quick Start -TinyPreprocessor requires three components: +TinyPreprocessor is a small pipeline that: + +1. Parses directives from each resource. +2. Uses `IDirectiveModel` to decide which directives represent dependencies (and where they are). +3. Resolves dependencies via `IResourceResolver` (building a dependency graph). +4. Topologically orders resources (dependencies first). +5. Merges them via `IMergeStrategy` while building a source map and collecting diagnostics. + +TinyPreprocessor requires four components: 1. **`IDirectiveParser`** – Parses directives from resource content -2. **`IResourceResolver`** – Resolves references to actual resources -3. **`IMergeStrategy`** – Combines resources into final output +2. **`IDirectiveModel`** – Interprets directive locations and dependency references +3. **`IResourceResolver`** – Resolves references to actual resources +4. **`IMergeStrategy`** – Combines resources into final output -### Example: Simple Include Directive +### Example: Minimal In-Memory Includes ```csharp using System; using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; using TinyPreprocessor; using TinyPreprocessor.Core; using TinyPreprocessor.Diagnostics; -using TinyPreprocessor.Merging; +using TinyPreprocessor.Text; -// 1. Define your directive type -public sealed record IncludeDirective(string Path, Range Location) : IIncludeDirective +// 1) Define your directive type. +public sealed record IncludeDirective(string Reference, Range Location); + +// 2) Provide directive semantics to the pipeline. +public sealed class IncludeDirectiveModel : IDirectiveModel { - public string Reference => Path; + public Range GetLocation(IncludeDirective directive) => directive.Location; + + public bool TryGetReference(IncludeDirective directive, out string reference) + { + reference = directive.Reference; + return true; + } } -// 2. Implement directive parser -public sealed class IncludeParser : IDirectiveParser +// 3) Implement a tiny directive parser for lines like: #include other.txt +public sealed class IncludeParser : IDirectiveParser { public IEnumerable Parse(ReadOnlyMemory content, ResourceId resourceId) { @@ -71,48 +88,49 @@ public sealed class IncludeParser : IDirectiveParser } } -// 3. Implement resource resolver -public sealed class FileResolver : IResourceResolver +// 4) Implement an in-memory resolver. +public sealed class InMemoryResolver : IResourceResolver { - private readonly string _basePath; + private readonly IReadOnlyDictionary _files; - public FileResolver(string basePath) => _basePath = basePath; + public InMemoryResolver(IReadOnlyDictionary files) => _files = files; - public ValueTask ResolveAsync( + public ValueTask> ResolveAsync( string reference, - IResource? context, + IResource? context, CancellationToken ct) { - var fullPath = Path.Combine(_basePath, reference); - - if (!File.Exists(fullPath)) + if (!_files.TryGetValue(new ResourceId(reference), out var content)) { - return ValueTask.FromResult(new ResourceResolutionResult( + return ValueTask.FromResult(new ResourceResolutionResult( null, - new ResolutionFailedDiagnostic(reference, $"File not found: {fullPath}"))); + new ResolutionFailedDiagnostic(reference, $"Not found: {reference}"))); } - var content = File.ReadAllText(fullPath); - var resource = new Resource(reference, content.AsMemory()); - return ValueTask.FromResult(new ResourceResolutionResult(resource, null)); + var resource = new Resource(reference, content.AsMemory()); + return ValueTask.FromResult(new ResourceResolutionResult(resource, null)); } } -// 4. Use the preprocessor -var parser = new IncludeParser(); -var resolver = new FileResolver(@"C:\MyProject\src"); -var merger = new ConcatenatingMergeStrategy(); +// 5) Wire everything together. +var files = new Dictionary +{ + ["main.txt"] = "#include a.txt\nMAIN\n", + ["a.txt"] = "A\n#include b.txt\n", + ["b.txt"] = "B\n" +}; +var parser = new IncludeParser(); +var directiveModel = new IncludeDirectiveModel(); +var resolver = new InMemoryResolver(files); +var merger = new ConcatenatingMergeStrategy(); var context = new object(); - -var preprocessor = new Preprocessor(parser, resolver, merger); - -var rootContent = File.ReadAllText(@"C:\MyProject\src\main.txt"); -var root = new Resource("main.txt", rootContent.AsMemory()); +var preprocessor = new Preprocessor(parser, directiveModel, resolver, merger); +var root = new Resource("main.txt", files["main.txt"].AsMemory()); var result = await preprocessor.ProcessAsync(root, context); -if (result.Success) +if (!result.Diagnostics.HasErrors) { Console.WriteLine(result.Content.ToString()); } @@ -146,37 +164,38 @@ Query the source map to trace output positions back to original files: ```csharp var result = await preprocessor.ProcessAsync(root, context); -// Find where line 50, column 10 in output came from -var location = result.SourceMap.Query(new SourcePosition(line: 49, column: 9)); +// Find where generated offset 0 in output came from +var location = result.SourceMap.Query(generatedOffset: 0); if (location is not null) { - var (line, col) = location.OriginalPosition.ToOneBased(); - Console.WriteLine($"Originated from {location.Resource.Path} at line {line}, column {col}"); + Console.WriteLine($"Originated from {location.Resource.Path} at original offset {location.OriginalOffset}"); } // For precise diagnostic spans, query a range. // The range may map to multiple original resources (e.g., it crosses file boundaries). -var ranges = result.SourceMap.Query(new SourcePosition(line: 49, column: 9), length: 20); +var ranges = result.SourceMap.QueryRangeByLength(generatedStartOffset: 0, length: 20); foreach (var range in ranges) { Console.WriteLine( - $"Generated [{range.GeneratedStart} - {range.GeneratedEnd}) -> {range.Resource.Path} [{range.OriginalStart} - {range.OriginalEnd})"); + $"Generated [{range.GeneratedStartOffset} - {range.GeneratedEndOffset}) -> {range.Resource.Path} [{range.OriginalStartOffset} - {range.OriginalEndOffset})"); } ``` ## Custom Merge Strategy -Implement `IMergeStrategy` for custom output formatting: +Implement `IMergeStrategy` for custom output formatting: ```csharp -public sealed class JsonMergeStrategy : IMergeStrategy +public sealed record JsonMergeOptions; + +public sealed class JsonMergeStrategy : IMergeStrategy { public ReadOnlyMemory Merge( - IReadOnlyList orderedResources, - JsonMergeOptions context, - MergeContext mergeContext) + IReadOnlyList> orderedResources, + JsonMergeOptions userContext, + MergeContext mergeContext) { // Custom merge logic here // Use mergeContext.SourceMapBuilder to record mappings. @@ -204,8 +223,8 @@ public sealed class JsonMergeStrategy : IMergeStrategy ┌─────────────────────────────────────────────────────────────┐ │ Preprocessor │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ IDirective │ │ IResource │ │ IMergeStrategy │ │ -│ │ Parser │ │ Resolver │ │ │ │ +│ │ Directive │ │ IResource │ │ IMergeStrategy │ │ +│ │ Parser/Model │ │ Resolver │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ diff --git a/TinyPreprocessor.Tests/Core/ResourceTests.cs b/TinyPreprocessor.Tests/Core/ResourceTests.cs index e8e30fa..0331535 100644 --- a/TinyPreprocessor.Tests/Core/ResourceTests.cs +++ b/TinyPreprocessor.Tests/Core/ResourceTests.cs @@ -4,7 +4,7 @@ namespace TinyPreprocessor.Tests.Core; /// -/// Unit tests for . +/// Unit tests for . /// public sealed class ResourceTests { @@ -17,7 +17,7 @@ public void Constructor_WithAllParameters_SetsPropertiesCorrectly() var content = "Hello, World!".AsMemory(); var metadata = new Dictionary { ["key"] = "value" }; - var resource = new Resource(id, content, metadata); + var resource = new Resource(id, content, metadata); Assert.Equal(id, resource.Id); Assert.Equal("Hello, World!", resource.Content.ToString()); @@ -31,7 +31,7 @@ public void Constructor_WithoutMetadata_SetsMetadataToNull() var id = new ResourceId("test.txt"); var content = "Content".AsMemory(); - var resource = new Resource(id, content); + var resource = new Resource(id, content); Assert.Null(resource.Metadata); } @@ -42,7 +42,7 @@ public void Constructor_WithEmptyContent_Succeeds() var id = new ResourceId("empty.txt"); var content = ReadOnlyMemory.Empty; - var resource = new Resource(id, content); + var resource = new Resource(id, content); Assert.True(resource.Content.IsEmpty); } @@ -55,9 +55,9 @@ public void Constructor_WithEmptyContent_Succeeds() public void Id_ReturnsResourceId() { ResourceId id = "resource/path.txt"; - var resource = new Resource(id, "content".AsMemory()); + var resource = new Resource(id, "content".AsMemory()); - IResource iResource = resource; + IResource iResource = resource; Assert.Equal(id, iResource.Id); } @@ -66,7 +66,7 @@ public void Id_ReturnsResourceId() public void Content_ReturnsReadOnlyMemory() { const string contentString = "Test content with special chars: éàü"; - var resource = new Resource("test.txt", contentString.AsMemory()); + var resource = new Resource("test.txt", contentString.AsMemory()); Assert.Equal(contentString, resource.Content.ToString()); } @@ -79,8 +79,8 @@ public void Content_ReturnsReadOnlyMemory() public void Equals_SameIdAndContent_ReturnsTrue() { var content = "Same content".AsMemory(); - var resource1 = new Resource("file.txt", content); - var resource2 = new Resource("file.txt", content); + var resource1 = new Resource("file.txt", content); + var resource2 = new Resource("file.txt", content); // Records compare by value for properties Assert.Equal(resource1.Id, resource2.Id); @@ -90,8 +90,8 @@ public void Equals_SameIdAndContent_ReturnsTrue() public void Equals_DifferentId_ReturnsFalse() { var content = "Content".AsMemory(); - var resource1 = new Resource("file1.txt", content); - var resource2 = new Resource("file2.txt", content); + var resource1 = new Resource("file1.txt", content); + var resource2 = new Resource("file2.txt", content); Assert.NotEqual(resource1.Id, resource2.Id); } @@ -99,7 +99,7 @@ public void Equals_DifferentId_ReturnsFalse() [Fact] public void With_CreatesModifiedCopy() { - var original = new Resource("original.txt", "original content".AsMemory()); + var original = new Resource("original.txt", "original content".AsMemory()); var newContent = "new content".AsMemory(); var modified = original with { Content = newContent }; @@ -123,7 +123,7 @@ public void Metadata_WhenProvided_IsAccessible() ["IsGenerated"] = true }; - var resource = new Resource("test.txt", "content".AsMemory(), metadata); + var resource = new Resource("test.txt", "content".AsMemory(), metadata); Assert.Equal("Test Author", resource.Metadata!["Author"]); Assert.Equal(42, resource.Metadata["LineCount"]); @@ -134,7 +134,7 @@ public void Metadata_WhenProvided_IsAccessible() public void Metadata_IsReadOnly() { var metadata = new Dictionary { ["key"] = "value" }; - var resource = new Resource("test.txt", "content".AsMemory(), metadata); + var resource = new Resource("test.txt", "content".AsMemory(), metadata); // IReadOnlyDictionary doesn't have Add method Assert.IsAssignableFrom>(resource.Metadata); diff --git a/TinyPreprocessor.Tests/Integration/PreprocessorIntegrationTests.cs b/TinyPreprocessor.Tests/Integration/PreprocessorIntegrationTests.cs index f725df7..4cd8722 100644 --- a/TinyPreprocessor.Tests/Integration/PreprocessorIntegrationTests.cs +++ b/TinyPreprocessor.Tests/Integration/PreprocessorIntegrationTests.cs @@ -1,8 +1,8 @@ using Moq; using TinyPreprocessor.Core; using TinyPreprocessor.Diagnostics; -using TinyPreprocessor.Merging; using TinyPreprocessor.SourceMaps; +using TinyPreprocessor.Text; using Xunit; namespace TinyPreprocessor.Tests.Integration; @@ -18,7 +18,7 @@ public sealed class PreprocessorIntegrationTests public async Task ProcessAsync_SingleFileNoIncludes_ReturnsContent() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var root = new Resource("main.txt", "Hello, World!".AsMemory()); + var root = new Resource("main.txt", "Hello, World!".AsMemory()); var result = await preprocessor.ProcessAsync(root, new object()); @@ -30,11 +30,11 @@ public async Task ProcessAsync_SingleFileNoIncludes_ReturnsContent() public async Task ProcessAsync_WithSimpleInclude_MergesCorrectly() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var header = new Resource("header.txt", "Header content".AsMemory()); - var main = new Resource("main.txt", "#include header.txt\nMain content".AsMemory()); + var header = new Resource("header.txt", "Header content".AsMemory()); + var main = new Resource("main.txt", "#include header.txt\nMain content".AsMemory()); - resolver.Setup(r => r.ResolveAsync("header.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(header, null)); + resolver.Setup(r => r.ResolveAsync("header.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(header, null)); var result = await preprocessor.ProcessAsync(main, new object()); @@ -47,36 +47,38 @@ public async Task ProcessAsync_WithSimpleInclude_MergesCorrectly() public async Task ProcessAsync_WithSimpleInclude_ProducesExactFlattenedOutputAndSourceMap() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var header = new Resource("header.txt", "H1\nH2".AsMemory()); - var main = new Resource("main.txt", "#include header.txt\nM1".AsMemory()); + const string headerText = "H1\nH2"; + var header = new Resource("header.txt", headerText.AsMemory()); + var main = new Resource("main.txt", "#include header.txt\nM1".AsMemory()); - resolver.Setup(r => r.ResolveAsync("header.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(header, null)); + resolver.Setup(r => r.ResolveAsync("header.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(header, null)); var result = await preprocessor.ProcessAsync(main, new object()); Assert.True(result.Success); - Assert.Equal("H1\nH2\n\nM1", result.Content.ToString()); + var content = result.Content.ToString(); + Assert.Equal("H1\nH2\n\nM1", content); - AssertMapped(result.SourceMap, generatedLine: 0, generatedColumn: 0, expectedResource: "header.txt", expectedOriginalLine: 0, expectedOriginalColumn: 0); - AssertMapped(result.SourceMap, generatedLine: 1, generatedColumn: 1, expectedResource: "header.txt", expectedOriginalLine: 1, expectedOriginalColumn: 1); + AssertMapped(result.SourceMap, content, generatedLine: 0, generatedColumn: 0, expectedResource: "header.txt", expectedOriginalOffset: 0); + AssertMapped(result.SourceMap, content, generatedLine: 1, generatedColumn: 1, expectedResource: "header.txt", expectedOriginalOffset: OffsetOfLineColumn(headerText, line: 1, column: 1)); // This blank line is formed by the original newline after the include directive. - AssertMapped(result.SourceMap, generatedLine: 2, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalLine: 0, expectedOriginalColumn: "#include header.txt".Length); - AssertMapped(result.SourceMap, generatedLine: 3, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalLine: 1, expectedOriginalColumn: 0); + AssertMapped(result.SourceMap, content, generatedLine: 2, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalOffset: "#include header.txt".Length); + AssertMapped(result.SourceMap, content, generatedLine: 3, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalOffset: "#include header.txt\n".Length); } [Fact] public async Task ProcessAsync_MultiLevelIncludes_ProcessesAll() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var level2 = new Resource("level2.txt", "Level 2".AsMemory()); - var level1 = new Resource("level1.txt", "#include level2.txt\nLevel 1".AsMemory()); - var main = new Resource("main.txt", "#include level1.txt\nMain".AsMemory()); + var level2 = new Resource("level2.txt", "Level 2".AsMemory()); + var level1 = new Resource("level1.txt", "#include level2.txt\nLevel 1".AsMemory()); + var main = new Resource("main.txt", "#include level1.txt\nMain".AsMemory()); - resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level1, null)); - resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level2, null)); + resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level1, null)); + resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level2, null)); var result = await preprocessor.ProcessAsync(main, new object()); @@ -90,28 +92,33 @@ public async Task ProcessAsync_MultiLevelIncludes_ProcessesAll() public async Task ProcessAsync_MultiLevelIncludes_FlattensAndMapsNestedIncludesCorrectly() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var level2 = new Resource("level2.txt", "L2a\nL2b".AsMemory()); - var level1 = new Resource("level1.txt", "#include level2.txt\nL1".AsMemory()); - var main = new Resource("main.txt", "#include level1.txt\nM".AsMemory()); + const string level2Text = "L2a\nL2b"; + const string level1Text = "#include level2.txt\nL1"; + const string mainText = "#include level1.txt\nM"; + + var level2 = new Resource("level2.txt", level2Text.AsMemory()); + var level1 = new Resource("level1.txt", level1Text.AsMemory()); + var main = new Resource("main.txt", mainText.AsMemory()); - resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level1, null)); - resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level2, null)); + resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level1, null)); + resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level2, null)); var result = await preprocessor.ProcessAsync(main, new object()); Assert.True(result.Success); - Assert.Equal("L2a\nL2b\n\nL1\n\nM", result.Content.ToString()); + var content = result.Content.ToString(); + Assert.Equal("L2a\nL2b\n\nL1\n\nM", content); - AssertMapped(result.SourceMap, generatedLine: 0, generatedColumn: 1, expectedResource: "level2.txt", expectedOriginalLine: 0, expectedOriginalColumn: 1); - AssertMapped(result.SourceMap, generatedLine: 1, generatedColumn: 0, expectedResource: "level2.txt", expectedOriginalLine: 1, expectedOriginalColumn: 0); + AssertMapped(result.SourceMap, content, generatedLine: 0, generatedColumn: 1, expectedResource: "level2.txt", expectedOriginalOffset: OffsetOfLineColumn(level2Text, line: 0, column: 1)); + AssertMapped(result.SourceMap, content, generatedLine: 1, generatedColumn: 0, expectedResource: "level2.txt", expectedOriginalOffset: OffsetOfLineColumn(level2Text, line: 1, column: 0)); // Blank line after level2 comes from the original newline after the include directive in level1. - AssertMapped(result.SourceMap, generatedLine: 2, generatedColumn: 0, expectedResource: "level1.txt", expectedOriginalLine: 0, expectedOriginalColumn: "#include level2.txt".Length); - AssertMapped(result.SourceMap, generatedLine: 3, generatedColumn: 0, expectedResource: "level1.txt", expectedOriginalLine: 1, expectedOriginalColumn: 0); + AssertMapped(result.SourceMap, content, generatedLine: 2, generatedColumn: 0, expectedResource: "level1.txt", expectedOriginalOffset: "#include level2.txt".Length); + AssertMapped(result.SourceMap, content, generatedLine: 3, generatedColumn: 0, expectedResource: "level1.txt", expectedOriginalOffset: "#include level2.txt\n".Length); // Blank line after level1 comes from the original newline after the include directive in main. - AssertMapped(result.SourceMap, generatedLine: 4, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalLine: 0, expectedOriginalColumn: "#include level1.txt".Length); - AssertMapped(result.SourceMap, generatedLine: 5, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalLine: 1, expectedOriginalColumn: 0); + AssertMapped(result.SourceMap, content, generatedLine: 4, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalOffset: "#include level1.txt".Length); + AssertMapped(result.SourceMap, content, generatedLine: 5, generatedColumn: 0, expectedResource: "main.txt", expectedOriginalOffset: "#include level1.txt\n".Length); } [Fact] @@ -119,20 +126,20 @@ public async Task ProcessAsync_BranchingIncludes_FlattensAllAndMapsEachOriginWit { var (preprocessor, resolver, _) = CreatePreprocessor(); - var c = new Resource("c.txt", "C1".AsMemory()); - var a = new Resource("a.txt", "#include c.txt\nA1".AsMemory()); - var d = new Resource("d.txt", "D1".AsMemory()); - var b = new Resource("b.txt", "#include d.txt\nB1".AsMemory()); - var main = new Resource("main.txt", "#include a.txt\n#include b.txt\nM1".AsMemory()); - - resolver.Setup(r => r.ResolveAsync("a.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(a, null)); - resolver.Setup(r => r.ResolveAsync("b.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(b, null)); - resolver.Setup(r => r.ResolveAsync("c.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(c, null)); - resolver.Setup(r => r.ResolveAsync("d.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(d, null)); + var c = new Resource("c.txt", "C1".AsMemory()); + var a = new Resource("a.txt", "#include c.txt\nA1".AsMemory()); + var d = new Resource("d.txt", "D1".AsMemory()); + var b = new Resource("b.txt", "#include d.txt\nB1".AsMemory()); + var main = new Resource("main.txt", "#include a.txt\n#include b.txt\nM1".AsMemory()); + + resolver.Setup(r => r.ResolveAsync("a.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(a, null)); + resolver.Setup(r => r.ResolveAsync("b.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(b, null)); + resolver.Setup(r => r.ResolveAsync("c.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(c, null)); + resolver.Setup(r => r.ResolveAsync("d.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(d, null)); var result = await preprocessor.ProcessAsync(main, new object()); @@ -152,33 +159,33 @@ public async Task ProcessAsync_BranchingIncludes_FlattensAllAndMapsEachOriginWit Assert.True(content.IndexOf("D1", StringComparison.Ordinal) < content.IndexOf("B1", StringComparison.Ordinal)); // Validate source mapping for each unique token without assuming a/b branch ordering. - AssertMappedAtToken(result.SourceMap, content, token: "C1", expectedResource: "c.txt", expectedOriginalLine: 0, expectedOriginalColumn: 0); - AssertMappedAtToken(result.SourceMap, content, token: "A1", expectedResource: "a.txt", expectedOriginalLine: 1, expectedOriginalColumn: 0); - AssertMappedAtToken(result.SourceMap, content, token: "D1", expectedResource: "d.txt", expectedOriginalLine: 0, expectedOriginalColumn: 0); - AssertMappedAtToken(result.SourceMap, content, token: "B1", expectedResource: "b.txt", expectedOriginalLine: 1, expectedOriginalColumn: 0); - AssertMappedAtToken(result.SourceMap, content, token: "M1", expectedResource: "main.txt", expectedOriginalLine: 2, expectedOriginalColumn: 0); + AssertMappedAtToken(result.SourceMap, content, token: "C1", expectedResource: "c.txt", expectedOriginalOffset: 0); + AssertMappedAtToken(result.SourceMap, content, token: "A1", expectedResource: "a.txt", expectedOriginalOffset: "#include c.txt\n".Length); + AssertMappedAtToken(result.SourceMap, content, token: "D1", expectedResource: "d.txt", expectedOriginalOffset: 0); + AssertMappedAtToken(result.SourceMap, content, token: "B1", expectedResource: "b.txt", expectedOriginalOffset: "#include d.txt\n".Length); + AssertMappedAtToken(result.SourceMap, content, token: "M1", expectedResource: "main.txt", expectedOriginalOffset: "#include a.txt\n#include b.txt\n".Length); } [Fact] public async Task ProcessAsync_BuildsSourceMap() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var root = new Resource("source.txt", "Line 1\nLine 2".AsMemory()); + var root = new Resource("source.txt", "Line 1\nLine 2".AsMemory()); var result = await preprocessor.ProcessAsync(root, new object()); Assert.NotNull(result.SourceMap); - Assert.NotNull(result.SourceMap.Query(new SourcePosition(0, 0))); + Assert.NotNull(result.SourceMap.Query(generatedOffset: 0)); } [Fact] public async Task ProcessAsync_PopulatesDiagnostics() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var root = new Resource("main.txt", "#include missing.txt\nContent".AsMemory()); + var root = new Resource("main.txt", "#include missing.txt\nContent".AsMemory()); - resolver.Setup(r => r.ResolveAsync("missing.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(null, + resolver.Setup(r => r.ResolveAsync("missing.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(null, new ResolutionFailedDiagnostic("missing.txt", "File not found"))); var result = await preprocessor.ProcessAsync(root, new object()); @@ -191,11 +198,11 @@ public async Task ProcessAsync_PopulatesDiagnostics() public async Task ProcessAsync_TracksProcessedResources() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var dep = new Resource("dep.txt", "Dependency".AsMemory()); - var main = new Resource("main.txt", "#include dep.txt\nMain".AsMemory()); + var dep = new Resource("dep.txt", "Dependency".AsMemory()); + var main = new Resource("main.txt", "#include dep.txt\nMain".AsMemory()); - resolver.Setup(r => r.ResolveAsync("dep.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(dep, null)); + resolver.Setup(r => r.ResolveAsync("dep.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(dep, null)); var result = await preprocessor.ProcessAsync(main, new object()); @@ -212,13 +219,13 @@ public async Task ProcessAsync_TracksProcessedResources() public async Task ProcessAsync_CircularDependency_ReportsDiagnostic() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var fileA = new Resource("a.txt", "#include b.txt\nFile A".AsMemory()); - var fileB = new Resource("b.txt", "#include a.txt\nFile B".AsMemory()); + var fileA = new Resource("a.txt", "#include b.txt\nFile A".AsMemory()); + var fileB = new Resource("b.txt", "#include a.txt\nFile B".AsMemory()); - resolver.Setup(r => r.ResolveAsync("b.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(fileB, null)); - resolver.Setup(r => r.ResolveAsync("a.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(fileA, null)); + resolver.Setup(r => r.ResolveAsync("b.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(fileB, null)); + resolver.Setup(r => r.ResolveAsync("a.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(fileA, null)); var result = await preprocessor.ProcessAsync(fileA, new object()); @@ -235,10 +242,10 @@ public async Task ProcessAsync_CircularDependency_ReportsDiagnostic() public async Task ProcessAsync_SelfReference_ReportsDiagnostic() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var file = new Resource("self.txt", "#include self.txt\nContent".AsMemory()); + var file = new Resource("self.txt", "#include self.txt\nContent".AsMemory()); - resolver.Setup(r => r.ResolveAsync("self.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(file, null)); + resolver.Setup(r => r.ResolveAsync("self.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(file, null)); var result = await preprocessor.ProcessAsync(file, new object()); @@ -253,13 +260,13 @@ public async Task ProcessAsync_SelfReference_ReportsDiagnostic() public async Task ProcessAsync_CircularDependency_ContinuesProcessing() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var fileA = new Resource("a.txt", "#include b.txt\nContent A".AsMemory()); - var fileB = new Resource("b.txt", "#include a.txt\nContent B".AsMemory()); + var fileA = new Resource("a.txt", "#include b.txt\nContent A".AsMemory()); + var fileB = new Resource("b.txt", "#include a.txt\nContent B".AsMemory()); - resolver.Setup(r => r.ResolveAsync("b.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(fileB, null)); - resolver.Setup(r => r.ResolveAsync("a.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(fileA, null)); + resolver.Setup(r => r.ResolveAsync("b.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(fileB, null)); + resolver.Setup(r => r.ResolveAsync("a.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(fileA, null)); var result = await preprocessor.ProcessAsync(fileA, new object()); @@ -275,17 +282,17 @@ public async Task ProcessAsync_CircularDependency_ContinuesProcessing() public async Task ProcessAsync_DeduplicationEnabled_IncludesOnlyOnce() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var shared = new Resource("shared.txt", "SHARED".AsMemory()); - var libA = new Resource("libA.txt", "#include shared.txt\nLibA".AsMemory()); - var libB = new Resource("libB.txt", "#include shared.txt\nLibB".AsMemory()); - var main = new Resource("main.txt", "#include libA.txt\n#include libB.txt\nMain".AsMemory()); - - resolver.Setup(r => r.ResolveAsync("shared.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(shared, null)); - resolver.Setup(r => r.ResolveAsync("libA.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(libA, null)); - resolver.Setup(r => r.ResolveAsync("libB.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(libB, null)); + var shared = new Resource("shared.txt", "SHARED".AsMemory()); + var libA = new Resource("libA.txt", "#include shared.txt\nLibA".AsMemory()); + var libB = new Resource("libB.txt", "#include shared.txt\nLibB".AsMemory()); + var main = new Resource("main.txt", "#include libA.txt\n#include libB.txt\nMain".AsMemory()); + + resolver.Setup(r => r.ResolveAsync("shared.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(shared, null)); + resolver.Setup(r => r.ResolveAsync("libA.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(libA, null)); + resolver.Setup(r => r.ResolveAsync("libB.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(libB, null)); var options = new PreprocessorOptions(DeduplicateIncludes: true); var result = await preprocessor.ProcessAsync(main, new object(), options); @@ -302,11 +309,11 @@ public async Task ProcessAsync_DeduplicationDisabled_RevisitsForDependencies() // When deduplication is disabled, we still only include content once, // but we do revisit resources for dependency tracking purposes var (preprocessor, resolver, _) = CreatePreprocessor(); - var shared = new Resource("shared.txt", "SHARED".AsMemory()); - var main = new Resource("main.txt", "#include shared.txt\n#include shared.txt\nMain".AsMemory()); + var shared = new Resource("shared.txt", "SHARED".AsMemory()); + var main = new Resource("main.txt", "#include shared.txt\n#include shared.txt\nMain".AsMemory()); - resolver.Setup(r => r.ResolveAsync("shared.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(shared, null)); + resolver.Setup(r => r.ResolveAsync("shared.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(shared, null)); var options = new PreprocessorOptions(DeduplicateIncludes: false); var result = await preprocessor.ProcessAsync(main, new object(), options); @@ -331,20 +338,20 @@ public async Task ProcessAsync_ExceedsMaxDepth_ReportsDiagnostic() var (preprocessor, resolver, _) = CreatePreprocessor(); // Create a chain of includes that exceeds depth 3 - var level3 = new Resource("level3.txt", "#include level4.txt\nL3".AsMemory()); - var level2 = new Resource("level2.txt", "#include level3.txt\nL2".AsMemory()); - var level1 = new Resource("level1.txt", "#include level2.txt\nL1".AsMemory()); - var level0 = new Resource("level0.txt", "#include level1.txt\nL0".AsMemory()); - var level4 = new Resource("level4.txt", "L4".AsMemory()); - - resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level1, null)); - resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level2, null)); - resolver.Setup(r => r.ResolveAsync("level3.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level3, null)); - resolver.Setup(r => r.ResolveAsync("level4.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level4, null)); + var level3 = new Resource("level3.txt", "#include level4.txt\nL3".AsMemory()); + var level2 = new Resource("level2.txt", "#include level3.txt\nL2".AsMemory()); + var level1 = new Resource("level1.txt", "#include level2.txt\nL1".AsMemory()); + var level0 = new Resource("level0.txt", "#include level1.txt\nL0".AsMemory()); + var level4 = new Resource("level4.txt", "L4".AsMemory()); + + resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level1, null)); + resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level2, null)); + resolver.Setup(r => r.ResolveAsync("level3.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level3, null)); + resolver.Setup(r => r.ResolveAsync("level4.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level4, null)); var options = new PreprocessorOptions(MaxIncludeDepth: 3); var result = await preprocessor.ProcessAsync(level0, new object(), options); @@ -361,14 +368,14 @@ public async Task ProcessAsync_AtMaxDepth_NoError() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var level2 = new Resource("level2.txt", "L2".AsMemory()); - var level1 = new Resource("level1.txt", "#include level2.txt\nL1".AsMemory()); - var level0 = new Resource("level0.txt", "#include level1.txt\nL0".AsMemory()); + var level2 = new Resource("level2.txt", "L2".AsMemory()); + var level1 = new Resource("level1.txt", "#include level2.txt\nL1".AsMemory()); + var level0 = new Resource("level0.txt", "#include level1.txt\nL0".AsMemory()); - resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level1, null)); - resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(level2, null)); + resolver.Setup(r => r.ResolveAsync("level1.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level1, null)); + resolver.Setup(r => r.ResolveAsync("level2.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(level2, null)); var options = new PreprocessorOptions(MaxIncludeDepth: 5); var result = await preprocessor.ProcessAsync(level0, new object(), options); @@ -384,11 +391,11 @@ public async Task ProcessAsync_AtMaxDepth_NoError() public async Task ProcessAsync_MaxDepthZero_FailsOnFirstInclude() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var child = new Resource("child.txt", "Child".AsMemory()); - var main = new Resource("main.txt", "#include child.txt\nMain".AsMemory()); + var child = new Resource("child.txt", "Child".AsMemory()); + var main = new Resource("main.txt", "#include child.txt\nMain".AsMemory()); - resolver.Setup(r => r.ResolveAsync("child.txt", It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResourceResolutionResult(child, null)); + resolver.Setup(r => r.ResolveAsync("child.txt", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ResourceResolutionResult(child, null)); var options = new PreprocessorOptions(MaxIncludeDepth: 0); var result = await preprocessor.ProcessAsync(main, new object(), options); @@ -408,13 +415,13 @@ public async Task ProcessAsync_MaxDepthZero_FailsOnFirstInclude() public async Task ProcessAsync_Cancelled_ThrowsOperationCancelledException() { var (preprocessor, resolver, _) = CreatePreprocessor(); - var root = new Resource("main.txt", "#include slow.txt\nContent".AsMemory()); + var root = new Resource("main.txt", "#include slow.txt\nContent".AsMemory()); - resolver.Setup(r => r.ResolveAsync("slow.txt", It.IsAny(), It.IsAny())) - .Returns(async (string _, IResource? _, CancellationToken ct) => + resolver.Setup(r => r.ResolveAsync("slow.txt", It.IsAny?>(), It.IsAny())) + .Returns(async (string _, IResource? _, CancellationToken ct) => { await Task.Delay(1000, ct); - return new ResourceResolutionResult(new Resource("slow.txt", "".AsMemory()), null); + return new ResourceResolutionResult(new Resource("slow.txt", "".AsMemory()), null); }); using var cts = new CancellationTokenSource(); @@ -442,14 +449,14 @@ await Assert.ThrowsAsync(async () => #region Test Infrastructure private static ( - Preprocessor Preprocessor, - Mock Resolver, - Mock> Parser) + Preprocessor Preprocessor, + Mock> Resolver, + Mock> Parser) CreatePreprocessor() { - var parser = new Mock>(); - var resolver = new Mock(); - var mergeStrategy = new ConcatenatingMergeStrategy(); + var parser = new Mock>(); + var resolver = new Mock>(); + var mergeStrategy = new ConcatenatingMergeStrategy(); // Setup parser to find #include directives parser.Setup(p => p.Parse(It.IsAny>(), It.IsAny())) @@ -473,8 +480,9 @@ private static ( return directives; }); - var preprocessor = new Preprocessor( + var preprocessor = new Preprocessor( parser.Object, + new TestIncludeDirectiveModel(), resolver.Object, mergeStrategy); @@ -495,18 +503,18 @@ private static int CountOccurrences(string text, string pattern) private static void AssertMapped( SourceMap sourceMap, + string generatedContent, int generatedLine, int generatedColumn, string expectedResource, - int expectedOriginalLine, - int expectedOriginalColumn) + int expectedOriginalOffset) { - var mapped = sourceMap.Query(new SourcePosition(generatedLine, generatedColumn)); + var generatedOffset = OffsetOfLineColumn(generatedContent, generatedLine, generatedColumn); + var mapped = sourceMap.Query(generatedOffset); Assert.NotNull(mapped); Assert.Equal(new ResourceId(expectedResource), mapped.Resource); - Assert.Equal(expectedOriginalLine, mapped.OriginalPosition.Line); - Assert.Equal(expectedOriginalColumn, mapped.OriginalPosition.Column); + Assert.Equal(expectedOriginalOffset, mapped.OriginalOffset); } private static void AssertMappedAtToken( @@ -514,42 +522,76 @@ private static void AssertMappedAtToken( string generatedContent, string token, string expectedResource, - int expectedOriginalLine, - int expectedOriginalColumn) + int expectedOriginalOffset) { - var position = FindPosition(generatedContent, token); - var mapped = sourceMap.Query(position); + var generatedOffset = FindOffset(generatedContent, token); + var mapped = sourceMap.Query(generatedOffset); Assert.NotNull(mapped); Assert.Equal(new ResourceId(expectedResource), mapped.Resource); - Assert.Equal(expectedOriginalLine, mapped.OriginalPosition.Line); - Assert.Equal(expectedOriginalColumn, mapped.OriginalPosition.Column); + Assert.Equal(expectedOriginalOffset, mapped.OriginalOffset); } - private static SourcePosition FindPosition(string text, string token) + private static int FindOffset(string text, string token) { var index = text.IndexOf(token, StringComparison.Ordinal); Assert.True(index >= 0, $"Token '{token}' not found in generated output."); - var line = 0; - var lastNewLineIndex = -1; - for (var i = 0; i < index; i++) + return index; + } + + private static int OffsetOfLineColumn(string text, int line, int column) + { + ArgumentOutOfRangeException.ThrowIfNegative(line); + ArgumentOutOfRangeException.ThrowIfNegative(column); + + var offset = 0; + var currentLine = 0; + var currentColumn = 0; + + for (var i = 0; i < text.Length; i++) { + if (currentLine == line && currentColumn == column) + { + return offset; + } + if (text[i] == '\n') { - line++; - lastNewLineIndex = i; + currentLine++; + currentColumn = 0; } + else + { + currentColumn++; + } + + offset++; + } + + if (currentLine == line && currentColumn == column) + { + return offset; } - var column = index - (lastNewLineIndex + 1); - return new SourcePosition(line, column); + throw new ArgumentOutOfRangeException(nameof(line), "Line/column is outside the provided text."); } #endregion } /// -/// Test implementation of IIncludeDirective for integration tests. +/// Test include-like directive for integration tests. /// -public sealed record TestIncludeDirective(string Reference, System.Range Location) : IIncludeDirective; +public sealed record TestIncludeDirective(string Reference, System.Range Location); + +file sealed class TestIncludeDirectiveModel : IDirectiveModel +{ + public System.Range GetLocation(TestIncludeDirective directive) => directive.Location; + + public bool TryGetReference(TestIncludeDirective directive, out string reference) + { + reference = directive.Reference; + return true; + } +} diff --git a/TinyPreprocessor.Tests/Merging/ConcatenatingMergeStrategyTests.cs b/TinyPreprocessor.Tests/Merging/ConcatenatingMergeStrategyTests.cs index e001e02..998f815 100644 --- a/TinyPreprocessor.Tests/Merging/ConcatenatingMergeStrategyTests.cs +++ b/TinyPreprocessor.Tests/Merging/ConcatenatingMergeStrategyTests.cs @@ -2,12 +2,13 @@ using TinyPreprocessor.Diagnostics; using TinyPreprocessor.Merging; using TinyPreprocessor.SourceMaps; +using TinyPreprocessor.Text; using Xunit; namespace TinyPreprocessor.Tests.Merging; /// -/// Unit tests for . +/// Unit tests for . /// public sealed class ConcatenatingMergeStrategyTests { @@ -16,7 +17,7 @@ public sealed class ConcatenatingMergeStrategyTests [Fact] public void Merge_SingleResource_ReturnsContentUnmodified() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var content = "Hello, World!"; var resources = CreateResolvedResources(("test.txt", content)); var context = CreateMergeContext(); @@ -29,7 +30,7 @@ public void Merge_SingleResource_ReturnsContentUnmodified() [Fact] public void Merge_MultipleResources_ConcatenatesInOrder() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var resources = CreateResolvedResources( ("first.txt", "First\n"), ("second.txt", "Second\n"), @@ -45,8 +46,8 @@ public void Merge_MultipleResources_ConcatenatesInOrder() [Fact] public void Merge_EmptyResources_ReturnsEmpty() { - var strategy = new ConcatenatingMergeStrategy(); - var resources = new List(); + var strategy = new ConcatenatingMergeStrategy(); + var resources = new List>(); var context = CreateMergeContext(); var result = strategy.Merge(resources, new object(), context); @@ -58,7 +59,7 @@ public void Merge_EmptyResources_ReturnsEmpty() public void Merge_CustomSeparator_UsesSeparator() { var options = new ConcatenatingMergeOptions(Separator: "\n\n---\n\n"); - var strategy = new ConcatenatingMergeStrategy(options); + var strategy = new ConcatenatingMergeStrategy(options); var resources = CreateResolvedResources( ("a.txt", "Part A"), ("b.txt", "Part B")); @@ -76,7 +77,7 @@ public void Merge_WithResourceMarkers_IncludesMarkers() var options = new ConcatenatingMergeOptions( IncludeResourceMarkers: true, MarkerFormat: "// File: {0}\n"); - var strategy = new ConcatenatingMergeStrategy(options); + var strategy = new ConcatenatingMergeStrategy(options); var resources = CreateResolvedResources(("code.cs", "var x = 1;")); var context = CreateMergeContext(); @@ -92,11 +93,11 @@ public void Merge_WithResourceMarkers_IncludesMarkers() [Fact] public void Merge_WithDirectives_StripsDirectiveRanges() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var content = "Line 1\n#include \nLine 3"; var directive = new TestDirective(7..26); // The #include directive - var resource = new ResolvedResource( - new Resource("test.txt", content.AsMemory()), + var resource = new ResolvedResource( + new Resource("test.txt", content.AsMemory()), new[] { directive }); var context = CreateMergeContext(); @@ -110,14 +111,14 @@ public void Merge_WithDirectives_StripsDirectiveRanges() [Fact] public void Merge_MultipleDirectives_StripsAll() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); // Content with directives at known positions var content = "#include \ncode\n#include "; var directive1 = new TestDirective(0..12); var directive2 = new TestDirective(18..30); - var resource = new ResolvedResource( - new Resource("test.txt", content.AsMemory()), - new IDirective[] { directive1, directive2 }); + var resource = new ResolvedResource( + new Resource("test.txt", content.AsMemory()), + new[] { directive1, directive2 }); var context = CreateMergeContext(); var result = strategy.Merge([resource], new object(), context); @@ -129,11 +130,11 @@ public void Merge_MultipleDirectives_StripsAll() [Fact] public void Merge_NoDirectives_ContentUnchanged() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var content = "Pure content without directives"; - var resource = new ResolvedResource( - new Resource("clean.txt", content.AsMemory()), - Array.Empty()); + var resource = new ResolvedResource( + new Resource("clean.txt", content.AsMemory()), + Array.Empty()); var context = CreateMergeContext(); var result = strategy.Merge([resource], new object(), context); @@ -144,11 +145,11 @@ public void Merge_NoDirectives_ContentUnchanged() [Fact] public void Merge_DirectiveAtStart_StripsCorrectly() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var content = "#pragma once\nActual content"; var directive = new TestDirective(0..13); - var resource = new ResolvedResource( - new Resource("header.h", content.AsMemory()), + var resource = new ResolvedResource( + new Resource("header.h", content.AsMemory()), new[] { directive }); var context = CreateMergeContext(); @@ -161,11 +162,11 @@ public void Merge_DirectiveAtStart_StripsCorrectly() [Fact] public void Merge_DirectiveAtEnd_StripsCorrectly() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var content = "Content\n#endif"; var directive = new TestDirective(8..14); - var resource = new ResolvedResource( - new Resource("guarded.h", content.AsMemory()), + var resource = new ResolvedResource( + new Resource("guarded.h", content.AsMemory()), new[] { directive }); var context = CreateMergeContext(); @@ -182,34 +183,30 @@ public void Merge_DirectiveAtEnd_StripsCorrectly() [Fact] public void Merge_RecordsMappingsToSourceMap() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var resources = CreateResolvedResources(("test.txt", "Line 1\nLine 2")); var context = CreateMergeContext(resources); var merged = strategy.Merge(resources, new object(), context); - context.SourceMapBuilder.SetGeneratedContent(merged); - context.SourceMapBuilder.SetOriginalResources(context.ResolvedCache); var sourceMap = context.SourceMapBuilder.Build(); - Assert.NotNull(sourceMap.Query(new SourcePosition(0, 0))); + Assert.NotNull(sourceMap.Query(generatedOffset: 0)); } [Fact] public void Merge_MappingsPointToCorrectResource() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); ResourceId resourceId = "source.txt"; - var resource = new ResolvedResource( - new Resource(resourceId, "content".AsMemory()), - Array.Empty()); + var resource = new ResolvedResource( + new Resource(resourceId, "content".AsMemory()), + Array.Empty()); var context = CreateMergeContext([resource]); var merged = strategy.Merge([resource], new object(), context); - context.SourceMapBuilder.SetGeneratedContent(merged); - context.SourceMapBuilder.SetOriginalResources(context.ResolvedCache); var sourceMap = context.SourceMapBuilder.Build(); - var mapped = sourceMap.Query(new SourcePosition(0, 0)); + var mapped = sourceMap.Query(generatedOffset: 0); Assert.NotNull(mapped); Assert.Equal(resourceId, mapped.Resource); } @@ -217,21 +214,19 @@ public void Merge_MappingsPointToCorrectResource() [Fact] public void Merge_MultipleFiles_MappingsTrackOrigin() { - var strategy = new ConcatenatingMergeStrategy(); - var resources = new List + var strategy = new ConcatenatingMergeStrategy(); + var resources = new List> { - new(new Resource("file1.txt", "Content 1".AsMemory()), Array.Empty()), - new(new Resource("file2.txt", "Content 2".AsMemory()), Array.Empty()) + new(new Resource("file1.txt", "Content 1".AsMemory()), Array.Empty()), + new(new Resource("file2.txt", "Content 2".AsMemory()), Array.Empty()) }; var context = CreateMergeContext(resources); var merged = strategy.Merge(resources, new object(), context); - context.SourceMapBuilder.SetGeneratedContent(merged); - context.SourceMapBuilder.SetOriginalResources(context.ResolvedCache); var sourceMap = context.SourceMapBuilder.Build(); - var mapped1 = sourceMap.Query(new SourcePosition(0, 0)); - var mapped2 = sourceMap.Query(new SourcePosition(1, 0)); + var mapped1 = sourceMap.Query(generatedOffset: 0); + var mapped2 = sourceMap.Query(generatedOffset: "Content 1".Length + "\n".Length); Assert.NotNull(mapped1); Assert.NotNull(mapped2); @@ -242,25 +237,23 @@ public void Merge_MultipleFiles_MappingsTrackOrigin() [Fact] public void Merge_AfterDirectiveStrip_MappingsAreAccurate() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); // Line 0: #include // Line 1: keep this var content = "#include\nkeep this"; var directive = new TestDirective(0..9); - var resource = new ResolvedResource( - new Resource("test.txt", content.AsMemory()), + var resource = new ResolvedResource( + new Resource("test.txt", content.AsMemory()), new[] { directive }); var context = CreateMergeContext([resource]); var merged = strategy.Merge([resource], new object(), context); - context.SourceMapBuilder.SetGeneratedContent(merged); - context.SourceMapBuilder.SetOriginalResources(context.ResolvedCache); var sourceMap = context.SourceMapBuilder.Build(); - var mapped = sourceMap.Query(new SourcePosition(0, 0)); + var mapped = sourceMap.Query(generatedOffset: 0); Assert.NotNull(mapped); Assert.Equal(new ResourceId("test.txt"), mapped.Resource); - Assert.Equal(1, mapped.OriginalPosition.Line); + Assert.Equal("#include\n".Length, mapped.OriginalOffset); } #endregion @@ -270,7 +263,7 @@ public void Merge_AfterDirectiveStrip_MappingsAreAccurate() [Fact] public void Merge_NullResources_ThrowsArgumentNullException() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var context = CreateMergeContext(); Assert.Throws(() => @@ -280,7 +273,7 @@ public void Merge_NullResources_ThrowsArgumentNullException() [Fact] public void Merge_NullContext_ThrowsArgumentNullException() { - var strategy = new ConcatenatingMergeStrategy(); + var strategy = new ConcatenatingMergeStrategy(); var resources = CreateResolvedResources(("test.txt", "content")); Assert.Throws(() => @@ -291,36 +284,48 @@ public void Merge_NullContext_ThrowsArgumentNullException() public void Constructor_NullOptions_ThrowsArgumentNullException() { Assert.Throws(() => - new ConcatenatingMergeStrategy(null!)); + new ConcatenatingMergeStrategy(null!)); } #endregion #region Test Helpers - private static List CreateResolvedResources( + private static List> CreateResolvedResources( params (string Path, string Content)[] items) { return items - .Select(item => new ResolvedResource( - new Resource(item.Path, item.Content.AsMemory()), - Array.Empty())) + .Select(item => new ResolvedResource( + new Resource(item.Path, item.Content.AsMemory()), + Array.Empty())) .ToList(); } - private static MergeContext CreateMergeContext(IReadOnlyList? resources = null) + private static MergeContext CreateMergeContext(IReadOnlyList>? resources = null) { var resolvedCache = resources is null - ? new Dictionary() + ? new Dictionary>() : resources.ToDictionary(r => r.Id, r => r.Resource); - return new MergeContext( + return new MergeContext( new SourceMapBuilder(), new DiagnosticCollection(), - resolvedCache); + resolvedCache, + new TestDirectiveModel()); } - private sealed record TestDirective(Range Location) : IDirective; + private sealed record TestDirective(Range Location); + + private sealed class TestDirectiveModel : IDirectiveModel + { + public Range GetLocation(TestDirective directive) => directive.Location; + + public bool TryGetReference(TestDirective directive, out string reference) + { + reference = string.Empty; + return false; + } + } #endregion } diff --git a/TinyPreprocessor.Tests/SourceMaps/SourceMapBuilderTests.cs b/TinyPreprocessor.Tests/SourceMaps/SourceMapBuilderTests.cs index 5fe5fe8..3868caf 100644 --- a/TinyPreprocessor.Tests/SourceMaps/SourceMapBuilderTests.cs +++ b/TinyPreprocessor.Tests/SourceMaps/SourceMapBuilderTests.cs @@ -15,45 +15,32 @@ public void Build_SortsOffsetSegmentsByGeneratedStart() { var builder = new SourceMapBuilder(); - var generated = "0123456789"; - builder.SetGeneratedContent(generated.AsMemory()); + var r1 = new ResourceId("a.txt"); + var r2 = new ResourceId("b.txt"); - var r1 = new Resource("a.txt", "xxxxx".AsMemory()); - var r2 = new Resource("b.txt", "yyyyy".AsMemory()); - builder.SetOriginalResources(new Dictionary - { - [r1.Id] = r1, - [r2.Id] = r2 - }); - - builder.AddOffsetSegment(r2.Id, generatedStartOffset: 5, originalStartOffset: 0, length: 5); - builder.AddOffsetSegment(r1.Id, generatedStartOffset: 0, originalStartOffset: 0, length: 5); + builder.AddOffsetSegment(r2, generatedStartOffset: 5, originalStartOffset: 0, length: 5); + builder.AddOffsetSegment(r1, generatedStartOffset: 0, originalStartOffset: 0, length: 5); var map = builder.Build(); - var first = map.Query(new SourcePosition(0, 0)); - var second = map.Query(new SourcePosition(0, 7)); + var first = map.Query(generatedOffset: 0); + var second = map.Query(generatedOffset: 7); Assert.NotNull(first); Assert.NotNull(second); - Assert.Equal(r1.Id, first.Resource); - Assert.Equal(r2.Id, second.Resource); + Assert.Equal(r1, first.Resource); + Assert.Equal(r2, second.Resource); } [Fact] - public void Clear_RemovesAllSegmentsAndIndexes() + public void Clear_RemovesAllSegments() { var builder = new SourceMapBuilder(); - builder.SetGeneratedContent("abc".AsMemory()); - builder.SetOriginalResources(new Dictionary - { - [new ResourceId("x.txt")] = new Resource("x.txt", "abc".AsMemory()) - }); builder.AddOffsetSegment("x.txt", generatedStartOffset: 0, originalStartOffset: 0, length: 3); builder.Clear(); var map = builder.Build(); - Assert.Null(map.Query(new SourcePosition(0, 0))); + Assert.Null(map.Query(generatedOffset: 0)); } } diff --git a/TinyPreprocessor.Tests/SourceMaps/SourceMapQueryTests.cs b/TinyPreprocessor.Tests/SourceMaps/SourceMapQueryTests.cs index 4897d2b..becf7c8 100644 --- a/TinyPreprocessor.Tests/SourceMaps/SourceMapQueryTests.cs +++ b/TinyPreprocessor.Tests/SourceMaps/SourceMapQueryTests.cs @@ -19,23 +19,15 @@ public void Query_PositionWithinMapping_ReturnsOriginalLocation() var builder = new SourceMapBuilder(); ResourceId resource = "original.txt"; - var generated = "0123456789"; - builder.SetGeneratedContent(generated.AsMemory()); - - var originalContent = "AAAAAAAAAA"; - var original = new Resource(resource, originalContent.AsMemory()); - builder.SetOriginalResources(new Dictionary { [resource] = original }); - // Generated: offsets [0..10) -> Original: offsets [0..10) builder.AddOffsetSegment(resource, generatedStartOffset: 0, originalStartOffset: 0, length: 10); var sourceMap = builder.Build(); - var result = sourceMap.Query(new SourcePosition(0, 5)); + var result = sourceMap.Query(generatedOffset: 5); Assert.NotNull(result); Assert.Equal(resource, result.Resource); - Assert.Equal(0, result.OriginalPosition.Line); - Assert.Equal(5, result.OriginalPosition.Column); + Assert.Equal(5, result.OriginalOffset); } [Fact] @@ -43,22 +35,15 @@ public void Query_PositionAtMappingStart_ReturnsOriginalLocation() { var builder = new SourceMapBuilder(); - var generated = string.Join("\n", Enumerable.Range(0, 11).Select(i => i == 10 ? new string('G', 50) : "")); - builder.SetGeneratedContent(generated.AsMemory()); - - var original = new Resource("file.txt", new string('O', 50).AsMemory()); - builder.SetOriginalResources(new Dictionary { [original.Id] = original }); - - // generated line 10, col 0 is offset of the start of that line. - var generatedStartOffset = GetOffset(generated, new SourcePosition(10, 0)); + // 11 lines joined with "\n" (10 separators). Line 10 begins at offset 10. + var generatedStartOffset = 10; builder.AddOffsetSegment("file.txt", generatedStartOffset, originalStartOffset: 0, length: 50); var sourceMap = builder.Build(); - var result = sourceMap.Query(new SourcePosition(10, 0)); + var result = sourceMap.Query(generatedStartOffset); Assert.NotNull(result); - Assert.Equal(0, result.OriginalPosition.Line); - Assert.Equal(0, result.OriginalPosition.Column); + Assert.Equal(0, result.OriginalOffset); } [Fact] @@ -66,23 +51,17 @@ public void Query_PositionOutsideAllMappings_ReturnsNull() { var builder = new SourceMapBuilder(); - var generated = string.Join("\n", Enumerable.Range(0, 6).Select(i => i == 5 ? new string('G', 20) : "")); - builder.SetGeneratedContent(generated.AsMemory()); - - var original = new Resource("file.txt", new string('O', 20).AsMemory()); - builder.SetOriginalResources(new Dictionary { [original.Id] = original }); - - var start = GetOffset(generated, new SourcePosition(5, 0)); - builder.AddOffsetSegment("file.txt", start, originalStartOffset: 0, length: 20); + // 6 lines joined with "\n" (5 separators). Line 5 begins at offset 5. + builder.AddOffsetSegment("file.txt", generatedStartOffset: 5, originalStartOffset: 0, length: 20); var sourceMap = builder.Build(); // Query position before any mapping - var result1 = sourceMap.Query(new SourcePosition(0, 0)); + var result1 = sourceMap.Query(generatedOffset: 0); Assert.Null(result1); // Query position after all mappings - var result2 = sourceMap.Query(new SourcePosition(100, 0)); + var result2 = sourceMap.Query(generatedOffset: 100); Assert.Null(result2); } @@ -90,11 +69,9 @@ public void Query_PositionOutsideAllMappings_ReturnsNull() public void Query_EmptySourceMap_ReturnsNull() { var builder = new SourceMapBuilder(); - builder.SetGeneratedContent("".AsMemory()); - builder.SetOriginalResources(new Dictionary()); var sourceMap = builder.Build(); - var result = sourceMap.Query(new SourcePosition(0, 0)); + var result = sourceMap.Query(generatedOffset: 0); Assert.Null(result); } @@ -104,30 +81,18 @@ public void Query_MultipleMappings_FindsCorrectOne() { var builder = new SourceMapBuilder(); - var generated = "XXXXXXXXXX\nYYYYYYYYYY\nZZZZZZZZZZ"; - builder.SetGeneratedContent(generated.AsMemory()); - - var r1 = new Resource("file1.txt", "XXXXXXXXXX".AsMemory()); - var r2 = new Resource("file2.txt", "YYYYYYYYYY".AsMemory()); - var r3 = new Resource("file3.txt", "ZZZZZZZZZZ".AsMemory()); - builder.SetOriginalResources(new Dictionary - { - [r1.Id] = r1, - [r2.Id] = r2, - [r3.Id] = r3 - }); - - builder.AddOffsetSegment("file1.txt", generatedStartOffset: GetOffset(generated, new SourcePosition(0, 0)), originalStartOffset: 0, length: 10); - builder.AddOffsetSegment("file2.txt", generatedStartOffset: GetOffset(generated, new SourcePosition(1, 0)), originalStartOffset: 0, length: 10); - builder.AddOffsetSegment("file3.txt", generatedStartOffset: GetOffset(generated, new SourcePosition(2, 0)), originalStartOffset: 0, length: 10); + // Each line is 10 chars + 1 newline, so line starts are 0, 11, 22. + builder.AddOffsetSegment("file1.txt", generatedStartOffset: 0, originalStartOffset: 0, length: 10); + builder.AddOffsetSegment("file2.txt", generatedStartOffset: 11, originalStartOffset: 0, length: 10); + builder.AddOffsetSegment("file3.txt", generatedStartOffset: 22, originalStartOffset: 0, length: 10); var sourceMap = builder.Build(); - var result = sourceMap.Query(new SourcePosition(1, 5)); + var result = sourceMap.Query(generatedOffset: 11 + 5); Assert.NotNull(result); Assert.Equal(new ResourceId("file2.txt"), result.Resource); - Assert.Equal(0, result.OriginalPosition.Line); + Assert.Equal(5, result.OriginalOffset); } [Fact] @@ -135,39 +100,26 @@ public void Query_BinarySearchWithManyMappings_FindsCorrectMapping() { var builder = new SourceMapBuilder(); - // Generated is 100 lines of single char plus newline. - var generatedLines = Enumerable.Range(0, 100).Select(_ => "XXXXXXXXXX"); - var generated = string.Join("\n", generatedLines); - builder.SetGeneratedContent(generated.AsMemory()); - - var originals = new Dictionary(); - for (var i = 0; i < 100; i++) - { - var r = new Resource($"file{i}.txt", "XXXXXXXXXX".AsMemory()); - originals[r.Id] = r; - } - builder.SetOriginalResources(originals); - // Add 100 offset segments. for (var i = 0; i < 100; i++) { - var offset = GetOffset(generated, new SourcePosition(i, 0)); + var offset = i * 11; builder.AddOffsetSegment($"file{i}.txt", offset, originalStartOffset: 0, length: 10); } var sourceMap = builder.Build(); // Query various positions - var result50 = sourceMap.Query(new SourcePosition(50, 5)); + var result50 = sourceMap.Query(generatedOffset: 50 * 11 + 5); Assert.NotNull(result50); Assert.Equal(new ResourceId("file50.txt"), result50.Resource); - Assert.Equal(0, result50.OriginalPosition.Line); + Assert.Equal(5, result50.OriginalOffset); - var result0 = sourceMap.Query(new SourcePosition(0, 0)); + var result0 = sourceMap.Query(generatedOffset: 0); Assert.NotNull(result0); Assert.Equal(new ResourceId("file0.txt"), result0.Resource); - var result99 = sourceMap.Query(new SourcePosition(99, 0)); + var result99 = sourceMap.Query(generatedOffset: 99 * 11); Assert.NotNull(result99); Assert.Equal(new ResourceId("file99.txt"), result99.Resource); } @@ -177,19 +129,14 @@ public void Query_PositionBetweenMappings_ReturnsNull() { var builder = new SourceMapBuilder(); - var generated = string.Join("\n", Enumerable.Range(0, 6).Select(i => i == 0 || i == 5 ? new string('X', 10) : "")); - builder.SetGeneratedContent(generated.AsMemory()); - - var original = new Resource("file.txt", new string('O', 100).AsMemory()); - builder.SetOriginalResources(new Dictionary { [original.Id] = original }); - - builder.AddOffsetSegment("file.txt", GetOffset(generated, new SourcePosition(0, 0)), originalStartOffset: 0, length: 10); - builder.AddOffsetSegment("file.txt", GetOffset(generated, new SourcePosition(5, 0)), originalStartOffset: 50, length: 10); + // 6 lines joined with "\n" (5 separators). Line 5 begins at offset 15. + builder.AddOffsetSegment("file.txt", generatedStartOffset: 0, originalStartOffset: 0, length: 10); + builder.AddOffsetSegment("file.txt", generatedStartOffset: 15, originalStartOffset: 50, length: 10); var sourceMap = builder.Build(); // Query position in the gap - var result = sourceMap.Query(new SourcePosition(2, 5)); + var result = sourceMap.Query(generatedOffset: 12); Assert.Null(result); } @@ -203,40 +150,32 @@ public void Query_RangeOverTwoSegments_ReturnsTwoMappings() { var builder = new SourceMapBuilder(); - var generated = "AAAAABBBBBCCCCCDDDDDEEEEE"; - builder.SetGeneratedContent(generated.AsMemory()); - - var resource1 = new Resource("file1.txt", "xxxxx".AsMemory()); - var resource2 = new Resource("file2.txt", "yyyyy".AsMemory()); - builder.SetOriginalResources(new Dictionary - { - [resource1.Id] = resource1, - [resource2.Id] = resource2 - }); + var resource1 = new ResourceId("file1.txt"); + var resource2 = new ResourceId("file2.txt"); // Segment 1: generated [0..5) -> file1 [0..5) - builder.AddOffsetSegment(resource1.Id, generatedStartOffset: 0, originalStartOffset: 0, length: 5); + builder.AddOffsetSegment(resource1, generatedStartOffset: 0, originalStartOffset: 0, length: 5); // Segment 2: generated [10..15) -> file2 [0..5) - builder.AddOffsetSegment(resource2.Id, generatedStartOffset: 10, originalStartOffset: 0, length: 5); + builder.AddOffsetSegment(resource2, generatedStartOffset: 10, originalStartOffset: 0, length: 5); var sourceMap = builder.Build(); - var results = sourceMap.Query(new SourcePosition(0, 0), length: 15); + var results = sourceMap.QueryRangeByLength(generatedStartOffset: 0, length: 15); Assert.Equal(2, results.Count); - Assert.Equal(resource1.Id, results[0].Resource); - Assert.Equal(new SourcePosition(0, 0), results[0].GeneratedStart); - Assert.Equal(new SourcePosition(0, 5), results[0].GeneratedEnd); - Assert.Equal(new SourcePosition(0, 0), results[0].OriginalStart); - Assert.Equal(new SourcePosition(0, 5), results[0].OriginalEnd); - - Assert.Equal(resource2.Id, results[1].Resource); - Assert.Equal(new SourcePosition(0, 10), results[1].GeneratedStart); - Assert.Equal(new SourcePosition(0, 15), results[1].GeneratedEnd); - Assert.Equal(new SourcePosition(0, 0), results[1].OriginalStart); - Assert.Equal(new SourcePosition(0, 5), results[1].OriginalEnd); + Assert.Equal(resource1, results[0].Resource); + Assert.Equal(0, results[0].GeneratedStartOffset); + Assert.Equal(5, results[0].GeneratedEndOffset); + Assert.Equal(0, results[0].OriginalStartOffset); + Assert.Equal(5, results[0].OriginalEndOffset); + + Assert.Equal(resource2, results[1].Resource); + Assert.Equal(10, results[1].GeneratedStartOffset); + Assert.Equal(15, results[1].GeneratedEndOffset); + Assert.Equal(0, results[1].OriginalStartOffset); + Assert.Equal(5, results[1].OriginalEndOffset); } [Fact] @@ -244,59 +183,18 @@ public void Query_RangeByStartEnd_ReturnsSameResultsAsLengthOverload() { var builder = new SourceMapBuilder(); - var generated = "0123456789ABCDEFGHIJ"; - builder.SetGeneratedContent(generated.AsMemory()); + var resource = new ResourceId("file.txt"); - var resource = new Resource("file.txt", "xxxxxxxxxxxxxxxxxxxx".AsMemory()); - builder.SetOriginalResources(new Dictionary - { - [resource.Id] = resource - }); - - builder.AddOffsetSegment(resource.Id, generatedStartOffset: 3, originalStartOffset: 7, length: 5); + builder.AddOffsetSegment(resource, generatedStartOffset: 3, originalStartOffset: 7, length: 5); var sourceMap = builder.Build(); - var byLength = sourceMap.Query(new SourcePosition(0, 0), length: 10); - var byStartEnd = sourceMap.Query(new SourcePosition(0, 0), new SourcePosition(0, 10)); + var byLength = sourceMap.QueryRangeByLength(generatedStartOffset: 0, length: 10); + var byStartEnd = sourceMap.QueryRangeByEnd(generatedStartOffset: 0, generatedEndOffset: 10); Assert.Equal(byLength, byStartEnd); } #endregion - private static int GetOffset(string text, SourcePosition position) - { - var offset = 0; - var line = 0; - var column = 0; - - for (var i = 0; i < text.Length; i++) - { - if (line == position.Line && column == position.Column) - { - return offset; - } - - if (text[i] == '\n') - { - line++; - column = 0; - } - else - { - column++; - } - - offset++; - } - - // Allow querying at end of text. - if (line == position.Line && column == position.Column) - { - return offset; - } - - throw new ArgumentOutOfRangeException(nameof(position)); - } } diff --git a/TinyPreprocessor.Tests/SourceMaps/SourcePositionTests.cs b/TinyPreprocessor.Tests/SourceMaps/SourcePositionTests.cs index 1d257c0..552f8f8 100644 --- a/TinyPreprocessor.Tests/SourceMaps/SourcePositionTests.cs +++ b/TinyPreprocessor.Tests/SourceMaps/SourcePositionTests.cs @@ -1,3 +1,4 @@ +#if false using TinyPreprocessor.Core; using TinyPreprocessor.SourceMaps; using Xunit; @@ -223,3 +224,10 @@ public void ToOneBased_NonZeroValues_ConvertsCorrectly() #endregion } + +#endif + +namespace TinyPreprocessor.Tests.SourceMaps; + +// NOTE: The legacy SourcePosition (line/column) API has been removed. +// Source maps are now offset-only. diff --git a/TinyPreprocessor/Core/IDirective.cs b/TinyPreprocessor/Core/IDirective.cs deleted file mode 100644 index 589b2e3..0000000 --- a/TinyPreprocessor/Core/IDirective.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace TinyPreprocessor.Core; - -/// -/// Marker interface for parsed directives found within resource content. -/// -/// -/// Downstream users define their own directive types (IncludeDirective, ImportDirective, etc.) -/// by implementing this interface. The property indicates where -/// the directive appears in the source content, enabling directive stripping during merge. -/// -public interface IDirective -{ - /// - /// Gets the location of this directive within the source content. - /// - /// - /// Uses for efficient content slicing. - /// - Range Location { get; } -} diff --git a/TinyPreprocessor/Core/IDirectiveModel.cs b/TinyPreprocessor/Core/IDirectiveModel.cs new file mode 100644 index 0000000..ed93735 --- /dev/null +++ b/TinyPreprocessor/Core/IDirectiveModel.cs @@ -0,0 +1,27 @@ +namespace TinyPreprocessor.Core; + +/// +/// Provides directive semantics to the preprocessing pipeline without requiring directives +/// to implement any specific interface. +/// +/// The directive type. +public interface IDirectiveModel +{ + /// + /// Gets the location of the directive within its source content. + /// + /// The directive instance. + /// The directive location. + Range GetLocation(TDirective directive); + + /// + /// Attempts to extract a dependency reference from the directive. + /// + /// The directive instance. + /// When true is returned, receives the reference to resolve. + /// + /// if this directive represents a dependency reference to resolve; + /// otherwise . + /// + bool TryGetReference(TDirective directive, out string reference); +} diff --git a/TinyPreprocessor/Core/IDirectiveParser.cs b/TinyPreprocessor/Core/IDirectiveParser.cs index 5021157..ccf4a44 100644 --- a/TinyPreprocessor/Core/IDirectiveParser.cs +++ b/TinyPreprocessor/Core/IDirectiveParser.cs @@ -3,12 +3,13 @@ namespace TinyPreprocessor.Core; /// /// Extracts directives from resource content. /// +/// The symbol type of the content being parsed. /// The type of directive this parser produces. /// /// Parsing is CPU-bound, so this interface uses synchronous methods. /// The return type allows lazy evaluation for streaming large files. /// -public interface IDirectiveParser where TDirective : IDirective +public interface IDirectiveParser { /// /// Parses the content and extracts all directives. @@ -16,5 +17,5 @@ public interface IDirectiveParser where TDirective : IDirective /// The content to parse. /// The identifier of the resource being parsed, for context-aware parsing. /// An enumerable of parsed directives. - IEnumerable Parse(ReadOnlyMemory content, ResourceId resourceId); + IEnumerable Parse(ReadOnlyMemory content, ResourceId resourceId); } diff --git a/TinyPreprocessor/Core/IResource.cs b/TinyPreprocessor/Core/IResource.cs index 67a84c1..0821af0 100644 --- a/TinyPreprocessor/Core/IResource.cs +++ b/TinyPreprocessor/Core/IResource.cs @@ -3,7 +3,7 @@ namespace TinyPreprocessor.Core; /// /// Represents a single resource (file, module, or abstract content unit) in the preprocessing pipeline. /// -public interface IResource +public interface IResource { /// /// Gets the unique identifier for this resource. @@ -13,7 +13,7 @@ public interface IResource /// /// Gets the content of this resource. /// - ReadOnlyMemory Content { get; } + ReadOnlyMemory Content { get; } /// /// Gets optional metadata associated with this resource. diff --git a/TinyPreprocessor/Core/IResourceResolver.cs b/TinyPreprocessor/Core/IResourceResolver.cs index 2d06e3d..0393cc2 100644 --- a/TinyPreprocessor/Core/IResourceResolver.cs +++ b/TinyPreprocessor/Core/IResourceResolver.cs @@ -7,7 +7,7 @@ namespace TinyPreprocessor.Core; /// Implementations may involve I/O operations (file system, network, database), /// hence the async-first design with . /// -public interface IResourceResolver +public interface IResourceResolver { /// /// Resolves a reference string to an actual resource. @@ -16,10 +16,10 @@ public interface IResourceResolver /// The resource from which the reference is made, enabling relative resolution. /// A cancellation token to cancel the operation. /// - /// A containing either the resolved resource or an error diagnostic. + /// A containing either the resolved resource or an error diagnostic. /// - ValueTask ResolveAsync( + ValueTask> ResolveAsync( string reference, - IResource? relativeTo, + IResource? relativeTo, CancellationToken ct); } diff --git a/TinyPreprocessor/Core/Resource.cs b/TinyPreprocessor/Core/Resource.cs index 086ec94..36c4e43 100644 --- a/TinyPreprocessor/Core/Resource.cs +++ b/TinyPreprocessor/Core/Resource.cs @@ -1,13 +1,13 @@ namespace TinyPreprocessor.Core; /// -/// Default implementation of as an immutable record. +/// Default implementation of as an immutable record. /// /// The unique identifier for this resource. /// The content of this resource. /// Optional metadata associated with this resource. -public sealed record Resource( +public sealed record Resource( ResourceId Id, - ReadOnlyMemory Content, + ReadOnlyMemory Content, IReadOnlyDictionary? Metadata = null -) : IResource; +) : IResource; diff --git a/TinyPreprocessor/Core/ResourceResolutionResult.cs b/TinyPreprocessor/Core/ResourceResolutionResult.cs index 0113adc..27443ae 100644 --- a/TinyPreprocessor/Core/ResourceResolutionResult.cs +++ b/TinyPreprocessor/Core/ResourceResolutionResult.cs @@ -5,10 +5,11 @@ namespace TinyPreprocessor.Core; /// /// Represents the result of a resource resolution operation. /// +/// The symbol type of the resolved resource content. /// The resolved resource, or null if resolution failed. /// The error diagnostic if resolution failed, or null on success. -public sealed record ResourceResolutionResult( - IResource? Resource, +public sealed record ResourceResolutionResult( + IResource? Resource, IPreprocessorDiagnostic? Error) { /// @@ -20,15 +21,15 @@ public sealed record ResourceResolutionResult( /// Creates a successful resolution result. /// /// The resolved resource. - /// A successful . - public static ResourceResolutionResult Success(IResource resource) + /// A successful . + public static ResourceResolutionResult Success(IResource resource) => new(resource, null); /// /// Creates a failed resolution result. /// /// The error diagnostic describing the failure. - /// A failed . - public static ResourceResolutionResult Failure(IPreprocessorDiagnostic error) + /// A failed . + public static ResourceResolutionResult Failure(IPreprocessorDiagnostic error) => new(null, error); } diff --git a/TinyPreprocessor/IIncludeDirective.cs b/TinyPreprocessor/IIncludeDirective.cs deleted file mode 100644 index 5ac2058..0000000 --- a/TinyPreprocessor/IIncludeDirective.cs +++ /dev/null @@ -1,18 +0,0 @@ -using TinyPreprocessor.Core; - -namespace TinyPreprocessor; - -/// -/// Marker interface for directives that represent resource includes. -/// -/// -/// Implement this interface on directive types that should trigger recursive resolution. -/// The property provides the string used for resolution. -/// -public interface IIncludeDirective : IDirective -{ - /// - /// Gets the reference string to resolve (e.g., file path, module name). - /// - string Reference { get; } -} diff --git a/TinyPreprocessor/Merging/ConcatenatingMergeStrategy.cs b/TinyPreprocessor/Merging/ConcatenatingMergeStrategy.cs index 443f63c..56e22d5 100644 --- a/TinyPreprocessor/Merging/ConcatenatingMergeStrategy.cs +++ b/TinyPreprocessor/Merging/ConcatenatingMergeStrategy.cs @@ -1,283 +1,3 @@ -using System.Buffers; -using System.Text; -using TinyPreprocessor.Diagnostics; -using TinyPreprocessor.Core; -using TinyPreprocessor.SourceMaps; - namespace TinyPreprocessor.Merging; -/// -/// Options for the concatenating merge strategy. -/// -/// The separator between resources. Defaults to newline. -/// Whether to include debug markers for resource boundaries. -/// The format string for resource markers. {0} is replaced with the resource ID. -public sealed record ConcatenatingMergeOptions( - string Separator = "\n", - bool IncludeResourceMarkers = false, - string MarkerFormat = "/* === {0} === */\n"); - -/// -/// Default merge strategy that concatenates resources and strips directives. -/// -/// User-defined context type (unused by this strategy). -public sealed class ConcatenatingMergeStrategy : IMergeStrategy -{ - private readonly ConcatenatingMergeOptions _options; - - /// - /// Initializes a new instance of with default options. - /// - public ConcatenatingMergeStrategy() : this(new ConcatenatingMergeOptions()) - { - } - - /// - /// Initializes a new instance of with the specified options. - /// - /// The merge options. - public ConcatenatingMergeStrategy(ConcatenatingMergeOptions options) - { - ArgumentNullException.ThrowIfNull(options); - _options = options; - } - - /// - public ReadOnlyMemory Merge( - IReadOnlyList orderedResources, - TContext userContext, - MergeContext context) - { - ArgumentNullException.ThrowIfNull(orderedResources); - ArgumentNullException.ThrowIfNull(context); - - if (orderedResources.Count == 0) - { - return ReadOnlyMemory.Empty; - } - - var output = new ArrayBufferWriter(); - - for (var i = 0; i < orderedResources.Count; i++) - { - var resource = orderedResources[i]; - - // Ensure original resources are registered (when available) so offset-based queries can resolve. - // The pipeline already does this in Preprocessor, but merge unit tests may construct a context manually. - if (context.ResolvedCache.Count > 0) - { - context.SourceMapBuilder.SetOriginalResources(context.ResolvedCache); - } - - // Add resource marker if enabled - if (_options.IncludeResourceMarkers) - { - var marker = string.Format(_options.MarkerFormat, resource.Id.Path); - Append(output, marker.AsSpan()); - } - - StripDirectivesAndEmitSegments(resource, output, context.Diagnostics, context.SourceMapBuilder); - - // Add separator between resources (but not after the last one) - if (i < orderedResources.Count - 1) - { - Append(output, _options.Separator.AsSpan()); - } - } - - var merged = new string(output.WrittenSpan); - - // Register generated output so offset-based queries can convert SourcePosition -> offset. - // The pipeline already does this in Preprocessor, but merge unit tests may construct a context manually. - context.SourceMapBuilder.SetGeneratedContent(merged.AsMemory()); - - return merged.AsMemory(); - } - - /// - /// Strips directive ranges from the resource content. - /// - /// The resource to process. - /// - /// A tuple containing the stripped content and a list mapping output line indices to original line indices. - /// - private static void StripDirectivesAndEmitSegments( - ResolvedResource resource, - ArrayBufferWriter output, - DiagnosticCollection diagnostics, - SourceMapBuilder builder) - { - var content = resource.Content.Span; - if (content.Length == 0) - { - return; - } - - if (resource.Directives.Count == 0) - { - var generatedStart = output.WrittenCount; - Append(output, content); - builder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: 0, length: content.Length); - return; - } - - var excludedRanges = BuildExcludedRanges(resource, content, diagnostics); - if (excludedRanges.Count == 0) - { - var generatedStart = output.WrittenCount; - Append(output, content); - builder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: 0, length: content.Length); - return; - } - - var current = 0; - foreach (var range in excludedRanges) - { - if (range.Start > current) - { - var length = range.Start - current; - var generatedStart = output.WrittenCount; - Append(output, content.Slice(current, length)); - builder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: current, length: length); - } - - current = Math.Max(current, range.End); - if (current >= content.Length) - { - break; - } - } - - if (current < content.Length) - { - var length = content.Length - current; - var generatedStart = output.WrittenCount; - Append(output, content.Slice(current, length)); - builder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: current, length: length); - } - } - - private static List<(int Start, int End)> BuildExcludedRanges( - ResolvedResource resource, - ReadOnlySpan content, - DiagnosticCollection diagnostics) - { - var ranges = new List<(int Start, int End)>(capacity: resource.Directives.Count); - - foreach (var directive in resource.Directives) - { - var start = directive.Location.Start.GetOffset(content.Length); - var end = directive.Location.End.GetOffset(content.Length); - - start = Math.Clamp(start, 0, content.Length); - end = Math.Clamp(end, 0, content.Length); - - if (end < start) - { - (start, end) = (end, start); - } - - // Whole-line validation (diagnostic-only). - if (!IsWholeLineDirective(content, start, end)) - { - diagnostics.Add(new NonWholeLineDirectiveDiagnostic(resource.Id, directive.Location)); - } - - if (end > start) - { - ranges.Add((start, end)); - } - } - - if (ranges.Count == 0) - { - return ranges; - } - - ranges.Sort(static (a, b) => a.Start.CompareTo(b.Start)); - - // Coalesce overlaps/adjacency. - var coalesced = new List<(int Start, int End)>(capacity: ranges.Count); - var current = ranges[0]; - for (var i = 1; i < ranges.Count; i++) - { - var next = ranges[i]; - if (next.Start <= current.End) - { - current = (current.Start, Math.Max(current.End, next.End)); - continue; - } - - coalesced.Add(current); - current = next; - } - - coalesced.Add(current); - return coalesced; - } - - private static bool IsWholeLineDirective(ReadOnlySpan content, int start, int end) - { - if ((uint)start > (uint)content.Length || (uint)end > (uint)content.Length) - { - return false; - } - - // Find start-of-line. - var lineStart = 0; - if (start > 0) - { - var prevNewline = content.Slice(0, start).LastIndexOf('\n'); - lineStart = prevNewline >= 0 ? prevNewline + 1 : 0; - } - - // Find end-of-line for the line containing 'start'. - var lineEnd = content.Length; - var nextNewline = content.Slice(start).IndexOf('\n'); - if (nextNewline >= 0) - { - lineEnd = start + nextNewline; - } - - // Disallow spans that extend beyond the line (except optionally including the newline itself). - if (end > lineEnd + 1) - { - return false; - } - - // Only whitespace allowed before directive start. - for (var i = lineStart; i < start; i++) - { - if (!char.IsWhiteSpace(content[i])) - { - return false; - } - } - - // Only whitespace allowed after directive end until end-of-line. - if (end <= lineEnd) - { - for (var i = end; i < lineEnd; i++) - { - if (!char.IsWhiteSpace(content[i])) - { - return false; - } - } - } - - return true; - } - - private static void Append(ArrayBufferWriter writer, ReadOnlySpan value) - { - if (value.Length == 0) - { - return; - } - - var dest = writer.GetSpan(value.Length); - value.CopyTo(dest); - writer.Advance(value.Length); - } -} +// NOTE: Text-only merge strategy has moved to TinyPreprocessor.Text. diff --git a/TinyPreprocessor/Merging/IMergeStrategy.cs b/TinyPreprocessor/Merging/IMergeStrategy.cs index 26af8cd..175d849 100644 --- a/TinyPreprocessor/Merging/IMergeStrategy.cs +++ b/TinyPreprocessor/Merging/IMergeStrategy.cs @@ -3,8 +3,10 @@ namespace TinyPreprocessor.Merging; /// /// Interface for custom merge implementations. /// +/// The symbol type of content being merged. +/// The directive type associated with each resource. /// User-defined context type for strategy-specific options. -public interface IMergeStrategy +public interface IMergeStrategy { /// /// Merges resolved resources into a single output. @@ -13,8 +15,8 @@ public interface IMergeStrategy /// User-provided context for strategy customization. /// Merge context with source map builder and diagnostics. /// The merged content. - ReadOnlyMemory Merge( - IReadOnlyList orderedResources, + ReadOnlyMemory Merge( + IReadOnlyList> orderedResources, TContext userContext, - MergeContext context); + MergeContext context); } diff --git a/TinyPreprocessor/Merging/MergeContext.cs b/TinyPreprocessor/Merging/MergeContext.cs index 09adf01..98522a5 100644 --- a/TinyPreprocessor/Merging/MergeContext.cs +++ b/TinyPreprocessor/Merging/MergeContext.cs @@ -7,26 +7,30 @@ namespace TinyPreprocessor.Merging; /// /// Shared context provided to merge strategies for source map building and diagnostics. /// -public sealed class MergeContext +public sealed class MergeContext { /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// /// The builder for recording source mappings. /// The collection for reporting diagnostics. /// The cache of resolved resources. + /// The directive model for interpreting directive locations and references. public MergeContext( SourceMapBuilder sourceMapBuilder, DiagnosticCollection diagnostics, - IReadOnlyDictionary resolvedCache) + IReadOnlyDictionary> resolvedCache, + IDirectiveModel directiveModel) { ArgumentNullException.ThrowIfNull(sourceMapBuilder); ArgumentNullException.ThrowIfNull(diagnostics); ArgumentNullException.ThrowIfNull(resolvedCache); + ArgumentNullException.ThrowIfNull(directiveModel); SourceMapBuilder = sourceMapBuilder; Diagnostics = diagnostics; ResolvedCache = resolvedCache; + DirectiveModel = directiveModel; } /// @@ -42,5 +46,10 @@ public MergeContext( /// /// Gets the cache of resolved resources for cross-referencing. /// - public IReadOnlyDictionary ResolvedCache { get; } + public IReadOnlyDictionary> ResolvedCache { get; } + + /// + /// Gets the directive model to interpret directive locations and references. + /// + public IDirectiveModel DirectiveModel { get; } } diff --git a/TinyPreprocessor/Merging/ResolvedResource.cs b/TinyPreprocessor/Merging/ResolvedResource.cs index 9aa650e..2994bde 100644 --- a/TinyPreprocessor/Merging/ResolvedResource.cs +++ b/TinyPreprocessor/Merging/ResolvedResource.cs @@ -7,9 +7,9 @@ namespace TinyPreprocessor.Merging; /// /// The resolved resource. /// The parsed directives found in the resource. -public sealed record ResolvedResource( - IResource Resource, - IReadOnlyList Directives) +public sealed record ResolvedResource( + IResource Resource, + IReadOnlyList Directives) { /// /// Gets the resource identifier. @@ -19,5 +19,5 @@ public sealed record ResolvedResource( /// /// Gets the resource content. /// - public ReadOnlyMemory Content => Resource.Content; + public ReadOnlyMemory Content => Resource.Content; } diff --git a/TinyPreprocessor/PreprocessResult.cs b/TinyPreprocessor/PreprocessResult.cs index bf56a4b..e4bcfeb 100644 --- a/TinyPreprocessor/PreprocessResult.cs +++ b/TinyPreprocessor/PreprocessResult.cs @@ -13,8 +13,8 @@ namespace TinyPreprocessor; /// All collected diagnostics during processing. /// The resources in topological order (dependencies first). /// The dependency graph for downstream analysis. -public sealed record PreprocessResult( - ReadOnlyMemory Content, +public sealed record PreprocessResult( + ReadOnlyMemory Content, SourceMap SourceMap, DiagnosticCollection Diagnostics, IReadOnlyList ProcessedResources, diff --git a/TinyPreprocessor/Preprocessor.cs b/TinyPreprocessor/Preprocessor.cs index 35421fe..b9e18d1 100644 --- a/TinyPreprocessor/Preprocessor.cs +++ b/TinyPreprocessor/Preprocessor.cs @@ -9,6 +9,7 @@ namespace TinyPreprocessor; /// /// The main orchestrator that coordinates the entire preprocessing pipeline. /// +/// The symbol type of resource content. /// The type of directive parsed from resources. /// User-defined context type for merge strategy customization. /// @@ -20,28 +21,33 @@ namespace TinyPreprocessor; /// The dependencies (parser, resolver, merger) should be thread-safe or documented otherwise. /// /// -public sealed class Preprocessor where TDirective : IDirective +public sealed class Preprocessor { - private readonly IDirectiveParser _parser; - private readonly IResourceResolver _resolver; - private readonly IMergeStrategy _mergeStrategy; + private readonly IDirectiveParser _parser; + private readonly IDirectiveModel _directiveModel; + private readonly IResourceResolver _resolver; + private readonly IMergeStrategy _mergeStrategy; /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// /// The directive parser for extracting directives from resources. + /// The directive model for interpreting directive locations and dependency references. /// The resource resolver for resolving references. /// The merge strategy for combining resources. public Preprocessor( - IDirectiveParser parser, - IResourceResolver resolver, - IMergeStrategy mergeStrategy) + IDirectiveParser parser, + IDirectiveModel directiveModel, + IResourceResolver resolver, + IMergeStrategy mergeStrategy) { ArgumentNullException.ThrowIfNull(parser); + ArgumentNullException.ThrowIfNull(directiveModel); ArgumentNullException.ThrowIfNull(resolver); ArgumentNullException.ThrowIfNull(mergeStrategy); _parser = parser; + _directiveModel = directiveModel; _resolver = resolver; _mergeStrategy = mergeStrategy; } @@ -54,8 +60,8 @@ public Preprocessor( /// Processing options, or for defaults. /// A cancellation token to cancel the operation. /// The preprocessing result containing merged content, source map, and diagnostics. - public async ValueTask ProcessAsync( - IResource root, + public async ValueTask> ProcessAsync( + IResource root, TContext context, PreprocessorOptions? options = null, CancellationToken ct = default) @@ -67,7 +73,7 @@ public async ValueTask ProcessAsync( // Initialize per-call state var diagnostics = new DiagnosticCollection(); var graph = new ResourceDependencyGraph(); - var cache = new Dictionary(); + var cache = new Dictionary>(); var sourceMapBuilder = new SourceMapBuilder(); // Phase 1: Recursive resolution @@ -80,11 +86,13 @@ public async ValueTask ProcessAsync( var processingOrder = GetProcessingOrder(graph, cache); // Phase 4: Merge - var resolvedCache = cache.ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value.Resource); + var resolvedCache = cache.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Resource); - var mergeContext = new MergeContext(sourceMapBuilder, diagnostics, resolvedCache); + var mergeContext = new MergeContext( + sourceMapBuilder, + diagnostics, + resolvedCache, + _directiveModel); var orderedResources = processingOrder .Where(id => cache.ContainsKey(id)) .Select(id => cache[id]) @@ -92,12 +100,8 @@ public async ValueTask ProcessAsync( var mergedContent = _mergeStrategy.Merge(orderedResources, context, mergeContext); - // Register content for offset-based source map queries. - sourceMapBuilder.SetGeneratedContent(mergedContent); - sourceMapBuilder.SetOriginalResources(resolvedCache); - // Phase 5: Build result - return new PreprocessResult( + return new PreprocessResult( mergedContent, sourceMapBuilder.Build(), diagnostics, @@ -111,12 +115,12 @@ public async ValueTask ProcessAsync( /// Recursively resolves a resource and its dependencies. /// private async ValueTask ResolveRecursiveAsync( - IResource resource, + IResource resource, int depth, PreprocessorOptions options, DiagnosticCollection diagnostics, ResourceDependencyGraph graph, - Dictionary cache, + Dictionary> cache, CancellationToken ct) { ct.ThrowIfCancellationRequested(); @@ -131,28 +135,30 @@ private async ValueTask ResolveRecursiveAsync( graph.AddResource(resource.Id); // Parse directives - var directives = _parser.Parse(resource.Content, resource.Id).Cast().ToList(); + var directives = _parser.Parse(resource.Content, resource.Id).ToList(); // Cache the resolved resource - cache[resource.Id] = new ResolvedResource(resource, directives); + cache[resource.Id] = new ResolvedResource(resource, directives); // Process include directives foreach (var directive in directives) { - if (directive is not IIncludeDirective includeDirective) + if (!_directiveModel.TryGetReference(directive, out var reference)) { continue; } + var directiveLocation = _directiveModel.GetLocation(directive); + // Check depth limit if (depth >= options.MaxIncludeDepth) { diagnostics.Add(new MaxDepthExceededDiagnostic( - includeDirective.Reference, + reference, depth, options.MaxIncludeDepth, resource.Id, - directive.Location)); + directiveLocation)); if (!options.ContinueOnError) { @@ -163,7 +169,7 @@ private async ValueTask ResolveRecursiveAsync( } // Resolve the reference - var result = await _resolver.ResolveAsync(includeDirective.Reference, resource, ct); + var result = await _resolver.ResolveAsync(reference, resource, ct); if (result.Error is not null) { @@ -181,10 +187,10 @@ private async ValueTask ResolveRecursiveAsync( { // Should not happen if Error is null, but handle defensively diagnostics.Add(new ResolutionFailedDiagnostic( - includeDirective.Reference, + reference, Reason: null, resource.Id, - directive.Location)); + directiveLocation)); if (!options.ContinueOnError) { @@ -243,7 +249,7 @@ private static void DetectAndReportCycles( /// private static IReadOnlyList GetProcessingOrder( ResourceDependencyGraph graph, - Dictionary cache) + Dictionary> cache) { var order = graph.GetProcessingOrder(); diff --git a/TinyPreprocessor/SourceMaps/SourceLocation.cs b/TinyPreprocessor/SourceMaps/SourceLocation.cs index 2025d7f..c38a1ba 100644 --- a/TinyPreprocessor/SourceMaps/SourceLocation.cs +++ b/TinyPreprocessor/SourceMaps/SourceLocation.cs @@ -6,13 +6,9 @@ namespace TinyPreprocessor.SourceMaps; /// Represents a location in an original resource, returned as the result of a source map query. /// /// The identifier of the original resource. -/// The position within the original resource. -public sealed record SourceLocation(ResourceId Resource, SourcePosition OriginalPosition) +/// The 0-based offset within the original resource. +public sealed record SourceLocation(ResourceId Resource, int OriginalOffset) { /// - public override string ToString() - { - var (line, column) = OriginalPosition.ToOneBased(); - return $"{Resource.Path}:{line}:{column}"; - } + public override string ToString() => $"{Resource.Path}@{OriginalOffset}"; } diff --git a/TinyPreprocessor/SourceMaps/SourceMap.cs b/TinyPreprocessor/SourceMaps/SourceMap.cs index c56f3c9..9ea475c 100644 --- a/TinyPreprocessor/SourceMaps/SourceMap.cs +++ b/TinyPreprocessor/SourceMaps/SourceMap.cs @@ -14,23 +14,15 @@ namespace TinyPreprocessor.SourceMaps; public sealed class SourceMap { private readonly IReadOnlyList _segments; - private readonly TextLineIndex? _generatedLineIndex; - private readonly IReadOnlyDictionary? _originalLineIndexes; /// /// Initializes a new instance of with the specified offset segments. /// /// The sorted list of offset mapping segments. - /// Line index for converting generated positions to offsets. - /// Line indexes for converting original offsets to positions. internal SourceMap( - IReadOnlyList segments, - TextLineIndex? generatedLineIndex, - IReadOnlyDictionary? originalLineIndexes) + IReadOnlyList segments) { _segments = segments; - _generatedLineIndex = generatedLineIndex; - _originalLineIndexes = originalLineIndexes; } /// @@ -38,94 +30,83 @@ internal SourceMap( /// /// /// This is an implementation detail exposed for debugging and advanced scenarios. - /// The primary API surface is the set of and range query overloads. + /// The primary API surface is the set of offset-based and range query overloads. /// internal IReadOnlyList Segments => _segments; /// - /// Queries the source map for the original location corresponding to a generated position. + /// Queries the source map for the original location corresponding to a generated offset. /// - /// The position in the generated output. + /// The 0-based offset in the generated output. /// /// The original source location, or if no mapping contains the position. /// - public SourceLocation? Query(SourcePosition generatedPosition) + public SourceLocation? Query(int generatedOffset) { - var ranges = Query(generatedPosition, length: 1); + ArgumentOutOfRangeException.ThrowIfNegative(generatedOffset); + + var ranges = QueryRangeByLength(generatedOffset, length: 1); return ranges.Count == 0 ? null - : new SourceLocation(ranges[0].Resource, ranges[0].OriginalStart); + : new SourceLocation(ranges[0].Resource, ranges[0].OriginalStartOffset); } /// /// Queries the source map for all original ranges corresponding to a generated range. /// - /// Inclusive start position in the generated output. - /// Length of the generated range (in characters). + /// Inclusive start offset in the generated output. + /// Length of the generated range (in symbols). /// Zero or more exact mappings. Unmapped gaps are omitted. - public IReadOnlyList Query(SourcePosition generatedStart, int length) + public IReadOnlyList QueryRangeByLength(int generatedStartOffset, int length) { ArgumentOutOfRangeException.ThrowIfNegative(length); + ArgumentOutOfRangeException.ThrowIfNegative(generatedStartOffset); if (length == 0) { return Array.Empty(); } - if (_generatedLineIndex is null || _originalLineIndexes is null || _segments.Count == 0) - { - return Array.Empty(); - } - - var generatedIndex = _generatedLineIndex.Value; - if (!generatedIndex.TryGetOffset(generatedStart, out var startOffset)) + if (_segments.Count == 0) { return Array.Empty(); } - var endOffset = Math.Min(startOffset + length, generatedIndex.TextLength); - return QueryOffsetRange(new OffsetSpan(startOffset, endOffset)); + return QueryOffsetRange(new OffsetSpan(generatedStartOffset, generatedStartOffset + length)); } /// /// Queries the source map for all original ranges corresponding to a generated range. /// - /// Inclusive start position in the generated output. - /// Exclusive end position in the generated output. + /// Inclusive start offset in the generated output. + /// Exclusive end offset in the generated output. /// Zero or more exact mappings. Unmapped gaps are omitted. - public IReadOnlyList Query(SourcePosition generatedStart, SourcePosition generatedEnd) + public IReadOnlyList QueryRangeByEnd(int generatedStartOffset, int generatedEndOffset) { - if (generatedEnd < generatedStart) + if (generatedEndOffset < generatedStartOffset) { - throw new ArgumentException("End position must be greater than or equal to start position.", nameof(generatedEnd)); + throw new ArgumentException("End offset must be greater than or equal to start offset.", nameof(generatedEndOffset)); } - if (_generatedLineIndex is null || _originalLineIndexes is null || _segments.Count == 0) - { - return Array.Empty(); - } + ArgumentOutOfRangeException.ThrowIfNegative(generatedStartOffset); + ArgumentOutOfRangeException.ThrowIfNegative(generatedEndOffset); - var generatedIndex = _generatedLineIndex.Value; - if (!generatedIndex.TryGetOffset(generatedStart, out var startOffset) || - !generatedIndex.TryGetOffset(generatedEnd, out var endOffset)) + if (_segments.Count == 0) { return Array.Empty(); } - endOffset = Math.Min(endOffset, generatedIndex.TextLength); - return QueryOffsetRange(new OffsetSpan(startOffset, endOffset)); + return QueryOffsetRange(new OffsetSpan(generatedStartOffset, generatedEndOffset)); } #region Offset-Based Query Core private IReadOnlyList QueryOffsetRange(OffsetSpan generatedRange) { - if (_generatedLineIndex is null || _originalLineIndexes is null || generatedRange.Length <= 0) + if (generatedRange.Length <= 0) { return Array.Empty(); } - - var generatedIndex = _generatedLineIndex.Value; var results = new List(); var startIdx = FindFirstOverlappingSegmentIndex(generatedRange.Start); @@ -154,26 +135,16 @@ private IReadOnlyList QueryOffsetRange(OffsetSpan generated continue; } - if (!_originalLineIndexes.TryGetValue(segment.OriginalResource, out var originalIndex)) - { - continue; - } - var delta = overlap.Start - segment.Generated.Start; var originalStartOffset = segment.Original.Start + delta; var originalEndOffset = originalStartOffset + overlap.Length; - var mappedGeneratedStart = generatedIndex.GetPosition(overlap.Start); - var mappedGeneratedEnd = generatedIndex.GetPosition(overlap.End); - var mappedOriginalStart = originalIndex.GetPosition(originalStartOffset); - var mappedOriginalEnd = originalIndex.GetPosition(originalEndOffset); - results.Add(new SourceRangeLocation( segment.OriginalResource, - mappedOriginalStart, - mappedOriginalEnd, - mappedGeneratedStart, - mappedGeneratedEnd)); + originalStartOffset, + originalEndOffset, + overlap.Start, + overlap.End)); } return results; diff --git a/TinyPreprocessor/SourceMaps/SourceMapBuilder.cs b/TinyPreprocessor/SourceMaps/SourceMapBuilder.cs index bdb5cb5..9e16776 100644 --- a/TinyPreprocessor/SourceMaps/SourceMapBuilder.cs +++ b/TinyPreprocessor/SourceMaps/SourceMapBuilder.cs @@ -12,37 +12,6 @@ public sealed class SourceMapBuilder { private readonly List _offsetSegments = []; - private TextLineIndex? _generatedLineIndex; - private readonly Dictionary _originalLineIndexes = []; - - #region Content Registration - - /// - /// Registers the final generated output so queries can convert to offsets. - /// - /// The merged output content. - public void SetGeneratedContent(ReadOnlyMemory generatedContent) - { - _generatedLineIndex = TextLineIndex.Build(generatedContent.Span); - } - - /// - /// Registers original resources so queries can convert offsets to . - /// - /// All resolved resources available during preprocessing. - public void SetOriginalResources(IReadOnlyDictionary resources) - { - ArgumentNullException.ThrowIfNull(resources); - - _originalLineIndexes.Clear(); - foreach (var (resourceId, resource) in resources) - { - _originalLineIndexes[resourceId] = TextLineIndex.Build(resource.Content.Span); - } - } - - #endregion - /// /// Adds an exact offset-based mapping segment. /// @@ -84,10 +53,7 @@ public SourceMap Build() .ToList() .AsReadOnly(); - return new SourceMap( - segments, - _generatedLineIndex, - _originalLineIndexes.Count > 0 ? new Dictionary(_originalLineIndexes) : null); + return new SourceMap(segments); } /// @@ -96,7 +62,5 @@ public SourceMap Build() public void Clear() { _offsetSegments.Clear(); - _generatedLineIndex = null; - _originalLineIndexes.Clear(); } } diff --git a/TinyPreprocessor/SourceMaps/SourceMapping.cs b/TinyPreprocessor/SourceMaps/SourceMapping.cs index 547b2b8..7ac6529 100644 --- a/TinyPreprocessor/SourceMaps/SourceMapping.cs +++ b/TinyPreprocessor/SourceMaps/SourceMapping.cs @@ -1,4 +1,4 @@ namespace TinyPreprocessor.SourceMaps; // NOTE: The legacy SourceMapping/SourceSpan-based source map APIs have been removed. -// Source maps are now defined exclusively in terms of offset-based segments and SourcePosition range queries. +// Source maps are now defined exclusively in terms of offset-based segments and offset range queries. diff --git a/TinyPreprocessor/SourceMaps/SourcePosition.cs b/TinyPreprocessor/SourceMaps/SourcePosition.cs index bcd7812..f2e5f07 100644 --- a/TinyPreprocessor/SourceMaps/SourcePosition.cs +++ b/TinyPreprocessor/SourceMaps/SourcePosition.cs @@ -1,109 +1,4 @@ namespace TinyPreprocessor.SourceMaps; -/// -/// Represents a position within text content using 0-based line and column numbers. -/// -/// -/// This struct follows the standard source map convention of 0-based indexing. -/// Use for display purposes. -/// -public readonly struct SourcePosition : IEquatable, IComparable -{ - /// - /// Gets the 0-based line number. - /// - public int Line { get; } - - /// - /// Gets the 0-based column number. - /// - public int Column { get; } - - /// - /// Initializes a new instance of . - /// - /// The 0-based line number. - /// The 0-based column number. - /// - /// Thrown when or is negative. - /// - public SourcePosition(int line, int column) - { - ArgumentOutOfRangeException.ThrowIfNegative(line); - ArgumentOutOfRangeException.ThrowIfNegative(column); - - Line = line; - Column = column; - } - - /// - /// Converts this position to 1-based line and column numbers for display purposes. - /// - /// A tuple containing 1-based line and column numbers. - public (int Line, int Column) ToOneBased() => (Line + 1, Column + 1); - - #region IComparable Implementation - - /// - public int CompareTo(SourcePosition other) - { - var lineComparison = Line.CompareTo(other.Line); - return lineComparison != 0 ? lineComparison : Column.CompareTo(other.Column); - } - - #endregion - - #region IEquatable Implementation - - /// - public bool Equals(SourcePosition other) => Line == other.Line && Column == other.Column; - - /// - public override bool Equals(object? obj) => obj is SourcePosition other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(Line, Column); - - #endregion - - #region Operators - - /// - /// Determines whether two instances are equal. - /// - public static bool operator ==(SourcePosition left, SourcePosition right) => left.Equals(right); - - /// - /// Determines whether two instances are not equal. - /// - public static bool operator !=(SourcePosition left, SourcePosition right) => !left.Equals(right); - - /// - /// Determines whether the left is less than the right. - /// - public static bool operator <(SourcePosition left, SourcePosition right) => left.CompareTo(right) < 0; - - /// - /// Determines whether the left is less than or equal to the right. - /// - public static bool operator <=(SourcePosition left, SourcePosition right) => left.CompareTo(right) <= 0; - - /// - /// Determines whether the left is greater than the right. - /// - public static bool operator >(SourcePosition left, SourcePosition right) => left.CompareTo(right) > 0; - - /// - /// Determines whether the left is greater than or equal to the right. - /// - public static bool operator >=(SourcePosition left, SourcePosition right) => left.CompareTo(right) >= 0; - - #endregion - - /// - public override string ToString() - { - var (line, column) = ToOneBased(); - return $"({line}:{column})"; - } -} +// NOTE: The legacy SourcePosition (line/column) API has been removed. +// Source maps are now offset-only. diff --git a/TinyPreprocessor/SourceMaps/SourceRangeLocation.cs b/TinyPreprocessor/SourceMaps/SourceRangeLocation.cs index 8fa374e..b31ae23 100644 --- a/TinyPreprocessor/SourceMaps/SourceRangeLocation.cs +++ b/TinyPreprocessor/SourceMaps/SourceRangeLocation.cs @@ -6,13 +6,13 @@ namespace TinyPreprocessor.SourceMaps; /// Represents an exact mapping between a generated range and an original range. /// /// The identifier of the original resource. -/// Inclusive start position in the original resource. -/// Exclusive end position in the original resource. -/// Inclusive start position in the generated output. -/// Exclusive end position in the generated output. +/// Inclusive start offset in the original resource. +/// Exclusive end offset in the original resource. +/// Inclusive start offset in the generated output. +/// Exclusive end offset in the generated output. public sealed record SourceRangeLocation( ResourceId Resource, - SourcePosition OriginalStart, - SourcePosition OriginalEnd, - SourcePosition GeneratedStart, - SourcePosition GeneratedEnd); + int OriginalStartOffset, + int OriginalEndOffset, + int GeneratedStartOffset, + int GeneratedEndOffset); diff --git a/TinyPreprocessor/SourceMaps/SourceSpan.cs b/TinyPreprocessor/SourceMaps/SourceSpan.cs index c06c462..00cf177 100644 --- a/TinyPreprocessor/SourceMaps/SourceSpan.cs +++ b/TinyPreprocessor/SourceMaps/SourceSpan.cs @@ -1,4 +1,4 @@ namespace TinyPreprocessor.SourceMaps; // NOTE: The legacy SourceSpan type has been removed. -// Use offset-based segment mapping and SourcePosition-based range queries instead. +// Use offset-based segment mapping and offset-based range queries instead. diff --git a/TinyPreprocessor/SourceMaps/TextLineIndex.cs b/TinyPreprocessor/SourceMaps/TextLineIndex.cs index 5fc305e..076b957 100644 --- a/TinyPreprocessor/SourceMaps/TextLineIndex.cs +++ b/TinyPreprocessor/SourceMaps/TextLineIndex.cs @@ -1,112 +1,4 @@ -using System; -using System.Collections.Generic; - namespace TinyPreprocessor.SourceMaps; -/// -/// Precomputed line start offsets for fast offset <-> conversion. -/// -/// -/// Newlines are detected using '\n'. Line and column numbers are 0-based. -/// -internal readonly struct TextLineIndex -{ - private readonly int[] _lineStarts; - private readonly int[] _lineLengths; - private readonly int _textLength; - - private TextLineIndex(int[] lineStarts, int[] lineLengths, int textLength) - { - _lineStarts = lineStarts; - _lineLengths = lineLengths; - _textLength = textLength; - } - - public int LineCount => _lineStarts.Length; - - public int TextLength => _textLength; - - public static TextLineIndex Build(ReadOnlySpan text) - { - // Worst case (all '\n'): line count is text.Length + 1. - var lineStarts = new List(capacity: Math.Min(text.Length + 1, 1024)); - var lineLengths = new List(capacity: Math.Min(text.Length + 1, 1024)); - - var currentLineStart = 0; - lineStarts.Add(0); - - for (var i = 0; i < text.Length; i++) - { - if (text[i] != '\n') - { - continue; - } - - lineLengths.Add(i - currentLineStart); - currentLineStart = i + 1; - lineStarts.Add(currentLineStart); - } - - // Final line (may be empty). - lineLengths.Add(text.Length - currentLineStart); - - return new TextLineIndex(lineStarts.ToArray(), lineLengths.ToArray(), text.Length); - } - - public bool TryGetOffset(SourcePosition position, out int offset) - { - offset = 0; - - if ((uint)position.Line >= (uint)_lineStarts.Length) - { - return false; - } - - var lineStart = _lineStarts[position.Line]; - var lineLength = _lineLengths[position.Line]; - - // Allow column at end-of-line for end-exclusive spans. - if (position.Column > lineLength) - { - return false; - } - - offset = lineStart + position.Column; - - // Defensive: should never exceed text length. - return (uint)offset <= (uint)_textLength; - } - - public SourcePosition GetPosition(int offset) - { - if (offset < 0 || offset > _textLength) - { - throw new ArgumentOutOfRangeException(nameof(offset)); - } - - // Find the rightmost line start <= offset. - var idx = Array.BinarySearch(_lineStarts, offset); - if (idx < 0) - { - idx = ~idx - 1; - } - - if (idx < 0) - { - idx = 0; - } - - var column = offset - _lineStarts[idx]; - return new SourcePosition(idx, column); - } - - public int GetLineLength(int line) - { - if ((uint)line >= (uint)_lineLengths.Length) - { - throw new ArgumentOutOfRangeException(nameof(line)); - } - - return _lineLengths[line]; - } -} +// NOTE: The legacy TextLineIndex (newline-based line indexing) has been removed. +// Source maps are now offset-only. diff --git a/TinyPreprocessor/Text/ConcatenatingMergeOptions.cs b/TinyPreprocessor/Text/ConcatenatingMergeOptions.cs new file mode 100644 index 0000000..5b904dd --- /dev/null +++ b/TinyPreprocessor/Text/ConcatenatingMergeOptions.cs @@ -0,0 +1,12 @@ +namespace TinyPreprocessor.Text; + +/// +/// Options for the concatenating merge strategy. +/// +/// The separator between resources. Defaults to newline. +/// Whether to include debug markers for resource boundaries. +/// The format string for resource markers. {0} is replaced with the resource ID. +public sealed record ConcatenatingMergeOptions( + string Separator = "\n", + bool IncludeResourceMarkers = false, + string MarkerFormat = "/* === {0} === */\n"); diff --git a/TinyPreprocessor/Text/ConcatenatingMergeStrategy.cs b/TinyPreprocessor/Text/ConcatenatingMergeStrategy.cs new file mode 100644 index 0000000..0a78652 --- /dev/null +++ b/TinyPreprocessor/Text/ConcatenatingMergeStrategy.cs @@ -0,0 +1,246 @@ +using System.Buffers; +using TinyPreprocessor.Core; +using TinyPreprocessor.Diagnostics; +using TinyPreprocessor.Merging; +using TinyPreprocessor.SourceMaps; + +namespace TinyPreprocessor.Text; + +/// +/// Default merge strategy that concatenates resources and strips directives (text/char specialization). +/// +/// The directive type associated with resources. +/// User-defined context type (unused by this strategy). +public sealed class ConcatenatingMergeStrategy : IMergeStrategy +{ + private readonly ConcatenatingMergeOptions _options; + + /// + /// Initializes a new instance of with default options. + /// + public ConcatenatingMergeStrategy() : this(new ConcatenatingMergeOptions()) + { + } + + /// + /// Initializes a new instance of with the specified options. + /// + /// The merge options. + public ConcatenatingMergeStrategy(ConcatenatingMergeOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + } + + /// + public ReadOnlyMemory Merge( + IReadOnlyList> orderedResources, + TContext userContext, + MergeContext context) + { + ArgumentNullException.ThrowIfNull(orderedResources); + ArgumentNullException.ThrowIfNull(context); + + if (orderedResources.Count == 0) + { + return ReadOnlyMemory.Empty; + } + + var output = new ArrayBufferWriter(); + + for (var i = 0; i < orderedResources.Count; i++) + { + var resource = orderedResources[i]; + + if (_options.IncludeResourceMarkers) + { + var marker = string.Format(_options.MarkerFormat, resource.Id.Path); + Append(output, marker.AsSpan()); + } + + StripDirectivesAndEmitSegments(resource, output, context); + + if (i < orderedResources.Count - 1) + { + Append(output, _options.Separator.AsSpan()); + } + } + + var merged = new string(output.WrittenSpan); + return merged.AsMemory(); + } + + private static void StripDirectivesAndEmitSegments( + ResolvedResource resource, + ArrayBufferWriter output, + MergeContext context) + { + var content = resource.Content.Span; + if (content.Length == 0) + { + return; + } + + if (resource.Directives.Count == 0) + { + var generatedStart = output.WrittenCount; + Append(output, content); + context.SourceMapBuilder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: 0, length: content.Length); + return; + } + + var excludedRanges = BuildExcludedRanges(resource, content, context); + if (excludedRanges.Count == 0) + { + var generatedStart = output.WrittenCount; + Append(output, content); + context.SourceMapBuilder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: 0, length: content.Length); + return; + } + + var current = 0; + foreach (var range in excludedRanges) + { + if (range.Start > current) + { + var length = range.Start - current; + var generatedStart = output.WrittenCount; + Append(output, content.Slice(current, length)); + context.SourceMapBuilder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: current, length: length); + } + + current = Math.Max(current, range.End); + if (current >= content.Length) + { + break; + } + } + + if (current < content.Length) + { + var length = content.Length - current; + var generatedStart = output.WrittenCount; + Append(output, content.Slice(current, length)); + context.SourceMapBuilder.AddOffsetSegment(resource.Id, generatedStart, originalStartOffset: current, length: length); + } + } + + private static List<(int Start, int End)> BuildExcludedRanges( + ResolvedResource resource, + ReadOnlySpan content, + MergeContext context) + { + var ranges = new List<(int Start, int End)>(capacity: resource.Directives.Count); + + foreach (var directive in resource.Directives) + { + var location = context.DirectiveModel.GetLocation(directive); + + var start = location.Start.GetOffset(content.Length); + var end = location.End.GetOffset(content.Length); + + start = Math.Clamp(start, 0, content.Length); + end = Math.Clamp(end, 0, content.Length); + + if (end < start) + { + (start, end) = (end, start); + } + + if (!IsWholeLineDirective(content, start, end)) + { + context.Diagnostics.Add(new NonWholeLineDirectiveDiagnostic(resource.Id, location)); + } + + if (end > start) + { + ranges.Add((start, end)); + } + } + + if (ranges.Count == 0) + { + return ranges; + } + + ranges.Sort(static (a, b) => a.Start.CompareTo(b.Start)); + + var coalesced = new List<(int Start, int End)>(capacity: ranges.Count); + var current = ranges[0]; + for (var i = 1; i < ranges.Count; i++) + { + var next = ranges[i]; + if (next.Start <= current.End) + { + current = (current.Start, Math.Max(current.End, next.End)); + continue; + } + + coalesced.Add(current); + current = next; + } + + coalesced.Add(current); + return coalesced; + } + + private static bool IsWholeLineDirective(ReadOnlySpan content, int start, int end) + { + if ((uint)start > (uint)content.Length || (uint)end > (uint)content.Length) + { + return false; + } + + var lineStart = 0; + if (start > 0) + { + var prevNewline = content.Slice(0, start).LastIndexOf('\n'); + lineStart = prevNewline >= 0 ? prevNewline + 1 : 0; + } + + var lineEnd = content.Length; + var nextNewline = content.Slice(start).IndexOf('\n'); + if (nextNewline >= 0) + { + lineEnd = start + nextNewline; + } + + if (end > lineEnd + 1) + { + return false; + } + + for (var i = lineStart; i < start; i++) + { + if (!char.IsWhiteSpace(content[i])) + { + return false; + } + } + + if (end <= lineEnd) + { + for (var i = end; i < lineEnd; i++) + { + if (!char.IsWhiteSpace(content[i])) + { + return false; + } + } + } + + return true; + } + + private static void Append(ArrayBufferWriter writer, ReadOnlySpan value) + { + if (value.Length == 0) + { + return; + } + + var dest = writer.GetSpan(value.Length); + value.CopyTo(dest); + writer.Advance(value.Length); + } +} diff --git a/TinyPreprocessor/Text/NonWholeLineDirectiveDiagnostic.cs b/TinyPreprocessor/Text/NonWholeLineDirectiveDiagnostic.cs new file mode 100644 index 0000000..16fbf21 --- /dev/null +++ b/TinyPreprocessor/Text/NonWholeLineDirectiveDiagnostic.cs @@ -0,0 +1,23 @@ +using TinyPreprocessor.Core; +using TinyPreprocessor.Diagnostics; + +namespace TinyPreprocessor.Text; + +/// +/// Diagnostic reported when a directive does not occupy an entire line. +/// +/// The resource containing the directive. +/// The location of the directive. +public sealed record NonWholeLineDirectiveDiagnostic( + ResourceId? Resource = null, + Range? Location = null) : IPreprocessorDiagnostic +{ + /// + public DiagnosticSeverity Severity => DiagnosticSeverity.Error; + + /// + public string Code => "TPP0300"; + + /// + public string Message => "Directive must occupy an entire line (only whitespace allowed before/after)."; +} diff --git a/docs/01-core-abstractions.md b/docs/01-core-abstractions.md index 455da59..1056c3b 100644 --- a/docs/01-core-abstractions.md +++ b/docs/01-core-abstractions.md @@ -51,45 +51,40 @@ public interface IResource **Design Decisions:** - **Interface**: Allows custom resource implementations (lazy-loaded, cached, virtual, etc.) -- **ReadOnlyMemory**: Efficient slicing without allocations, spans for processing +- **ReadOnlyMemory**: Efficient slicing without allocations, spans for processing - **Nullable Metadata**: Optional extensibility point for custom data (timestamps, checksums, etc.) **Default Implementation:** ```csharp -public sealed record Resource( +public sealed record Resource( ResourceId Id, - ReadOnlyMemory Content, + ReadOnlyMemory Content, IReadOnlyDictionary? Metadata = null -) : IResource; +) : IResource; ``` --- -### IDirective +### Directives (TDirective) + IDirectiveModel -Marker interface for parsed directives found within resource content. +Directives are modeled as an unconstrained `TDirective` type. Directive semantics needed by the pipeline +(location + dependency reference extraction) are provided via `IDirectiveModel`. ```csharp -public interface IDirective +public interface IDirectiveModel { - Range Location { get; } - + Range GetLocation(TDirective directive); + bool TryGetReference(TDirective directive, out string reference); } ``` -**Design Decisions:** - -- **Marker interface**: Downstream users define their own directive types (IncludeDirective, ImportDirective, etc.) -- **Range Location**: Uses System.Range for efficient content slicing; indicates where the directive appears in source -- **No reference property**: The meaning of "what to include" is directive-specific - -**Example Implementation:** +**Example Directive Types:** ```csharp -public sealed record IncludeDirective(string Reference, Range Location) : IDirective; -public sealed record ImportDirective(string Module, bool IsRelative, Range Location) : IDirective; +public sealed record IncludeDirective(string Reference, Range Location); +public sealed record ImportDirective(string Module, bool IsRelative, Range Location); ``` --- @@ -99,12 +94,12 @@ public sealed record ImportDirective(string Module, bool IsRelative, Range Locat Resolves string references (from directives) into actual resources. ```csharp -public interface IResourceResolver +public interface IResourceResolver { - ValueTask ResolveAsync( + ValueTask> ResolveAsync( string reference, - IResource? relativeTo, + IResource? relativeTo, CancellationToken ct); } ``` @@ -134,14 +129,14 @@ public sealed record ResourceResolutionResult( --- -### IDirectiveParser +### IDirectiveParser Extracts directives from resource content. ```csharp -public interface IDirectiveParser where TDirective : IDirective +public interface IDirectiveParser { - IEnumerable Parse(ReadOnlyMemory content, ResourceId resourceId); + IEnumerable Parse(ReadOnlyMemory content, ResourceId resourceId); } ``` @@ -155,7 +150,7 @@ public interface IDirectiveParser where TDirective : IDirective **Example Implementation:** ```csharp -public sealed class CStyleIncludeParser : IDirectiveParser +public sealed class CStyleIncludeParser : IDirectiveParser { // Parses: #include "path" or #include public IEnumerable Parse(ReadOnlyMemory content, ResourceId resourceId) @@ -177,7 +172,7 @@ flowchart LR end subgraph Parsing - IDirectiveParserT["IDirectiveParser<T>"] -->|extracts| IDirective + IDirectiveParserT["IDirectiveParser<T>"] -->|extracts| TDirective end ``` @@ -191,6 +186,6 @@ flowchart LR ## Extension Points 1. **Custom Resource Types**: Implement `IResource` for lazy loading, caching, or virtual resources -2. **Custom Directives**: Define directive records implementing `IDirective` -3. **Custom Parsers**: Implement `IDirectiveParser` for different syntax styles -4. **Custom Resolvers**: Implement `IResourceResolver` for file systems, databases, or network resources +2. **Custom Directives**: Define any directive type + provide an `IDirectiveModel` +3. **Custom Parsers**: Implement `IDirectiveParser` for different syntax styles +4. **Custom Resolvers**: Implement `IResourceResolver` for file systems, databases, or network resources diff --git a/docs/05-merge-system.md b/docs/05-merge-system.md index 7d09ff8..c7e5b6e 100644 --- a/docs/05-merge-system.md +++ b/docs/05-merge-system.md @@ -14,8 +14,8 @@ A resource paired with its parsed directives, ready for merging. ``` record ResolvedResource( - Resource : IResource, - Directives : IReadOnlyList + Resource : IResource, + Directives : IReadOnlyList ) // Convenience properties: Id, Content (delegate to Resource) ``` @@ -32,11 +32,12 @@ record ResolvedResource( Shared context provided to merge strategies for source map building and diagnostics. ``` -class MergeContext +class MergeContext Properties: SourceMapBuilder : SourceMapBuilder // for recording mappings Diagnostics : DiagnosticCollection // for reporting issues - ResolvedCache : IReadOnlyDictionary // for cross-referencing + ResolvedCache : IReadOnlyDictionary> // for cross-referencing + DirectiveModel : IDirectiveModel // for interpreting directive locations ``` **Design Decisions:** @@ -47,12 +48,12 @@ class MergeContext --- -### IMergeStrategy +### IMergeStrategy Interface for custom merge implementations. ```csharp -public interface IMergeStrategy +public interface IMergeStrategy { /// /// Merges resolved resources into a single output. @@ -62,10 +63,10 @@ public interface IMergeStrategy /// User-provided context for strategy customization. /// Merge context with source map builder and diagnostics. /// The merged content. - ReadOnlyMemory Merge( - IReadOnlyList orderedResources, + ReadOnlyMemory Merge( + IReadOnlyList> orderedResources, TContext userContext, - MergeContext context); + MergeContext context); } ``` @@ -178,7 +179,8 @@ class ConditionalMergeStrategy : IMergeStrategy> ## Directive Stripping -The default behavior strips directives from output. This is handled by examining `IDirective.Location`: +The default behavior strips directives from output. This is handled by examining directive locations via +`IDirectiveModel.GetLocation(...)`: ```mermaid flowchart LR diff --git a/docs/06-preprocessor-orchestrator.md b/docs/06-preprocessor-orchestrator.md index 76b1c62..9a6d47b 100644 --- a/docs/06-preprocessor-orchestrator.md +++ b/docs/06-preprocessor-orchestrator.md @@ -48,29 +48,29 @@ record PreprocessResult Content : ReadOnlyMemory // merged output SourceMap : SourceMap // position mappings Diagnostics : DiagnosticCollection // all collected diagnostics - Success : bool // true if no errors ProcessedResources : IReadOnlyList // in topological order DependencyGraph : ResourceDependencyGraph // for downstream analysis ``` **Design Decisions:** -- **Success property**: Quick check for usable output +- Prefer checking `Diagnostics.HasErrors` for a quick "usable output" signal - **ProcessedResources**: Useful for cache invalidation, dependency tracking - **DependencyGraph exposed**: Enables downstream analysis (affected files, etc.) --- -### Preprocessor +### Preprocessor The main orchestrator class. ``` -class Preprocessor where TDirective : IDirective +class Preprocessor Dependencies: - parser : IDirectiveParser - resolver : IResourceResolver - mergeStrategy : IMergeStrategy + parser : IDirectiveParser + directiveModel: IDirectiveModel + resolver : IResourceResolver + mergeStrategy : IMergeStrategy function ProcessAsync(root, context, options?, ct) → PreprocessResult options ← options ?? PreprocessorOptions.Default @@ -167,19 +167,10 @@ cache[resource.Id] = ResolvedResource(resource, directives) --- -## IIncludeDirective Convention +## Include Convention (via IDirectiveModel) -For the preprocessor to know which directives represent includes, use a marker interface: - -``` -interface IIncludeDirective : IDirective - Reference : string // file path, module name, etc. - -// Example implementations -record IncludeDirective(Reference, Location) : IIncludeDirective -record ImportDirective(Module, IsRelative, Location) : IIncludeDirective - Reference = IsRelative ? "./{Module}" : Module -``` +For the preprocessor to know which directives represent dependencies, provide an `IDirectiveModel`. +This model extracts a `string` reference for directives that should trigger recursive resolution. --- @@ -254,4 +245,4 @@ if originalLocation exists: 2. **Custom IResourceResolver**: Load from any source (files, network, database) 3. **Custom IMergeStrategy**: Control how resources combine 4. **PreprocessorOptions**: Tune behavior for specific use cases -5. **IIncludeDirective**: Define custom include semantics +5. **IDirectiveModel**: Define custom include semantics