diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..d857dff53 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish NuGet Package + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Pack + run: dotnet pack Src/CSharpier.Cli/CSharpier.Cli.csproj -c Release -o ./nupkg + + - name: Push to GitHub Packages + run: dotnet nuget push ./nupkg/*.nupkg --skip-duplicate --api-key ${{ secrets.GITHUB_TOKEN }} --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + + - name: Push to nuget.org + run: dotnet nuget push ./nupkg/*.nupkg --skip-duplicate --api-key ${{ secrets.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" diff --git a/.github/workflows/publish_beta.yml b/.github/workflows/publish_beta.yml deleted file mode 100644 index c92e2c8da..000000000 --- a/.github/workflows/publish_beta.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Publish -on: - push: - branches: [ 1.0.0 ] -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - uses: actions/setup-dotnet@v3 - - uses: gittools/actions/gitversion/setup@v3.1.1 - with: - versionSpec: '6.0.x' - - uses: gittools/actions/gitversion/execute@v3.1.1 - - run: | - dotnet pack Src/CSharpier.Cli/CSharpier.Cli.csproj -c Release /p:Version=${{env.VERSION}} /p:InformationalVersion=${{env.VERSION}} -o dist - dotnet nuget push ./dist/CSharpier.${{env.VERSION}}.nupkg -k ${{secrets.NUGET_API_KEY}} -s https://api.nuget.org/v3/index.json - git tag ${{env.VERSION}} - git push origin ${{env.VERSION}} - env: - VERSION: ${{env.branchName}}-alpha${{env.commitsSinceVersionSource}} diff --git a/.github/workflows/publish_nuget.yml b/.github/workflows/publish_nuget.yml deleted file mode 100644 index 998f83e70..000000000 --- a/.github/workflows/publish_nuget.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Publish Nuget -on: - push: - branches: [ main ] -jobs: - test: - runs-on: ubuntu-latest - name: test - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v3 - - run: > - dotnet build CSharpier.slnx -c release -p:TreatWarningsAsErrors=true - - publish-nuget: - runs-on: ubuntu-latest - name: publish nuget - needs: test - env: - VERSION_FILE_PATH: Nuget/Build.props - NUGET_KEY: ${{secrets.NUGET_API_KEY}} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v3 - - name: Publish CSharpier.Core library on version change - uses: alirezanet/publish-nuget@v3.1.0 - with: - PROJECT_FILE_PATH: Src/CSharpier.Core/CSharpier.Core.csproj - TAG_FORMAT: "*" - - name: Publish CSharpier dotnet tool on version change - uses: alirezanet/publish-nuget@v3.1.0 - with: - PACKAGE_NAME: CSharpier - PROJECT_FILE_PATH: Src/CSharpier.Cli/CSharpier.Cli.csproj - TAG_FORMAT: "*" - - name: Publish CSharpier.MsBuild library on version change - uses: alirezanet/publish-nuget@v3.1.0 - with: - PROJECT_FILE_PATH: Src/CSharpier.MsBuild/CSharpier.MsBuild.csproj - TAG_FORMAT: "*" diff --git a/MODIFICATIONS.md b/MODIFICATIONS.md new file mode 100644 index 000000000..91b8de464 --- /dev/null +++ b/MODIFICATIONS.md @@ -0,0 +1,131 @@ +# CSharpier-Flex 扩展记录 + +本文档记录对 CSharpier 原版所做的所有定制化修改。 + +--- + +## 配置方式 + +所有扩展行为统一由 `formattingStyle` 配置项控制: + +```json +// .csharpierrc +{ + "formattingStyle": "resharper", + "printWidth": 130 +} +``` + +- `"default"`(默认,不写此项时):使用原版 CSharpier 格式化行为 +- `"resharper"`:启用下列所有修改 + +--- + +## 修改列表 + + + +### 3. 方法调用尾部 Lambda 参数格式化 + +**日期**:2026-05-22 + +**目的**:当方法调用的最后一个参数是带有代码块(block body)的 lambda 表达式时,非 lambda 参数保持在方法调用的同一行,仅 lambda 体换行。避免所有参数被打散到各自的新行。 + +**修改前**(所有参数各占一行): +```csharp +BindButton( + _buttonChallenge, + () => + { + var playBtn = _buttonChallenge.GetComponent(); + bool allowDiamond = playBtn != null && playBtn.AllowDiamond; + Presenter.HandleChallengeClicked(allowDiamond, CloseCT); + } +); +``` + +**修改后**(非 lambda 参数保持内联,`{` 与方法调用同级,`});` 紧跟): +```csharp +BindButton(_buttonChallenge, () => +{ + var playBtn = _buttonChallenge.GetComponent(); + bool allowDiamond = playBtn != null && playBtn.AllowDiamond; + Presenter.HandleChallengeClicked(allowDiamond, CloseCT); +}); +``` + +**修改文件**: +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/ArgumentListLikeSyntax.cs` — 在默认多参数分支前插入新分支,非 lambda 参数内联打印,lambda 的 Body 直接跟在 Head 后 + +--- + +### 2. 二元表达式操作符换行位置 + 括号风格 + +**日期**:2026-05-22 + +**目的**:将二元表达式(`||`、`&&`、`+`、`==`、`??` 等)换行时操作符的位置从"下一行开头"改为"当前行末尾";同时统一 `if` / `while` / `do-while` / `switch` 的条件括号风格,使条件紧跟 `(`,`)` 紧跟最后一个条件。 + +**修改前**(操作符在下一行开头): +```csharp +if ( + GameManager.Instance.gameMode == GameManager.GameMode.Classic + || GameManager.Instance.gameMode == GameManager.GameMode.GroupChallenge +) { } +``` + +**修改后**(操作符在当前行末尾): +```csharp +if (GameManager.Instance.gameMode == GameManager.GameMode.Classic || + GameManager.Instance.gameMode == GameManager.GameMode.GroupChallenge) +{ +``` + +**修改文件**: +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BinaryExpression.cs` — 条件化操作符与换行符的 Doc 顺序 +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/IfStatement.cs` — 条件化括号风格 +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/WhileStatement.cs` — 同上 +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/DoStatement.cs` — 同上 +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/SwitchStatement.cs` — 同上 + +--- + +### 1. 字段属性同行 + +**日期**:2026-05-22 + +**目的**:允许字段声明的属性(Attribute)与字段保持在同一行,而不是强制换行。适用于 Unity 项目中常见的 `[SerializeField] private Button _buttonQuit;` 风格。 + +**行为**(`formattingStyle: "resharper"` 时生效): +```csharp +// 1-2 个属性 → 全部同行 +[SerializeField] private Button _buttonQuit; +[SerializeField] [HideInInspector] private Text _label; +// 3 个属性 → 属性同行,字段换行 +[SerializeField] [HideInInspector] [Header("Title")] +private Text _labelTitle; +// 4+ 个属性 → 全部换行(原版行为) +[SerializeField] +[HideInInspector] +[Header("Title")] +[Tooltip("tip")] +private Text _labelTitle; +``` + +**修改文件**: +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/AttributeLists.cs` — 字段声明时根据 FormattingStyle 选择分隔符 +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BaseFieldDeclaration.cs` — 启用时用 `Doc.Group` 包裹 + +--- + +## 配置基础设施 + +**配置项 `formattingStyle`**(`"default"` / `"resharper"`)统一控制所有修改行为。 + +**修改文件**: +- `Src/CSharpier.Core/PrinterOptions.cs` — 新增 `FormattingStyle` 属性和 `public enum FormattingStyle` +- `Src/CSharpier.Core/CodeFormatterOptions.cs` — 公共 API 新增 `FormattingStyle` 属性 +- `Src/CSharpier.Core/CSharp/SyntaxPrinter/PrintingContext.cs` — `PrintingContextOptions` 新增 `FormattingStyle` +- `Src/CSharpier.Core/CSharp/CSharpFormatter.cs` — 传递配置到 `PrintingContext` +- `Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs` — 配置文件解析新增字段 +- `Src/CSharpier.Core/PublicAPI.Unshipped.txt` — 更新公共 API 声明 +- `Src/CSharpier.Tests/AllowFieldAttributeOnSameLineTests.cs` — 测试适配 diff --git a/Nuget/Build.props b/Nuget/Build.props index bc58afe0b..01a3ed439 100644 --- a/Nuget/Build.props +++ b/Nuget/Build.props @@ -1,10 +1,10 @@ - 1.2.6 + 1.0.0 MIT - https://github.com/belav/csharpier + https://github.com/AnyAnq/csharpier-flex git - CSharpier is an opinionated code formatter for c#. + CSharpier-Flex: CSharpier fork with ReSharper-style formatting options. logo.png README.md false diff --git a/README.md b/README.md index d809cb1c5..ccf7f283f 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,181 @@ -![CSharpier](./banner.svg) +# CSharpier-Flex -CSharpier is an opinionated code formatter for c# and XML. It parses your code and re-prints it using its own rules. -The printing process was ported from [prettier](https://github.com/prettier/prettier) but has evolved over time. +基于 [CSharpier](https://github.com/belav/csharpier) 的 Fork,在保留原版全部功能的基础上,新增 `formattingStyle` 配置项,提供更贴近 ReSharper / Rider 风格的 C# 代码格式化选项。 -CSharpier provides a few basic options that affect formatting and has no plans to add more. It follows the [Option Philosophy](https://prettier.io/docs/en/option-philosophy.html) of prettier. +> 原版 CSharpier 遵循 Prettier 的 [Option Philosophy](https://prettier.io/docs/en/option-philosophy.html),仅提供极少量格式化选项。本 Fork 在此基础上扩展,通过一个配置项切换整套风格预设,不破坏原版行为。 + +--- + +## 环境要求 + +| 依赖 | 版本 | +|---|---| +| .NET SDK | 8.0 / 9.0 / 10.0(任一即可) | +| 操作系统 | Windows / macOS / Linux | + +--- + +## 安装 + +### 方式一:从 NuGet 安装(推荐) -### Quick Start -Install CSharpier globally using the following command. ```bash -dotnet tool install csharpier -g +dotnet tool install CSharpier-Flex -g ``` -Then format the contents of a directory and its children with the following command. + +安装后即可在任意目录使用 `csharpier-flex` 命令。 + +### 方式二:从源码构建 + +```bash +git clone https://github.com/AnyAnq/csharpier-flex.git +cd csharpier-flex +dotnet build Src/CSharpier.Cli/CSharpier.Cli.csproj +``` + +--- + +## 使用 + ```bash -csharpier format . +# 格式化当前目录下所有 .cs 文件 +csharpier-flex format . + +# 格式化单个文件 +csharpier-flex format MyFile.cs + +# 格式化并输出到标准输出(不修改文件) +csharpier-flex format --write-stdout MyFile.cs + +# 检查格式化是否一致(CI 用) +csharpier-flex check . ``` -CSharpier can also format [on save in your editor](https://csharpier.com/docs/Editors) or as a [pre-commit hook](https://csharpier.com/docs/Pre-commit). Then you can ensure code was formatted with a [CI/CD tool](https://csharpier.com/docs/ContinuousIntegration). +如果通过源码构建,使用 `dotnet run` 替代: + +```bash +dotnet run --project Src/CSharpier.Cli/CSharpier.Cli.csproj -- format . +``` --- -[Read the documentation](https://csharpier.com) - -[Try it out](https://playground.csharpier.com) +## 更新 & 卸载 + +```bash +# 更新到最新版 +dotnet tool update CSharpier-Flex -g + +# 卸载 +dotnet tool uninstall CSharpier-Flex -g +``` --- -### Before -```c# -public class ClassName { - public void CallMethod() { - this.LongUglyMethod("1234567890", "abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"); - } +## 配置 + +在项目根目录创建 `.csharpierrc` 文件: + +```json +{ + "formattingStyle": "resharper", + "printWidth": 120 } ``` -### After -```c# -public class ClassName -{ - public void CallMethod() +### `formattingStyle` + +| 值 | 说明 | +|---|---| +| `"default"` | 原版 CSharpier 行为(不写此项时的默认值) | +| `"resharper"` | 启用下列所有扩展格式化规则 | + +### 其他配置项 + +原版 CSharpier 的所有配置项均可正常使用: + +| 配置项 | 默认值 | 说明 | +|---|---|---| +| `printWidth` | `100` | 行宽限制 | +| `indentSize` | `4` | 缩进空格数 | +| `useTabs` | `false` | 是否使用 Tab 缩进 | +| `endOfLine` | `"auto"` | 换行符类型(`auto` / `lf` / `crlf`) | + +--- + +## 扩展格式化规则 + +以下规则仅在 `"formattingStyle": "resharper"` 时生效。 + +### 1. 字段属性同行 + +属性(Attribute)与字段声明保持在同一行,适用于 Unity 项目中常见的 `[SerializeField]` 风格。 + +```csharp +// 1-2 个属性 → 全部同行 +[SerializeField] private Button _buttonQuit; +[SerializeField] [HideInInspector] private Text _label; + +// 3 个属性 → 属性同行,字段换行 +[SerializeField] [HideInInspector] [Header("Title")] +private Text _labelTitle; + +// 4+ 个属性 → 保持原版行为(各自一行) +[SerializeField] +[HideInInspector] +[Header("Title")] +[Tooltip("tip")] +private Text _labelTitle; +``` + +### 2. 二元表达式操作符留在行尾 + +换行时操作符(`||`、`&&`、`??` 等)保留在当前行末尾,而非移到下一行开头。同时 `if` / `while` / `do-while` / `switch` 的条件紧贴括号。 + +```csharp +// default 风格 +if ( + condition1 + || condition2 +) + +// resharper 风格 +if (condition1 || + condition2) +``` + +### 3. 方法调用尾部 Lambda 参数内联 + +当方法调用的最后一个参数是带代码块的 lambda 时,非 lambda 参数保持在方法名同一行,lambda 体自然换行。 + +```csharp +// default 风格 +BindButton( + _buttonChallenge, + () => { - this.LongUglyMethod( - "1234567890", - "abcdefghijklmnopqrstuvwxyz", - "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - ); + DoSomething(); } -} +); + +// resharper 风格 +BindButton(_buttonChallenge, () => +{ + DoSomething(); +}); ``` -## Contributing -See [Development Readme](CONTRIBUTING.md) +--- -Join Us [![Discord](https://img.shields.io/badge/Discord-chat?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/HfAKGEZQcX) +## 与原版 CSharpier 的关系 -## Sponsors +- **完全兼容**:不设置 `formattingStyle` 或设为 `"default"` 时,行为与原版 CSharpier 完全一致 +- **非破坏性**:所有扩展规则仅在显式配置 `"resharper"` 时生效 +- **持续同步**:定期从上游 [belav/csharpier](https://github.com/belav/csharpier) 合并更新 -Thanks to the following companies for sponsoring the ongoing development of CSharpier. +详细修改记录见 [MODIFICATIONS.md](MODIFICATIONS.md)。 -[.NET on AWS Open Source Software Fund](https://github.com/aws/dotnet-foss) \ - \ -[](https://github.com/aws/dotnet-foss) +--- -[Fern](https://buildwithfern.com/) \ - \ -[]((https://buildwithfern.com/)) +## 致谢 -And a huge thanks to all the others who sponsor the project through [Github sponsors](https://github.com/sponsors/belav) \ No newline at end of file +本项目基于 [CSharpier](https://github.com/belav/csharpier),感谢原作者及所有贡献者的工作。 diff --git a/Src/CSharpier.Cli/CSharpier.Cli.csproj b/Src/CSharpier.Cli/CSharpier.Cli.csproj index a2f9b21a9..6d6f3fa6a 100644 --- a/Src/CSharpier.Cli/CSharpier.Cli.csproj +++ b/Src/CSharpier.Cli/CSharpier.Cli.csproj @@ -3,11 +3,11 @@ $(NoWarn);NU1510 Exe - CSharpier + CSharpier-Flex CSharpier net8.0;net9.0;net10.0 true - csharpier + csharpier-flex ../../Nuget/csharpier.snk Major True diff --git a/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs b/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs index 73b4c10d3..bab0d81f2 100644 --- a/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs +++ b/Src/CSharpier.Cli/Options/ConfigurationFileOptions.cs @@ -16,6 +16,9 @@ internal class ConfigurationFileOptions [JsonConverter(typeof(CaseInsensitiveEnumConverter))] public EndOfLine EndOfLine { get; init; } + [JsonConverter(typeof(CaseInsensitiveEnumConverter))] + public FormattingStyle FormattingStyle { get; init; } + public Override[] Overrides { get; init; } = []; public PrinterOptions? ConvertToPrinterOptions(string filePath) @@ -45,6 +48,7 @@ out var parsedFormatter UseTabs = matchingOverride.UseTabs, Width = matchingOverride.PrintWidth, EndOfLine = matchingOverride.EndOfLine, + FormattingStyle = this.FormattingStyle, }; } @@ -61,6 +65,7 @@ out var parsedFormatter UseTabs = this.UseTabs, Width = this.PrintWidth, EndOfLine = this.EndOfLine, + FormattingStyle = this.FormattingStyle, }; } diff --git a/Src/CSharpier.Core/CSharp/CSharpFormatter.cs b/Src/CSharpier.Core/CSharp/CSharpFormatter.cs index e4dc78093..ffaab7ccc 100644 --- a/Src/CSharpier.Core/CSharp/CSharpFormatter.cs +++ b/Src/CSharpier.Core/CSharp/CSharpFormatter.cs @@ -157,6 +157,7 @@ bool TryGetCompilationFailure(out CodeFormatterResult compilationResult) IndentSize = printerOptions.IndentSize, UseTabs = printerOptions.UseTabs, XmlWhitespaceSensitivity = XmlWhitespaceSensitivity.Strict, + FormattingStyle = printerOptions.FormattingStyle, }, }; var document = Node.Print(rootNode, printingContext); @@ -185,6 +186,7 @@ bool TryGetCompilationFailure(out CodeFormatterResult compilationResult) IndentSize = printerOptions.IndentSize, UseTabs = printerOptions.UseTabs, XmlWhitespaceSensitivity = XmlWhitespaceSensitivity.Strict, + FormattingStyle = printerOptions.FormattingStyle, }, }; document = Node.Print( diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/ArgumentListLikeSyntax.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/ArgumentListLikeSyntax.cs index 9bdb318d5..9492a6ea2 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/ArgumentListLikeSyntax.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/ArgumentListLikeSyntax.cs @@ -1,5 +1,6 @@ using CSharpier.Core.CSharp.SyntaxPrinter.SyntaxNodePrinters; using CSharpier.Core.DocTypes; +using CSharpier.Core.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -78,6 +79,43 @@ lambda.Block is not null { args = SeparatedSyntaxList.Print(arguments, Argument.Print, Doc.Line, context); } + else if ( + context.Options.FormattingStyle == FormattingStyle.Resharper + && arguments.Count >= 2 + && arguments[arguments.Count - 1].Expression + is ParenthesizedLambdaExpressionSyntax { Block.Statements.Count: > 0 } + or SimpleLambdaExpressionSyntax { Body: BlockSyntax { Statements.Count: > 0 } } + ) + { + var docs = new DocListBuilder(arguments.Count * 3 + 2); + + for (var i = 0; i < arguments.Count - 1; i++) + { + docs.Add(Argument.Print(arguments[i], context)); + if (i < arguments.SeparatorCount) + { + docs.Add(Token.Print(arguments.GetSeparator(i), context)); + docs.Add(" "); + } + } + + var lastArg = arguments[arguments.Count - 1]; + docs.Add(Argument.PrintModifiers(lastArg, context)); + + if (lastArg.Expression is ParenthesizedLambdaExpressionSyntax pLambda) + { + docs.Add(ParenthesizedLambdaExpression.PrintHead(pLambda, context)); + docs.Add(ParenthesizedLambdaExpression.PrintBody(pLambda, context)); + } + else if (lastArg.Expression is SimpleLambdaExpressionSyntax sLambda) + { + docs.Add(SimpleLambdaExpression.PrintHead(sLambda, context)); + docs.Add(SimpleLambdaExpression.PrintBody(sLambda, context)); + } + + args = Doc.Concat(ref docs); + docs.Dispose(); + } else if (arguments.Count > 0) { args = Doc.Concat( diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/AttributeLists.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/AttributeLists.cs index f0942a4de..48898b5b6 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/AttributeLists.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/AttributeLists.cs @@ -2,12 +2,28 @@ using CSharpier.Core.DocTypes; using CSharpier.Core.Utilities; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace CSharpier.Core.CSharp.SyntaxPrinter; internal static class AttributeLists { + internal static int GetTotalAttributeCount(SyntaxList attributeLists) => + attributeLists.Sum(list => list.Attributes.Count); + + internal static bool HasOriginalLineBreaks(SyntaxList attributeLists) + { + for (var i = 0; i < attributeLists.Count - 1; i++) + { + if (attributeLists[i].GetTrailingTrivia().Any(SyntaxKind.EndOfLineTrivia)) + { + return true; + } + } + return false; + } + public static Doc Print( SyntaxNode node, SyntaxList attributeLists, @@ -19,6 +35,52 @@ PrintingContext context return Doc.Null; } + var isFieldSameLine = + node is BaseFieldDeclarationSyntax + && context.Options.FormattingStyle == FormattingStyle.Resharper; + + if (isFieldSameLine) + { + var totalAttrs = GetTotalAttributeCount(attributeLists); + if (totalAttrs == 3 && !HasOriginalLineBreaks(attributeLists)) + { + return Doc.Concat( + Doc.Group( + Doc.Join( + Doc.Line, + attributeLists.Select(o => AttributeList.Print(o, context)) + ) + ), + Doc.HardLine + ); + } + + if ( + totalAttrs <= 2 + && attributeLists.Count > 1 + && HasOriginalLineBreaks(attributeLists) + ) + { + var result = new DocListBuilder(attributeLists.Count * 2); + for (var i = 0; i < attributeLists.Count; i++) + { + result.Add(AttributeList.Print(attributeLists[i], context)); + if (i < attributeLists.Count - 1) + { + result.Add( + attributeLists[i] + .GetTrailingTrivia() + .Any(SyntaxKind.EndOfLineTrivia) + ? Doc.HardLine + : (Doc)" " + ); + } + } + result.Add(" "); + return Doc.Concat(ref result); + } + } + var docs = new DocListBuilder(2); Doc separator = node is TypeParameterSyntax @@ -26,7 +88,9 @@ or ParameterSyntax or ParenthesizedLambdaExpressionSyntax or AccessorDeclarationSyntax ? Doc.Line - : Doc.HardLine; + : isFieldSameLine && GetTotalAttributeCount(attributeLists) <= 2 + ? Doc.Line + : Doc.HardLine; docs.Add(Doc.Join(separator, attributeLists.Select(o => AttributeList.Print(o, context)))); diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/PrintingContext.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/PrintingContext.cs index b895605e7..04db974db 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/PrintingContext.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/PrintingContext.cs @@ -38,6 +38,7 @@ public class PrintingContextOptions public required int IndentSize { get; init; } public required bool UseTabs { get; init; } public required XmlWhitespaceSensitivity XmlWhitespaceSensitivity { get; init; } + public FormattingStyle FormattingStyle { get; init; } } public class PrintingContextState diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BaseFieldDeclaration.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BaseFieldDeclaration.cs index 8fdab6a93..fc655e1f6 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BaseFieldDeclaration.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BaseFieldDeclaration.cs @@ -8,6 +8,23 @@ internal static class BaseFieldDeclaration { public static Doc Print(BaseFieldDeclarationSyntax node, PrintingContext context) { + var canSameLine = + context.Options.FormattingStyle == FormattingStyle.Resharper + && node.AttributeLists.Count > 0 + && AttributeLists.GetTotalAttributeCount(node.AttributeLists) <= 2 + && !AttributeLists.HasOriginalLineBreaks(node.AttributeLists); + + Doc leadingTrivia = Doc.Null; + if (canSameLine) + { + var firstToken = node.AttributeLists[0].GetFirstToken(); + leadingTrivia = Token.PrintLeadingTrivia(firstToken, context); + if (leadingTrivia != Doc.Null) + { + context.State.SkipNextLeadingTrivia = true; + } + } + var docs = new DocListBuilder(5); docs.Add(AttributeLists.Print(node, node.AttributeLists, context)); docs.Add(Modifiers.PrintSorted(node.Modifiers, context)); @@ -20,6 +37,12 @@ public static Doc Print(BaseFieldDeclarationSyntax node, PrintingContext context VariableDeclaration.Print(node.Declaration, context), Token.Print(node.SemicolonToken, context) ); + + if (canSameLine) + { + return Doc.Concat(leadingTrivia, Doc.Group(docs.ToArray())); + } + return Doc.Concat(ref docs); } } diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BinaryExpression.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BinaryExpression.cs index 228f0c753..ba63d33bb 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BinaryExpression.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/BinaryExpression.cs @@ -15,8 +15,8 @@ public static Doc Print(BinaryExpressionSyntax node, PrintingContext context) if (node.Parent is IfStatementSyntax) { - // avoid grouping here so that the ifBreaks in IfStatement can understand when - // this BinaryExpression breaks + // avoid grouping here so that the outer Group in IfStatement controls + // when all Doc.Line breaks in this BinaryExpression fire return Doc.Concat(docs); } @@ -88,11 +88,13 @@ private static List PrintBinaryExpression(SyntaxNode node, PrintingContext var binaryOnTheRight = binaryExpressionSyntax.Kind() == SyntaxKind.CoalesceExpression; if (binaryOnTheRight) { + var isResharper = + context.Options.FormattingStyle == FormattingStyle.Resharper; docs.Add( Node.Print(binaryExpressionSyntax.Left, context), - Doc.Line, + isResharper ? " " : Doc.Line, Token.Print(binaryExpressionSyntax.OperatorToken, context), - " " + isResharper ? Doc.Line : (Doc)" " ); } @@ -125,12 +127,21 @@ possibleBinary is BinaryExpressionSyntax childBinary return shouldGroup ? [docs[0], Doc.Group(docs.Skip(1).ToList())] : docs; } - var right = Doc.Concat( - Doc.Line, - Token.Print(binaryExpressionSyntax.OperatorToken, context), - " ", - Node.Print(binaryExpressionSyntax.Right, context) - ); + var isResharperStyle = + context.Options.FormattingStyle == FormattingStyle.Resharper; + var right = isResharperStyle + ? Doc.Concat( + " ", + Token.Print(binaryExpressionSyntax.OperatorToken, context), + Doc.Line, + Node.Print(binaryExpressionSyntax.Right, context) + ) + : Doc.Concat( + Doc.Line, + Token.Print(binaryExpressionSyntax.OperatorToken, context), + " ", + Node.Print(binaryExpressionSyntax.Right, context) + ); docs.Add(shouldGroup ? Doc.Group(right) : right); return docs; diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/DoStatement.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/DoStatement.cs index 71aca2f2e..8984d1f78 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/DoStatement.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/DoStatement.cs @@ -17,9 +17,20 @@ public static Doc Print(DoStatementSyntax node, PrintingContext context) Node.Print(node.Statement, context), node.Statement is BlockSyntax ? " " : Doc.HardLine, Token.PrintWithSuffix(node.WhileKeyword, " ", context), - Token.Print(node.OpenParenToken, context), - Doc.Group(Doc.Indent(Doc.SoftLine, Node.Print(node.Condition, context)), Doc.SoftLine), - Token.Print(node.CloseParenToken, context), + context.Options.FormattingStyle == FormattingStyle.Resharper + ? Doc.Group( + Token.Print(node.OpenParenToken, context), + Doc.Indent(Node.Print(node.Condition, context)), + Token.Print(node.CloseParenToken, context) + ) + : Doc.Concat( + Token.Print(node.OpenParenToken, context), + Doc.Group( + Doc.Indent(Doc.SoftLine, Node.Print(node.Condition, context)), + Doc.SoftLine + ), + Token.Print(node.CloseParenToken, context) + ), Token.Print(node.SemicolonToken, context) ); } diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/IfStatement.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/IfStatement.cs index 80d79f0f7..94de9d095 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/IfStatement.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/IfStatement.cs @@ -14,20 +14,36 @@ public static Doc Print(IfStatementSyntax node, PrintingContext context) docs.Add(ExtraNewLines.Print(node)); } - docs.Add( - Token.Print(node.IfKeyword, context), - " ", - Doc.Group( - Token.Print(node.OpenParenToken, context), - Doc.Indent( - Doc.IfBreak(Doc.SoftLine, Doc.Null), - Node.Print(node.Condition, context) + if (context.Options.FormattingStyle == FormattingStyle.Resharper) + { + docs.Add( + Token.Print(node.IfKeyword, context), + " ", + Doc.Group( + Token.Print(node.OpenParenToken, context), + Doc.Indent(Node.Print(node.Condition, context)), + Token.Print(node.CloseParenToken, context) ), - Doc.IfBreak(Doc.SoftLine, Doc.Null) - ), - Token.Print(node.CloseParenToken, context), - OptionalBraces.Print(node.Statement, context) - ); + OptionalBraces.Print(node.Statement, context) + ); + } + else + { + docs.Add( + Token.Print(node.IfKeyword, context), + " ", + Doc.Group( + Token.Print(node.OpenParenToken, context), + Doc.Indent( + Doc.IfBreak(Doc.SoftLine, Doc.Null), + Node.Print(node.Condition, context) + ), + Doc.IfBreak(Doc.SoftLine, Doc.Null) + ), + Token.Print(node.CloseParenToken, context), + OptionalBraces.Print(node.Statement, context) + ); + } if (node.Else != null) { diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/SwitchStatement.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/SwitchStatement.cs index 23e6eeb6a..3531d4e18 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/SwitchStatement.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/SwitchStatement.cs @@ -33,13 +33,25 @@ public static Doc Print(SwitchStatementSyntax node, PrintingContext context) Doc.Group( Token.PrintWithoutLeadingTrivia(node.SwitchKeyword, context), " ", - Token.Print(node.OpenParenToken, context), - Doc.GroupWithId( - groupId, - Doc.Indent(Doc.SoftLine, Node.Print(node.Expression, context)), - Doc.SoftLine - ), - Token.Print(node.CloseParenToken, context), + context.Options.FormattingStyle == FormattingStyle.Resharper + ? Doc.GroupWithId( + groupId, + Token.Print(node.OpenParenToken, context), + Doc.Indent(Node.Print(node.Expression, context)), + Token.Print(node.CloseParenToken, context) + ) + : Doc.Concat( + Token.Print(node.OpenParenToken, context), + Doc.GroupWithId( + groupId, + Doc.Indent( + Doc.SoftLine, + Node.Print(node.Expression, context) + ), + Doc.SoftLine + ), + Token.Print(node.CloseParenToken, context) + ), node.Sections.Count == 0 ? " " : Doc.Line, Token.Print(node.OpenBraceToken, context), sections, diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/WhileStatement.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/WhileStatement.cs index a9ba36a47..5ca341620 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/WhileStatement.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/WhileStatement.cs @@ -7,16 +7,19 @@ internal static class WhileStatement { public static Doc Print(WhileStatementSyntax node, PrintingContext context) { + var isResharper = context.Options.FormattingStyle == FormattingStyle.Resharper; var result = Doc.Concat( ExtraNewLines.Print(node), Doc.Group( Token.Print(node.WhileKeyword, context), " ", Token.Print(node.OpenParenToken, context), - Doc.Group( - Doc.Indent(Doc.SoftLine, Node.Print(node.Condition, context)), - Doc.SoftLine - ), + isResharper + ? Doc.Indent(Node.Print(node.Condition, context)) + : Doc.Group( + Doc.Indent(Doc.SoftLine, Node.Print(node.Condition, context)), + Doc.SoftLine + ), Token.Print(node.CloseParenToken, context) ), node.Statement switch diff --git a/Src/CSharpier.Core/CodeFormatterOptions.cs b/Src/CSharpier.Core/CodeFormatterOptions.cs index 5f123d9c8..625fa2bd3 100644 --- a/Src/CSharpier.Core/CodeFormatterOptions.cs +++ b/Src/CSharpier.Core/CodeFormatterOptions.cs @@ -7,6 +7,7 @@ public class CodeFormatterOptions public int IndentSize { get; init; } = 4; public EndOfLine EndOfLine { get; init; } = EndOfLine.Auto; public bool IncludeGenerated { get; init; } + public FormattingStyle FormattingStyle { get; init; } public XmlWhitespaceSensitivity XmlWhitespaceSensitivity { get; init; } = XmlWhitespaceSensitivity.Strict; @@ -19,6 +20,7 @@ internal PrinterOptions ToPrinterOptions() IndentSize = this.IndentSize, EndOfLine = this.EndOfLine, IncludeGenerated = this.IncludeGenerated, + FormattingStyle = this.FormattingStyle, }; } } diff --git a/Src/CSharpier.Core/PrinterOptions.cs b/Src/CSharpier.Core/PrinterOptions.cs index 6f31acb68..4105a209d 100644 --- a/Src/CSharpier.Core/PrinterOptions.cs +++ b/Src/CSharpier.Core/PrinterOptions.cs @@ -29,6 +29,7 @@ public int IndentSize public EndOfLine EndOfLine { get; set; } = EndOfLine.Auto; public bool TrimInitialLines { get; init; } = true; public bool IncludeGenerated { get; set; } + public FormattingStyle FormattingStyle { get; set; } public Formatter Formatter { get; } = formatter; public XmlWhitespaceSensitivity XmlWhitespaceSensitivity { get; set; } = xmlWhitespaceSensitivity; @@ -100,3 +101,9 @@ internal enum Formatter CSharpScript, XML, } + +public enum FormattingStyle +{ + Default, + Resharper, +} diff --git a/Src/CSharpier.Core/PublicAPI.Unshipped.txt b/Src/CSharpier.Core/PublicAPI.Unshipped.txt index f02a42556..426fe35c5 100644 --- a/Src/CSharpier.Core/PublicAPI.Unshipped.txt +++ b/Src/CSharpier.Core/PublicAPI.Unshipped.txt @@ -1,5 +1,10 @@ -CSharpier.Core.CodeFormatterOptions.XmlWhitespaceSensitivity.get -> CSharpier.Core.XmlWhitespaceSensitivity +CSharpier.Core.CodeFormatterOptions.FormattingStyle.get -> CSharpier.Core.FormattingStyle +CSharpier.Core.CodeFormatterOptions.FormattingStyle.init -> void +CSharpier.Core.CodeFormatterOptions.XmlWhitespaceSensitivity.get -> CSharpier.Core.XmlWhitespaceSensitivity CSharpier.Core.CodeFormatterOptions.XmlWhitespaceSensitivity.init -> void +CSharpier.Core.FormattingStyle +CSharpier.Core.FormattingStyle.Default = 0 -> CSharpier.Core.FormattingStyle +CSharpier.Core.FormattingStyle.Resharper = 1 -> CSharpier.Core.FormattingStyle CSharpier.Core.XmlWhitespaceSensitivity CSharpier.Core.XmlWhitespaceSensitivity.Ignore = 1 -> CSharpier.Core.XmlWhitespaceSensitivity -CSharpier.Core.XmlWhitespaceSensitivity.Strict = 0 -> CSharpier.Core.XmlWhitespaceSensitivity \ No newline at end of file +CSharpier.Core.XmlWhitespaceSensitivity.Strict = 0 -> CSharpier.Core.XmlWhitespaceSensitivity diff --git a/Src/CSharpier.Generators/CSharpier.Generators.csproj b/Src/CSharpier.Generators/CSharpier.Generators.csproj index ea01ed6af..713f09e87 100644 --- a/Src/CSharpier.Generators/CSharpier.Generators.csproj +++ b/Src/CSharpier.Generators/CSharpier.Generators.csproj @@ -5,7 +5,7 @@ true true 13 - SYSLIB0013 + SYSLIB0013;NU1903 diff --git a/Src/CSharpier.Tests/AllowFieldAttributeOnSameLineTests.cs b/Src/CSharpier.Tests/AllowFieldAttributeOnSameLineTests.cs new file mode 100644 index 000000000..c76edee98 --- /dev/null +++ b/Src/CSharpier.Tests/AllowFieldAttributeOnSameLineTests.cs @@ -0,0 +1,148 @@ +using AwesomeAssertions; +using CSharpier.Core; +using CSharpier.Core.CSharp; + +namespace CSharpier.Tests; + +internal sealed class AllowFieldAttributeOnSameLineTests +{ + private static PrinterOptions CreateOptions(bool allowFieldAttributeOnSameLine) + { + return new PrinterOptions(Formatter.CSharp, XmlWhitespaceSensitivity.Strict) + { + Width = 100, + FormattingStyle = allowFieldAttributeOnSameLine + ? FormattingStyle.Resharper + : FormattingStyle.Default, + }; + } + + [Test] + public async Task Field_With_Single_Attribute_Stays_On_Same_Line_When_Enabled() + { + var code = "[SerializeField] private Button _buttonQuit;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("[SerializeField] private Button _buttonQuit;"); + } + + [Test] + public async Task Field_With_Single_Attribute_Breaks_Line_When_Disabled() + { + var code = "[SerializeField] private Button _buttonQuit;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: false) + ); + result.Code.Should().Contain("[SerializeField]\n private Button _buttonQuit;"); + } + + [Test] + public async Task Field_With_Long_Attribute_Breaks_When_Exceeds_Width() + { + var code = + "[VeryLongAttributeName(SomeFlag.SomeValue, SomeOtherFlag.SomeOtherLongValue)] private Button _buttonQuit;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("]\n private Button _buttonQuit;"); + } + + [Test] + public async Task Field_With_Two_Attributes_Stays_On_Same_Line_When_Enabled() + { + var code = "[SerializeField] [HideInInspector] private Button _buttonQuit;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("[SerializeField] [HideInInspector] private Button _buttonQuit;"); + } + + [Test] + public async Task Field_With_Two_Attributes_On_Separate_Lines_Preserves_Breaks_When_Enabled() + { + var code = "[Header(\"Title\")]\n [SerializeField] private Button _buttonQuit;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("[Header(\"Title\")]\n [SerializeField] private Button _buttonQuit;"); + } + + [Test] + public async Task Field_With_Three_Attributes_Groups_Attrs_Then_Breaks_Field_When_Enabled() + { + var code = "[SerializeField] [HideInInspector] [Header(\"Title\")] private Text _labelTitle;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("[SerializeField] [HideInInspector] [Header(\"Title\")]\n private Text _labelTitle;"); + } + + [Test] + public async Task Field_With_More_Than_Three_Attributes_Breaks_Lines_When_Enabled() + { + var code = + "[SerializeField]\n [HideInInspector]\n [Header(\"Title\")]\n [Tooltip(\"tip\")]\n private Text _labelTitle;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("[SerializeField]\n [HideInInspector]\n [Header(\"Title\")]\n [Tooltip(\"tip\")]\n private Text _labelTitle;"); + } + + [Test] + public async Task Field_With_Three_Comma_Separated_Attrs_Breaks_Field_When_Enabled() + { + var code = "[Obsolete, NonSerialized, CLSCompliant(true)] private int _field;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("]\n private int _field;"); + } + + [Test] + public async Task Field_With_Mixed_Attrs_Totaling_Three_Breaks_Field_When_Enabled() + { + var code = "[Obsolete, NonSerialized]\n [Header(\"X\")]\n private int _field;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("]\n private int _field;"); + } + + [Test] + public async Task Field_With_Four_Comma_Separated_Attrs_Breaks_All_When_Enabled() + { + var code = + "[Obsolete, NonSerialized, CLSCompliant(true), Browsable(false)] private int _field;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("]\n private int _field;"); + } + + [Test] + public async Task Field_With_Two_Comma_Separated_Attrs_Stays_On_Same_Line_When_Enabled() + { + var code = "[Obsolete, NonSerialized] private int _field;\n"; + var result = await CSharpFormatter.FormatAsync( + WrapInClass(code), + CreateOptions(allowFieldAttributeOnSameLine: true) + ); + result.Code.Should().Contain("[Obsolete, NonSerialized] private int _field;"); + } + + private static string WrapInClass(string fieldCode) + { + return $"class TestClass\n{{\n {fieldCode}}}\n"; + } +} diff --git a/docs/Configuration.md b/docs/Configuration.md index 15832d7a0..9d57bb2f9 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -17,7 +17,8 @@ JSON "printWidth": 100, "useTabs": false, "indentSize": 4, - "endOfLine": "auto" + "endOfLine": "auto", + "allowFieldAttributeOnSameLine": false } ``` YAML @@ -26,6 +27,7 @@ printWidth: 100 useTabs: false indentSize: 4 endOfLine: auto +allowFieldAttributeOnSameLine: false ``` #### Print Width @@ -52,6 +54,28 @@ Valid options: Default `auto` +#### Allow Field Attribute On Same Line +When set to `true`, field attributes are allowed to stay on the same line, with tiered behavior based on attribute count. If the combined line exceeds the print width, it will also break. + +```csharp +// true (1-2 attrs): all on same line +// [SerializeField] private Button _buttonQuit; +// true (3 attrs): attributes on same line, field on new line +// [SerializeField] [HideInInspector] [Header("X")] +// private Text _label; +// true (4+ attrs): each on its own line +// [SerializeField] +// [HideInInspector] +// [Header("Title")] +// [Tooltip("tip")] +// private Text _labelTitle; +// false: always break (original behavior) +// [SerializeField] +// private Button _buttonQuit; +``` + +Default `false` + ### Configuration Overrides ### 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}`