From 28c9bc42279f7c5609f8cfedc382119ab0a15eb2 Mon Sep 17 00:00:00 2001 From: Bela VanderVoort Date: Fri, 16 Aug 2024 12:12:46 -0500 Subject: [PATCH] Basics of new override ability by extension, which will also allow other file extensions to be formatted. (#1251) closes #1220 I mostly went with how prettier works for overrides. Using a file glob, a user can specify options to be used for a file. One of those options is which formatter to use. Right now this means non-standard files can be formatted with the csharp formatter. Some day the xml formatting PR will finally get some attention. --------- Co-authored-by: Lasath Fernando --- Src/CSharpier.Cli.Tests/CliTests.cs | 25 +++ Src/CSharpier.Cli/CommandLineFormatter.cs | 80 +++++----- .../EditorConfig/EditorConfigSections.cs | 15 +- Src/CSharpier.Cli/EditorConfig/Globber.cs | 36 +++++ Src/CSharpier.Cli/EditorConfig/Section.cs | 29 +--- Src/CSharpier.Cli/Options/ConfigFileParser.cs | 119 ++++++++++++++ .../Options/ConfigurationFileOptions.cs | 150 +++++------------- Src/CSharpier.Cli/Options/OptionsProvider.cs | 44 ++--- Src/CSharpier.Cli/PublicAPI.Unshipped.txt | 2 +- .../Server/CSharpierServiceImplementation.cs | 8 +- Src/CSharpier.Cli/Server/Status.cs | 3 +- .../Controllers/FormatController.cs | 2 +- .../Cli/Options/ConfigFileParserTests.cs | 62 ++++++++ .../CommandLineFormatterTests.cs | 36 ++++- Src/CSharpier.Tests/OptionsProviderTests.cs | 90 +++++++---- Src/CSharpier/CSharpFormatter.cs | 4 +- Src/CSharpier/CodeFormatter.cs | 10 +- Src/CSharpier/DocPrinter/DocPrinter.cs | 2 +- Src/CSharpier/DocPrinter/Indent.cs | 8 +- Src/CSharpier/PrinterOptions.cs | 5 +- .../SyntaxPrinter/FormattingContext.cs | 1 - docs/Configuration.md | 29 ++++ 22 files changed, 516 insertions(+), 244 deletions(-) create mode 100644 Src/CSharpier.Cli/EditorConfig/Globber.cs create mode 100644 Src/CSharpier.Cli/Options/ConfigFileParser.cs create mode 100644 Src/CSharpier.Tests/Cli/Options/ConfigFileParserTests.cs diff --git a/Src/CSharpier.Cli.Tests/CliTests.cs b/Src/CSharpier.Cli.Tests/CliTests.cs index bce9eaf52..74d1d85e7 100644 --- a/Src/CSharpier.Cli.Tests/CliTests.cs +++ b/Src/CSharpier.Cli.Tests/CliTests.cs @@ -236,6 +236,7 @@ public async Task With_Check_Should_Write_Unformatted_File() result.ExitCode.Should().Be(1); } + // TODO overrides tests for piping files [TestCase("\n")] [TestCase("\r\n")] public async Task Should_Format_Multiple_Piped_Files(string lineEnding) @@ -312,6 +313,30 @@ public async Task Should_Support_Config_With_Multiple_Piped_Files() result.Output.TrimEnd('\u0003').Should().Be("var myVariable =\n someLongValue;\n"); } + [Test] + public async Task Should_Support_Override_Config_With_Multiple_Piped_Files() + { + const string fileContent = "var myVariable = someLongValue;"; + var fileName = Path.Combine(testFileDirectory, "TooWide.cst"); + await this.WriteFileAsync( + ".csharpierrc", + """ + overrides: + - files: "*.cst" + formatter: "csharp" + printWidth: 10 + """ + ); + + var result = await new CsharpierProcess() + .WithArguments("--pipe-multiple-files") + .WithPipedInput($"{fileName}{'\u0003'}{fileContent}{'\u0003'}") + .ExecuteAsync(); + + result.ErrorOutput.Should().BeEmpty(); + result.Output.TrimEnd('\u0003').Should().Be("var myVariable =\n someLongValue;\n"); + } + [Test] public async Task Should_Not_Fail_On_Empty_File() { diff --git a/Src/CSharpier.Cli/CommandLineFormatter.cs b/Src/CSharpier.Cli/CommandLineFormatter.cs index e6a3e7878..1b80ef17c 100644 --- a/Src/CSharpier.Cli/CommandLineFormatter.cs +++ b/Src/CSharpier.Cli/CommandLineFormatter.cs @@ -65,18 +65,21 @@ CancellationToken cancellationToken ); var printerOptions = optionsProvider.GetPrinterOptionsFor(filePath); - printerOptions.IncludeGenerated = commandLineOptions.IncludeGenerated; - - await PerformFormattingSteps( - fileToFormatInfo, - new StdOutFormattedFileWriter(console), - commandLineFormatterResult, - fileIssueLogger, - printerOptions, - commandLineOptions, - FormattingCacheFactory.NullCache, - cancellationToken - ); + if (printerOptions is not null) + { + printerOptions.IncludeGenerated = commandLineOptions.IncludeGenerated; + + await PerformFormattingSteps( + fileToFormatInfo, + new StdOutFormattedFileWriter(console), + commandLineFormatterResult, + fileIssueLogger, + printerOptions, + commandLineOptions, + FormattingCacheFactory.NullCache, + cancellationToken + ); + } } } else @@ -193,7 +196,11 @@ CancellationToken cancellationToken } } - async Task FormatFile(string actualFilePath, string originalFilePath) + async Task FormatFile( + string actualFilePath, + string originalFilePath, + bool warnForUnsupported = false + ) { if ( ( @@ -206,25 +213,33 @@ async Task FormatFile(string actualFilePath, string originalFilePath) } var printerOptions = optionsProvider.GetPrinterOptionsFor(actualFilePath); - printerOptions.IncludeGenerated = commandLineOptions.IncludeGenerated; - await FormatPhysicalFile( - actualFilePath, - originalFilePath, - fileSystem, - logger, - commandLineFormatterResult, - writer, - commandLineOptions, - printerOptions, - formattingCache, - cancellationToken - ); + if (printerOptions is not null) + { + printerOptions.IncludeGenerated = commandLineOptions.IncludeGenerated; + await FormatPhysicalFile( + actualFilePath, + originalFilePath, + fileSystem, + logger, + commandLineFormatterResult, + writer, + commandLineOptions, + printerOptions, + formattingCache, + cancellationToken + ); + } + else if (warnForUnsupported) + { + var fileIssueLogger = new FileIssueLogger(originalFilePath, logger); + fileIssueLogger.WriteWarning("Is an unsupported file type."); + } } if (isFile) { - await FormatFile(directoryOrFilePath, originalDirectoryOrFile); + await FormatFile(directoryOrFilePath, originalDirectoryOrFile, true); } else if (isDirectory) { @@ -246,7 +261,6 @@ await FormatPhysicalFile( "*.*", SearchOption.AllDirectories ) - .Where(o => o.EndsWithIgnoreCase(".cs") || o.EndsWithIgnoreCase(".csx")) .Select(o => { var normalizedPath = o.Replace("\\", "/"); @@ -296,16 +310,6 @@ CancellationToken cancellationToken var fileIssueLogger = new FileIssueLogger(originalFilePath, logger); - if ( - !actualFilePath.EndsWithIgnoreCase(".cs") - && !actualFilePath.EndsWithIgnoreCase(".cst") - && !actualFilePath.EndsWithIgnoreCase(".csx") - ) - { - fileIssueLogger.WriteWarning("Is an unsupported file type."); - return; - } - logger.LogDebug( commandLineOptions.Check ? $"Checking - {originalFilePath}" diff --git a/Src/CSharpier.Cli/EditorConfig/EditorConfigSections.cs b/Src/CSharpier.Cli/EditorConfig/EditorConfigSections.cs index 8c304cfd4..e7d14ccc2 100644 --- a/Src/CSharpier.Cli/EditorConfig/EditorConfigSections.cs +++ b/Src/CSharpier.Cli/EditorConfig/EditorConfigSections.cs @@ -9,11 +9,17 @@ internal class EditorConfigSections public required string DirectoryName { get; init; } public required IReadOnlyCollection
SectionsIncludingParentFiles { get; init; } - public PrinterOptions ConvertToPrinterOptions(string filePath) + public PrinterOptions? ConvertToPrinterOptions(string filePath) { + if (!(filePath.EndsWith(".cs") || filePath.EndsWith(".csx"))) + { + return null; + } + var sections = this.SectionsIncludingParentFiles.Where(o => o.IsMatch(filePath)).ToList(); var resolvedConfiguration = new ResolvedConfiguration(sections); - var printerOptions = new PrinterOptions(); + + var printerOptions = new PrinterOptions { Formatter = "csharp" }; if (resolvedConfiguration.MaxLineLength is { } maxLineLength) { @@ -27,11 +33,12 @@ public PrinterOptions ConvertToPrinterOptions(string filePath) if (printerOptions.UseTabs) { - printerOptions.TabWidth = resolvedConfiguration.TabWidth ?? printerOptions.TabWidth; + printerOptions.IndentSize = resolvedConfiguration.TabWidth ?? printerOptions.IndentSize; } else { - printerOptions.TabWidth = resolvedConfiguration.IndentSize ?? printerOptions.TabWidth; + printerOptions.IndentSize = + resolvedConfiguration.IndentSize ?? printerOptions.IndentSize; } if (resolvedConfiguration.EndOfLine is { } endOfLine) diff --git a/Src/CSharpier.Cli/EditorConfig/Globber.cs b/Src/CSharpier.Cli/EditorConfig/Globber.cs new file mode 100644 index 000000000..3ef3c2879 --- /dev/null +++ b/Src/CSharpier.Cli/EditorConfig/Globber.cs @@ -0,0 +1,36 @@ +namespace CSharpier.Cli.EditorConfig; + +internal static class Globber +{ + private static readonly GlobMatcherOptions globOptions = + new() + { + MatchBase = true, + Dot = true, + AllowWindowsPaths = true, + AllowSingleBraceSets = true, + }; + + public static GlobMatcher Create(string files, string directory) + { + var pattern = FixGlob(files, directory); + return GlobMatcher.Create(pattern, globOptions); + } + + private static string FixGlob(string glob, string directory) + { + glob = glob.IndexOf('/') switch + { + -1 => "**/" + glob, + 0 => glob[1..], + _ => glob + }; + directory = directory.Replace(@"\", "/"); + if (!directory.EndsWith("/")) + { + directory += "/"; + } + + return directory + glob; + } +} diff --git a/Src/CSharpier.Cli/EditorConfig/Section.cs b/Src/CSharpier.Cli/EditorConfig/Section.cs index 33a137ea7..50f98ed7f 100644 --- a/Src/CSharpier.Cli/EditorConfig/Section.cs +++ b/Src/CSharpier.Cli/EditorConfig/Section.cs @@ -4,15 +4,6 @@ namespace CSharpier.Cli.EditorConfig; internal class Section { - private static readonly GlobMatcherOptions globOptions = - new() - { - MatchBase = true, - Dot = true, - AllowWindowsPaths = true, - AllowSingleBraceSets = true, - }; - private readonly GlobMatcher matcher; public string? IndentStyle { get; } @@ -23,8 +14,7 @@ internal class Section public Section(SectionData section, string directory) { - var pattern = FixGlob(section.SectionName, directory); - this.matcher = GlobMatcher.Create(pattern, globOptions); + this.matcher = Globber.Create(section.SectionName, directory); this.IndentStyle = section.Keys["indent_style"]; this.IndentSize = section.Keys["indent_size"]; this.TabWidth = section.Keys["tab_width"]; @@ -36,21 +26,4 @@ public bool IsMatch(string fileName) { return this.matcher.IsMatch(fileName); } - - private static string FixGlob(string glob, string directory) - { - glob = glob.IndexOf('/') switch - { - -1 => "**/" + glob, - 0 => glob[1..], - _ => glob - }; - directory = directory.Replace(@"\", "/"); - if (!directory.EndsWith("/")) - { - directory += "/"; - } - - return directory + glob; - } } diff --git a/Src/CSharpier.Cli/Options/ConfigFileParser.cs b/Src/CSharpier.Cli/Options/ConfigFileParser.cs new file mode 100644 index 000000000..5591d2f4a --- /dev/null +++ b/Src/CSharpier.Cli/Options/ConfigFileParser.cs @@ -0,0 +1,119 @@ +namespace CSharpier.Cli.Options; + +using System.IO.Abstractions; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +internal static class ConfigFileParser +{ + private static readonly string[] validExtensions = { ".csharpierrc", ".json", ".yml", ".yaml" }; + + /// Finds all configs above the given directory as well as within the subtree of this directory + internal static List FindForDirectoryName( + string directoryName, + IFileSystem fileSystem, + ILogger logger, + bool limitEditorConfigSearch + ) + { + var results = new List(); + var directoryInfo = fileSystem.DirectoryInfo.New(directoryName); + + var filesByDirectory = directoryInfo + .EnumerateFiles( + ".csharpierrc*", + limitEditorConfigSearch + ? SearchOption.TopDirectoryOnly + : SearchOption.AllDirectories + ) + .GroupBy(o => o.DirectoryName); + + foreach (var group in filesByDirectory) + { + var firstFile = group + .Where(o => validExtensions.Contains(o.Extension, StringComparer.OrdinalIgnoreCase)) + .MinBy(o => o.Extension); + + if (firstFile != null) + { + results.Add( + new CSharpierConfigData( + firstFile.DirectoryName!, + Create(firstFile.FullName, fileSystem, logger) + ) + ); + } + } + + // already found any in this directory above + directoryInfo = directoryInfo.Parent; + + while (directoryInfo is not null) + { + var file = directoryInfo + .EnumerateFiles(".csharpierrc*", SearchOption.TopDirectoryOnly) + .Where(o => validExtensions.Contains(o.Extension, StringComparer.OrdinalIgnoreCase)) + .MinBy(o => o.Extension); + + if (file != null) + { + results.Add( + new CSharpierConfigData( + file.DirectoryName!, + Create(file.FullName, fileSystem, logger) + ) + ); + } + + directoryInfo = directoryInfo.Parent; + } + + return results.OrderByDescending(o => o.DirectoryName.Length).ToList(); + } + + internal static ConfigurationFileOptions Create( + string configPath, + IFileSystem fileSystem, + ILogger? logger = null + ) + { + var directoryName = fileSystem.Path.GetDirectoryName(configPath)!; + var content = fileSystem.File.ReadAllText(configPath); + + if (!string.IsNullOrWhiteSpace(content)) + { + var configFile = CreateFromContent(content); + configFile.Init(directoryName); + return configFile; + } + + logger?.LogWarning("The configuration file at " + configPath + " was empty."); + + return new(); + } + + internal static ConfigurationFileOptions CreateFromContent(string content) + { + return content.TrimStart().StartsWith("{") ? ReadJson(content) : ReadYaml(content); + } + + private static ConfigurationFileOptions ReadJson(string contents) + { + return JsonSerializer.Deserialize( + contents, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ) ?? new(); + } + + private static ConfigurationFileOptions ReadYaml(string contents) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + return deserializer.Deserialize(contents); + } +} diff --git a/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs b/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs index e5ea7888b..e6cb1a16f 100644 --- a/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs +++ b/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs @@ -1,11 +1,7 @@ namespace CSharpier.Cli.Options; -using System.IO.Abstractions; -using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; +using CSharpier.Cli.EditorConfig; internal class ConfigurationFileOptions { @@ -16,128 +12,70 @@ internal class ConfigurationFileOptions [JsonConverter(typeof(CaseInsensitiveEnumConverter))] public EndOfLine EndOfLine { get; init; } - private static readonly string[] validExtensions = { ".csharpierrc", ".json", ".yml", ".yaml" }; + public Override[] Overrides { get; init; } = []; - internal static PrinterOptions CreatePrinterOptionsFromPath( - string configPath, - IFileSystem fileSystem, - ILogger logger - ) + public PrinterOptions? ConvertToPrinterOptions(string filePath) { - var configurationFileOptions = Create(configPath, fileSystem, logger); - - return ConvertToPrinterOptions(configurationFileOptions); - } - - internal static PrinterOptions ConvertToPrinterOptions( - ConfigurationFileOptions configurationFileOptions - ) - { - return new PrinterOptions - { - TabWidth = configurationFileOptions.TabWidth, - UseTabs = configurationFileOptions.UseTabs, - Width = configurationFileOptions.PrintWidth, - EndOfLine = configurationFileOptions.EndOfLine - }; - } - - /// Finds all configs above the given directory as well as within the subtree of this directory - internal static List FindForDirectoryName( - string directoryName, - IFileSystem fileSystem, - ILogger logger, - bool limitEditorConfigSearch - ) - { - var results = new List(); - var directoryInfo = fileSystem.DirectoryInfo.New(directoryName); - - var filesByDirectory = directoryInfo - .EnumerateFiles( - ".csharpierrc*", - limitEditorConfigSearch - ? SearchOption.TopDirectoryOnly - : SearchOption.AllDirectories - ) - .GroupBy(o => o.DirectoryName); - - foreach (var group in filesByDirectory) + DebugLogger.Log("finding options for " + filePath); + var matchingOverride = this.Overrides.LastOrDefault(o => o.IsMatch(filePath)); + if (matchingOverride is not null) { - var firstFile = group - .Where(o => validExtensions.Contains(o.Extension, StringComparer.OrdinalIgnoreCase)) - .MinBy(o => o.Extension); - - if (firstFile != null) + return new PrinterOptions { - results.Add( - new CSharpierConfigData( - firstFile.DirectoryName!, - Create(firstFile.FullName, fileSystem, logger) - ) - ); - } + IndentSize = matchingOverride.TabWidth, + UseTabs = matchingOverride.UseTabs, + Width = matchingOverride.PrintWidth, + EndOfLine = matchingOverride.EndOfLine, + Formatter = matchingOverride.Formatter + }; } - // already found any in this directory above - directoryInfo = directoryInfo.Parent; - - while (directoryInfo is not null) + if (filePath.EndsWith(".cs") || filePath.EndsWith(".csx")) { - var file = directoryInfo - .EnumerateFiles(".csharpierrc*", SearchOption.TopDirectoryOnly) - .Where(o => validExtensions.Contains(o.Extension, StringComparer.OrdinalIgnoreCase)) - .MinBy(o => o.Extension); - - if (file != null) + return new PrinterOptions { - results.Add( - new CSharpierConfigData( - file.DirectoryName!, - Create(file.FullName, fileSystem, logger) - ) - ); - } - - directoryInfo = directoryInfo.Parent; + IndentSize = this.TabWidth, + UseTabs = this.UseTabs, + Width = this.PrintWidth, + EndOfLine = this.EndOfLine, + Formatter = "csharp" + }; } - return results.OrderByDescending(o => o.DirectoryName.Length).ToList(); + return null; } - private static ConfigurationFileOptions Create( - string configPath, - IFileSystem fileSystem, - ILogger? logger = null - ) + public void Init(string directory) { - var contents = fileSystem.File.ReadAllText(configPath); - - if (!string.IsNullOrWhiteSpace(contents)) + foreach (var thing in this.Overrides) { - return contents.TrimStart().StartsWith("{") ? ReadJson(contents) : ReadYaml(contents); + thing.Init(directory); } + } +} - logger?.LogWarning("The configuration file at " + configPath + " was empty."); +internal class Override +{ + private GlobMatcher? matcher; - return new(); - } + public int PrintWidth { get; init; } = 100; + public int TabWidth { get; init; } = 4; + public bool UseTabs { get; init; } + + [JsonConverter(typeof(CaseInsensitiveEnumConverter))] + public EndOfLine EndOfLine { get; init; } - private static ConfigurationFileOptions ReadJson(string contents) + public string Files { get; init; } = string.Empty; + + public string Formatter { get; init; } = string.Empty; + + public void Init(string directory) { - return JsonSerializer.Deserialize( - contents, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true } - ) ?? new(); + this.matcher = Globber.Create(this.Files, directory); } - private static ConfigurationFileOptions ReadYaml(string contents) + public bool IsMatch(string fileName) { - var deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - return deserializer.Deserialize(contents); + return this.matcher?.IsMatch(fileName) ?? false; } } diff --git a/Src/CSharpier.Cli/Options/OptionsProvider.cs b/Src/CSharpier.Cli/Options/OptionsProvider.cs index 3cd7b0e79..2874bd609 100644 --- a/Src/CSharpier.Cli/Options/OptionsProvider.cs +++ b/Src/CSharpier.Cli/Options/OptionsProvider.cs @@ -3,30 +3,28 @@ namespace CSharpier.Cli.Options; using System.IO.Abstractions; using System.Text.Json; using CSharpier.Cli.EditorConfig; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.Extensions.Logging; -using PrinterOptions = CSharpier.PrinterOptions; internal class OptionsProvider { private readonly IList editorConfigs; private readonly List csharpierConfigs; private readonly IgnoreFile ignoreFile; - private readonly PrinterOptions? specifiedPrinterOptions; + private readonly ConfigurationFileOptions? specifiedConfigFile; private readonly IFileSystem fileSystem; private OptionsProvider( IList editorConfigs, List csharpierConfigs, IgnoreFile ignoreFile, - PrinterOptions? specifiedPrinterOptions, + ConfigurationFileOptions? specifiedPrinterOptions, IFileSystem fileSystem ) { this.editorConfigs = editorConfigs; this.csharpierConfigs = csharpierConfigs; this.ignoreFile = ignoreFile; - this.specifiedPrinterOptions = specifiedPrinterOptions; + this.specifiedConfigFile = specifiedPrinterOptions; this.fileSystem = fileSystem; } @@ -39,18 +37,18 @@ public static async Task Create( bool limitConfigSearch = false ) { - var specifiedPrinterOptions = configPath is not null - ? ConfigurationFileOptions.CreatePrinterOptionsFromPath(configPath, fileSystem, logger) + var specifiedConfigFile = configPath is not null + ? ConfigFileParser.Create(configPath, fileSystem, logger) : null; var csharpierConfigs = configPath is null - ? ConfigurationFileOptions.FindForDirectoryName( + ? ConfigFileParser.FindForDirectoryName( directoryName, fileSystem, logger, limitConfigSearch ) - : Array.Empty().ToList(); + : []; IList? editorConfigSections = null; @@ -78,16 +76,16 @@ public static async Task Create( editorConfigSections ?? Array.Empty(), csharpierConfigs, ignoreFile, - specifiedPrinterOptions, + specifiedConfigFile, fileSystem ); } - public PrinterOptions GetPrinterOptionsFor(string filePath) + public PrinterOptions? GetPrinterOptionsFor(string filePath) { - if (this.specifiedPrinterOptions is not null) + if (this.specifiedConfigFile is not null) { - return this.specifiedPrinterOptions; + return this.specifiedConfigFile.ConvertToPrinterOptions(filePath); } var directoryName = this.fileSystem.Path.GetDirectoryName(filePath); @@ -101,19 +99,23 @@ public PrinterOptions GetPrinterOptionsFor(string filePath) directoryName.StartsWith(o.DirectoryName) ); - if (resolvedEditorConfig is null && resolvedCSharpierConfig is null) + if (resolvedCSharpierConfig is not null) { - return new PrinterOptions(); + return resolvedCSharpierConfig.CSharpierConfig.ConvertToPrinterOptions(filePath); } - if (resolvedCSharpierConfig is not null) + if (resolvedEditorConfig is not null) { - return ConfigurationFileOptions.ConvertToPrinterOptions( - resolvedCSharpierConfig!.CSharpierConfig - ); + DebugLogger.Log("has editorconfig"); + return resolvedEditorConfig.ConvertToPrinterOptions(filePath); + } + + if (filePath.EndsWith(".cs") || filePath.EndsWith(".csx")) + { + return new PrinterOptions { Formatter = "csharp" }; } - return resolvedEditorConfig!.ConvertToPrinterOptions(filePath); + return null; } public bool IsIgnored(string actualFilePath) @@ -126,7 +128,7 @@ public string Serialize() return JsonSerializer.Serialize( new { - specified = this.specifiedPrinterOptions, + specified = this.specifiedConfigFile, csharpierConfigs = this.csharpierConfigs, editorConfigs = this.editorConfigs } diff --git a/Src/CSharpier.Cli/PublicAPI.Unshipped.txt b/Src/CSharpier.Cli/PublicAPI.Unshipped.txt index 5f282702b..9ec71bf90 100644 --- a/Src/CSharpier.Cli/PublicAPI.Unshipped.txt +++ b/Src/CSharpier.Cli/PublicAPI.Unshipped.txt @@ -1 +1 @@ - \ No newline at end of file +CSharpier.Cli.Server.Status.UnsupportedFile = 3 -> CSharpier.Cli.Server.Status \ No newline at end of file diff --git a/Src/CSharpier.Cli/Server/CSharpierServiceImplementation.cs b/Src/CSharpier.Cli/Server/CSharpierServiceImplementation.cs index ee1364f39..22f156ddd 100644 --- a/Src/CSharpier.Cli/Server/CSharpierServiceImplementation.cs +++ b/Src/CSharpier.Cli/Server/CSharpierServiceImplementation.cs @@ -41,9 +41,15 @@ CancellationToken cancellationToken return new FormatFileResult(Status.Ignored); } + var printerOptions = optionsProvider.GetPrinterOptionsFor(formatFileParameter.fileName); + if (printerOptions == null) + { + return new FormatFileResult(Status.UnsupportedFile); + } + var result = await CSharpFormatter.FormatAsync( formatFileParameter.fileContents, - optionsProvider.GetPrinterOptionsFor(formatFileParameter.fileName), + printerOptions, cancellationToken ); diff --git a/Src/CSharpier.Cli/Server/Status.cs b/Src/CSharpier.Cli/Server/Status.cs index 0607ebd2a..d2fefb166 100644 --- a/Src/CSharpier.Cli/Server/Status.cs +++ b/Src/CSharpier.Cli/Server/Status.cs @@ -4,5 +4,6 @@ public enum Status { Formatted, Ignored, - Failed + Failed, + UnsupportedFile } diff --git a/Src/CSharpier.Playground/Controllers/FormatController.cs b/Src/CSharpier.Playground/Controllers/FormatController.cs index 5920d34da..2fd4c262c 100644 --- a/Src/CSharpier.Playground/Controllers/FormatController.cs +++ b/Src/CSharpier.Playground/Controllers/FormatController.cs @@ -68,7 +68,7 @@ CancellationToken cancellationToken IncludeAST = true, IncludeDocTree = true, Width = model.PrintWidth, - TabWidth = model.IndentSize, + IndentSize = model.IndentSize, UseTabs = model.UseTabs }, sourceCodeKind, diff --git a/Src/CSharpier.Tests/Cli/Options/ConfigFileParserTests.cs b/Src/CSharpier.Tests/Cli/Options/ConfigFileParserTests.cs new file mode 100644 index 000000000..8dbf3cd1f --- /dev/null +++ b/Src/CSharpier.Tests/Cli/Options/ConfigFileParserTests.cs @@ -0,0 +1,62 @@ +namespace CSharpier.Tests.Cli.Options; + +using CSharpier.Cli.Options; +using FluentAssertions; +using NUnit.Framework; + +[TestFixture] +public class ConfigFileParserTests +{ + [Test] + public void Should_Parse_Yaml_With_Overrides() + { + var options = ConfigFileParser.CreateFromContent( + """ + overrides: + - files: "*.cst" + formatter: "csharp" + tabWidth: 2 + useTabs: true + printWidth: 10 + endOfLine: "LF" + """ + ); + + options.Overrides.Should().HaveCount(1); + options.Overrides.First().Files.Should().Be("*.cst"); + options.Overrides.First().Formatter.Should().Be("csharp"); + options.Overrides.First().TabWidth.Should().Be(2); + options.Overrides.First().UseTabs.Should().Be(true); + options.Overrides.First().PrintWidth.Should().Be(10); + options.Overrides.First().EndOfLine.Should().Be(EndOfLine.LF); + } + + [Test] + public void Should_Parse_Json_With_Overrides() + { + var options = ConfigFileParser.CreateFromContent( + """ + { + "overrides": [ + { + "files": "*.cst", + "formatter": "csharp", + "tabWidth": 2, + "useTabs": true, + "printWidth": 10, + "endOfLine": "LF" + } + ] + } + """ + ); + + options.Overrides.Should().HaveCount(1); + options.Overrides.First().Files.Should().Be("*.cst"); + options.Overrides.First().Formatter.Should().Be("csharp"); + options.Overrides.First().TabWidth.Should().Be(2); + options.Overrides.First().UseTabs.Should().Be(true); + options.Overrides.First().PrintWidth.Should().Be(10); + options.Overrides.First().EndOfLine.Should().Be(EndOfLine.LF); + } +} diff --git a/Src/CSharpier.Tests/CommandLineFormatterTests.cs b/Src/CSharpier.Tests/CommandLineFormatterTests.cs index d5498d78c..94c5748dc 100644 --- a/Src/CSharpier.Tests/CommandLineFormatterTests.cs +++ b/Src/CSharpier.Tests/CommandLineFormatterTests.cs @@ -106,14 +106,25 @@ public void Format_Writes_Unsupported() result .OutputLines.First() .Should() - .Be(@"Warning ./Unsupported.js - Is an unsupported file type."); + .Be("Warning ./Unsupported.js - Is an unsupported file type."); + } + + [Test] + public void Format_Does_Not_Write_Unsupported_When_Formatting_Directory() + { + var context = new TestContext(); + context.WhenAFileExists("Unsupported.js", "asdfasfasdf"); + + var result = this.Format(context); + + result.OutputLines.First().Should().StartWith("Formatted 0 files"); } [Test] public void Format_Writes_File_With_Directory_Path() { var context = new TestContext(); - var unformattedFilePath = $"Unformatted.cs"; + var unformattedFilePath = "Unformatted.cs"; context.WhenAFileExists(unformattedFilePath, UnformattedClassContent); this.Format(context); @@ -153,6 +164,27 @@ public static void Run() { } ); } + [Test] + public void Formats_Overrides_File() + { + var context = new TestContext(); + var unformattedFilePath = "Unformatted.cst"; + context.WhenAFileExists(unformattedFilePath, UnformattedClassContent); + context.WhenAFileExists( + ".csharpierrc", + """ + overrides: + - files: "*.cst" + formatter: "csharp" + """ + ); + + var result = this.Format(context); + result.OutputLines.First().Should().StartWith("Formatted 1 files"); + + context.GetFileContent(unformattedFilePath).Should().Be(FormattedClassContent); + } + [TestCase("0.9.0", false)] [TestCase("9999.0.0", false)] [TestCase("current", true)] diff --git a/Src/CSharpier.Tests/OptionsProviderTests.cs b/Src/CSharpier.Tests/OptionsProviderTests.cs index f5ff96962..00dfe6354 100644 --- a/Src/CSharpier.Tests/OptionsProviderTests.cs +++ b/Src/CSharpier.Tests/OptionsProviderTests.cs @@ -22,7 +22,7 @@ public async Task Should_Return_Default_Options_With_Empty_Json() } [Test] - public async Task Should_Return_Default_Options_With_No_File() + public async Task Should_Return_Default_Options_With_No_File_And_Known_Extension() { var context = new TestContext(); var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); @@ -30,6 +30,16 @@ public async Task Should_Return_Default_Options_With_No_File() ShouldHaveDefaultOptions(result); } + [Test] + public async Task Should_Return_Default_Options_With_No_File_And_Unknown_Extension() + { + var context = new TestContext(); + var result = async () => + await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cst"); + + await result.Should().ThrowAsync(); + } + [TestCase(".csharpierrc")] [TestCase(".csharpierrc.json")] [TestCase(".csharpierrc.yaml")] @@ -145,7 +155,7 @@ public async Task Should_Return_TabWidth_With_Json() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(10); + result.IndentSize.Should().Be(10); } [Test] @@ -178,7 +188,7 @@ public async Task Should_Return_TabWidth_With_Yaml() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(10); + result.IndentSize.Should().Be(10); } [Test] @@ -192,6 +202,25 @@ public async Task Should_Return_UseTabs_With_Yaml() result.UseTabs.Should().BeTrue(); } + [Test] + public async Task Should_Return_TabWidth_For_Overrid() + { + var context = new TestContext(); + context.WhenAFileExists( + "c:/test/.csharpierrc", + """ + overrides: + - files: "*.cst" + formatter: "csharp" + tabWidth: 2 + """ + ); + + var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cst"); + + result.IndentSize.Should().Be(2); + } + [Test] public async Task Should_Support_EditorConfig_Basic() { @@ -210,7 +239,7 @@ public async Task Should_Support_EditorConfig_Basic() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); result.UseTabs.Should().BeFalse(); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); result.Width.Should().Be(10); result.EndOfLine.Should().Be(EndOfLine.CRLF); } @@ -239,7 +268,7 @@ public async Task Should_Support_EditorConfig_With_Comments() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); result.UseTabs.Should().BeFalse(); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); result.Width.Should().Be(10); result.EndOfLine.Should().Be(EndOfLine.CRLF); } @@ -261,7 +290,7 @@ public async Task Should_Support_EditorConfig_With_Duplicated_Sections() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(4); + result.IndentSize.Should().Be(4); } [Test] @@ -279,7 +308,7 @@ public async Task Should_Support_EditorConfig_With_Duplicated_Rules() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(4); + result.IndentSize.Should().Be(4); } [Test] @@ -296,7 +325,7 @@ public async Task Should_Not_Fail_With_Bad_EditorConfig() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(4); + result.IndentSize.Should().Be(4); } [TestCase("tab_width")] @@ -316,7 +345,7 @@ public async Task Should_Support_EditorConfig_Tabs(string propertyName) var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); result.UseTabs.Should().BeTrue(); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); } [Test] @@ -336,7 +365,7 @@ public async Task Should_Support_EditorConfig_Tabs_With_Tab_Width() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); result.UseTabs.Should().BeTrue(); - result.TabWidth.Should().Be(3); + result.IndentSize.Should().Be(3); } [Test] @@ -354,7 +383,7 @@ public async Task Should_Support_EditorConfig_Tabs_With_Indent_Size_Tab() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(3); + result.IndentSize.Should().Be(3); } [Test] @@ -382,7 +411,7 @@ public async Task Should_Support_EditorConfig_Tabs_With_Multiple_Files() "c:/test/subfolder", "c:/test/subfolder/test.cs" ); - result.TabWidth.Should().Be(1); + result.IndentSize.Should().Be(1); result.Width.Should().Be(10); } @@ -410,7 +439,7 @@ public async Task Should_Support_EditorConfig_Tabs_With_Multiple_Files_And_Unset "c:/test/subfolder", "c:/test/subfolder/test.cs" ); - result.TabWidth.Should().Be(4); + result.IndentSize.Should().Be(4); } [Test] @@ -458,7 +487,7 @@ public async Task Should_Support_EditorConfig_Tabs_With_Globs() ); var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); } [Test] @@ -477,7 +506,7 @@ public async Task Should_Support_EditorConfig_Tabs_With_Glob_Braces() ); var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); } [Test] @@ -496,7 +525,7 @@ public async Task Should_Support_EditorConfig_Tabs_With_Glob_Braces_Multiples() ); var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); } [Test] @@ -515,7 +544,7 @@ public async Task Should_Find_EditorConfig_In_Parent_Directory() "c:/test/subfolder", "c:/test/subfolder/test.cs" ); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); } [Test] @@ -535,7 +564,7 @@ public async Task Should_Prefer_CSharpierrc_In_SameFolder() "c:/test/subfolder", "c:/test/test.cs" ); - result.TabWidth.Should().Be(1); + result.IndentSize.Should().Be(1); } [Test] @@ -555,7 +584,7 @@ public async Task Should_Not_Prefer_Closer_EditorConfig() "c:/test", "c:/test/subfolder/test.cs" ); - result.TabWidth.Should().Be(1); + result.IndentSize.Should().Be(1); } [Test] @@ -573,7 +602,7 @@ public async Task Should_Ignore_Invalid_EditorConfig_Lines() var result = await context.CreateProviderAndGetOptionsFor("c:/test", "c:/test/test.cs"); - result.TabWidth.Should().Be(2); + result.IndentSize.Should().Be(2); } [Test] @@ -602,7 +631,7 @@ public async Task Should_Ignore_Ignored_EditorConfig() "c:/test", "c:/test/subfolder/test.cs" ); - result.TabWidth.Should().Be(1); + result.IndentSize.Should().Be(1); } [Test] @@ -622,7 +651,7 @@ public async Task Should_Prefer_Closer_CSharpierrc() "c:/test", "c:/test/subfolder/test.cs" ); - result.TabWidth.Should().Be(1); + result.IndentSize.Should().Be(1); } [Test] @@ -644,7 +673,7 @@ public async Task Should_Not_Look_For_Subfolders_EditorConfig_When_Limited() "c:/test/subfolder/test.cs", limitEditorConfigSearch: true ); - result.TabWidth.Should().Be(4); + result.IndentSize.Should().Be(4); } [Test] @@ -666,7 +695,7 @@ public async Task Should_Not_Look_For_Subfolders_CSharpierRc_When_Limited() "c:/test/subfolder/test.cs", limitEditorConfigSearch: true ); - result.TabWidth.Should().Be(4); + result.IndentSize.Should().Be(4); } [Test] @@ -686,13 +715,13 @@ public async Task Should_Look_For_Subfolders_When_Limited() "c:/test/subfolder/test.cs", limitEditorConfigSearch: true ); - result.TabWidth.Should().Be(1); + result.IndentSize.Should().Be(1); } private static void ShouldHaveDefaultOptions(PrinterOptions printerOptions) { printerOptions.Width.Should().Be(100); - printerOptions.TabWidth.Should().Be(4); + printerOptions.IndentSize.Should().Be(4); printerOptions.UseTabs.Should().BeFalse(); printerOptions.EndOfLine.Should().Be(EndOfLine.Auto); } @@ -733,7 +762,14 @@ public async Task CreateProviderAndGetOptionsFor( limitEditorConfigSearch ); - return provider.GetPrinterOptionsFor(filePath); + var printerOptions = provider.GetPrinterOptionsFor(filePath); + + if (printerOptions is null) + { + throw new Exception("PrinterOptions was null"); + } + + return printerOptions; } } } diff --git a/Src/CSharpier/CSharpFormatter.cs b/Src/CSharpier/CSharpFormatter.cs index 59e3942ff..76cc54c6a 100644 --- a/Src/CSharpier/CSharpFormatter.cs +++ b/Src/CSharpier/CSharpFormatter.cs @@ -123,7 +123,7 @@ bool TryGetCompilationFailure(out CodeFormatterResult compilationResult) var formattingContext = new FormattingContext { LineEnding = lineEnding, - IndentSize = printerOptions.TabWidth, + IndentSize = printerOptions.IndentSize, UseTabs = printerOptions.UseTabs, }; var document = Node.Print(rootNode, formattingContext); @@ -143,7 +143,7 @@ bool TryGetCompilationFailure(out CodeFormatterResult compilationResult) var formattingContext2 = new FormattingContext { LineEnding = lineEnding, - IndentSize = printerOptions.TabWidth, + IndentSize = printerOptions.IndentSize, UseTabs = printerOptions.UseTabs, }; document = Node.Print( diff --git a/Src/CSharpier/CodeFormatter.cs b/Src/CSharpier/CodeFormatter.cs index f6468e530..cae0b26d0 100644 --- a/Src/CSharpier/CodeFormatter.cs +++ b/Src/CSharpier/CodeFormatter.cs @@ -25,9 +25,10 @@ public static Task FormatAsync( { Width = options.Width, UseTabs = options.IndentStyle == IndentStyle.Tabs, - TabWidth = options.IndentSize, + IndentSize = options.IndentSize, EndOfLine = options.EndOfLine, - IncludeGenerated = options.IncludeGenerated + IncludeGenerated = options.IncludeGenerated, + Formatter = "csharp" }, cancellationToken ); @@ -55,8 +56,9 @@ public static Task FormatAsync( { Width = options.Width, UseTabs = options.IndentStyle == IndentStyle.Tabs, - TabWidth = options.IndentSize, - EndOfLine = options.EndOfLine + IndentSize = options.IndentSize, + EndOfLine = options.EndOfLine, + Formatter = "csharp" }, SourceCodeKind.Regular, cancellationToken diff --git a/Src/CSharpier/DocPrinter/DocPrinter.cs b/Src/CSharpier/DocPrinter/DocPrinter.cs index b7b53bc46..03c5440a4 100644 --- a/Src/CSharpier/DocPrinter/DocPrinter.cs +++ b/Src/CSharpier/DocPrinter/DocPrinter.cs @@ -189,7 +189,7 @@ private void ProcessNextCommand() private void AppendComment(LeadingComment leadingComment, Indent indent) { int CalculateIndentLength(string line) => - line.CalculateCurrentLeadingIndentation(this.PrinterOptions.TabWidth); + line.CalculateCurrentLeadingIndentation(this.PrinterOptions.IndentSize); var stringReader = new StringReader(leadingComment.Comment); var line = stringReader.ReadLine(); diff --git a/Src/CSharpier/DocPrinter/Indent.cs b/Src/CSharpier/DocPrinter/Indent.cs index fcc4660f0..8ee95c702 100644 --- a/Src/CSharpier/DocPrinter/Indent.cs +++ b/Src/CSharpier/DocPrinter/Indent.cs @@ -49,15 +49,15 @@ public Indent IncreaseIndent(Indent indent) return new Indent { Value = indent.Value + "\t", - Length = indent.Length + this.PrinterOptions.TabWidth + Length = indent.Length + this.PrinterOptions.IndentSize }; } else { return new Indent { - Value = indent.Value + new string(' ', this.PrinterOptions.TabWidth), - Length = indent.Length + this.PrinterOptions.TabWidth + Value = indent.Value + new string(' ', this.PrinterOptions.IndentSize), + Length = indent.Length + this.PrinterOptions.IndentSize }; } } @@ -137,7 +137,7 @@ private Indent MakeIndentWithTypesForTabs(Indent indent, IIndentType nextIndentT else { value.Append('\t'); - length += this.PrinterOptions.TabWidth; + length += this.PrinterOptions.IndentSize; } } diff --git a/Src/CSharpier/PrinterOptions.cs b/Src/CSharpier/PrinterOptions.cs index 7745bc91b..895176012 100644 --- a/Src/CSharpier/PrinterOptions.cs +++ b/Src/CSharpier/PrinterOptions.cs @@ -5,11 +5,12 @@ internal class PrinterOptions public bool IncludeAST { get; init; } public bool IncludeDocTree { get; init; } public bool UseTabs { get; set; } - public int TabWidth { get; set; } = 4; + public int IndentSize { get; set; } = 4; public int Width { get; set; } = 100; public EndOfLine EndOfLine { get; set; } = EndOfLine.Auto; public bool TrimInitialLines { get; init; } = true; - public bool IncludeGenerated { get; set; } = false; + public bool IncludeGenerated { get; set; } + public string Formatter { get; init; } = string.Empty; public const int WidthUsedByTests = 100; diff --git a/Src/CSharpier/SyntaxPrinter/FormattingContext.cs b/Src/CSharpier/SyntaxPrinter/FormattingContext.cs index feb1df2dc..810ce602c 100644 --- a/Src/CSharpier/SyntaxPrinter/FormattingContext.cs +++ b/Src/CSharpier/SyntaxPrinter/FormattingContext.cs @@ -1,7 +1,6 @@ namespace CSharpier.SyntaxPrinter; // TODO rename this to PrintingContext -// TODO and rename PrinterOptions.TabWidth to PrinterOptions.IndentSize internal class FormattingContext { // TODO these go into Options diff --git a/docs/Configuration.md b/docs/Configuration.md index 4505cb664..ec56fbe7f 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -79,6 +79,35 @@ When supplying symbol sets, they will be used for all files being formatted. Thi The long term plan is to improve Csharpier's ability to determine the symbol sets itself and to allow specifying them for individual files. +### Configuration Overrides ### +_First available in 0.29.0_ +Overrides allows you to specify different configuration options based on glob patterns. This can be used to format non-standard extensions, or to change options based on file path. Top level options will apply to `**/*.{cs,csx}` + +```json +{ + "overrides": [ + { + "files": ["*.cst"], + "formatter": "csharp", + "tabWidth": 2, + "useTabs": true, + "printWidth": 10, + "endOfLine": "LF" + } + ] +} +``` + +```yaml +overrides: + - files: "*.cst" + formatter: "csharp" + tabWidth: 2 + useTabs: true + printWidth: 10 + endOfLine: "LF" +``` + ### EditorConfig _First available in 0.26.0_