Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Mapsicle.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<PropertyGroup>
<LangVersion>9.0</LangVersion>
</PropertyGroup>
```

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
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/sourcegen-netstandard.fixed.md
Original file line number Diff line number Diff line change
@@ -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 `<LangVersion>9.0</LangVersion>` or later to generate.
1 change: 1 addition & 0 deletions src/Mapsicle.SourceGen/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
84 changes: 79 additions & 5 deletions src/Mapsicle.SourceGen/MapperGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");

/// <summary>Reported once when the project's language version is too old to emit for.</summary>
/// <remarks>
/// Generated code needs a module initializer and an unconstrained <c>TDest?</c>, 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.
/// </remarks>
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 <LangVersion>9.0</LangVersion> 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";

/// <summary>Wires the generator to the assembly's declared pairs.</summary>
/// <param name="context">Supplied by Roslyn.</param>
public void Initialize(IncrementalGeneratorInitializationContext context)
Expand Down Expand Up @@ -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));
});
}
Expand Down Expand Up @@ -175,14 +195,18 @@ private static (INamedTypeSymbol Source, INamedTypeSymbol Destination)? ResolveC

private readonly struct Requested
{
internal Requested(List<MapPlan> plans, List<Diagnostic> diagnostics)
internal Requested(List<MapPlan> plans, List<Diagnostic> diagnostics, bool needsModuleInitializer = false)
{
Plans = plans;
Diagnostics = diagnostics;
NeedsModuleInitializer = needsModuleInitializer;
}

internal List<MapPlan> Plans { get; }
internal List<Diagnostic> Diagnostics { get; }

/// <summary>Set when the target has no <c>ModuleInitializerAttribute</c> this assembly can use.</summary>
internal bool NeedsModuleInitializer { get; }
}

private static Requested ReadPairs(
Expand All @@ -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<string>(StringComparer.Ordinal);

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -261,7 +307,17 @@ private static Requested ReadPairs(
index++;
}

return new Requested(plans, diagnostics);
return new Requested(plans, diagnostics, needsModuleInitializer);
}

/// <summary>Whether the assembly carries either attribute that asks for generated mappers.</summary>
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)));
}

/// <summary>A stable key for a pair, so the two doors cannot emit it twice.</summary>
Expand Down Expand Up @@ -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.
/// </remarks>
private static string Emit(List<MapPlan> plans)
private static string Emit(List<MapPlan> 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(" /// <summary>Compile-time mappers, registered into the engine at startup.</summary>");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
-->
<ItemGroup>
<ProjectReference Include="../../src/Mapsicle/Mapsicle.csproj" SetTargetFramework="TargetFramework=netstandard2.0" />
<ProjectReference Include="../Mapsicle.SourceGen.NetStandard.Fixture/Mapsicle.SourceGen.NetStandard.Fixture.csproj" />
<ProjectReference Include="../Mapsicle.SourceGen.OldLanguage.Fixture/Mapsicle.SourceGen.OldLanguage.Fixture.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
104 changes: 104 additions & 0 deletions tests/Mapsicle.NetStandard.Tests/NetStandardGeneratedTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Generated mappers in a consumer that targets netstandard2.0.
/// </summary>
/// <remarks>
/// 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 <c>ModuleInitializerAttribute</c>, and its default language version has no nullable
/// annotations.
/// </remarks>
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<object>()
.Select(k => k.ToString() ?? "")
.Any(k => k.Contains(source.Name) && k.Contains(destination.Name));
}

[Fact]
public void TheFixtureIsANetStandardBuild()
{
var target = typeof(NsGenCalls).Assembly.GetCustomAttribute<System.Runtime.Versioning.TargetFrameworkAttribute>();

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<NsGenOrderDto>(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)));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
A consumer on netstandard2.0 that has raised its language version, which is the oldest setup
the generator emits for. The target has no ModuleInitializerAttribute, and the generated file
used to reference it anyway, so this project did not build.
-->
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Mapsicle\Mapsicle.csproj" />
<ProjectReference Include="..\..\src\Mapsicle.SourceGen\Mapsicle.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
Loading
Loading