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
+ 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" />