diff --git a/Mapsicle.sln b/Mapsicle.sln index 23fb7d2..3d4f641 100644 --- a/Mapsicle.sln +++ b/Mapsicle.sln @@ -81,6 +81,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapsicle.SourceGen.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapsicle.SourceGen.OptIn.Tests", "tests\Mapsicle.SourceGen.OptIn.Tests\Mapsicle.SourceGen.OptIn.Tests.csproj", "{7D3DC0FB-7864-436B-B4A3-C164A98289BA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapsicle.SourceGen.NetStandard.Fixture", "tests\Mapsicle.SourceGen.NetStandard.Fixture\Mapsicle.SourceGen.NetStandard.Fixture.csproj", "{567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapsicle.SourceGen.OldLanguage.Fixture", "tests\Mapsicle.SourceGen.OldLanguage.Fixture\Mapsicle.SourceGen.OldLanguage.Fixture.csproj", "{F91CD029-D893-43C2-AB7D-8A0B07E0C755}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -523,6 +527,30 @@ Global {7D3DC0FB-7864-436B-B4A3-C164A98289BA}.Release|x64.Build.0 = Release|Any CPU {7D3DC0FB-7864-436B-B4A3-C164A98289BA}.Release|x86.ActiveCfg = Release|Any CPU {7D3DC0FB-7864-436B-B4A3-C164A98289BA}.Release|x86.Build.0 = Release|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Debug|x64.ActiveCfg = Debug|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Debug|x64.Build.0 = Debug|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Debug|x86.ActiveCfg = Debug|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Debug|x86.Build.0 = Debug|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Release|Any CPU.Build.0 = Release|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Release|x64.ActiveCfg = Release|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Release|x64.Build.0 = Release|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Release|x86.ActiveCfg = Release|Any CPU + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B}.Release|x86.Build.0 = Release|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Debug|x64.ActiveCfg = Debug|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Debug|x64.Build.0 = Debug|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Debug|x86.ActiveCfg = Debug|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Debug|x86.Build.0 = Debug|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Release|Any CPU.Build.0 = Release|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Release|x64.ActiveCfg = Release|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Release|x64.Build.0 = Release|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Release|x86.ActiveCfg = Release|Any CPU + {F91CD029-D893-43C2-AB7D-8A0B07E0C755}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -564,5 +592,7 @@ Global {C613DDA4-8E7C-4D39-A8EB-94B5836D04F4} = {6B6174EC-A782-4FDE-A29C-B16A5A827845} {9B1FDC73-02B6-454C-8957-AF70DDFE6D62} = {3A0525C5-7A1E-4E6E-A617-442A73AC1522} {7D3DC0FB-7864-436B-B4A3-C164A98289BA} = {3A0525C5-7A1E-4E6E-A617-442A73AC1522} + {567DDBA6-E35F-4B16-B1B9-A6D68F52BD3B} = {3A0525C5-7A1E-4E6E-A617-442A73AC1522} + {F91CD029-D893-43C2-AB7D-8A0B07E0C755} = {3A0525C5-7A1E-4E6E-A617-442A73AC1522} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 5a67fef..9de75a3 100644 --- a/README.md +++ b/README.md @@ -635,6 +635,20 @@ the fallback than fix the pair, exempt it: Nothing is broken when you see it. It is the difference between 1.00x and 1.80x on that pair, and it names the member responsible. +**On netstandard2.0 or .NET Framework, set the language version.** Generated code needs C# 9, and +those targets default to 7.3. Below C# 9 the generator emits nothing and reports `MSG003` once, and +every pair maps through the engine: + +```xml + + 9.0 + +``` + +With that set, a netstandard2.0 project generates like any other. The target has no +`ModuleInitializerAttribute`, so the generated file declares an internal one, unless your project +already has its own. + **To check it worked**, turn on the emitted files and read them: ```xml diff --git a/changelog.d/sourcegen-netstandard.fixed.md b/changelog.d/sourcegen-netstandard.fixed.md new file mode 100644 index 0000000..f615984 --- /dev/null +++ b/changelog.d/sourcegen-netstandard.fixed.md @@ -0,0 +1,6 @@ +- **A netstandard2.0 or .NET Framework project with `Mapsicle.SourceGen` installed builds again.** + The generated file referenced `ModuleInitializerAttribute`, which those targets do not have, so the + build failed with CS0234. The generator now declares an internal copy when the target has none. + Below C# 9, which is where those targets start, the generated code failed with CS8370 and CS8627. + The generator now emits nothing there, reports the new `MSG003` warning once, and every pair maps + through the engine. Set `9.0` or later to generate. diff --git a/src/Mapsicle.SourceGen/AnalyzerReleases.Unshipped.md b/src/Mapsicle.SourceGen/AnalyzerReleases.Unshipped.md index 732f015..1ff3ef0 100644 --- a/src/Mapsicle.SourceGen/AnalyzerReleases.Unshipped.md +++ b/src/Mapsicle.SourceGen/AnalyzerReleases.Unshipped.md @@ -7,3 +7,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- MSG001 | Mapsicle | Warning | A declared pair could not be generated and falls back to the runtime engine. MSG002 | Mapsicle | Info | MapperGenerator +MSG003 | Mapsicle | Warning | The project compiles with a C# version older than 9, so nothing is generated and the engine maps every pair. diff --git a/src/Mapsicle.SourceGen/MapperGenerator.cs b/src/Mapsicle.SourceGen/MapperGenerator.cs index 766e5bb..a9b27a3 100644 --- a/src/Mapsicle.SourceGen/MapperGenerator.cs +++ b/src/Mapsicle.SourceGen/MapperGenerator.cs @@ -70,6 +70,24 @@ public sealed class MapperGenerator : IIncrementalGenerator "keeps working through the engine, and is reported as information because nothing asked " + "for it by name."); + /// Reported once when the project's language version is too old to emit for. + /// + /// Generated code needs a module initializer and an unconstrained TDest?, and both + /// arrived in C# 9. A netstandard2.0 project defaults to 7.3, and emitting there failed the + /// build with CS8370 and CS8627 inside the generated files. + /// + private static readonly DiagnosticDescriptor LanguageTooOld = new( + id: "MSG003", + title: "Mapsicle needs C# 9 to generate mappers", + messageFormat: "Nothing was generated because this project compiles with C# {0} and generated mappers need C# 9 or later. Every pair still maps through the runtime engine. Set 9.0 or later to generate.", + category: "Mapsicle", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A project on netstandard2.0 or .NET Framework defaults to C# 7.3. The generator stands aside " + + "there and the runtime engine maps every pair, so the build and the call sites are unaffected."); + + private const string ModuleInitializerName = "System.Runtime.CompilerServices.ModuleInitializerAttribute"; + /// Wires the generator to the assembly's declared pairs. /// Supplied by Roslyn. public void Initialize(IncrementalGeneratorInitializationContext context) @@ -97,7 +115,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (requested.Plans.Count == 0) return; - spc.AddSource("MapsicleGenerated.g.cs", SourceText.From(Emit(requested.Plans), Encoding.UTF8)); + spc.AddSource( + "MapsicleGenerated.g.cs", + SourceText.From(Emit(requested.Plans, requested.NeedsModuleInitializer), Encoding.UTF8)); spc.AddSource("MapsicleGeneratedExtensions.g.cs", SourceText.From(EmitExtensions(requested.Plans), Encoding.UTF8)); }); } @@ -175,14 +195,18 @@ private static (INamedTypeSymbol Source, INamedTypeSymbol Destination)? ResolveC private readonly struct Requested { - internal Requested(List plans, List diagnostics) + internal Requested(List plans, List diagnostics, bool needsModuleInitializer = false) { Plans = plans; Diagnostics = diagnostics; + NeedsModuleInitializer = needsModuleInitializer; } internal List Plans { get; } internal List Diagnostics { get; } + + /// Set when the target has no ModuleInitializerAttribute this assembly can use. + internal bool NeedsModuleInitializer { get; } } private static Requested ReadPairs( @@ -195,6 +219,28 @@ private static Requested ReadPairs( var marker = compilation.GetTypeByMetadataName(AttributeName); if (marker is null) return new Requested(plans, diagnostics); + if (compilation is CSharpCompilation { LanguageVersion: < LanguageVersion.CSharp9 } old) + { + // Only when the assembly asked for something. The package can arrive through a + // reference the author never looked at, and a warning about pairs nobody declared + // is noise. + if (AsksForGeneration(compilation, marker)) + { + diagnostics.Add(Diagnostic.Create( + LanguageTooOld, Location.None, old.LanguageVersion.ToDisplayString())); + } + + return new Requested(plans, diagnostics); + } + + // A copy already in this assembly's source is used as it stands, since a second one + // would be a duplicate definition. A non-public copy in a referenced assembly is not + // relied on, whether or not it happens to be visible from here. + var initializer = compilation.GetTypeByMetadataName(ModuleInitializerName); + var needsModuleInitializer = initializer is null + || (initializer.DeclaredAccessibility != Accessibility.Public + && !SymbolEqualityComparer.Default.Equals(initializer.ContainingAssembly, compilation.Assembly)); + var index = 0; var seen = new HashSet(StringComparer.Ordinal); @@ -233,7 +279,7 @@ private static Requested ReadPairs( && compilation.Assembly.GetAttributes().Any( a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, allMarker)); - if (!scanAll) return new Requested(plans, diagnostics); + if (!scanAll) return new Requested(plans, diagnostics, needsModuleInitializer); foreach (var candidate in scanned .Where(c => c is not null) @@ -261,7 +307,17 @@ private static Requested ReadPairs( index++; } - return new Requested(plans, diagnostics); + return new Requested(plans, diagnostics, needsModuleInitializer); + } + + /// Whether the assembly carries either attribute that asks for generated mappers. + private static bool AsksForGeneration(Compilation compilation, INamedTypeSymbol marker) + { + var allMarker = compilation.GetTypeByMetadataName(GenerateAllAttributeName); + + return compilation.Assembly.GetAttributes().Any( + a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, marker) + || (allMarker is not null && SymbolEqualityComparer.Default.Equals(a.AttributeClass, allMarker))); } /// A stable key for a pair, so the two doors cannot emit it twice. @@ -1402,11 +1458,29 @@ private static void Preamble(StringBuilder sb) /// write and cannot edit. Prefixing unconditionally is valid for ordinary identifiers too, /// so there is no keyword table to keep current. /// - private static string Emit(List plans) + private static string Emit(List plans, bool needsModuleInitializer) { var sb = new StringBuilder(); Preamble(sb); + // netstandard2.0 and .NET Framework have no ModuleInitializerAttribute, and the file + // referenced it anyway, so a project on either failed with CS0234. The compiler only + // looks for the name, so declaring it here is enough, and every runtime runs a module + // initializer. CS0436 is for a referenced assembly that exposes its own copy. + if (needsModuleInitializer) + { + sb.AppendLine("#pragma warning disable CS0436"); + sb.AppendLine(); + sb.AppendLine("namespace System.Runtime.CompilerServices"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]"); + sb.AppendLine(" internal sealed class ModuleInitializerAttribute : global::System.Attribute"); + sb.AppendLine(" {"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + sb.AppendLine(); + } + sb.AppendLine("namespace Mapsicle.Generated"); sb.AppendLine("{"); sb.AppendLine(" /// Compile-time mappers, registered into the engine at startup."); diff --git a/tests/Mapsicle.NetStandard.Tests/Mapsicle.NetStandard.Tests.csproj b/tests/Mapsicle.NetStandard.Tests/Mapsicle.NetStandard.Tests.csproj index f9d3c83..2d14713 100644 --- a/tests/Mapsicle.NetStandard.Tests/Mapsicle.NetStandard.Tests.csproj +++ b/tests/Mapsicle.NetStandard.Tests/Mapsicle.NetStandard.Tests.csproj @@ -18,6 +18,8 @@ --> + + diff --git a/tests/Mapsicle.NetStandard.Tests/NetStandardGeneratedTests.cs b/tests/Mapsicle.NetStandard.Tests/NetStandardGeneratedTests.cs new file mode 100644 index 0000000..bf3f770 --- /dev/null +++ b/tests/Mapsicle.NetStandard.Tests/NetStandardGeneratedTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using Mapsicle.SourceGen.NetStandard.Fixture; +using Mapsicle.SourceGen.OldLanguage.Fixture; +using Xunit; + +namespace Mapsicle.NetStandard.Tests +{ + /// + /// Generated mappers in a consumer that targets netstandard2.0. + /// + /// + /// The two fixture projects are the test as much as the assertions are. Each one is a + /// netstandard2.0 library with the generator installed, and neither used to build: the target + /// has no ModuleInitializerAttribute, and its default language version has no nullable + /// annotations. + /// + public class NetStandardGeneratedTests + { + private static NsGenOrder Order() => new NsGenOrder + { + Id = 7, + Name = "Ana", + Status = NsGenStatus.Paid, + Label = NsGenStatus.Paid, + PlacedAt = new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc), + Country = new NsGenCountry { Iso = "PH" }, + Lines = { new NsGenLine { Sku = "a", Qty = 2 } }, + Extras = new[] { new NsGenLine { Sku = "b", Qty = 3 } }, + }; + + private static bool IsRegistered(Type source, Type destination) + { + var registry = typeof(Mapper) + .GetField("_generatedPairs", BindingFlags.NonPublic | BindingFlags.Static)! + .GetValue(null)!; + + return ((IEnumerable)registry.GetType().GetProperty("Keys")!.GetValue(registry)!) + .Cast() + .Select(k => k.ToString() ?? "") + .Any(k => k.Contains(source.Name) && k.Contains(destination.Name)); + } + + [Fact] + public void TheFixtureIsANetStandardBuild() + { + var target = typeof(NsGenCalls).Assembly.GetCustomAttribute(); + + Assert.Equal(".NETStandard,Version=v2.0", target!.FrameworkName); + } + + [Fact] + public void ADeclaredPairRegistersFromTheModuleInitializer() + { + NsGenCalls.Typed(Order()); + + Assert.True(IsRegistered(typeof(NsGenOrder), typeof(NsGenOrderDto))); + } + + [Fact] + public void ADeclaredPairMapsLikeTheEngine() + { + var typed = NsGenCalls.Typed(Order()); + var untyped = NsGenCalls.Untyped(Order()); + + using var runtime = MapperFactory.Create(); + var interpreted = runtime.MapTo(Order())!; + + foreach (var generated in new[] { typed, untyped }) + { + Assert.Equal(7L, generated.Id); + Assert.Equal("Ana", generated.Name); + Assert.Equal(NsGenStatusDto.Paid, generated.Status); + Assert.Equal("Paid", generated.Label); + Assert.Equal(interpreted.PlacedAt, generated.PlacedAt); + Assert.Equal("PH", generated.Country.Iso); + Assert.Equal("PH", generated.CountryIso); + Assert.Equal("a", Assert.Single(generated.Lines).Sku); + Assert.Equal(2L, generated.Lines[0].Qty); + Assert.Equal("b", Assert.Single(generated.Extras).Sku); + Assert.Equal(3L, generated.Extras[0].Qty); + } + + Assert.Equal(7L, interpreted.Id); + Assert.Equal(NsGenStatusDto.Paid, interpreted.Status); + Assert.Equal("Paid", interpreted.Label); + Assert.Equal("PH", interpreted.CountryIso); + Assert.Equal(2L, interpreted.Lines[0].Qty); + Assert.Equal(3L, interpreted.Extras[0].Qty); + } + + [Fact] + public void ADeclaredPairUnderAnOlderLanguageVersionMapsThroughTheEngine() + { + var mapped = NsOldCalls.Typed(new NsOldBox { X = 42, Name = "Ana" }); + + Assert.Equal(42L, mapped.X); + Assert.Equal("Ana", mapped.Name); + Assert.False(IsRegistered(typeof(NsOldBox), typeof(NsOldBoxDto))); + } + } +} diff --git a/tests/Mapsicle.SourceGen.NetStandard.Fixture/Mapsicle.SourceGen.NetStandard.Fixture.csproj b/tests/Mapsicle.SourceGen.NetStandard.Fixture/Mapsicle.SourceGen.NetStandard.Fixture.csproj new file mode 100644 index 0000000..7a8e7c6 --- /dev/null +++ b/tests/Mapsicle.SourceGen.NetStandard.Fixture/Mapsicle.SourceGen.NetStandard.Fixture.csproj @@ -0,0 +1,20 @@ + + + + + netstandard2.0 + 9.0 + false + + + + + + + + diff --git a/tests/Mapsicle.SourceGen.NetStandard.Fixture/NsGenerated.cs b/tests/Mapsicle.SourceGen.NetStandard.Fixture/NsGenerated.cs new file mode 100644 index 0000000..abd9e5b --- /dev/null +++ b/tests/Mapsicle.SourceGen.NetStandard.Fixture/NsGenerated.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Mapsicle; + +[assembly: MapsicleGenerate(typeof(Mapsicle.SourceGen.NetStandard.Fixture.NsGenOrder), typeof(Mapsicle.SourceGen.NetStandard.Fixture.NsGenOrderDto))] + +namespace Mapsicle.SourceGen.NetStandard.Fixture +{ + public enum NsGenStatus { Draft = 0, Paid = 1 } + public enum NsGenStatusDto { Paid = 5, Draft = 6 } + + public sealed class NsGenCountry { public string Iso { get; set; } = ""; } + public sealed class NsGenCountryDto { public string Iso { get; set; } = ""; } + + public class NsGenLine { public string Sku { get; set; } = ""; public int Qty { get; set; } } + public class NsGenLineDto { public string Sku { get; set; } = ""; public long Qty { get; set; } } + + public class NsGenOrder + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public NsGenStatus Status { get; set; } + public NsGenStatus Label { get; set; } + public DateTime PlacedAt { get; set; } + public NsGenCountry Country { get; set; } = new NsGenCountry(); + public List Lines { get; set; } = new List(); + public NsGenLine[] Extras { get; set; } = new NsGenLine[0]; + } + + public class NsGenOrderDto + { + public long Id { get; set; } + public string Name { get; set; } = ""; + public NsGenStatusDto Status { get; set; } + public string Label { get; set; } = ""; + public DateTimeOffset PlacedAt { get; set; } + public NsGenCountryDto Country { get; set; } = new NsGenCountryDto(); + public string CountryIso { get; set; } = ""; + public List Lines { get; set; } = new List(); + public List Extras { get; set; } = new List(); + } + + /// The calls a consumer on this target would write, compiled on this target. + public static class NsGenCalls + { + public static NsGenOrderDto Typed(NsGenOrder order) => order.MapTo(); + + public static NsGenOrderDto Untyped(NsGenOrder order) => ((object)order).MapTo(); + } +} diff --git a/tests/Mapsicle.SourceGen.OldLanguage.Fixture/Mapsicle.SourceGen.OldLanguage.Fixture.csproj b/tests/Mapsicle.SourceGen.OldLanguage.Fixture/Mapsicle.SourceGen.OldLanguage.Fixture.csproj new file mode 100644 index 0000000..7d9b318 --- /dev/null +++ b/tests/Mapsicle.SourceGen.OldLanguage.Fixture/Mapsicle.SourceGen.OldLanguage.Fixture.csproj @@ -0,0 +1,20 @@ + + + + + netstandard2.0 + 7.3 + false + + + + + + + + diff --git a/tests/Mapsicle.SourceGen.OldLanguage.Fixture/NsOld.cs b/tests/Mapsicle.SourceGen.OldLanguage.Fixture/NsOld.cs new file mode 100644 index 0000000..c062632 --- /dev/null +++ b/tests/Mapsicle.SourceGen.OldLanguage.Fixture/NsOld.cs @@ -0,0 +1,15 @@ +using Mapsicle; + +[assembly: MapsicleGenerate(typeof(Mapsicle.SourceGen.OldLanguage.Fixture.NsOldBox), typeof(Mapsicle.SourceGen.OldLanguage.Fixture.NsOldBoxDto))] + +namespace Mapsicle.SourceGen.OldLanguage.Fixture +{ + public class NsOldBox { public int X { get; set; } public string Name { get; set; } } + public class NsOldBoxDto { public long X { get; set; } public string Name { get; set; } } + + /// The call a consumer on this target would write, compiled with C# 7.3. + public static class NsOldCalls + { + public static NsOldBoxDto Typed(NsOldBox box) => box.MapTo(); + } +} diff --git a/tests/Mapsicle.SourceGen.Tests/LanguageVersionTests.cs b/tests/Mapsicle.SourceGen.Tests/LanguageVersionTests.cs new file mode 100644 index 0000000..75c43c1 --- /dev/null +++ b/tests/Mapsicle.SourceGen.Tests/LanguageVersionTests.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Mapsicle.SourceGen.Tests +{ + /// + /// What the generator does when the project's language version cannot compile its output. + /// + public class LanguageVersionTests + { + private const string Declared = @" +using Mapsicle; +[assembly: MapsicleGenerate(typeof(LvBox), typeof(LvBoxDto))] +public class LvBox { public int X { get; set; } } +public class LvBoxDto { public int X { get; set; } }"; + + private const string Undeclared = @" +public class LvBox { public int X { get; set; } } +public class LvBoxDto { public int X { get; set; } }"; + + private static GeneratorDriverRunResult Run(LanguageVersion version, string source) + { + var options = new CSharpParseOptions(version); + + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)); + + var compilation = CSharpCompilation.Create( + "LanguageVersionProbe", + new[] { CSharpSyntaxTree.ParseText(source, options) }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + return CSharpGeneratorDriver + .Create(new[] { new MapperGenerator().AsSourceGenerator() }, parseOptions: options) + .RunGenerators(compilation) + .GetRunResult(); + } + + [Theory] + [InlineData(LanguageVersion.CSharp7_3, "7.3")] + [InlineData(LanguageVersion.CSharp8, "8.0")] + public void BelowCSharp9NothingIsEmittedAndOneWarningSaysWhy(LanguageVersion version, string shown) + { + var result = Run(version, Declared); + + Assert.Empty(result.GeneratedTrees); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("MSG003", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Contains($"compiles with C# {shown}", diagnostic.GetMessage()); + Assert.Contains("9.0", diagnostic.GetMessage()); + } + + [Fact] + public void BelowCSharp9AskingForEveryPairGetsTheSameSingleWarning() + { + var result = Run(LanguageVersion.CSharp7_3, "[assembly: Mapsicle.MapsicleGenerateAll]" + Undeclared); + + Assert.Empty(result.GeneratedTrees); + Assert.Equal("MSG003", Assert.Single(result.Diagnostics).Id); + } + + [Fact] + public void AtCSharp9ThePairIsEmittedWithNoDiagnostic() + { + var result = Run(LanguageVersion.CSharp9, Declared); + + Assert.Empty(result.Diagnostics); + Assert.Equal(2, result.GeneratedTrees.Length); + } + + [Fact] + public void AnOldLanguageVersionWithNothingDeclaredIsSilent() + { + var result = Run(LanguageVersion.CSharp7_3, Undeclared); + + Assert.Empty(result.Diagnostics); + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public void AProjectThatDeclaresTheAttributeItselfGetsNoSecondCopy() + { + const string ownCopy = @" +namespace System.Runtime.CompilerServices +{ + internal sealed class ModuleInitializerAttribute : System.Attribute { } +}"; + + var result = Run(LanguageVersion.CSharp9, Declared + ownCopy); + + Assert.Equal(2, result.GeneratedTrees.Length); + Assert.DoesNotContain( + result.GeneratedTrees, + tree => tree.ToString().Contains("class ModuleInitializerAttribute")); + } + + [Fact] + public void ATargetThatHasTheAttributeGetsNoSecondCopy() + { + var result = Run(LanguageVersion.CSharp9, Declared); + + Assert.DoesNotContain( + result.GeneratedTrees, + tree => tree.ToString().Contains("class ModuleInitializerAttribute")); + Assert.Contains( + result.GeneratedTrees, + tree => tree.ToString().Contains("[global::System.Runtime.CompilerServices.ModuleInitializer]")); + } + } +} diff --git a/tests/Mapsicle.SourceGen.Tests/Mapsicle.SourceGen.Tests.csproj b/tests/Mapsicle.SourceGen.Tests/Mapsicle.SourceGen.Tests.csproj index c50eb66..ad66788 100644 --- a/tests/Mapsicle.SourceGen.Tests/Mapsicle.SourceGen.Tests.csproj +++ b/tests/Mapsicle.SourceGen.Tests/Mapsicle.SourceGen.Tests.csproj @@ -10,15 +10,18 @@ + + this project's own source is the only way to test what a consumer actually gets. It is + referenced as an assembly too, for the tests that read its diagnostics: a diagnostic about + the project's own settings cannot be provoked from inside the project that reports it. --> + ReferenceOutputAssembly="true" />