diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aca642..f9207de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- Issue #24: partial mappers with framework attributes or `UseMapper` on another part now generate once; genuine duplicate `LiteMapper` configuration still reports one fatal diagnostic. [#24](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/24) - Issue #23: explicit omitted-`Source` converters receive the root object before automatic source-member matching in new-object, constructor-bound, and update mappings. Unrelated source names and patch getters no longer override that selection. [#23](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/23) - Issue #22: generated nested and collection helpers preserve the calling mapper instance for stateful converters and mappings, while instance-free helpers remain static. Static entry methods no longer select instance-only mappings. [#22](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/22) - Issue #21: nested and collection helpers retain each entry method's effective mapping policies and nullable shape. Equivalent recursive helpers remain shared while preserving the initiating entry's cycle metadata. [#21](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/21) diff --git a/DECISIONS.md b/DECISIONS.md index 54bc55d..fb932f3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Decision Log +## Issue #24: discover the marker-owning partial declaration once (2026-10-09) + +- Context: broad attributed-type discovery sees the merged LiteMapper marker on every attributed partial part. Repeated planning yields duplicate diagnostics and source hint collisions that discard unrelated generated output. +- Decision: retain the existing incremental syntax-provider identity and select the declaration containing the symbol's first LiteMapper marker before planning. The owner check also handles genuinely repeated markers, allowing existing merged-symbol validation to report LITEMAPPER0013 once. No compilation-wide collect/deduplicate stage or mutable generator state is introduced. A first ForAttributeWithMetadataName candidate recreated output identities for cross-file removal and tree reordering on Roslyn 4.8; the narrower owner filter preserves the established caching contract without weakening existing tests. +- Contract: sections 7, 19.2, 19.3, 19.7, 20.2, and 21.4 already require one file per mapper, duplicate-configuration validation, and incremental isolation. All members and registrations still come from the merged symbol; no public API or specification semantics change is required. +- Evidence: 24 initial tests on unchanged production yielded 23 failures and one unattributed-part control, zero skips; the Basic sample separately failed with CS8785 and three CS8795 errors. Focused cases cover declaration shapes, aliases, registrations, warning/fault diagnostic counts, duplicate markers, ordering, and same-driver changes. + ## Issue #23: resolve explicit root input before member matching (2026-10-08) - Context: new-object planning rejected absent source-member matches before resolving a root converter. Constructor/update planning also allowed automatic ambiguity or patch captures to override the explicit selection. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 952fae3..349b9ad 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #24: one discovery per partial mapper (2026-10-09) + +- Scope: a single marker-owning declaration per mapper symbol, retaining the established incremental candidate identity. Other partial declarations may carry framework attributes or UseMapper registrations without duplicating planning, diagnostics, or generated source. Genuine duplicate LiteMapper markers remain invalid. +- Test-first: 24 focused same-file/multi-file partial-declaration, registration, diagnostic, planning-count, alias, ordering, and same-driver cases produced 23 failures and one control on unchanged production. The Basic sample independently failed CS8785/CS8795 after adding another attributed partial part. Clean-package validation exercises the same sample. +- Implementation: a minimal discovery-boundary change that preserves merged symbol validation and existing per-mapper emission equality. No compilation-wide deduplication or mutable generator state. +- Validation: focused and full generator tests, runtime/integration checks, source samples and available packaging checks; independent review and exact-head ordinary PR CI. Record unavailable toolchain stages separately. +- Boundary: issue #24 only, no public API or specification semantics change. Stop at draft PR/human review; no merge, release, tag, package publication, or workflow dispatch. + ## Issue #23: explicit root-source converter priority (2026-10-08) - Scope: select omitted-`Source` explicit `Use` configuration before automatic source-member matching in new-object, constructor-bound, and existing-target planning. Keep source-path selection and converter validation intact. diff --git a/STATUS.md b/STATUS.md index af54b6f..2d07a1e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,15 @@ # Mammoth.LiteMapper Status +## GitHub issue #24 partial-discovery checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop 1583ddbcff4e14e677d92acba86d222567196824 after PR #37 merged. Issue #24 is open with no dependencies/sub-issues or competing PR. The user authorized one next issue; exec:agent and ready:agent were added and read back. AgentStack CLI is absent; authorized connector fallback is used without issue comments or claim writes. +- Red-first: all 24 new tests compile against unchanged production with 23 failures and one unattributed-part control, zero skips. The Basic sample separately fails with duplicate-source CS8785 and missing implementations for all three mappers (CS8795). +- Implementation: filter semantic discovery to the declaration that owns the merged symbol's first LiteMapper marker. Other attributed parts still contribute members and registrations, while duplicate markers produce one existing LITEMAPPER0013. The Basic sample and clean-package sample checks cover an unrelated attribute on another partial part; usage/changelog are synchronized. +- Candidate refinement: an initial ForAttributeWithMetadataName candidate recreated candidate identity in two existing caching tests. The narrower marker-owner filter preserves the existing syntax-provider pipeline and passes all 23 existing incremental-isolation tests without changing them. The new harness counts active tracked outputs separately from Removed entries; semantic planning may re-run once per mapper while unchanged emissions remain cached. +- Final local validation: Release solution build has zero warnings/errors. All 676 generator tests pass, including 24 new cases; runtime five and integration one pass, zero failures/skips. All three source sample smoke runs pass. Available packaging suite: 220 passed, two infrastructure failures, zero skips, including all 12 consumer framework/language combinations, clean package-backed samples, package/API checks, usage validation, and benchmark smoke. Generated Basic source was inspected: exactly one file per mapper, with the existing mapping body intact; the rebuilt sample passed again. Independent read-only review found no blocker; CRLF-aware diff validation passes. Exact-head ordinary CI belongs in the draft PR. +- Environment: the fresh cloud workspace lacked .NET. With user approval, official Microsoft SDK 10.0.401 and runtimes 8.0.31/9.0.20 were downloaded, verified against published SHA-512 hashes, and installed in the task workspace; runtime 10.0.12 is included. An initial restore failed to write the default read-only NuGet cache; configuring workspace-local cache paths allowed explicit solution restore to complete. PowerShell and clang are absent; 49 PowerShell-dependent cases were not run locally. Trimming and Native AOT fail at ILLink ComputeManagedAssemblies task-host creation (MSB4216 / Unix-domain SocketException 13), before publish behavior is established. No local trimming/AOT success is claimed and no assertion, dependency, warning, or prerequisite was weakened. No security or credential settings changed. +- Boundary: issue #24 only, then draft PR/human review. No user-computer access, merge, release, tag push, package publication, or workflow dispatch. + ## GitHub issue #23 root converter priority checkpoint (2026-10-08) - Base: isolated cloud worktree from develop d80ff4cc6a5e515896b6054a454a1ee2910450a5 after PR #36 merged. Issue #23 is open with no dependencies/sub-issues or competing PR. The user requested the next issue; exec:agent and ready:agent were added and read back, preserving existing labels. AgentStack CLI is absent; authorized connector fallback is used without issue comments or claim writes. diff --git a/docs/USAGE.md b/docs/USAGE.md index e231fca..4603d09 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -24,6 +24,8 @@ Normal consumers reference the primary package only: A generated mapper is a partial method inside a class marked with `[LiteMapper]`. +Apply `[LiteMapper]` once across the mapper's partial declarations. Other parts may contain mapping methods, `[UseMapper]` registrations, or unrelated attributes. LiteMapper combines those parts into one generated file. Repeating `[LiteMapper]` is invalid and reports `LITEMAPPER0013`. The Basic sample includes a separate partial part with `[DebuggerDisplay]`. + From `samples/Mammoth.LiteMapper.Samples.Basic/Program.cs`: ```csharp diff --git a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs index 2f63877..5b4e569 100644 --- a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs @@ -59,6 +59,11 @@ public static partial class StaticMapper private static string BuildDisplayName(StaticSource source) => "Name: " + source.Name; } + [System.Diagnostics.DebuggerDisplay("Static mapper")] + public static partial class StaticMapper + { + } + [LiteMapper] public sealed partial class InstanceMapper { diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 7d599cb..7bb541e 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -225,7 +225,10 @@ private static bool IsTypeWithAttributes(SyntaxNode node) { var typeSyntax = (TypeDeclarationSyntax)context.Node; var symbol = context.SemanticModel.GetDeclaredSymbol(typeSyntax, cancellationToken) as INamedTypeSymbol; - if (symbol == null || !HasAttribute(symbol, LiteMapperAttributeName)) + // Only the part owning the first marker plans the merged mapper. Other + // attributed parts must not duplicate source or configuration diagnostics. + var marker = symbol?.GetAttributes().FirstOrDefault(static attribute => IsAttribute(attribute, LiteMapperAttributeName))?.ApplicationSyntaxReference; + if (symbol == null || marker == null || marker.SyntaxTree != typeSyntax.SyntaxTree || !typeSyntax.Span.Contains(marker.Span)) { return null; } diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/PartialMapperDiscoveryTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/PartialMapperDiscoveryTests.cs new file mode 100644 index 0000000..2b0f9c5 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/PartialMapperDiscoveryTests.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Mammoth.LiteMapper.Generator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + [TestClass] + public sealed class PartialMapperDiscoveryTests + { + private const string Imports = "using Mammoth.LiteMapper; using Marker = Mammoth.LiteMapper.LiteMapperAttribute;\n"; + private const string QualifiedMarker = "global::Mammoth.LiteMapper.LiteMapperAttribute"; + private const string FrameworkAttribute = "[global::System.Runtime.CompilerServices.CompilerGenerated]"; + private const string OtherFrameworkAttribute = "[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"; + private const string Models = "public sealed class Source { public int Value { get; set; } } public sealed class Target { public int Value { get; set; } }"; + private const string HealthyMapper = "[LiteMapper] public static partial class HealthyMapper { public static partial Target Map(Source source); }"; + + [TestMethod] + [DataRow(2, true, false, false, "LiteMapper")] + [DataRow(2, false, false, false, "Marker")] + [DataRow(3, true, false, false, QualifiedMarker)] + [DataRow(3, false, false, false, "LiteMapper")] + [DataRow(2, true, true, false, "Marker")] + [DataRow(2, false, true, false, QualifiedMarker)] + [DataRow(3, true, true, false, "LiteMapper")] + [DataRow(3, false, true, false, "Marker")] + [DataRow(2, false, false, true, QualifiedMarker)] + [DataRow(3, false, true, true, "Marker")] + public void AttributedPartialPartsProduceOnePlanAndOneExecutableFile(int partCount, bool sameFile, bool instance, bool nested, string marker) + { + var compilation = FlatCompilation(partCount, sameFile, instance, nested, marker); + AssertInputIsValid(compilation); + var planned = new List(); + var driver = CreateDriver(planned).RunGenerators(compilation); + var mapper = nested ? "Outer.Mapper" : "Mapper"; + + AssertSuccessfulOutput(driver, mapper); + CollectionAssert.AreEqual(new[] { mapper }, planned.ToArray(), "Merged partial declarations must be planned once, before rendering or diagnostic publication."); + AssertCompilesAndExecutes(compilation, driver, 7); + } + + [TestMethod] + [DataRow(2, true, "Marker")] + [DataRow(2, false, QualifiedMarker)] + [DataRow(3, true, QualifiedMarker)] + [DataRow(3, false, "Marker")] + public void SeparatePartRegistrationAndFrameworkAttributesPreserveMergedMappingAndHealthyMapper(int partCount, bool sameFile, string marker) + { + var compilation = RegisteredCompilation(partCount, sameFile, marker); + AssertInputIsValid(compilation); + var planned = new List(); + var driver = CreateDriver(planned).RunGenerators(compilation); + + AssertSuccessfulOutput(driver, "Mapper", "HealthyMapper"); + AssertPlannedOnce(planned, "Mapper", "HealthyMapper"); + StringAssert.Contains(SourceFor(driver, "Mapper"), "External.Convert"); + AssertCompilesAndExecutes(compilation, driver, "17|11"); + } + + [TestMethod] + public void OrdinaryWarningIsReportedOnceForThreeAttributedPartialParts() + { + var compilation = FlatCompilation(3, false, false, false, + "LiteMapper(UnmappedTargetMembers = UnmappedMemberPolicy.Warning)", + Models.Replace("class Target {", "class Target { public int Extra { get; set; }")); + AssertInputIsValid(compilation); + var planned = new List(); + var driver = CreateDriver(planned).RunGenerators(compilation); + var result = driver.GetRunResult(); + + Assert.IsNull(result.Results.Single().Exception); + Assert.AreEqual(1, result.Diagnostics.Length, "A warning belongs to the mapper, not to each attributed partial declaration."); + var diagnostic = result.Diagnostics.Single(); + Assert.AreEqual("LITEMAPPER1001", diagnostic.Id); + Assert.AreEqual(DiagnosticSeverity.Warning, diagnostic.Severity); + StringAssert.Contains(diagnostic.GetMessage(), "Extra"); + CollectionAssert.AreEqual(new[] { "Mapper" }, planned.ToArray()); + Assert.AreEqual(1, result.Results.Single().GeneratedSources.Length); + AssertOutputStep(driver, "Mapper"); + AssertCompilesAndExecutes(compilation, driver, 7); + } + + [TestMethod] + public void PlanningFailureIsReportedOnceAtTheMarkerPartAndHealthyMapperStillGenerates() + { + var compilation = RegisteredCompilation(3, false, "Marker"); + AssertInputIsValid(compilation); + var planned = new List(); + var driver = CreateDriver(planned, failMapper: true).RunGenerators(compilation); + var result = driver.GetRunResult(); + + Assert.IsNull(result.Results.Single().Exception); + Assert.AreEqual(1, result.Diagnostics.Length, "One failed mapper must have one diagnostic even when several partial parts have attributes."); + var diagnostic = result.Diagnostics.Single(); + Assert.AreEqual("LITEMAPPER9001", diagnostic.Id); + Assert.AreEqual("Mapper.Part2.cs", diagnostic.Location.SourceTree!.FilePath); + Assert.AreEqual("Mapper", diagnostic.Location.SourceTree.GetText().ToString(diagnostic.Location.SourceSpan)); + AssertPlannedOnce(planned, "Mapper", "HealthyMapper"); + Assert.AreEqual(1, result.Results.Single().GeneratedSources.Length); + AssertOutputStep(driver, "HealthyMapper"); + StringAssert.Contains(result.Results.Single().GeneratedSources.Single().SourceText.ToString(), "class HealthyMapper"); + var outputErrors = compilation.AddSyntaxTrees(result.GeneratedTrees).GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.AreEqual(1, outputErrors.Length, DiagnosticsText(outputErrors)); + Assert.AreEqual("CS8795", outputErrors.Single().Id, "Only the deliberately failed mapper may lack its implementation."); + } + + [TestMethod] + [DataRow(true, true)] + [DataRow(true, false)] + [DataRow(false, true)] + [DataRow(false, false)] + public void GenuineDuplicateMarkersRemainOneFatalConfigurationDiagnostic(bool samePart, bool sameFile) + { + var parts = new[] + { + "[Marker] " + (samePart ? "[" + QualifiedMarker + "] " : string.Empty) + "public static partial class Mapper { public static partial Target Map(Source source); }", + (samePart ? FrameworkAttribute : "[" + QualifiedMarker + "]") + " public static partial class Mapper { }", + OtherFrameworkAttribute + " public static partial class Mapper { }" + }; + var compilation = CompilationFromParts(parts, sameFile, Models, HealthyMapper); + AssertInputIsValid(compilation, duplicateMarker: true); + var planned = new List(); + var driver = CreateDriver(planned).RunGenerators(compilation); + var result = driver.GetRunResult(); + + Assert.IsNull(result.Results.Single().Exception); + Assert.AreEqual(1, result.Diagnostics.Length, "Discovery deduplication must retain the actual duplicate marker error exactly once."); + var diagnostic = result.Diagnostics.Single(); + Assert.AreEqual("LITEMAPPER0013", diagnostic.Id); + Assert.AreEqual(DiagnosticSeverity.Error, diagnostic.Severity); + CollectionAssert.Contains(diagnostic.Descriptor.CustomTags.ToArray(), WellKnownDiagnosticTags.NotConfigurable); + StringAssert.Contains(diagnostic.GetMessage(), "LiteMapperAttribute"); + Assert.AreEqual("Mapper", diagnostic.Location.SourceTree!.GetText().ToString(diagnostic.Location.SourceSpan)); + AssertPlannedOnce(planned, "Mapper", "HealthyMapper"); + Assert.AreEqual(1, result.Results.Single().GeneratedSources.Length); + AssertOutputStep(driver, "HealthyMapper"); + StringAssert.Contains(result.Results.Single().GeneratedSources.Single().SourceText.ToString(), "class HealthyMapper"); + var outputErrors = compilation.AddSyntaxTrees(result.GeneratedTrees).GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.AreEqual(1, outputErrors.Count(static diagnostic => diagnostic.Id == "CS0579")); + Assert.AreEqual(1, outputErrors.Count(static diagnostic => diagnostic.Id == "CS8795")); + Assert.AreEqual(2, outputErrors.Length, DiagnosticsText(outputErrors)); + } + + [TestMethod] + public void SameDriverRerunsAndReversedTreesPreserveOneOutputPerMapper() + { + var compilation = RegisteredCompilation(3, false, QualifiedMarker); + AssertInputIsValid(compilation); + var planned = new List(); + var first = CreateDriver(planned).RunGenerators(compilation); + AssertSuccessfulOutput(first, "Mapper", "HealthyMapper"); + AssertPlannedOnce(planned, "Mapper", "HealthyMapper"); + + planned.Clear(); + var unchanged = first.RunGenerators(compilation); + AssertSuccessfulOutput(unchanged, "Mapper", "HealthyMapper"); + AssertNoRepeatedPlanning(planned); + AssertOutputStep(unchanged, "Mapper", IncrementalStepRunReason.Cached); + AssertOutputStep(unchanged, "HealthyMapper", IncrementalStepRunReason.Cached); + + planned.Clear(); + var reversedCompilation = compilation.RemoveAllSyntaxTrees().AddSyntaxTrees(compilation.SyntaxTrees.Reverse()); + var reversed = unchanged.RunGenerators(reversedCompilation); + AssertSuccessfulOutput(reversed, "Mapper", "HealthyMapper"); + AssertOutputStep(reversed, "Mapper", IncrementalStepRunReason.Cached); + AssertOutputStep(reversed, "HealthyMapper", IncrementalStepRunReason.Cached); + AssertNoRepeatedPlanning(planned); + CollectionAssert.AreEqual(Files(first), Files(reversed)); + AssertCompilesAndExecutes(reversedCompilation, reversed, "17|11"); + + planned.Clear(); + var fresh = CreateDriver(planned).RunGenerators(reversedCompilation); + AssertSuccessfulOutput(fresh, "Mapper", "HealthyMapper"); + AssertPlannedOnce(planned, "Mapper", "HealthyMapper"); + CollectionAssert.AreEqual(Files(first), Files(fresh), "A fresh driver must choose the same mapper identity and source regardless of tree enumeration."); + AssertCompilesAndExecutes(reversedCompilation, fresh, "17|11"); + } + + [TestMethod] + public void RegistrationEditOnAnotherPartUpdatesOnlyAffectedEmissionInSameDriver() + { + var compilation = RegisteredCompilation(3, false, "Marker"); + AssertInputIsValid(compilation); + var planned = new List(); + var first = CreateDriver(planned).RunGenerators(compilation); + AssertSuccessfulOutput(first, "Mapper", "HealthyMapper"); + AssertCompilesAndExecutes(compilation, first, "17|11"); + + var original = compilation.SyntaxTrees.Single(static tree => tree.FilePath == "Mapper.Part0.cs"); + var changed = compilation.ReplaceSyntaxTree(original, CSharpSyntaxTree.ParseText(original.GetText().ToString().Replace("typeof(External)", "typeof(Alternative)"), ParseOptions(), original.FilePath)); + AssertInputIsValid(changed); + planned.Clear(); + var second = first.RunGenerators(changed); + + AssertSuccessfulOutput(second, "Mapper", "HealthyMapper"); + Assert.AreEqual(1, planned.Count(static mapper => mapper == "Mapper"), "Changing a registration on another part must replan the merged mapper exactly once."); + AssertNoRepeatedPlanning(planned); + AssertOutputStep(second, "Mapper", IncrementalStepRunReason.Modified); + AssertOutputStep(second, "HealthyMapper", IncrementalStepRunReason.Cached); + Assert.AreEqual(SourceFor(first, "HealthyMapper"), SourceFor(second, "HealthyMapper")); + StringAssert.Contains(SourceFor(second, "Mapper"), "Alternative.Convert"); + Assert.IsFalse(SourceFor(second, "Mapper").Contains("External.Convert", StringComparison.Ordinal)); + AssertCompilesAndExecutes(changed, second, "27|11"); + } + + [TestMethod] + public void FrameworkAttributeEditOnAnotherPartKeepsUnchangedEmissionCached() + { + var compilation = FlatCompilation(3, false, false, false, "LiteMapper"); + AssertInputIsValid(compilation); + var planned = new List(); + var first = CreateDriver(planned).RunGenerators(compilation); + AssertSuccessfulOutput(first, "Mapper"); + var original = compilation.SyntaxTrees.Single(static tree => tree.FilePath == "Mapper.Part1.cs"); + var changed = compilation.ReplaceSyntaxTree(original, CSharpSyntaxTree.ParseText(original.GetText().ToString().Replace(FrameworkAttribute, "[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"), ParseOptions(), original.FilePath)); + AssertInputIsValid(changed); + planned.Clear(); + var second = first.RunGenerators(changed); + + AssertSuccessfulOutput(second, "Mapper"); + AssertNoRepeatedPlanning(planned); + AssertOutputStep(second, "Mapper", IncrementalStepRunReason.Cached); + CollectionAssert.AreEqual(Files(first), Files(second)); + AssertCompilesAndExecutes(changed, second, 7); + } + + [TestMethod] + public void UnattributedPartialPartsRemainAHealthyControl() + { + var compilation = FlatCompilation(3, false, false, false, "LiteMapper"); + compilation = compilation.RemoveAllSyntaxTrees().AddSyntaxTrees(compilation.SyntaxTrees.Select(tree => + CSharpSyntaxTree.ParseText(tree.GetText().ToString().Replace(FrameworkAttribute, string.Empty).Replace(OtherFrameworkAttribute, string.Empty), ParseOptions(), tree.FilePath))); + AssertInputIsValid(compilation); + var planned = new List(); + var driver = CreateDriver(planned).RunGenerators(compilation); + + AssertSuccessfulOutput(driver, "Mapper"); + CollectionAssert.AreEqual(new[] { "Mapper" }, planned.ToArray()); + AssertCompilesAndExecutes(compilation, driver, 7); + } + + private static CSharpCompilation FlatCompilation(int partCount, bool sameFile, bool instance, bool nested, string marker, string? models = null) + { + var declaration = "public " + (instance ? "sealed " : "static ") + "partial class Mapper"; + var method = "public " + (instance ? string.Empty : "static ") + "partial Target Map(Source source);"; + var parts = new[] { "[" + marker + "] " + declaration + " { }", FrameworkAttribute + " " + declaration + " { " + method + " }", OtherFrameworkAttribute + " " + declaration + " { }" } + .Take(partCount).Select(part => nested ? "public partial class Outer { " + part + " }" : part).ToArray(); + var mapper = nested ? "Outer.Mapper" : "Mapper"; + var invocation = instance ? "new " + mapper + "().Map" : mapper + ".Map"; + var probe = "public static class Probe { public static int Run() => " + invocation + "(new Source { Value = 7 }).Value; }"; + return CompilationFromParts(parts, sameFile, models ?? Models, probe); + } + + private static CSharpCompilation RegisteredCompilation(int partCount, bool sameFile, string marker) + { + // Put the marker last and the method/registration elsewhere: the marker owns discovery, while the symbol owns all members and configuration. + var parts = partCount == 2 + ? new[] { "[UseMapper(typeof(External))] " + FrameworkAttribute + " public static partial class Mapper { public static partial TextTarget Map(Source source); }", "[" + marker + "] public static partial class Mapper { }" } + : new[] { "[UseMapper(typeof(External))] public static partial class Mapper { }", FrameworkAttribute + " public static partial class Mapper { public static partial TextTarget Map(Source source); }", "[" + marker + "] " + OtherFrameworkAttribute + " public static partial class Mapper { }" }; + const string external = "public sealed class TextTarget { public string Value { get; set; } = string.Empty; } " + + "public static class External { [MappingConverter] public static string Convert(int source) => (source + 10).ToString(); } " + + "public static class Alternative { [MappingConverter] public static string Convert(int source) => (source + 20).ToString(); }"; + const string probe = "public static class Probe { public static string Run() => Mapper.Map(new Source { Value = 7 }).Value + \"|\" + HealthyMapper.Map(new Source { Value = 11 }).Value; }"; + return CompilationFromParts(parts, sameFile, Models, HealthyMapper, external, probe); + } + + private static CSharpCompilation CompilationFromParts(string[] parts, bool sameFile, params string[] support) + { + var trees = sameFile + ? new[] { CSharpSyntaxTree.ParseText(Imports + string.Join("\n", parts), ParseOptions(), "Mappers.cs") } + : parts.Select((part, index) => CSharpSyntaxTree.ParseText(Imports + part, ParseOptions(), "Mapper.Part" + index + ".cs")).ToArray(); + return CSharpCompilation.Create("PartialMapperDiscoveryTests", trees.Concat(new[] { CSharpSyntaxTree.ParseText(Imports + string.Join("\n", support), ParseOptions(), "Support.cs") }), + AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!.Split(Path.PathSeparator).Select(static path => MetadataReference.CreateFromFile(path)) + .Concat(new[] { MetadataReference.CreateFromFile(typeof(LiteMapperAttribute).Assembly.Location) }), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + } + + private static CSharpParseOptions ParseOptions() => CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + + private static void AssertInputIsValid(CSharpCompilation compilation, bool duplicateMarker = false) + { + var errors = compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + var unexpected = errors.Where(diagnostic => diagnostic.Id != "CS8795" && !(duplicateMarker && diagnostic.Id == "CS0579")).ToArray(); + Assert.AreEqual(0, unexpected.Length, "The fixture must be legal C# apart from expected missing partial implementations and the deliberate duplicate marker.\n" + DiagnosticsText(unexpected)); + Assert.AreEqual(duplicateMarker ? 1 : 0, errors.Count(static diagnostic => diagnostic.Id == "CS0579")); + } + + private static GeneratorDriver CreateDriver(List planned, bool failMapper = false) => CSharpGeneratorDriver.Create( + new[] { new PlanningProbe(symbol => + { + planned.Add(symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)); + if (failMapper && symbol.Name == "Mapper") + { + throw new InvalidOperationException("Injected partial-mapper planning failure"); + } + }).AsSourceGenerator() }, parseOptions: ParseOptions(), + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + + private static void AssertSuccessfulOutput(GeneratorDriver driver, params string[] mappers) + { + var result = driver.GetRunResult(); + Assert.IsNull(result.Results.Single().Exception); + Assert.AreEqual(0, result.Diagnostics.Length, DiagnosticsText(result.Diagnostics)); + Assert.AreEqual(mappers.Length, result.Results.Single().GeneratedSources.Length, "Each mapper class must have exactly one generated file."); + Assert.AreEqual(mappers.Length, result.Results.Single().GeneratedSources.Select(static source => source.HintName).Distinct(StringComparer.Ordinal).Count()); + foreach (var mapper in mappers) + { + AssertOutputStep(driver, mapper); + } + } + + private static void AssertPlannedOnce(List planned, params string[] mappers) => CollectionAssert.AreEqual( + mappers.OrderBy(static name => name, StringComparer.Ordinal).ToArray(), planned.OrderBy(static name => name, StringComparer.Ordinal).ToArray(), "Planning must run once per mapper symbol, before diagnostics or source emission."); + + private static void AssertNoRepeatedPlanning(List planned) => Assert.AreEqual(planned.Count, planned.Distinct(StringComparer.Ordinal).Count(), "Replanning must never repeat a mapper symbol for its attributed partial parts."); + + private static IncrementalGeneratorRunStep[] OutputStepsFor(GeneratorDriver driver, string mapper) => + driver.GetRunResult().Results.Single().TrackedOutputSteps.Values.SelectMany(static values => values) + .Where(step => step.Inputs.Any(input => input.Source.Name == "MapperEmission" && + (string?)input.Source.Outputs[input.OutputIndex].Value.GetType().GetProperty("DisplayName")?.GetValue(input.Source.Outputs[input.OutputIndex].Value) == mapper)).ToArray(); + + private static IncrementalStepRunReason AssertOutputStep(GeneratorDriver driver, string mapper, IncrementalStepRunReason? expected = null) + { + var allSteps = OutputStepsFor(driver, mapper); + var states = string.Join(", ", allSteps.SelectMany(static step => step.Outputs).Select(static output => output.Reason)); + var steps = allSteps + .Where(static step => step.Outputs.Any(static output => output.Reason != IncrementalStepRunReason.Removed)).ToArray(); + Assert.AreEqual(1, steps.Length, "Expected one active independent source-output callback for " + mapper + ". Observed states: " + states); + var outputs = steps.SelectMany(static step => step.Outputs).Where(static output => output.Reason != IncrementalStepRunReason.Removed).ToArray(); + Assert.AreEqual(1, outputs.Length, "Expected one active output for " + mapper + ". Observed states: " + states); + var reason = outputs.Single().Reason; + if (expected.HasValue) + { + Assert.AreEqual(expected.Value, reason, "Unexpected incremental output state for " + mapper + ". Observed states: " + states); + } + return reason; + } + + private static string SourceFor(GeneratorDriver driver, string mapper) => driver.GetRunResult().Results.Single().GeneratedSources + .Single(source => source.HintName.StartsWith(mapper + ".", StringComparison.Ordinal)).SourceText.ToString(); + + private static string[] Files(GeneratorDriver driver) => driver.GetRunResult().Results.Single().GeneratedSources.OrderBy(static source => source.HintName, StringComparer.Ordinal) + .Select(static source => source.HintName + "\n" + source.SourceText).ToArray(); + + private static string DiagnosticsText(IEnumerable diagnostics) => string.Join(Environment.NewLine, diagnostics.Select(static diagnostic => diagnostic.ToString())); + + private static void AssertCompilesAndExecutes(CSharpCompilation compilation, GeneratorDriver driver, object expected) + { + var output = compilation.AddSyntaxTrees(driver.GetRunResult().GeneratedTrees); + var errors = output.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.AreEqual(0, errors.Length, DiagnosticsText(errors)); + using var stream = new MemoryStream(); + var emitted = output.Emit(stream); + Assert.IsTrue(emitted.Success, DiagnosticsText(emitted.Diagnostics)); + var assembly = Assembly.Load(stream.ToArray()); + Assert.AreEqual(expected, assembly.GetType("Probe")!.GetMethod("Run")!.Invoke(null, null)); + } + + private sealed class PlanningProbe : IIncrementalGenerator + { + private readonly Action beforePlanning; + public PlanningProbe(Action beforePlanning) => this.beforePlanning = beforePlanning; + public void Initialize(IncrementalGeneratorInitializationContext context) => LiteMapperGenerator.InitializeCore(context, beforePlanning); + } + } +}