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;