Skip to content

Commit 4199944

Browse files
YoelDruxmanclaude
andcommitted
feat: Respect .editorconfig end_of_line and charset in generated code
Generated mapper files (.g.cs) now honor .editorconfig settings: - end_of_line: supports lf, crlf, cr (defaults to crlf) - charset: supports utf-8, utf-8-bom, utf-16be, utf-16le (defaults to utf-8 without BOM) This allows generated code to match the project's line ending and encoding preferences, avoiding git diffs and post-processing requirements. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 747fd46 commit 4199944

File tree

5 files changed

+191
-6
lines changed

5 files changed

+191
-6
lines changed

docs/docs/configuration/generated-source.mdx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,38 @@ The code generated by Mapperly can be identified in several ways:
8484
> Note: the version `1.0.0.0` will be the version of the NuGet package of Mapperly that you are using.
8585
8686
Using these identification points, you can configure most tooling to apply a different set of rules to the generated source.
87+
88+
## EditorConfig support
89+
90+
Mapperly respects `.editorconfig` settings for the generated source files:
91+
92+
### Line endings (`end_of_line`)
93+
94+
The generated code honors the `end_of_line` setting from your `.editorconfig`:
95+
96+
```ini
97+
[*.cs]
98+
end_of_line = lf
99+
```
100+
101+
Supported values:
102+
- `lf` - Unix-style line endings (`\n`)
103+
- `crlf` - Windows-style line endings (`\r\n`, default)
104+
- `cr` - Classic Mac-style line endings (`\r`)
105+
106+
### Character encoding (`charset`)
107+
108+
The generated code honors the `charset` setting from your `.editorconfig`:
109+
110+
```ini
111+
[*.cs]
112+
charset = utf-8
113+
```
114+
115+
Supported values:
116+
- `utf-8` - UTF-8 without BOM (default)
117+
- `utf-8-bom` - UTF-8 with BOM
118+
- `utf-16le` - UTF-16 Little Endian
119+
- `utf-16be` - UTF-16 Big Endian
120+
121+
This ensures generated mapper files match your project's coding standards and avoids unnecessary git diffs due to line ending or encoding differences.

src/Riok.Mapperly/Helpers/IncrementalValuesProviderExtensions.cs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text;
22
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.Text;
34
using Riok.Mapperly.Output;
45

56
namespace Riok.Mapperly.Helpers;
@@ -62,6 +63,9 @@ IncrementalValuesProvider<ImmutableEquatableArray<Diagnostic>> diagnostics
6263
);
6364
}
6465

66+
// UTF-8 encoding without BOM (default for 'charset = utf-8' in .editorconfig)
67+
private static readonly Encoding _utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
68+
6569
/// <summary>
6670
/// Registers an implementation source output for the provided mappers.
6771
/// </summary>
@@ -76,7 +80,40 @@ IncrementalValuesProvider<MapperNode> mappers
7680
mappers,
7781
static (spc, mapper) =>
7882
{
79-
var mapperText = mapper.Body.GetText(Encoding.UTF8);
83+
// Respect .editorconfig charset setting
84+
// Default to UTF-8 without BOM (most common for source files)
85+
var encoding = _utf8NoBom;
86+
if (!string.IsNullOrEmpty(mapper.Charset))
87+
{
88+
encoding = mapper.Charset switch
89+
{
90+
"utf-8-bom" => Encoding.UTF8, // UTF-8 with BOM
91+
"utf-16be" => Encoding.BigEndianUnicode,
92+
"utf-16le" => Encoding.Unicode,
93+
_ => _utf8NoBom, // utf-8 and others default to no BOM
94+
};
95+
}
96+
97+
var mapperText = mapper.Body.GetText(encoding);
98+
99+
// Respect .editorconfig end_of_line setting
100+
// The syntax tree uses CRLF by default (ElasticCarriageReturnLineFeed)
101+
if (!string.IsNullOrEmpty(mapper.EndOfLine) && !string.Equals(mapper.EndOfLine, "crlf", StringComparison.Ordinal))
102+
{
103+
var newLine = mapper.EndOfLine switch
104+
{
105+
"lf" => "\n",
106+
"cr" => "\r",
107+
_ => null,
108+
};
109+
110+
if (newLine != null)
111+
{
112+
var text = mapperText.ToString().Replace("\r\n", newLine);
113+
mapperText = SourceText.From(text, encoding);
114+
}
115+
}
116+
80117
spc.AddSource(mapper.FileName, mapperText);
81118
}
82119
);

src/Riok.Mapperly/MapperGenerator.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Immutable;
22
using Microsoft.CodeAnalysis;
33
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.Diagnostics;
45
using Riok.Mapperly.Abstractions;
56
using Riok.Mapperly.Configuration;
67
using Riok.Mapperly.Descriptors;
@@ -70,7 +71,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
7071
.Combine(compilationContext)
7172
.Combine(mapperDefaults)
7273
.Combine(useStaticMappers)
73-
.Select(static (x, ct) => BuildDescriptor(x.Left.Left.Right, x.Left.Left.Left, x.Left.Right, x.Right, ct))
74+
.Combine(context.AnalyzerConfigOptionsProvider)
75+
.Select(
76+
static (x, ct) =>
77+
BuildDescriptor(x.Left.Left.Left.Right, x.Left.Left.Left.Left, x.Left.Left.Right, x.Left.Right, x.Right, ct)
78+
)
7479
.WhereNotNull();
7580

7681
// output the diagnostics
@@ -89,6 +94,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
8994
MapperDeclaration mapperDeclaration,
9095
MapperConfiguration mapperDefaults,
9196
ImmutableArray<UseStaticMapperConfiguration> assemblyScopedStaticMappers,
97+
AnalyzerConfigOptionsProvider configOptionsProvider,
9298
CancellationToken cancellationToken
9399
)
94100
{
@@ -110,9 +116,18 @@ CancellationToken cancellationToken
110116
assemblyScopedStaticMappers
111117
);
112118
var (descriptor, diagnostics) = builder.Build(cancellationToken);
119+
120+
// Get editorconfig settings from the mapper's source file
121+
var syntaxTree = mapperDeclaration.Syntax.SyntaxTree;
122+
var configOptions = configOptionsProvider.GetOptions(syntaxTree);
123+
configOptions.TryGetValue("end_of_line", out var endOfLine);
124+
configOptions.TryGetValue("charset", out var charset);
125+
113126
var mapper = new MapperNode(
114127
compilationContext.FileNameBuilder.Build(descriptor),
115-
SourceEmitter.Build(descriptor, cancellationToken)
128+
SourceEmitter.Build(descriptor, cancellationToken),
129+
endOfLine,
130+
charset
116131
);
117132
return new MapperAndDiagnostics(mapper, diagnostics.ToImmutableEquatableArray());
118133
}

src/Riok.Mapperly/Output/MapperNode.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
namespace Riok.Mapperly.Output;
44

5-
public readonly record struct MapperNode(string FileName, CompilationUnitSyntax Body)
5+
public readonly record struct MapperNode(string FileName, CompilationUnitSyntax Body, string? EndOfLine = null, string? Charset = null)
66
{
77
public bool Equals(MapperNode other)
88
{
9-
return string.Equals(FileName, other.FileName, StringComparison.Ordinal) && Body.IsEquivalentTo(other.Body);
9+
return string.Equals(FileName, other.FileName, StringComparison.Ordinal)
10+
&& Body.IsEquivalentTo(other.Body)
11+
&& string.Equals(EndOfLine, other.EndOfLine, StringComparison.Ordinal)
12+
&& string.Equals(Charset, other.Charset, StringComparison.Ordinal);
1013
}
1114

12-
public override int GetHashCode() => HashCode.Combine(FileName, Body.SyntaxTree.Length);
15+
public override int GetHashCode() => HashCode.Combine(FileName, Body.SyntaxTree.Length, EndOfLine, Charset);
1316
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.Diagnostics;
5+
using Microsoft.CodeAnalysis.Text;
6+
7+
namespace Riok.Mapperly.Tests.Generator;
8+
9+
public class EndOfLineTest
10+
{
11+
[Theory]
12+
[InlineData(null, "\r\n")]
13+
[InlineData("lf", "\n")]
14+
[InlineData("crlf", "\r\n")]
15+
[InlineData("cr", "\r")]
16+
public void ShouldRespectEditorConfigEndOfLine(string? endOfLineSetting, string expectedLineEnding)
17+
{
18+
var generatedSource = GenerateSource(endOfLine: endOfLineSetting);
19+
20+
generatedSource.ShouldContain(expectedLineEnding);
21+
22+
// Verify other line endings are NOT present
23+
if (expectedLineEnding != "\r\n")
24+
{
25+
generatedSource.ShouldNotContain("\r\n");
26+
}
27+
28+
var withoutCrlf = generatedSource.Replace("\r\n", "");
29+
if (expectedLineEnding != "\n")
30+
{
31+
withoutCrlf.ShouldNotContain("\n");
32+
}
33+
34+
if (expectedLineEnding != "\r")
35+
{
36+
withoutCrlf.ShouldNotContain("\r");
37+
}
38+
}
39+
40+
[Theory]
41+
[InlineData(null, false)]
42+
[InlineData("utf-8", false)]
43+
[InlineData("utf-8-bom", true)]
44+
public void ShouldRespectEditorConfigCharset(string? charsetSetting, bool expectBom)
45+
{
46+
var generatedText = GenerateSourceText(charset: charsetSetting);
47+
var preamble = generatedText.Encoding?.GetPreamble() ?? [];
48+
49+
preamble.Length.ShouldBe(expectBom ? 3 : 0, $"charset={charsetSetting ?? "(default)"} should {(expectBom ? "" : "not ")}have BOM");
50+
}
51+
52+
private static string GenerateSource(string? endOfLine = null, string? charset = null) =>
53+
GenerateSourceText(endOfLine, charset).ToString();
54+
55+
private static SourceText GenerateSourceText(string? endOfLine = null, string? charset = null)
56+
{
57+
var source = TestSourceBuilder.Mapping("string", "string");
58+
var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default);
59+
var compilation = TestHelper.BuildCompilation(syntaxTree);
60+
var configOptionsProvider = new TestAnalyzerConfigOptionsProvider(endOfLine, charset);
61+
62+
GeneratorDriver driver = CSharpGeneratorDriver.Create(
63+
[new MapperGenerator().AsSourceGenerator()],
64+
optionsProvider: configOptionsProvider
65+
);
66+
driver = driver.RunGenerators(compilation);
67+
68+
return driver.GetRunResult().GeneratedTrees.First().GetText();
69+
}
70+
71+
private class TestAnalyzerConfigOptionsProvider(string? endOfLine, string? charset) : AnalyzerConfigOptionsProvider
72+
{
73+
private readonly TestAnalyzerConfigOptions _fileOptions = new(endOfLine, charset);
74+
75+
// GlobalOptions should NOT contain .editorconfig settings - they're per-file only
76+
public override AnalyzerConfigOptions GlobalOptions => new TestAnalyzerConfigOptions(null, null);
77+
78+
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _fileOptions;
79+
80+
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _fileOptions;
81+
}
82+
83+
private class TestAnalyzerConfigOptions(string? endOfLine, string? charset) : AnalyzerConfigOptions
84+
{
85+
private readonly ImmutableDictionary<string, string> _options = new Dictionary<string, string?>
86+
{
87+
["end_of_line"] = endOfLine,
88+
["charset"] = charset,
89+
}
90+
.Where(x => x.Value != null)
91+
.ToImmutableDictionary(x => x.Key, x => x.Value!);
92+
93+
public override bool TryGetValue(string key, out string value) => _options.TryGetValue(key, out value!);
94+
}
95+
}

0 commit comments

Comments
 (0)