From 122ccf46a841f9e3c1fa8b66ae1471eea90c137b Mon Sep 17 00:00:00 2001 From: Arnaud Van Herck Date: Fri, 9 Oct 2026 09:23:16 +0200 Subject: [PATCH] feat(all): Write consistent line endings, configurable via line-endings Generated files mixed CRLF and LF on macOS and Linux: most lines were written with AppendLine/Environment.NewLine (LF on Unix), while property, enum member and const lines had a hard-coded "\r\n". Repos with `*.ts text eol=crlf` in .gitattributes then saw every generated file as modified. All line breaks are now normalized before the file is written. The default is CRLF, matching what the tool already produced on Windows, so output is identical on every OS. It can be changed with `line-endings = "lf"` in typecontractor.config or `--line-endings lf`. - Add LineEndings enum (Crlf, Lf) and SetLineEndings on the configuration - Normalize output in TypeScriptWriter and ApiClientWriter - Add --line-endings option to the tool - Document the option in README and CHANGELOG --- CHANGELOG.md | 8 ++++ README.md | 5 +++ .../TypeScript/ApiClientWriterTests.cs | 39 +++++++++++++++++++ .../TypeScript/TypeScriptWriterTests.cs | 37 ++++++++++++++++++ TypeContractor.Tool/Generator.cs | 8 +++- TypeContractor.Tool/Program.cs | 11 +++++- TypeContractor/Contractor.cs | 4 +- TypeContractor/Helpers/LineEndingHelpers.cs | 23 +++++++++++ TypeContractor/LineEndings.cs | 14 +++++++ TypeContractor/TypeContractorConfiguration.cs | 12 ++++++ TypeContractor/TypeScript/ApiClientWriter.cs | 4 +- TypeContractor/TypeScript/TypeScriptWriter.cs | 4 +- 12 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 TypeContractor/Helpers/LineEndingHelpers.cs create mode 100644 TypeContractor/LineEndings.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c09b1..380a180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `line-endings` option (`--line-endings`) to choose between `crlf` (default) and `lf` in generated files + +### Fixed + +- Generated files no longer mix CRLF and LF line endings on macOS and Linux + ## [0.22.1] - 2026-08-23 ### Fixed diff --git a/README.md b/README.md index 4227fb4..19658e3 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,13 @@ dotnet-config tool for viewing and changing configuration similar to how root = "~/api" generate-api-clients = true build-zod-schemas = true + line-endings = "lf" # Defaults to "crlf" ``` +Generated files always use the same line endings, regardless of the OS +TypeContractor runs on. The default is `crlf`; set `line-endings = "lf"` (or +pass `--line-endings lf`) to use Unix line endings instead. + ## Run manually Get an instance of `Contractor` and call `contractor.Build();` diff --git a/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs b/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs index 4b5ac97..bf62046 100644 --- a/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs +++ b/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs @@ -52,6 +52,45 @@ public void Can_Write_Basic_Client() .And.Contain("return await response.json();"); } + [Fact] + public void Defaults_To_Crlf_Line_Endings() + { + // Arrange + var apiClient = new ApiClient("TestClient", "TestController", "test", null); + apiClient.AddEndpoint(new ApiClientEndpoint("getLatestId", "latest", EndpointMethod.GET, typeof(Guid), typeof(Guid), false, [], null)); + + // Act + var result = Sut.Write(apiClient, [], _converter, false, _templateFn, Casing.Pascal); + + // Assert + var text = File.ReadAllText(result); + text.Should().Contain("\r\n"); + text.Replace("\r\n", "", StringComparison.Ordinal).Should() + .NotContain("\r") + .And.NotContain("\n"); + } + + [Theory] + [InlineData(LineEndings.Crlf, "\r\n")] + [InlineData(LineEndings.Lf, "\n")] + public void Uses_Configured_Line_Endings(LineEndings lineEndings, string newLine) + { + // Arrange + var apiClient = new ApiClient("TestClient", "TestController", "test", null); + apiClient.AddEndpoint(new ApiClientEndpoint("getLatestId", "latest", EndpointMethod.GET, typeof(Guid), typeof(Guid), false, [], null)); + var sut = new ApiClientWriter(_configuration.OutputPath, "~", lineEndings); + + // Act + var result = sut.Write(apiClient, [], _converter, false, _templateFn, Casing.Pascal); + + // Assert + var text = File.ReadAllText(result); + text.Should().Contain(newLine); + text.Replace(newLine, "", StringComparison.Ordinal).Should() + .NotContain("\r") + .And.NotContain("\n"); + } + [Fact] public void Strips_Trailing_Slash() { diff --git a/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs b/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs index e1c8c98..bb84924 100644 --- a/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs +++ b/TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs @@ -49,6 +49,43 @@ public void Can_Write_Simple_Types() .And.Contain("someObject: any;"); } + [Fact] + public void Defaults_To_Crlf_Line_Endings() + { + // Arrange + var outputTypes = BuildOutputTypes(typeof(SimpleTypes)); + + // Act + var result = Sut.Write(outputTypes.First(), outputTypes, true); + + // Assert + var text = File.ReadAllText(result); + text.Should().Contain("\r\n"); + text.Replace("\r\n", "", StringComparison.Ordinal).Should() + .NotContain("\r") + .And.NotContain("\n"); + } + + [Theory] + [InlineData(LineEndings.Crlf, "\r\n")] + [InlineData(LineEndings.Lf, "\n")] + public void Uses_Configured_Line_Endings(LineEndings lineEndings, string newLine) + { + // Arrange + var outputTypes = BuildOutputTypes(typeof(SimpleTypes)); + var sut = new TypeScriptWriter(_configuration.OutputPath, lineEndings); + + // Act + var result = sut.Write(outputTypes.First(), outputTypes, true); + + // Assert + var text = File.ReadAllText(result); + text.Should().Contain(newLine); + text.Replace(newLine, "", StringComparison.Ordinal).Should() + .NotContain("\r") + .And.NotContain("\n"); + } + [Fact] public void Can_Write_Generic_Types() { diff --git a/TypeContractor.Tool/Generator.cs b/TypeContractor.Tool/Generator.cs index 5cd5308..ae0cc15 100644 --- a/TypeContractor.Tool/Generator.cs +++ b/TypeContractor.Tool/Generator.cs @@ -23,6 +23,7 @@ internal class Generator private readonly bool _generateApiClients; private readonly string _apiClientTemplate; private readonly Casing? _casing; + private readonly LineEndings? _lineEndings; public Generator(string assemblyPath, string[] alsoLookIn, @@ -38,7 +39,8 @@ public Generator(string assemblyPath, bool buildZodSchemas, bool generateApiClients, string apiClientTemplate, - Casing? casing) + Casing? casing, + LineEndings? lineEndings) { _assemblyPath = assemblyPath; _alsoLookIn = alsoLookIn; @@ -55,6 +57,7 @@ public Generator(string assemblyPath, _generateApiClients = generateApiClients; _apiClientTemplate = apiClientTemplate; _casing = casing; + _lineEndings = lineEndings; } public Task Execute(CancellationToken cancellationToken) @@ -239,6 +242,9 @@ private Contractor GenerateContractor(Dictionary> typesT if (_casing is not null) configuration = configuration.SetCasing(_casing.Value); + if (_lineEndings is not null) + configuration = configuration.SetLineEndings(_lineEndings.Value); + return Contractor.WithConfiguration(configuration); } } diff --git a/TypeContractor.Tool/Program.cs b/TypeContractor.Tool/Program.cs index 7af0500..faa9df4 100644 --- a/TypeContractor.Tool/Program.cs +++ b/TypeContractor.Tool/Program.cs @@ -106,6 +106,12 @@ Description = "Casing to use for generated file names", }; +var lineEndingsOptions = new Option("--line-endings") +{ + DefaultValueFactory = (arg) => config.GetEnum("line-endings", LineEndings.Crlf), + Description = "Line endings to use in generated files. Either 'crlf' (default) or 'lf'", +}; + rootCommand.Options.Add(assemblyOption); rootCommand.Options.Add(extraAssemblyOptions); rootCommand.Options.Add(outputOption); @@ -122,6 +128,7 @@ rootCommand.Options.Add(generateApiClientsOptions); rootCommand.Options.Add(apiClientsTemplateOptions); rootCommand.Options.Add(casingOptions); +rootCommand.Options.Add(lineEndingsOptions); apiClientsTemplateOptions.Validators.Add(result => { @@ -161,6 +168,7 @@ var generateApiClientsValue = parseResult.GetValue(generateApiClientsOptions); var apiClientsTemplateValue = parseResult.GetValue(apiClientsTemplateOptions)!; var casingValue = parseResult.GetValue(casingOptions); + var lineEndingsValue = parseResult.GetValue(lineEndingsOptions); Log.Instance = new ConsoleLogger(logLevelValue); var generator = new Generator(assemblyOptionValue, @@ -177,7 +185,8 @@ buildZodSchemasValue, generateApiClientsValue, apiClientsTemplateValue, - casingValue); + casingValue, + lineEndingsValue); return await generator.Execute(cancellationToken); }); diff --git a/TypeContractor/Contractor.cs b/TypeContractor/Contractor.cs index b2f774b..b265867 100644 --- a/TypeContractor/Contractor.cs +++ b/TypeContractor/Contractor.cs @@ -56,7 +56,7 @@ public int Build(MetadataLoadContext? metadataLoadContext = null, bool smartClea var allTypes = new List(); var converter = new TypeScriptConverter(Configuration, metadataLoadContext); - var writer = new TypeScriptWriter(Configuration.OutputPath); + var writer = new TypeScriptWriter(Configuration.OutputPath, Configuration.LineEndings); foreach (var (assemblyName, assemblyPath) in Configuration.Assemblies) { @@ -134,7 +134,7 @@ public int Build(MetadataLoadContext? metadataLoadContext = null, bool smartClea } var templateFn = Handlebars.Compile(template); - var apiWriter = new ApiClientWriter(Configuration.OutputPath, Configuration.RelativeRoot); + var apiWriter = new ApiClientWriter(Configuration.OutputPath, Configuration.RelativeRoot, Configuration.LineEndings); foreach (var client in Configuration.ApiClients) { try diff --git a/TypeContractor/Helpers/LineEndingHelpers.cs b/TypeContractor/Helpers/LineEndingHelpers.cs new file mode 100644 index 0000000..a68c54b --- /dev/null +++ b/TypeContractor/Helpers/LineEndingHelpers.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace TypeContractor.Helpers; + +internal static partial class LineEndingHelpers +{ + [GeneratedRegex(@"\r\n|\r|\n")] + private static partial Regex LineBreakRegex(); + + public static string ToNewLine(this LineEndings lineEndings) => lineEndings switch + { + LineEndings.Crlf => "\r\n", + LineEndings.Lf => "\n", + _ => throw new ArgumentOutOfRangeException(nameof(lineEndings), lineEndings, "Unknown line ending"), + }; + + /// + /// Replaces every line break in with the configured line ending, + /// so generated files are identical regardless of the OS TypeContractor runs on. + /// + public static string NormalizeLineEndings(string input, LineEndings lineEndings) => + LineBreakRegex().Replace(input, lineEndings.ToNewLine()); +} diff --git a/TypeContractor/LineEndings.cs b/TypeContractor/LineEndings.cs new file mode 100644 index 0000000..5089a35 --- /dev/null +++ b/TypeContractor/LineEndings.cs @@ -0,0 +1,14 @@ +namespace TypeContractor; + +public enum LineEndings +{ + /// + /// Windows line endings (\r\n) + /// + Crlf, + + /// + /// Unix line endings (\n) + /// + Lf, +} diff --git a/TypeContractor/TypeContractorConfiguration.cs b/TypeContractor/TypeContractorConfiguration.cs index 8e2b4e6..890b2ad 100644 --- a/TypeContractor/TypeContractorConfiguration.cs +++ b/TypeContractor/TypeContractorConfiguration.cs @@ -18,6 +18,7 @@ public class TypeContractorConfiguration private List _apiClients = []; private string? _apiClientTemplate; private Casing? _casing; + private LineEndings? _lineEndings; public IReadOnlyDictionary TypeMaps => _map; public IReadOnlyList Suffixes => _suffixes.AsReadOnly(); @@ -31,6 +32,7 @@ public class TypeContractorConfiguration public IReadOnlyList ApiClients => _apiClients.AsReadOnly(); public string ApiClientTemplate => _apiClientTemplate ?? "aurelia"; public Casing Casing => _casing ?? Casing.Kebab; + public LineEndings LineEndings => _lineEndings ?? LineEndings.Crlf; /// /// Set up a default configuration using and @@ -292,6 +294,16 @@ public TypeContractorConfiguration SetCasing(Casing casing) return this; } + /// + /// Set the line endings to use in generated files. Default is crlf. + /// + /// The configuration object for continued chaining + public TypeContractorConfiguration SetLineEndings(LineEndings lineEndings) + { + _lineEndings = lineEndings; + return this; + } + /// /// Enable generation of API clients for relevant endpoints. /// diff --git a/TypeContractor/TypeScript/ApiClientWriter.cs b/TypeContractor/TypeScript/ApiClientWriter.cs index 8749e61..97f913e 100644 --- a/TypeContractor/TypeScript/ApiClientWriter.cs +++ b/TypeContractor/TypeScript/ApiClientWriter.cs @@ -9,7 +9,7 @@ namespace TypeContractor.TypeScript; -public partial class ApiClientWriter(string outputPath, string? relativeRoot) +public partial class ApiClientWriter(string outputPath, string? relativeRoot, LineEndings lineEndings = LineEndings.Crlf) { private static readonly Encoding _utf8WithoutBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); private static readonly Dictionary _httpMethods = new() @@ -165,7 +165,7 @@ public string Write(ApiClient apiClient, IEnumerable allTypes, TypeS Directory.CreateDirectory(directory); // Write file - File.WriteAllText(filePath, result.Trim() + Environment.NewLine, _utf8WithoutBom); + File.WriteAllText(filePath, LineEndingHelpers.NormalizeLineEndings(result.Trim(), lineEndings) + lineEndings.ToNewLine(), _utf8WithoutBom); // Return the path we wrote to return filePath; diff --git a/TypeContractor/TypeScript/TypeScriptWriter.cs b/TypeContractor/TypeScript/TypeScriptWriter.cs index f41b542..2727358 100644 --- a/TypeContractor/TypeScript/TypeScriptWriter.cs +++ b/TypeContractor/TypeScript/TypeScriptWriter.cs @@ -6,7 +6,7 @@ namespace TypeContractor.TypeScript; #pragma warning disable CA1305 // Specify IFormatProvider -public class TypeScriptWriter(string outputPath) +public class TypeScriptWriter(string outputPath, LineEndings lineEndings = LineEndings.Crlf) { private static readonly Encoding _utf8WithoutBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); private readonly StringBuilder _builder = new(); @@ -31,7 +31,7 @@ public string Write(OutputType outputType, IEnumerable allTypes, boo Directory.CreateDirectory(directory); // Write file - File.WriteAllText(filePath, _builder.ToString().Trim() + Environment.NewLine, _utf8WithoutBom); + File.WriteAllText(filePath, LineEndingHelpers.NormalizeLineEndings(_builder.ToString().Trim(), lineEndings) + lineEndings.ToNewLine(), _utf8WithoutBom); // Return the path we wrote to return filePath;