From 542c24cb886aeae7c33fd2bc34f045e4bb277a35 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Fri, 9 Oct 2026 15:13:19 +0200 Subject: [PATCH] fix: detect HashSet capacity constructor capabilities Probe exact accessible capacity and capacity-plus-comparer signatures independently and preserve portable set construction. Add capability, runtime, package, sample, and documentation coverage. Fixes #30. Co-authored-by: Codex --- CHANGELOG.md | 1 + DECISIONS.md | 6 + IMPLEMENTATION_PLAN.md | 8 + STATUS.md | 10 + docs/USAGE.md | 4 +- .../PortableSetExample.cs | 73 ++++++ .../Program.cs | 2 + .../references/mapping-rules.md | 2 + .../LiteMapperGenerator.cs | 19 +- .../HashSetConstructorCapabilityTests.cs | 233 ++++++++++++++++++ .../CleanPackageSampleTests.cs | 5 +- .../ConsumerMatrixTests.cs | 11 +- .../Milestone14PackagingAndAotTests.cs | 3 + 13 files changed, 372 insertions(+), 5 deletions(-) create mode 100644 samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs create mode 100644 tests/Mammoth.LiteMapper.Generator.Tests/HashSetConstructorCapabilityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c25cc6..af2984e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- Issue #30: set mappings probe exact accessible HashSet capacity constructors and use portable fallbacks when absent, including on netstandard2.0. Capacity-plus-comparer support is detected independently. [#30](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/30) - Issue #28: IgnoreCase rejects multiple case-insensitive source matches even when one is exact; nested structural ambiguity retains LITEMAPPER1004. Exact and ExactThenIgnoreCase keep their existing exact-match priority. [#28](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/28) - Issue #27: by-name enum exceptions use stable source names and paths instead of applying `nameof` to guarded expressions; nullable enum captures retain source metadata. [#27](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/27) - Issue #26: structural collection mapping preserves nullable elements and applies Throw guards before object helpers; nullable nested collection helper signatures retain their annotations. [#26](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/26) diff --git a/DECISIONS.md b/DECISIONS.md index 9170982..411d246 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,11 @@ # Mammoth.LiteMapper Decision Log +## Issue #30: independently select exact HashSet capacity overloads (2026-10-09) + +- Context: counted set mappings always emitted a capacity argument without probing the reference API; netstandard2.0 lacks that constructor. Preserved-comparer mappings always omitted capacity, even when the combined overload exists. +- Decision: inspect the constructed HashSet type in the consumer compilation, requiring an accessible constructor with by-value Int32 and, independently when preserving a comparer, the exact constructed IEqualityComparer element type. Use the parameterless or comparer-only fallback if that overload is unavailable. Unknown-count sources are not enumerated to size a destination. +- Contract: sections 3.5, 15.3, 15.5-15.9, 19.9, and 22.12/22.17 already require capability-based portable optimization. No public API, comparer-selection, or specification semantics change is introduced; list and dictionary construction remain outside scope. + ## Issue #29: reuse direct patch captures without changing overload binding (2026-10-09) - Context: patch planning creates a single-evaluation capture for direct nullable members, but nested updater invocation used the original getter. Present values were read twice; changing getters could supply a different value or null after the guard. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index b83eca5..23d3c36 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #30: symbol-proven HashSet capacity constructors (2026-10-09) + +- Scope: probe the exact accessible HashSet capacity and capacity-plus-comparer constructors independently; use portable parameterless/comparer-only construction when unavailable. Preserve set contents, comparer selection, independent identity, and one enumeration. Do not change list or dictionary construction. +- Test-first: synthetic missing/inaccessible/inexact/asymmetric constructor fixtures, C# 9 runtime controls, same-driver capability changes, and actual clean-package netstandard2.0/net8.0/net9.0/net10.0 consumers. +- Implementation: select the constructor from the target element type and consumer Roslyn symbols, never from a target-framework name. Add a normally compiled Collections sample reused by package/matrix/trim/AOT consumers. +- Validation: focused/full generator, runtime/integration, source and clean-package samples, supported framework/language matrix, available packaging/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI. +- Boundary: issue #30 only; stop at draft PR/human review. No merge, release, tag, package publication, or workflow dispatch. AgentStack CLI is absent; authorized connector fallback adds/readbacks readiness labels without issue comments or claim writes. + ## Issue #29: captured patch-mode nested updater input (2026-10-09) - Scope: pass the existing per-member patch capture to direct nested updater calls so the guard and invocation consume the same value exactly once. Preserve configured paths, Throw captures, null skipping, updater selection, and per-member update order. diff --git a/STATUS.md b/STATUS.md index f18860e..5eda4d9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,15 @@ # Mammoth.LiteMapper Status +## GitHub issue #30 HashSet constructor capability checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop 63885e1af353b45f85c28fdf2306c06409cd92bb after PR #43 merged. Issue #30 was the only remaining P2, with no blocked-by dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. +- Red-first: final 36-case generator fixture on unchanged production yielded 15 failures and 21 controls, zero skips. The genuine package matrix independently produced three netstandard2.0 failures (C# 9/14/latest), each with four CS1503 capacity-constructor errors, and nine passing modern-framework controls. Packages for this baseline were built before the production fix. +- Implementation: independently probe exact accessible by-value HashSet(Int32) and HashSet(Int32, IEqualityComparer) constructors on the constructed consumer symbol. Counted set mappings use the proven overload; missing capabilities fall back to parameterless/comparer-only construction. List/dictionary construction, comparer selection, public API, and specification semantics are unchanged. +- Core evidence: final Release solution build has zero warnings/errors. All 873 generator tests pass, including 36 new cases; five runtime and one integration test pass, zero failures/skips. The final CRLF fixture rerun passes 36/36. Capability fixtures cover absent, asymmetric, inaccessible, inexact, and by-ref signatures, internal accessibility, same-driver changes, C# 9 compilation, converted contents, duplicate collapse, comparer identity, independent collections, and one enumeration. Independent read-only review found no remaining blocker; consumer-skill and CRLF-aware diff checks pass. +- Package evidence: available packaging suite completed with 224 passes, two infrastructure failures, zero skips. All 12 framework/language combinations compile and execute, asserting parameterless/comparer-only construction on actual netstandard2.0 references and capacity/combined overloads on net8.0/net9.0/net10.0. Clean-package/source samples, usage validation, package/API checks, and benchmark smoke pass. The shared PortableSetExample covers array/list sources, HashSet/ISet and available IReadOnlySet destinations, converted values, compatible comparers, and independent identity. Clean-package copying now includes all top-level sample C# files; normal, trimmed, and Native AOT consumer fixtures reuse the same example. +- Local limits: trimming and Native AOT fail before publish validation at ILLink ComputeManagedAssemblies task-host creation with MSB4216 / Unix-domain SocketException 13. PowerShell and clang are absent; 49 PowerShell-dependent cases were not run locally. No assertion, warning, dependency, prerequisite, credential, or security setting was weakened. The fresh cloud workspace restored the previously authorized official Microsoft SDK 10.0.401 and runtimes 8.0.31/9.0.20; runtime 10.0.12 is included. Caches and temporary files remain task-local with one-processor builds. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR. +- Boundary: issue #30 only, then human review. Dictionary-comparer issue #31 and the previously documented nullable-intermediate enum defect remain outside scope. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch. + ## GitHub issue #29 patch nested updater capture checkpoint (2026-10-09) - Base: isolated cloud worktree from develop af3fd9ed403da20043705ce8e387578fbe7ad78c after PR #42 merged. Issue #29 was the oldest remaining P2, with no blocked-by dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. diff --git a/docs/USAGE.md b/docs/USAGE.md index 0fe07ca..a82f0ce 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -704,7 +704,9 @@ Supported collection mapping includes: Nullable structural elements preserve null without calling an object-mapping helper. In the Collections sample, `MapNullableLines` maps a three-element source containing null, a line with quantity 7, and null to three elements with the same null positions and mapped quantity. This also applies to dictionary values and nested collections. Mapping a nullable element to a non-null target reports `LITEMAPPER2003` under `NullableMismatchPolicy.Error`; `Throw` checks the element before mapping and raises `InvalidOperationException` with its source element path. `NullCollectionStrategy.Empty` applies to the collection itself and does not replace or remove null object elements. -LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocated only when cheap count or length information is available. Comparers are preserved for compatible set and dictionary shapes. Unsupported shapes include custom collections, immutable collections, queues, stacks, and rectangular multidimensional arrays. +LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocated only when cheap count or length information and the exact accessible constructor are available. Set mappings use parameterless or comparer-only construction on targets such as `netstandard2.0` where capacity overloads are absent. Comparers are preserved for compatible set and dictionary shapes. Unsupported shapes include custom collections, immutable collections, queues, stacks, and rectangular multidimensional arrays. + +The [portable set example](../samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs) maps arrays and lists to `HashSet` and `ISet`, collapsing duplicate converted values. It also verifies detached copies with preserved case-insensitive comparers. `IReadOnlySet` is included only when that interface exists. For array results from sources without a cheap, reliable count, mapping uses an O(n) temporary growing buffer and then creates the final array. It does not count by enumerating first, and each element is converted once. Counted sources allocate their final array directly. The same rule applies to interfaces whose concrete result is an array. diff --git a/samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs b/samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs new file mode 100644 index 0000000..cf722f5 --- /dev/null +++ b/samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using Mammoth.LiteMapper; + +namespace Mammoth.LiteMapper.Samples.Collections +{ + public static class PortableSetExample + { + public static void Run() + { + var array = new[] { 1, 2, 1 }; + var list = new List(array); + Check(PortableSetMapper.ArrayToHashSet(array), array); + Check(PortableSetMapper.ArrayToSet(array), array); + Check(PortableSetMapper.ListToHashSet(list), list); + Check(PortableSetMapper.ListToSet(list), list); +#if !NETSTANDARD2_0 + Check(PortableSetMapper.ArrayToReadOnlySet(array), array); + Check(PortableSetMapper.ListToReadOnlySet(list), list); +#endif + var source = new HashSet(StringComparer.OrdinalIgnoreCase) { "A" }; + CheckComparer(PortableSetMapper.CopyHashSet(source), source); + CheckComparer(PortableSetMapper.CopySet(source), source); +#if !NETSTANDARD2_0 + CheckComparer(PortableSetMapper.CopyReadOnlySet(source), source); +#endif + if (PortableSetMapper.ArrayToHashSet(Array.Empty()).Count != 0) + { + throw new InvalidOperationException("An empty source must produce an empty set."); + } + } + + private static void Check(object mapped, object source) + { + if (!(mapped is HashSet set) || !set.SetEquals(new long[] { 1, 2 }) || + ReferenceEquals(source, mapped) || !ReferenceEquals(set.Comparer, EqualityComparer.Default)) + { + throw new InvalidOperationException("Converted sets must preserve contents and use independent default-comparer collections."); + } + } + + private static void CheckComparer(object mapped, HashSet source) + { + if (!(mapped is HashSet set) || !set.Contains("a") || + ReferenceEquals(source, mapped) || !ReferenceEquals(set.Comparer, source.Comparer)) + { + throw new InvalidOperationException("Compatible set copies must preserve the comparer without aliasing."); + } + + set.Add("B"); + if (source.Count != 1 || set.Count != 2) + { + throw new InvalidOperationException("Changing a set copy must not change its source."); + } + } + } + + [LiteMapper] + public static partial class PortableSetMapper + { + public static partial HashSet ArrayToHashSet(int[] source); + public static partial ISet ArrayToSet(int[] source); + public static partial HashSet ListToHashSet(List source); + public static partial ISet ListToSet(List source); + public static partial HashSet CopyHashSet(HashSet source); + public static partial ISet CopySet(HashSet source); +#if !NETSTANDARD2_0 + public static partial IReadOnlySet ArrayToReadOnlySet(int[] source); + public static partial IReadOnlySet ListToReadOnlySet(List source); + public static partial IReadOnlySet CopyReadOnlySet(HashSet source); +#endif + } +} diff --git a/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs b/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs index 60f9d14..bb6c72b 100644 --- a/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs @@ -8,6 +8,8 @@ internal static class Program { private static void Main() { + PortableSetExample.Run(); + var nullableLines = NullableLineMapper.MapNullableLines(new LineSource?[] { null, new LineSource { Sku = "A", Quantity = 7 }, null, diff --git a/skills/mammoth-litemapper/references/mapping-rules.md b/skills/mammoth-litemapper/references/mapping-rules.md index 13616c5..53761fa 100644 --- a/skills/mammoth-litemapper/references/mapping-rules.md +++ b/skills/mammoth-litemapper/references/mapping-rules.md @@ -102,6 +102,8 @@ Name matching uses ordinal comparisons. `Exact` accepts only an exact name; `Exa ## Collections, enums, and cycles +Set capacity is used only when the consumer compilation exposes the exact accessible constructor. Arrays and lists can map to `HashSet` or `ISet` on `netstandard2.0` through a parameterless/comparer-only fallback; `IReadOnlySet` requires that interface to exist. Capacity selection does not change duplicate collapsing, compatible-comparer preservation, or independent collection identity. + Enum numeric policy applies to declared values and unmatched-name ByValue fallback: Unchecked300 to a byte-backed enum yields44; Checked retains overflow validation. Missing composite flag names may map through their atoms (target Read4 OR Write8 gives12), including signed high-bit source flags; unknown source bits throw. A matching composite name must equal the mapped atomic OR: target Both12 is valid, Both16 reports LITEMAPPER7002 and omits the mapping. Every ordinary source alias must resolve consistently: Known/Alias1 may map to Known/Alias9. Declare public top-level collection mappings explicitly, for example `public static partial List MapCustomers(Customer[] source);` inside a static mapper. Supported shapes include one-dimensional/jagged arrays, common generic sequence/list interfaces, `List`, sets, and dictionaries; `IReadOnlySet` requires the symbol in the compilation. diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index ae5f057..340c52e 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -2021,15 +2021,17 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, builder.Append(ConcreteCollectionType(targetShape, targetType)); var countExpression = SourceCountExpression(sourceType, sourceShape, sourceExpression); var preserveComparer = CanPreserveComparer(sourceShape, targetShape, sourceType, targetType); + var useCapacity = countExpression != null && (targetShape.Kind != CollectionKind.Set || + HasHashSetCapacityConstructor(compilation, targetShape.ElementType, method.ContainingType, preserveComparer)); builder.Append('('); - if (countExpression != null && !(preserveComparer && targetShape.Kind == CollectionKind.Set)) + if (useCapacity) { builder.Append(countExpression); } if (preserveComparer) { - if (countExpression != null && targetShape.Kind == CollectionKind.Dictionary) + if (useCapacity) { builder.Append(", "); } @@ -2400,6 +2402,19 @@ private static string RenderEmptyCollection(CollectionShape shape, ITypeSymbol t return "new " + ConcreteCollectionType(shape, targetType) + "()"; } + private static bool HasHashSetCapacityConstructor(Compilation compilation, ITypeSymbol elementType, INamedTypeSymbol mapperType, bool preserveComparer) + { + var setType = compilation.GetTypeByMetadataName("System.Collections.Generic.HashSet`1")?.Construct(elementType); + var comparerType = compilation.GetTypeByMetadataName("System.Collections.Generic.IEqualityComparer`1")?.Construct(elementType); + return setType != null && setType.InstanceConstructors.Any(constructor => + compilation.IsSymbolAccessibleWithin(constructor, mapperType) && + constructor.Parameters.Length == (preserveComparer ? 2 : 1) && + constructor.Parameters.All(static parameter => parameter.RefKind == RefKind.None) && + constructor.Parameters[0].Type.SpecialType == SpecialType.System_Int32 && + (!preserveComparer || comparerType != null && + SymbolEqualityComparer.Default.Equals(constructor.Parameters[1].Type, comparerType))); + } + private static bool HasArrayEmpty(Compilation compilation) { var arrayType = compilation.GetSpecialType(SpecialType.System_Array); diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/HashSetConstructorCapabilityTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/HashSetConstructorCapabilityTests.cs new file mode 100644 index 0000000..cfc384b --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/HashSetConstructorCapabilityTests.cs @@ -0,0 +1,233 @@ +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 HashSetConstructorCapabilityTests + { + [TestMethod] + [DataRow("", false)] + [DataRow("public HashSet(int capacity) { }", true)] + [DataRow("internal HashSet(int capacity) { }", true)] + [DataRow("private HashSet(int capacity) { }", false)] + [DataRow("public HashSet(long capacity) { }", false)] + [DataRow("public HashSet(ref int capacity) { }", false)] + [DataRow("public HashSet(in int capacity) { }", false)] + [DataRow("public HashSet(params int[] capacity) { }", false)] + [DataRow("public HashSet(int capacity, int extra = 0) { }", false)] + [DataRow("public HashSet(int capacity, IEqualityComparer comparer) { }", false)] + public void CapacityRequiresItsOwnAccessibleExactSignature(string constructors, bool available) + { + var result = Run(Mapper("int[]", "HashSet"), Framework(constructors)); + AssertValid(result, synthetic: true); + StringAssert.Contains(result.Source, "new global::System.Collections.Generic.HashSet(" + (available ? "source.Length" : "") + ")"); + } + + [TestMethod] + [DataRow("", false)] + [DataRow("public HashSet(int capacity) { }", false)] + [DataRow("public HashSet(int capacity, IEqualityComparer comparer) { }", true)] + [DataRow("public HashSet(int capacity) { } public HashSet(int capacity, IEqualityComparer comparer) { }", true)] + [DataRow("internal HashSet(int capacity, IEqualityComparer comparer) { }", true)] + [DataRow("private HashSet(int capacity, IEqualityComparer comparer) { }", false)] + [DataRow("public HashSet(long capacity, IEqualityComparer comparer) { }", false)] + [DataRow("public HashSet(int capacity, IEqualityComparer comparer) { }", false)] + [DataRow("public HashSet(int capacity, ref IEqualityComparer comparer) { }", false)] + [DataRow("public HashSet(int capacity, in IEqualityComparer comparer) { }", false)] + [DataRow("public HashSet(IEqualityComparer comparer, int capacity) { }", false)] + public void CapacityAndComparerAreDetectedIndependently(string constructors, bool available) + { + var result = Run(Mapper("HashSet", "HashSet"), Framework(constructors)); + AssertValid(result, synthetic: true); + StringAssert.Contains(result.Source, "new global::System.Collections.Generic.HashSet(" + + (available ? "source.Count, " : "") + "source.Comparer)"); + } + + [TestMethod] + [DataRow("int[]", "HashSet", "new[] { 1, 2, 1 }", "source.Length")] + [DataRow("int[]", "ISet", "new[] { 1, 2, 1 }", "source.Length")] + [DataRow("int[]", "IReadOnlySet", "new[] { 1, 2, 1 }", "source.Length")] + [DataRow("List", "HashSet", "new List { 1, 2, 1 }", "source.Count")] + [DataRow("List", "ISet", "new List { 1, 2, 1 }", "source.Count")] + [DataRow("List", "IReadOnlySet", "new List { 1, 2, 1 }", "source.Count")] + [DataRow("ICollection", "HashSet", "new List { 1, 2, 1 }", "source.Count")] + [DataRow("IReadOnlyCollection", "HashSet", "new List { 1, 2, 1 }", "source.Count")] + [DataRow("IEnumerable", "HashSet", "new List { 1, 2, 1 }", "")] + public void ModernSetsRetainContentsAndIndependentIdentity(string sourceType, string targetType, string input, string count) + { + var result = Run(Mapper(sourceType, targetType) + @" +public static class Probe +{ + public static bool Run() + { + " + sourceType + " source = " + input + @"; + var target = Mapper.Map(source); + return target is HashSet set && set.SetEquals(new long[] { 1, 2 }) && + !object.ReferenceEquals(source, target) && object.ReferenceEquals(set.Comparer, EqualityComparer.Default); + } +}"); + AssertValid(result); + StringAssert.Contains(result.Source, "new global::System.Collections.Generic.HashSet(" + count + ")"); + Assert.IsTrue(Execute(result)); + } + + [TestMethod] + [DataRow("HashSet")] + [DataRow("ISet")] + [DataRow("IReadOnlySet")] + public void CompatibleComparerAndCapacityArePreserved(string targetType) + { + var result = Run(Mapper("HashSet", targetType) + @" +public static class Probe +{ + public static bool Run() + { + var source = new HashSet(StringComparer.OrdinalIgnoreCase) { ""A"" }; + var target = (HashSet)Mapper.Map(source); + target.Add(""B""); + return target.Contains(""a"") && target.Count == 2 && source.Count == 1 && + !object.ReferenceEquals(source, target) && object.ReferenceEquals(source.Comparer, target.Comparer); + } +}"); + AssertValid(result); + StringAssert.Contains(result.Source, "new global::System.Collections.Generic.HashSet(source.Count, source.Comparer)"); + Assert.IsTrue(Execute(result)); + } + + [TestMethod] + public void ConvertedSetElementsUseDefaultComparer() + { + var result = Run(Mapper("HashSet", "HashSet") + @" +public static class Probe +{ + public static bool Run() + { + var source = new HashSet { 1, 2 }; + var target = Mapper.Map(source); + return target.SetEquals(new long[] { 1, 2 }) && !object.ReferenceEquals(source, target) && + object.ReferenceEquals(target.Comparer, EqualityComparer.Default); + } +}"); + AssertValid(result); + StringAssert.Contains(result.Source, "new global::System.Collections.Generic.HashSet(source.Count)"); + Assert.IsTrue(Execute(result)); + } + + [TestMethod] + public void UnknownCountSourceIsEnumeratedOnceWithoutCapacityProbe() + { + var result = Run(Mapper("IEnumerable", "HashSet") + @" +public static class Probe +{ + private static int enumerations; + private static IEnumerable Items() + { + enumerations++; + if (enumerations > 1) throw new InvalidOperationException(); + yield return 1; + yield return 1; + yield return 2; + } + public static bool Run() => Mapper.Map(Items()).SetEquals(new[] { 1, 2 }) && enumerations == 1; +}"); + AssertValid(result); + StringAssert.Contains(result.Source, "new global::System.Collections.Generic.HashSet()"); + Assert.IsTrue(Execute(result)); + } + + [TestMethod] + public void CapabilityChangesInvalidateSameDriverOutput() + { + var source = Mapper("int[]", "HashSet"); + var firstCompilation = CreateCompilation(source, Framework("")); + var secondCompilation = firstCompilation.ReplaceSyntaxTree(firstCompilation.SyntaxTrees.Last(), + CSharpSyntaxTree.ParseText(Framework("public HashSet(int capacity) { }"), ParseOptions, "Framework.cs")); + var first = Run(firstCompilation, CreateDriver()); + var second = Run(secondCompilation, first.Driver); + var fresh = Run(secondCompilation, CreateDriver()); + AssertValid(first, synthetic: true); + AssertValid(second, synthetic: true); + AssertValid(fresh, synthetic: true); + StringAssert.Contains(first.Source, "new global::System.Collections.Generic.HashSet()"); + StringAssert.Contains(second.Source, "new global::System.Collections.Generic.HashSet(source.Length)"); + Assert.AreEqual(fresh.Source, second.Source); + Assert.AreEqual(second.Source, Run(secondCompilation, second.Driver).Source); + } + + private static string Mapper(string source, string target) => @" +using System; +using System.Collections.Generic; +using Mammoth.LiteMapper; +[LiteMapper] +public static partial class Mapper { public static partial " + target + " Map(" + source + " source); }\n"; + + // A source-owned framework stand-in deliberately produces only CS0436 conflict warnings. + // Its signatures vary independently from the host framework and its name stays canonical. + private static string Framework(string constructors) => @" +namespace System.Collections.Generic +{ + public sealed class HashSet : List + { + public HashSet() { } + public HashSet(IEqualityComparer comparer) { Comparer = comparer; } + public IEqualityComparer Comparer { get; } = EqualityComparer.Default; + " + constructors + @" + } +}"; + + private static readonly CSharpParseOptions ParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + + private static CSharpCompilation CreateCompilation(string source, string? framework = null) + { + var references = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!.Split(Path.PathSeparator) + .Where(static path => path.EndsWith("System.Private.CoreLib.dll", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("System.Runtime.dll", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("System.Collections.dll", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("netstandard.dll", StringComparison.OrdinalIgnoreCase)) + .Select(static path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(LiteMapperAttribute).Assembly.Location)); + var trees = new List { CSharpSyntaxTree.ParseText(source, ParseOptions, "Mapper.cs") }; + if (framework != null) trees.Add(CSharpSyntaxTree.ParseText(framework, ParseOptions, "Framework.cs")); + var compilation = CSharpCompilation.Create("HashSetTests_" + Guid.NewGuid().ToString("N"), trees, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var inputErrors = compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error && diagnostic.Id != "CS8795").ToArray(); + Assert.AreEqual(0, inputErrors.Length, string.Join(Environment.NewLine, inputErrors.Select(static diagnostic => diagnostic.ToString()))); + return compilation; + } + + private static GeneratorDriver CreateDriver() => CSharpGeneratorDriver.Create( + new[] { new LiteMapperGenerator().AsSourceGenerator() }, parseOptions: ParseOptions, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + private static Result Run(string source, string? framework = null) => Run(CreateCompilation(source, framework), CreateDriver()); + private static Result Run(CSharpCompilation compilation, GeneratorDriver driver) + { + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + return new Result(driver, output, string.Join(Environment.NewLine, driver.GetRunResult().GeneratedTrees.Select(static tree => tree.ToString()))); + } + private static void AssertValid(Result result, bool synthetic = false) + { + var diagnostics = result.Driver.GetRunResult().Diagnostics.Concat(result.Compilation.GetDiagnostics()) + .Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && !(synthetic && diagnostic.Id == "CS0436")).ToArray(); + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static diagnostic => diagnostic.ToString()))); + using var stream = new MemoryStream(); + var emit = result.Compilation.Emit(stream); + Assert.IsTrue(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + } + private static bool Execute(Result result) + { + using var stream = new MemoryStream(); + var emit = result.Compilation.Emit(stream); + Assert.IsTrue(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + return (bool)Assembly.Load(stream.ToArray()).GetType("Probe")!.GetMethod("Run")!.Invoke(null, null)!; + } + private sealed record Result(GeneratorDriver Driver, Compilation Compilation, string Source); + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/CleanPackageSampleTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/CleanPackageSampleTests.cs index 24f51b7..4ce9dd8 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/CleanPackageSampleTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/CleanPackageSampleTests.cs @@ -25,7 +25,10 @@ public void DocumentedSamplesCompileAndExecuteFromThePrimaryPackage() Directory.CreateDirectory(directory); // Execute the documented source unchanged, including its mapping assertions. - File.Copy(Path.Combine(sampleDirectory, "Program.cs"), Path.Combine(directory, "Program.cs")); + foreach (var sourceFile in Directory.EnumerateFiles(sampleDirectory, "*.cs")) + { + File.Copy(sourceFile, Path.Combine(directory, Path.GetFileName(sourceFile))); + } var properties = new XElement("PropertyGroup", new XElement("OutputType", "Exe"), new XElement("RestorePackagesPath", Path.Combine(directory, ".packages"))); diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index 5e667ea..9144e1f 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs @@ -48,13 +48,21 @@ public void PackageMappingsCompileAndExecuteAcrossFrameworksAndLanguages(string new XAttribute("Include", "Mammoth.LiteMapper"), new XAttribute("Version", "1.0.0"))))) .Save(Path.Combine(consumer, "Consumer.csproj")); File.WriteAllText(Path.Combine(consumer, "Consumer.cs"), Fixture.Replace("EXPECTED_RUNTIME_MAJOR", runtimeMajor, StringComparison.Ordinal)); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs"), + Path.Combine(consumer, "PortableSetExample.cs")); PackageSources(packageFeed, "https://api.nuget.org/v3/index.json") .Save(Path.Combine(directory, "NuGet.Config")); Milestone14PackagingAndAotTests.RunDotnet("restore --no-cache", consumer); Milestone14PackagingAndAotTests.RunDotnet("build -c Release --no-restore", consumer); var generated = Directory.GetFiles(Path.Combine(consumer, "obj", "generated"), "*.g.cs", SearchOption.AllDirectories); - Assert.AreEqual(8, generated.Length, "All eight mapper containers must generate in " + framework + "/" + language + "."); + Assert.AreEqual(9, generated.Length, "All nine mapper containers must generate in " + framework + "/" + language + "."); + var setSource = File.ReadAllText(generated.Single(static file => Path.GetFileName(file).StartsWith( + "Mammoth.LiteMapper.Samples.Collections.PortableSetMapper.", StringComparison.Ordinal))); + StringAssert.Contains(setSource, "new global::System.Collections.Generic.HashSet(" + + (standard ? "" : "source.Length") + ")"); + StringAssert.Contains(setSource, "new global::System.Collections.Generic.HashSet(" + + (standard ? "" : "source.Count, ") + "source.Comparer)"); var executable = consumer; if (standard) @@ -177,6 +185,7 @@ public static class MatrixFixture public static void Run() { + Mammoth.LiteMapper.Samples.Collections.PortableSetExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); if (Environment.Version.Major != EXPECTED_RUNTIME_MAJOR) throw new InvalidOperationException(""Wrong runtime was used.""); diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs index baa9ca7..378175f 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -223,6 +223,7 @@ private static string CreateConsumer(string feed, string targetFramework) using System.Collections.Generic; using Mammoth.LiteMapper; +Mammoth.LiteMapper.Samples.Collections.PortableSetExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); @@ -476,6 +477,8 @@ public sealed class PatchUpdaterChildTarget { public int Value { get; set; } } "); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs"), + Path.Combine(directory, "PortableSetExample.cs")); return directory; }