diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95db589f4..29dcbaaab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,14 +47,18 @@ jobs: swiftos: - focal runs-on: ubuntu-latest - container: + container: image: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }} options: --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --security-opt apparmor=unconfined steps: - name: Checkout uses: actions/checkout@v2 - name: Build and Test - run: swift test --enable-test-discovery + run: | + # Needed to be allowed to run git commands in the tests + # https://github.com/actions/runner-images/issues/6775 + git config --global --add safe.directory /__w/SwiftFormat/SwiftFormat + swift test --enable-test-discovery windows: strategy: diff --git a/README.md b/README.md index 2e1fa206c..98645dabd 100644 --- a/README.md +++ b/README.md @@ -781,7 +781,7 @@ Error codes The swiftformat command-line tool will always exit with one of the following codes: * 0 - Success. This code will be returned in the event of a successful formatting run or if `--lint` detects no violations. -* 1 - Lint failure. This code will be returned only when running in `--lint` mode if the input requires formatting. +* 1 - Lint failure. This code will be returned when running in `--lint` mode, or when autocorrecting in `--strict` mode, if the input requires formatting. * 70 - Program error. This code will be returned if there is a problem with the input or configuration arguments. @@ -826,6 +826,8 @@ It is common practice to include the file name, creation date and/or the current * `{file}` - the name of the file * `{year}` - the current year * `{created}` - the date on which the file was created +* `{created.name}` - the name of the user who first committed the file +* `{created.email}` - the email of the user who first committed the file * `{created.year}` - the year in which the file was created For example, a header template of: @@ -842,7 +844,7 @@ Will be formatted as: // Created by John Smith on 01/02/2016. ``` -**NOTE:** the `{year}` value and `{created}` date format are determined from the current locale and timezone of the machine running the script. +**NOTE:** the `{year}` value and `{created}` date format are determined from the current locale and timezone of the machine running the script. `{created.name}` and `{created.email}` requires the project to be version controlled by git. FAQ @@ -949,7 +951,13 @@ Known issues * If you have a generic typealias that defines a closure (e.g. `typealias ResultCompletion = (Result) -> Void`) and use this closure as an argument in a generic function (e.g. `func handle(_ completion: ResultCompletion)`), the `opaqueGenericParameters` rule may update the function definition to use `some` syntax (e.g. `func handle(_ completion: ResultCompletion)`). `some` syntax is not permitted in closure parameters, so this will no longer compile. Workarounds include spelling out the closure explicitly in the generic function (instead of using a `typealias`) or disabling the `opaqueGenericParameters` rule (e.g. with `// swiftformat:disable:next opaqueGenericParameters`). -* If compiling for macOS with Xcode 14.0 and configuring SwiftFormat with `--swift-version 5.7`, the `genericExtensions` rule may cause a build failure by updating extensions of the format `extension Collection where Element == Foo` to `extension Collection`. This fails to compile for macOS in Xcode 14.0, because the macOS SDK in that version of Xcode [does not include](https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171) the Swift 5.7 standard library. Workarounds include using `--swift-version 5.6` instead, updating to Xcode 14.1+, or disabling the `genericExtensions` rule (e.g. with `// swiftformat:next:disable genericExtensions`). +* If compiling for macOS with Xcode 14.0 and configuring SwiftFormat with `--swift-version 5.7`, the `genericExtensions` rule may cause a build failure by updating extensions of the format `extension Collection where Element == Foo` to `extension Collection`. This fails to compile for macOS in Xcode 14.0, because the macOS SDK in that version of Xcode [does not include](https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171) the Swift 5.7 standard library. Workarounds include using `--swift-version 5.6` instead, updating to Xcode 14.1+, or disabling the `genericExtensions` rule (e.g. with `// swiftformat:disable:next genericExtensions`). + +* The `propertyType` rule can cause a build failure in cases where there are multiple static overloads with the same name but different return types. As a workaround you can rename the overloads to no longer conflict, or exclude the property name with `--preservesymbols propertyName,otherPropertyName,etc`. + +* The `propertyType` rule can cause a build failure in cases where the property's type is a protocol / existential like `let shapeStyle: ShapeStyle = .myShapeStyle`, and the value used on the right-hand side is defined in an extension like `extension ShapeStyle where Self == MyShapeStyle { static var myShapeStyle: MyShapeStyle { ... } }`. As a workaround you can use the existential `any` syntax (`let shapeStyle: any ShapeStyle = .myShapeStyle`), which the rule will preserve as-is, or exclude the type name and/or property name with `--preservesymbols ShapeStyle,myShapeStyle,etc`. + +* The `propertyType` rule can cause a build failure in cases like `let foo = Foo.bar` where the value is a static member that doesn't return the same time. For example, `let foo: Foo = .bar` would be invalid if the `bar` property was defined as `static var bar: Bar`. As a workaround you can write the name of the type explicitly, like `let foo: Bar = Foo.bar`, or exclude the type name and/or property name with `--preservesymbols Bar,bar,etc`. Tip Jar diff --git a/Rules.md b/Rules.md index 9d4d8c08c..16dc39536 100644 --- a/Rules.md +++ b/Rules.md @@ -14,6 +14,7 @@ * [conditionalAssignment](#conditionalAssignment) * [consecutiveBlankLines](#consecutiveBlankLines) * [consecutiveSpaces](#consecutiveSpaces) +* [consistentSwitchStatementSpacing](#consistentSwitchStatementSpacing) * [duplicateImports](#duplicateImports) * [elseOnSameLine](#elseOnSameLine) * [emptyBraces](#emptyBraces) @@ -91,6 +92,7 @@ # Opt-in Rules (disabled by default) * [acronyms](#acronyms) +* [blankLineAfterMultilineSwitchCase](#blankLineAfterMultilineSwitchCase) * [blankLinesBetweenImports](#blankLinesBetweenImports) * [blockComments](#blockComments) * [docComments](#docComments) @@ -98,6 +100,8 @@ * [markTypes](#markTypes) * [noExplicitOwnership](#noExplicitOwnership) * [organizeDeclarations](#organizeDeclarations) +* [propertyType](#propertyType) +* [redundantProperty](#redundantProperty) * [sortSwitchCases](#sortSwitchCases) * [wrapConditionalBodies](#wrapConditionalBodies) * [wrapEnumCases](#wrapEnumCases) @@ -238,6 +242,38 @@ Insert blank line after import statements.
+## blankLineAfterMultilineSwitchCase + +Insert a blank line after multiline switch cases (excluding the last case, +which is followed by a closing brace). + +
+Examples + +```diff + func handle(_ action: SpaceshipAction) { + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() ++ + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArticialLife() ++ + case .handleIncomingEnergyBlast: + await energyShields.prepare() + energyShields.engage() + } + } +``` + +
+
+ ## blankLinesAroundMark Insert blank line before and after `MARK:` comments. @@ -472,6 +508,10 @@ Option | Description Assign properties using if / switch expressions. +Option | Description +--- | --- +`--condassignment` | Use cond. assignment: "after-property" (default) or "always". +
Examples @@ -485,7 +525,6 @@ Assign properties using if / switch expressions. - bar = "bar" + "bar" } -``` ```diff - let foo: String @@ -500,6 +539,18 @@ Assign properties using if / switch expressions. } ``` +// With --condassignment always (disabled by default) +- switch condition { ++ foo.bar = switch condition { + case true: +- foo.bar = "baaz" ++ "baaz" + case false: +- foo.bar = "quux" ++ "quux" + } +``` +

@@ -543,6 +594,63 @@ Replace consecutive spaces with a single space.
+## consistentSwitchStatementSpacing + +Ensures consistent spacing among all of the cases in a switch statement. + +
+Examples + +```diff + func handle(_ action: SpaceshipAction) { + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) ++ + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArtificialLife() + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + } +``` + +```diff + var name: PlanetType { + switch self { + case .mercury: + "Mercury" +- + case .venus: + "Venus" + case .earth: + "Earth" + case .mars: + "Mars" +- + case .jupiter: + "Jupiter" + case .saturn: + "Saturn" + case .uranus: + "Uranus" + case .neptune: + "Neptune" + } +``` + +
+
+ ## docComments Use doc comments for API declarations, otherwise use regular comments. @@ -737,6 +845,8 @@ Use specified source file header template for all files. Option | Description --- | --- `--header` | Header comments: "strip", "ignore", or the text you wish use +`--dateformat` | "system" (default), "iso", "dmy", "mdy" or custom +`--timezone` | "system" (default) or a valid identifier/abbreviation
Examples @@ -748,7 +858,16 @@ Token | Description `{file}` | File name `{year}` | Current year `{created}` | File creation date +`{created.name}` | Name of the user who first committed the file +`{created.email}` | Email of the user who first committed the file `{created.year}` | File creation year +`{created.follow}` | `{created}` but followed in git + +All `{created*}` options also have a `.follow` option to follow the file across +renames in the git history (e.g `{created.follow}` and `{created.name.follow}`). + +**Note**: `{created.follow*}, `{created.name}` and `{created.email}` requires +the project to be version controlled by git. **Example**: @@ -763,6 +882,66 @@ Token | Description + // ``` +You can use the following built-in formats for `--dateformat`: + +Token | Description +--- | --- +system | Use the local system locale +iso | ISO 8601 (yyyy-MM-dd) +dmy | Date/Month/Year (dd/MM/yyyy) +mdy | Month/Day/Year (MM/dd/yyyy) + +Custom formats are defined using +[Unicode symbols](https://www.unicode.org/reports/tr35/tr35-31/tr35-dates.html#Date_Field_Symbol_Table). + +`--dateformat iso` + +```diff +- // Created {created} ++ // Created 2023-08-10 +``` + +`--dateformat dmy` + +```diff +- // Created {created} ++ // Created 10/08/2023 +``` + +`--dateformat mdy` + +```diff +- // Created {created} ++ // Created 08/10/2023 +``` + +`--dateformat 'yyyy.MM.dd.HH.mm'` + +```diff +- // Created {created} ++ // Created 2023.08.10.11.00 +``` + +Setting a time zone enforces consistent date formatting across environments +around the world. By default the local system locale is used and for convenience +`gmt` and `utc` can be used. The time zone can be further customized by +setting it to a abbreviation/time zone identifier supported by the Swift +standard library. + +`--dateformat 'yyyy-MM-dd HH:mm ZZZZ' --timezone utc` + +```diff +- // Created {created} ++ // Created 2023-08-10 11:00 GMT +``` + +`--dateformat 'yyyy-MM-dd HH:mm ZZZZ' --timezone Pacific/Fiji` + +```diff +- // Created 2023-08-10 11:00 GMT ++ // Created 2023-08-10 23:00 GMT+12:00 +``` +

@@ -955,6 +1134,10 @@ Option | Description Add `@available(*, unavailable)` attribute to required `init(coder:)` when it hasn't been implemented. +Option | Description +--- | --- +`--initcodernil` | Replace fatalError with nil inside unavailable init +
Examples @@ -1299,6 +1482,47 @@ Convert trivial `map { $0.foo }` closures to keyPath-based syntax.

+## propertyType + +Convert property declarations to use inferred types (`let foo = Foo()`) or explicit types (`let foo: Foo = .init()`). + +Option | Description +--- | --- +`--inferredtypes` | "exclude-cond-exprs" (default) or "always" +`--preservesymbols` | Comma-delimited list of symbol names to preserve + +
+Examples + +```diff +- let foo: Foo = .init() ++ let foo: Foo = .init() + +- let bar: Bar = .defaultValue ++ let bar = .defaultValue + +- let baaz: Baaz = .buildBaaz(foo: foo, bar: bar) ++ let baaz = Baaz.buildBaaz(foo: foo, bar: bar) + + let float: CGFloat = 10.0 + let array: [String] = [] + let anyFoo: AnyFoo = foo + + // with --inferredtypes always: +- let foo: Foo = ++ let foo = + if condition { +- .init(bar) ++ Foo(bar) + } else { +- .init(baaz) ++ Foo(baaz) + } +``` + +
+
+ ## redundantBackticks Remove redundant backticks around identifiers. @@ -1644,6 +1868,24 @@ Remove redundant pattern matching parameter syntax.
+## redundantProperty + +Simplifies redundant property definitions that are immediately returned. + +
+Examples + +```diff + func foo() -> Foo { +- let foo = Foo() +- return foo ++ return Foo() + } +``` + +
+
+ ## redundantRawValues Remove redundant raw string values for enum cases. @@ -2074,6 +2316,7 @@ Option | Description `--operatorfunc` | Spacing for operator funcs: "spaced" (default) or "no-space" `--nospaceoperators` | Comma-delimited list of operators without surrounding space `--ranges` | Spacing for ranges: "spaced" (default) or "no-space" +`--typedelimiter` | "trailing" (default) or "leading-trailing"
Examples @@ -2453,10 +2696,12 @@ Option | Description `--wrapparameters` | Wrap func params: "before-first", "after-first", "preserve" `--wrapcollections` | Wrap array/dict: "before-first", "after-first", "preserve" `--closingparen` | Closing paren position: "balanced" (default) or "same-line" +`--callsiteparen` | Closing paren at callsite: "inherit" (default) or "same-line" `--wrapreturntype` | Wrap return type: "if-multiline", "preserve" (default) `--wrapconditions` | Wrap conditions: "before-first", "after-first", "preserve" `--wraptypealiases` | Wrap typealiases: "before-first", "after-first", "preserve" `--wrapeffects` | Wrap effects: "if-multiline", "never", "preserve" +`--conditionswrap` | Wrap conditions as Xcode 12:"auto", "always", "disabled"
Examples @@ -2517,6 +2762,17 @@ provided for `--wrapparameters`, the value for `--wraparguments` will be used. + ] ``` +`--conditionswrap auto`: + +```diff +- guard let foo = foo, let bar = bar, let third = third ++ guard let foo = foo, ++ let bar = bar, ++ let third = third + else {} +``` + +

@@ -2528,7 +2784,10 @@ Option | Description --- | --- `--funcattributes` | Function @attributes: "preserve", "prev-line", or "same-line" `--typeattributes` | Type @attributes: "preserve", "prev-line", or "same-line" -`--varattributes` | Property @attributes: "preserve", "prev-line", or "same-line" +`--storedvarattrs` | Stored var @attribs: "preserve", "prev-line", or "same-line" +`--computedvarattrs` | Computed var @attribs: "preserve", "prev-line", "same-line" +`--complexattrs` | Complex @attributes: "preserve", "prev-line", or "same-line" +`--noncomplexattrs` | List of @attributes to exclude from complexattrs rule
Examples diff --git a/Snapshots/Layout/UIDesigner/EditViewController.swift b/Snapshots/Layout/UIDesigner/EditViewController.swift index 22d48ef4e..e2875801e 100755 --- a/Snapshots/Layout/UIDesigner/EditViewController.swift +++ b/Snapshots/Layout/UIDesigner/EditViewController.swift @@ -54,7 +54,7 @@ class EditViewController: UIViewController, UITextFieldDelegate { @available(*, unavailable) required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") + nil } override func viewDidLoad() { diff --git a/Sources/Arguments.swift b/Sources/Arguments.swift index 8ebdcd185..c6770de1a 100644 --- a/Sources/Arguments.swift +++ b/Sources/Arguments.swift @@ -663,6 +663,7 @@ let commandLineArguments = [ "dryrun", "lint", "lenient", + "strict", "verbose", "quiet", "reporter", @@ -672,6 +673,8 @@ let commandLineArguments = [ "version", "options", "ruleinfo", + "dateformat", + "timezone", ] + optionsArguments let deprecatedArguments = Descriptors.all.compactMap { diff --git a/Sources/CommandLine.swift b/Sources/CommandLine.swift index 92f7cf40d..e43cf60dc 100644 --- a/Sources/CommandLine.swift +++ b/Sources/CommandLine.swift @@ -201,6 +201,7 @@ func printHelp(as type: CLI.OutputType) { --report Path to a file where --lint output should be written --reporter Report format: \(Reporters.help) --lenient Suppress errors for unformatted code in --lint mode + --strict Emit errors for unformatted code when formatting --verbose Display detailed formatting output and warnings/errors --quiet Disables non-critical output messages and warnings @@ -311,6 +312,7 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in var errors = [Error]() var verbose = false var lenient = false + var strict = false quietMode = false defer { @@ -328,9 +330,12 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in // Verbose verbose = (args["verbose"] != nil) - // Verbose + // Lenient lenient = (args["lenient"] != nil) + // Strict + strict = (args["strict"] != nil) + // Lint let lint = (args["lint"] != nil) @@ -482,6 +487,7 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in keys: [.creationDateKey, .pathKey] ) var formatOptions = options.formatOptions ?? .default + formatOptions.fileInfo = FileInfo( filePath: resourceValues.path, creationDate: resourceValues.creationDate @@ -509,6 +515,7 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in try addInputPaths(for: "quiet") try addInputPaths(for: "verbose") try addInputPaths(for: "lenient") + try addInputPaths(for: "strict") try addInputPaths(for: "dryrun") try addInputPaths(for: "lint") try addInputPaths(for: "inferoptions") @@ -703,6 +710,9 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in if lint, output != input { print("Source input did not pass lint check.", as: lenient ? .warning : .error) exitCode = lenient ? .ok : .lintFailure + } else if strict, output != input { + print("Source input was reformatted.", as: .error) + exitCode = .lintFailure } else { print("SwiftFormat completed successfully.", as: .success) exitCode = .ok @@ -787,7 +797,7 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in } } print("SwiftFormat completed in \(time).", as: .success) - return printResult(dryrun, lint, lenient, outputFlags) + return printResult(dryrun, lint, lenient, strict, outputFlags) } catch { _ = printWarnings(errors) // Fatal error @@ -837,7 +847,7 @@ func parseScriptInput(from environment: [String: String]) throws -> [URL] { } } -func printResult(_ dryrun: Bool, _ lint: Bool, _ lenient: Bool, _ flags: OutputFlags) -> ExitCode { +func printResult(_ dryrun: Bool, _ lint: Bool, _ lenient: Bool, _ strict: Bool, _ flags: OutputFlags) -> ExitCode { let (written, checked, skipped, failed) = flags let ignored = (skipped == 0) ? "" : ", \(skipped) file\(skipped == 1 ? "" : "s") skipped" if checked == 0 { @@ -852,7 +862,7 @@ func printResult(_ dryrun: Bool, _ lint: Bool, _ lenient: Bool, _ flags: OutputF } else { print("\(written)/\(checked) files formatted\(ignored).", as: .info) } - return !lenient && lint && failed > 0 ? .lintFailure : .ok + return ((!lenient && lint) || strict) && failed > 0 ? .lintFailure : .ok } func inferOptions(from inputURLs: [URL], options: FileOptions) -> (Int, FormatOptions, [Error]) { diff --git a/Sources/Examples.swift b/Sources/Examples.swift index 69a607686..350b0616b 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1160,6 +1160,17 @@ private struct Examples { + quuz + ] ``` + + `--conditionswrap auto`: + + ```diff + - guard let foo = foo, let bar = bar, let third = third + + guard let foo = foo, + + let bar = bar, + + let third = third + else {} + ``` + """ let wrapMultilineStatementBraces = """ @@ -1571,7 +1582,16 @@ private struct Examples { `{file}` | File name `{year}` | Current year `{created}` | File creation date + `{created.name}` | Name of the user who first committed the file + `{created.email}` | Email of the user who first committed the file `{created.year}` | File creation year + `{created.follow}` | `{created}` but followed in git + + All `{created*}` options also have a `.follow` option to follow the file across + renames in the git history (e.g `{created.follow}` and `{created.name.follow}`). + + **Note**: `{created.follow*}, `{created.name}` and `{created.email}` requires + the project to be version controlled by git. **Example**: @@ -1585,6 +1605,66 @@ private struct Examples { + // Copyright © 2023 CompanyName. + // ``` + + You can use the following built-in formats for `--dateformat`: + + Token | Description + --- | --- + system | Use the local system locale + iso | ISO 8601 (yyyy-MM-dd) + dmy | Date/Month/Year (dd/MM/yyyy) + mdy | Month/Day/Year (MM/dd/yyyy) + + Custom formats are defined using + [Unicode symbols](https://www.unicode.org/reports/tr35/tr35-31/tr35-dates.html#Date_Field_Symbol_Table). + + `--dateformat iso` + + ```diff + - // Created {created} + + // Created 2023-08-10 + ``` + + `--dateformat dmy` + + ```diff + - // Created {created} + + // Created 10/08/2023 + ``` + + `--dateformat mdy` + + ```diff + - // Created {created} + + // Created 08/10/2023 + ``` + + `--dateformat 'yyyy.MM.dd.HH.mm'` + + ```diff + - // Created {created} + + // Created 2023.08.10.11.00 + ``` + + Setting a time zone enforces consistent date formatting across environments + around the world. By default the local system locale is used and for convenience + `gmt` and `utc` can be used. The time zone can be further customized by + setting it to a abbreviation/time zone identifier supported by the Swift + standard library. + + `--dateformat 'yyyy-MM-dd HH:mm ZZZZ' --timezone utc` + + ```diff + - // Created {created} + + // Created 2023-08-10 11:00 GMT + ``` + + `--dateformat 'yyyy-MM-dd HH:mm ZZZZ' --timezone Pacific/Fiji` + + ```diff + - // Created 2023-08-10 11:00 GMT + + // Created 2023-08-10 23:00 GMT+12:00 + ``` """ let conditionalAssignment = """ @@ -1598,7 +1678,6 @@ private struct Examples { - bar = "bar" + "bar" } - ``` ```diff - let foo: String @@ -1612,6 +1691,18 @@ private struct Examples { + "bar" } ``` + + // With --condassignment always (disabled by default) + - switch condition { + + foo.bar = switch condition { + case true: + - foo.bar = "baaz" + + "baaz" + case false: + - foo.bar = "quux" + + "quux" + } + ``` """ let sortTypealiases = """ @@ -1683,6 +1774,29 @@ private struct Examples { ``` """ + let blankLineAfterMultilineSwitchCase = #""" + ```diff + func handle(_ action: SpaceshipAction) { + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArticialLife() + + + case .handleIncomingEnergyBlast: + await energyShields.prepare() + energyShields.engage() + } + } + ``` + """# + let wrapMultilineConditionalAssignment = #""" ```diff - let planetLocation = if let star = planet.star { @@ -1698,4 +1812,91 @@ private struct Examples { + } ``` """# + + let consistentSwitchStatementSpacing = #""" + ```diff + func handle(_ action: SpaceshipAction) { + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + + + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArtificialLife() + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + } + ``` + + ```diff + var name: PlanetType { + switch self { + case .mercury: + "Mercury" + - + case .venus: + "Venus" + case .earth: + "Earth" + case .mars: + "Mars" + - + case .jupiter: + "Jupiter" + case .saturn: + "Saturn" + case .uranus: + "Uranus" + case .neptune: + "Neptune" + } + ``` + """# + + let propertyType = """ + ```diff + - let foo: Foo = .init() + + let foo: Foo = .init() + + - let bar: Bar = .defaultValue + + let bar = .defaultValue + + - let baaz: Baaz = .buildBaaz(foo: foo, bar: bar) + + let baaz = Baaz.buildBaaz(foo: foo, bar: bar) + + let float: CGFloat = 10.0 + let array: [String] = [] + let anyFoo: AnyFoo = foo + + // with --inferredtypes always: + - let foo: Foo = + + let foo = + if condition { + - .init(bar) + + Foo(bar) + } else { + - .init(baaz) + + Foo(baaz) + } + ``` + """ + + let redundantProperty = """ + ```diff + func foo() -> Foo { + - let foo = Foo() + - return foo + + return Foo() + } + ``` + """ } diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index efe842138..ff75c812c 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -396,7 +396,9 @@ extension Formatter { keepParameterLabelsOnSameLine(startOfScope: i, endOfScope: &endOfScope) - if endOfScopeOnSameLine { + if options.closingCallSiteParenOnSameLine, isFunctionCall(at: i) { + removeLinebreakBeforeEndOfScope(at: &endOfScope) + } else if endOfScopeOnSameLine { removeLinebreakBeforeEndOfScope(at: &endOfScope) } else { // Insert linebreak before closing paren @@ -467,22 +469,34 @@ extension Formatter { // Remove linebreak after opening paren removeTokens(in: i + 1 ..< firstArgumentIndex) + endOfScope -= (firstArgumentIndex - (i + 1)) firstArgumentIndex = i + 1 // Get indent let start = startOfLine(at: i) let indent = spaceEquivalentToTokens(from: start, upTo: firstArgumentIndex) - removeLinebreakBeforeEndOfScope(at: &endOfScope) + + // Don't remove linebreak if there is one for `guard ... else` conditions + if token(at: endOfScope) != .keyword("else") { + removeLinebreakBeforeEndOfScope(at: &endOfScope) + } // Insert linebreak after each comma var lastBreakIndex: Int? var index = firstArgumentIndex + + let wrapIgnoringMaxWidth = Set([WrapMode.always, WrapMode.auto]).contains(options.conditionsWrap) + while let commaIndex = self.index(of: .delimiter(","), in: index ..< endOfScope), var linebreakIndex = self.index(of: .nonSpaceOrComment, after: commaIndex) { if let index = self.index(of: .nonSpace, before: linebreakIndex) { linebreakIndex = index + 1 } - if maxWidth > 0, lineLength(upTo: commaIndex) >= maxWidth, let breakIndex = lastBreakIndex { + + if maxWidth > 0, + wrapIgnoringMaxWidth || lineLength(upTo: commaIndex) >= maxWidth || wrapIgnoringMaxWidth, + let breakIndex = lastBreakIndex + { endOfScope += 1 + insertSpace(indent, at: breakIndex) insertLinebreak(at: breakIndex) lastBreakIndex = nil @@ -501,7 +515,10 @@ extension Formatter { } index = commaIndex + 1 } - if maxWidth > 0, let breakIndex = lastBreakIndex, lineLength(at: breakIndex) > maxWidth { + + if maxWidth > 0, let breakIndex = lastBreakIndex, + wrapIgnoringMaxWidth || lineLength(at: breakIndex) > maxWidth + { insertSpace(indent, at: breakIndex) insertLinebreak(at: breakIndex) } @@ -598,7 +615,7 @@ extension Formatter { wrapArgumentsAfterFirst(startOfScope: i, endOfScope: endOfScope, allowGrouping: true) - case .disabled, .default: + case .disabled, .default, .auto, .always: assertionFailure() // Shouldn't happen } @@ -654,7 +671,7 @@ extension Formatter { wrapArgumentsAfterFirst(startOfScope: i, endOfScope: endOfScope, allowGrouping: true) - case .disabled, .default: + case .disabled, .default, .auto, .always: assertionFailure() // Shouldn't happen } } @@ -677,7 +694,7 @@ extension Formatter { lastIndex = i } - // -- wrapconditions + // -- wrapconditions && -- conditionswrap forEach(.keyword) { index, token in let indent: String let endOfConditionsToken: Token @@ -695,16 +712,21 @@ extension Formatter { return } - // Only wrap when this is a control flow condition that spans multiple lines guard let endIndex = self.index(of: endOfConditionsToken, after: index), - let nextTokenIndex = self.index(of: .nonSpaceOrLinebreak, after: index), - !(onSameLine(index, endIndex) || self.index(of: .nonSpaceOrLinebreak, after: endOfLine(at: index)) == endIndex) + let nextTokenIndex = self.index(of: .nonSpaceOrLinebreak, after: index) else { return } + // Only wrap when this is a control flow condition that spans multiple lines + let controlFlowConditionSpansMultipleLines = self.index( + of: .nonSpaceOrLinebreak, + after: endOfLine(at: index) + ) != endIndex && !onSameLine(index, endIndex) + switch options.wrapConditions { - case .preserve, .disabled, .default: + case .preserve, .disabled, .default, .auto, .always: break case .beforeFirst: + guard controlFlowConditionSpansMultipleLines else { return } // Wrap if the next non-whitespace-or-comment // is on the same line as the control flow keyword if onSameLine(index, nextTokenIndex) { @@ -718,6 +740,7 @@ extension Formatter { linebreakIndex = self.index(of: .linebreak, after: index) } case .afterFirst: + guard controlFlowConditionSpansMultipleLines else { return } // Unwrap if the next non-whitespace-or-comment // is not on the same line as the control flow keyword if !onSameLine(index, nextTokenIndex), @@ -735,6 +758,71 @@ extension Formatter { lastIndex = index } } + + switch options.conditionsWrap { + case .auto, .always: + if !onSameLine(index, nextTokenIndex), + let linebreakIndex = self.index(of: .linebreak, in: index ..< nextTokenIndex) + { + removeToken(at: linebreakIndex) + } + + insertSpace(" ", at: index + 1) + + let isCaseForAutoWrap = lineLength(at: index) > maxWidth || controlFlowConditionSpansMultipleLines + if !(options.conditionsWrap == .always || isCaseForAutoWrap) { + return + } + + wrapArgumentsAfterFirst(startOfScope: index + 1, + endOfScope: endIndex, + allowGrouping: true) + + // Xcode 12 wraps guard's else on a new line + guard token == .keyword("guard") else { break } + + // Leave only one breakline before else + if let endOfConditionsTokenIndexAfterChanges = self.index(of: endOfConditionsToken, after: index), + let lastArgumentIndex = self.index(of: .nonSpaceOrLinebreak, before: endOfConditionsTokenIndexAfterChanges) + { + let slice = tokens[lastArgumentIndex ..< endOfConditionsTokenIndexAfterChanges] + let breaklineIndexes = slice.indices.filter { tokens[$0].isLinebreak } + + if breaklineIndexes.isEmpty { + insertLinebreak(at: endOfConditionsTokenIndexAfterChanges - 1) + } else if breaklineIndexes.count > 1 { + for breaklineIndex in breaklineIndexes.dropFirst() { + removeToken(at: breaklineIndex) + } + } + } + + // Space token before `else` should match space token before `guard` + if let endOfConditionsTokenIndexAfterChanges = self.index(of: endOfConditionsToken, after: index), + let lastArgumentIndex = self.index(of: .nonSpaceOrLinebreak, before: endOfConditionsTokenIndexAfterChanges) + { + let slice = tokens[lastArgumentIndex ..< endOfConditionsTokenIndexAfterChanges] + let spaceIndexes = slice.indices.filter { tokens[$0].isSpace } + + if let spaceToken = self.token(at: index - 1), spaceToken.isSpace { + if spaceIndexes.count == 1, let spaceIndex = spaceIndexes.first, + let existedSpaceToken = self.token(at: spaceIndex), spaceToken == existedSpaceToken + { + /* Nothing to do here */ + break + } else { + spaceIndexes.forEach { removeToken(at: $0) } + insertSpace(spaceToken.string, at: endOfConditionsTokenIndexAfterChanges) + } + } else { + spaceIndexes.forEach { removeToken(at: $0) } + } + } + + default: + /* Nothing to do here */ + break + } } // Wraps / re-wraps a multi-line statement where each delimiter index @@ -811,7 +899,7 @@ extension Formatter { wrapIndices = andTokenIndices case .beforeFirst: wrapIndices = [equalsIndex] + andTokenIndices - case .default, .disabled, .preserve: + case .default, .disabled, .preserve, .auto, .always: return } @@ -1258,7 +1346,7 @@ extension Formatter { { branches.append((startOfBranch: startOfBody, endOfBranch: endOfBody)) - if tokens[endOfBody].isSwitchCaseOrDefault { + if tokens[endOfBody].isSwitchCaseOrDefault || tokens[endOfBody] == .keyword("@unknown") { nextConditionalBranchIndex = endOfBody } else if tokens[startOfBody ..< endOfBody].contains(.startOfScope("#if")) { return nil @@ -1360,6 +1448,122 @@ extension Formatter { return allSatisfy } + /// Context describing the structure of a case in a switch statement + struct SwitchStatementBranchWithSpacingInfo { + let startOfBranchExcludingLeadingComments: Int + let endOfBranchExcludingTrailingComments: Int + let spansMultipleLines: Bool + let isLastCase: Bool + let isFollowedByBlankLine: Bool + let linebreakBeforeEndOfScope: Int? + let linebreakBeforeBlankLine: Int? + + /// Inserts a blank line at the end of the switch case + func insertTrailingBlankLine(using formatter: Formatter) { + guard let linebreakBeforeEndOfScope = linebreakBeforeEndOfScope else { + return + } + + formatter.insertLinebreak(at: linebreakBeforeEndOfScope) + } + + /// Removes the trailing blank line from the switch case if present + func removeTrailingBlankLine(using formatter: Formatter) { + guard let linebreakBeforeEndOfScope = linebreakBeforeEndOfScope, + let linebreakBeforeBlankLine = linebreakBeforeBlankLine + else { return } + + formatter.removeTokens(in: (linebreakBeforeBlankLine + 1) ... linebreakBeforeEndOfScope) + } + } + + /// Finds all of the branch bodies in a switch statement, and derives additional information + /// about the structure of each branch / case. + func switchStatementBranchesWithSpacingInfo(at switchIndex: Int) -> [SwitchStatementBranchWithSpacingInfo]? { + guard let switchStatementBranches = switchStatementBranches(at: switchIndex) else { return nil } + + return switchStatementBranches.enumerated().compactMap { caseIndex, switchCase -> SwitchStatementBranchWithSpacingInfo? in + // Exclude any comments when considering if this is a single line or multi-line branch + var startOfBranchExcludingLeadingComments = switchCase.startOfBranch + while let tokenAfterStartOfScope = index(of: .nonSpace, after: startOfBranchExcludingLeadingComments), + tokens[tokenAfterStartOfScope].isLinebreak, + let commentAfterStartOfScope = index(of: .nonSpace, after: tokenAfterStartOfScope), + tokens[commentAfterStartOfScope].isComment, + let endOfComment = endOfScope(at: commentAfterStartOfScope), + let tokenBeforeEndOfComment = index(of: .nonSpace, before: endOfComment) + { + if tokens[endOfComment].isLinebreak { + startOfBranchExcludingLeadingComments = tokenBeforeEndOfComment + } else { + startOfBranchExcludingLeadingComments = endOfComment + } + } + + var endOfBranchExcludingTrailingComments = switchCase.endOfBranch + while let tokenBeforeEndOfScope = index(of: .nonSpace, before: endOfBranchExcludingTrailingComments), + tokens[tokenBeforeEndOfScope].isLinebreak, + let commentBeforeEndOfScope = index(of: .nonSpace, before: tokenBeforeEndOfScope), + tokens[commentBeforeEndOfScope].isComment, + let startOfComment = startOfScope(at: commentBeforeEndOfScope), + tokens[startOfComment].isComment + { + endOfBranchExcludingTrailingComments = startOfComment + } + + guard let firstTokenInBody = index(of: .nonSpaceOrLinebreak, after: startOfBranchExcludingLeadingComments), + let lastTokenInBody = index(of: .nonSpaceOrLinebreak, before: endOfBranchExcludingTrailingComments) + else { return nil } + + let isLastCase = caseIndex == switchStatementBranches.indices.last + let spansMultipleLines = !onSameLine(firstTokenInBody, lastTokenInBody) + + var isFollowedByBlankLine = false + var linebreakBeforeEndOfScope: Int? + var linebreakBeforeBlankLine: Int? + + if let tokenBeforeEndOfScope = index(of: .nonSpace, before: endOfBranchExcludingTrailingComments), + tokens[tokenBeforeEndOfScope].isLinebreak + { + linebreakBeforeEndOfScope = tokenBeforeEndOfScope + } + + if let linebreakBeforeEndOfScope = linebreakBeforeEndOfScope, + let tokenBeforeBlankLine = index(of: .nonSpace, before: linebreakBeforeEndOfScope), + tokens[tokenBeforeBlankLine].isLinebreak + { + linebreakBeforeBlankLine = tokenBeforeBlankLine + isFollowedByBlankLine = true + } + + return SwitchStatementBranchWithSpacingInfo( + startOfBranchExcludingLeadingComments: startOfBranchExcludingLeadingComments, + endOfBranchExcludingTrailingComments: endOfBranchExcludingTrailingComments, + spansMultipleLines: spansMultipleLines, + isLastCase: isLastCase, + isFollowedByBlankLine: isFollowedByBlankLine, + linebreakBeforeEndOfScope: linebreakBeforeEndOfScope, + linebreakBeforeBlankLine: linebreakBeforeBlankLine + ) + } + } + + /// Whether the given index is in a function call (not declaration) + func isFunctionCall(at index: Int) -> Bool { + if let openingParenIndex = self.index(of: .startOfScope("("), before: index + 1) { + if let prevTokenIndex = self.index(of: .nonSpaceOrCommentOrLinebreak, before: openingParenIndex), + tokens[prevTokenIndex].isIdentifier + { + if let keywordIndex = self.index(of: .nonSpaceOrCommentOrLinebreak, before: prevTokenIndex), + tokens[keywordIndex] == .keyword("func") || tokens[keywordIndex] == .keyword("init") + { + return false + } + return true + } + } + return false + } + /// Whether the given index is directly within the body of the given scope, or part of a nested closure func indexIsWithinNestedClosure(_ index: Int, startOfScopeIndex: Int) -> Bool { let startOfScopeAtIndex: Int @@ -3277,3 +3481,41 @@ extension Formatter { isTypeRoot: false, isInit: false) } } + +extension Date { + static var yearFormatter: (Date) -> String = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy" + return { date in formatter.string(from: date) } + }() + + static var currentYear = yearFormatter(Date()) + + var yearString: String { + Date.yearFormatter(self) + } + + func format(with format: DateFormat, timeZone: FormatTimeZone) -> String { + let formatter = DateFormatter() + + if let chosenTimeZone = timeZone.timeZone { + formatter.timeZone = chosenTimeZone + } + + switch format { + case .system: + formatter.dateStyle = .short + formatter.timeStyle = .none + case .dayMonthYear: + formatter.dateFormat = "dd/MM/yyyy" + case .iso: + formatter.dateFormat = "yyyy-MM-dd" + case .monthDayYear: + formatter.dateFormat = "MM/dd/yyyy" + case let .custom(format): + formatter.dateFormat = format + } + + return formatter.string(from: self) + } +} diff --git a/Sources/GitHelpers.swift b/Sources/GitHelpers.swift new file mode 100644 index 000000000..a4d6e2600 --- /dev/null +++ b/Sources/GitHelpers.swift @@ -0,0 +1,157 @@ +// +// GitHelpers.swift +// SwiftFormat +// +// Created by Hampus Tågerud on 2023-08-08. +// Copyright 2023 Nick Lockwood and the SwiftFormat project authors +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/nicklockwood/SwiftFormat +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +private func memoize(_ keyFn: @escaping (K) -> String?, + _ workFn: @escaping (K) -> T) -> (K) -> T +{ + let lock = NSLock() + var cache: [String: T] = [:] + + return { input in + let key = keyFn(input) ?? "@nil" + + lock.lock() + defer { lock.unlock() } + + if let value = cache[key] { + return value + } + + let newValue = workFn(input) + cache[key] = newValue + + return newValue + } +} + +struct GitFileInfo { + var createdByName: String? + var createdByEmail: String? + var createdAt: Date? +} + +enum GitHelpers { + static let getGitRoot: (URL) -> URL? = memoize({ $0.relativePath }) { url in + let dir = "git rev-parse --show-toplevel".shellOutput(cwd: url) + + guard let root = dir, FileManager.default.fileExists(atPath: root) else { + return nil + } + + return URL(fileURLWithPath: root, isDirectory: true) + } + + // If a file has never been committed, default to the local git user for the repository + static let getDefaultGitInfo: (URL) -> GitFileInfo? = memoize({ $0.relativePath }) { url in + let name = "git config user.name".shellOutput(cwd: url) + let email = "git config user.email".shellOutput(cwd: url) + + guard let safeName = name, let safeEmail = email else { return nil } + + return GitFileInfo(createdByName: safeName, createdByEmail: safeEmail) + } + + private static func getGitCommit(_ url: URL, root: URL, follow: Bool) -> String? { + let command = [ + "git log", + // --follow to keep tracking the file across renames + follow ? "--follow" : "", + "--diff-filter=A", + "--author-date-order", + "--pretty=%H", + url.relativePath, + ] + .filter { ($0?.count ?? 0) > 0 } + .joined(separator: " ") + + let output = command.shellOutput(cwd: root) + + guard let safeValue = output, !safeValue.isEmpty else { return nil } + + if safeValue.contains("\n") { + let parts = safeValue.split(separator: "\n") + + if parts.count > 1, let first = parts.first { + return String(first) + } + } + + return safeValue + } + + static var json: JSONDecoder { JSONDecoder() } + + static let getCommitInfo: ((String, URL)) -> GitFileInfo? = memoize( + { hash, root in hash + root.relativePath }, + { hash, root in + let format = #"{"name":"%an","email":"%ae","time":"%at"}"# + let command = "git show --format='\(format)' -s \(hash)" + guard let commitInfo = command.shellOutput(cwd: root) else { + return nil + } + + guard let commitData = commitInfo.data(using: .utf8) else { + return nil + } + + let MapType = [String: String].self + guard let dict = try? json.decode(MapType, from: commitData) else { + return nil + } + + let (name, email) = (dict["name"], dict["email"]) + + var date: Date? + if let createdAtString = dict["time"], + let interval = TimeInterval(createdAtString) + { + date = Date(timeIntervalSince1970: interval) + } + + return GitFileInfo(createdByName: name, + createdByEmail: email, + createdAt: date) + } + ) + + static func fileInfo(_ url: URL, follow: Bool = false) -> GitFileInfo? { + let dir = url.deletingLastPathComponent() + guard let gitRoot = getGitRoot(dir) else { return nil } + + guard let commitHash = getGitCommit(url, root: gitRoot, follow: follow) else { + return nil + } + + return getCommitInfo((commitHash, gitRoot)) + } +} diff --git a/Sources/Inference.swift b/Sources/Inference.swift index 1952c1f85..9fa9fec2f 100644 --- a/Sources/Inference.swift +++ b/Sources/Inference.swift @@ -379,7 +379,9 @@ private struct Inference { } let closingParenOnSameLine = OptionInferrer { formatter, options in - var balanced = 0, sameLine = 0 + var functionCallSameLine = 0, functionCallBalanced = 0 + var functionDeclarationSameLine = 0, functionDeclarationBalanced = 0 + formatter.forEach(.startOfScope("(")) { i, _ in guard let closingBraceIndex = formatter.endOfScope(at: i), let linebreakIndex = formatter.index(of: .linebreak, after: i), @@ -387,13 +389,39 @@ private struct Inference { else { return } - if formatter.last(.nonSpaceOrComment, before: closingBraceIndex)?.isLinebreak == true { - balanced += 1 + + let isClosingParenOnSameLine = (formatter.last(.nonSpaceOrComment, before: closingBraceIndex)?.isLinebreak != true) + + if formatter.isFunctionCall(at: i) { + if isClosingParenOnSameLine { + functionCallSameLine += 1 + } else { + functionCallBalanced += 1 + } } else { - sameLine += 1 + if isClosingParenOnSameLine { + functionDeclarationSameLine += 1 + } else { + functionDeclarationBalanced += 1 + } } } - options.closingParenOnSameLine = (sameLine > balanced) + + // Decide on closingCallSiteParenOnSameLine + if functionCallSameLine > functionCallBalanced && functionDeclarationBalanced > functionDeclarationSameLine { + options.closingCallSiteParenOnSameLine = true + } else { + options.closingCallSiteParenOnSameLine = false + } + + // If closingCallSiteParenOnSameLine is true, trust only the declarations to infer closingParenOnSameLine + if options.closingCallSiteParenOnSameLine { + options.closingParenOnSameLine = functionDeclarationSameLine > functionDeclarationBalanced + } else { + let balanced = functionDeclarationBalanced + functionCallBalanced + let sameLine = functionDeclarationSameLine + functionCallSameLine + options.closingParenOnSameLine = sameLine > balanced + } } let uppercaseHex = OptionInferrer { formatter, options in diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 67fe709eb..a19a32672 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -496,6 +496,12 @@ struct _Descriptors { help: "Wrap ternary operators: \"default\", \"before-operators\"", keyPath: \.wrapTernaryOperators ) + let conditionsWrap = OptionDescriptor( + argumentName: "conditionswrap", + displayName: "Conditions Wrap", + help: "Wrap conditions as Xcode 12:\"auto\", \"always\", \"disabled\"", + keyPath: \.conditionsWrap + ) let closingParenOnSameLine = OptionDescriptor( argumentName: "closingparen", displayName: "Closing Paren Position", @@ -504,6 +510,14 @@ struct _Descriptors { trueValues: ["same-line"], falseValues: ["balanced"] ) + let closingCallSiteParenOnSameLine = OptionDescriptor( + argumentName: "callsiteparen", + displayName: "Call Site Closing Paren", + help: "Closing paren at callsite: \"inherit\" (default) or \"same-line\"", + keyPath: \.closingCallSiteParenOnSameLine, + trueValues: ["same-line"], + falseValues: ["inherit"] + ) let uppercaseHex = OptionDescriptor( argumentName: "hexliteralcase", displayName: "Hex Literal Case", @@ -692,6 +706,12 @@ struct _Descriptors { } } ) + let spaceAroundDelimiter = OptionDescriptor( + argumentName: "typedelimiter", + displayName: "Spacing around delimiter", + help: "\"trailing\" (default) or \"leading-trailing\"", + keyPath: \.spaceAroundDelimiter + ) let spaceAroundRangeOperators = OptionDescriptor( argumentName: "ranges", displayName: "Ranges", @@ -845,11 +865,29 @@ struct _Descriptors { help: "Type @attributes: \"preserve\", \"prev-line\", or \"same-line\"", keyPath: \.typeAttributes ) - let varAttributes = OptionDescriptor( - argumentName: "varattributes", - displayName: "Var Attributes", - help: "Property @attributes: \"preserve\", \"prev-line\", or \"same-line\"", - keyPath: \.varAttributes + let storedVarAttributes = OptionDescriptor( + argumentName: "storedvarattrs", + displayName: "Stored Property Attributes", + help: "Stored var @attribs: \"preserve\", \"prev-line\", or \"same-line\"", + keyPath: \.storedVarAttributes + ) + let computedVarAttributes = OptionDescriptor( + argumentName: "computedvarattrs", + displayName: "Computed Property Attributes", + help: "Computed var @attribs: \"preserve\", \"prev-line\", \"same-line\"", + keyPath: \.computedVarAttributes + ) + let complexAttributes = OptionDescriptor( + argumentName: "complexattrs", + displayName: "Complex Attributes", + help: "Complex @attributes: \"preserve\", \"prev-line\", or \"same-line\"", + keyPath: \.complexAttributes + ) + let complexAttributesExceptions = OptionDescriptor( + argumentName: "noncomplexattrs", + displayName: "Complex Attribute exceptions", + help: "List of @attributes to exclude from complexattrs rule", + keyPath: \.complexAttributesExceptions ) let yodaSwap = OptionDescriptor( argumentName: "yodaswap", @@ -869,6 +907,14 @@ struct _Descriptors { help: "\"inferred\", \"explicit\", or \"infer-locals-only\" (default)", keyPath: \.redundantType ) + let inferredTypesInConditionalExpressions = OptionDescriptor( + argumentName: "inferredtypes", + displayName: "Prefer Inferred Types", + help: "\"exclude-cond-exprs\" (default) or \"always\"", + keyPath: \.inferredTypesInConditionalExpressions, + trueValues: ["exclude-cond-exprs"], + falseValues: ["always"] + ) let emptyBracesSpacing = OptionDescriptor( argumentName: "emptybraces", displayName: "Empty Braces", @@ -949,6 +995,38 @@ struct _Descriptors { trueValues: ["preserve"], falseValues: ["before-declarations", "declarations"] ) + let conditionalAssignmentOnlyAfterNewProperties = OptionDescriptor( + argumentName: "condassignment", + displayName: "Apply conditionalAssignment rule", + help: "Use cond. assignment: \"after-property\" (default) or \"always\".", + keyPath: \.preserveSingleLineForEach, + trueValues: ["after-property"], + falseValues: ["always"] + ) + let initCoderNil = OptionDescriptor( + argumentName: "initcodernil", + displayName: "nil for initWithCoder", + help: "Replace fatalError with nil inside unavailable init", + keyPath: \.initCoderNil, + trueValues: ["true", "enabled"], + falseValues: ["false", "disabled"] + ) + let dateFormat = OptionDescriptor( + argumentName: "dateformat", + displayName: "Date format", + help: "\"system\" (default), \"iso\", \"dmy\", \"mdy\" or custom", + keyPath: \.dateFormat, + fromArgument: { DateFormat(rawValue: $0) }, + toArgument: { $0.rawValue } + ) + let timeZone = OptionDescriptor( + argumentName: "timezone", + displayName: "Date formatting timezone", + help: "\"system\" (default) or a valid identifier/abbreviation", + keyPath: \.timeZone, + fromArgument: { FormatTimeZone(rawValue: $0) }, + toArgument: { $0.rawValue } + ) // MARK: - Internal @@ -974,6 +1052,12 @@ struct _Descriptors { help: "The version of Swift used in the files being formatted", keyPath: \.swiftVersion ) + let preserveSymbols = OptionDescriptor( + argumentName: "preservesymbols", + displayName: "Preserve Symbols", + help: "Comma-delimited list of symbol names to preserve", + keyPath: \.preserveSymbols + ) // MARK: - DEPRECATED @@ -1013,6 +1097,13 @@ struct _Descriptors { trueValues: ["enabled", "true"], falseValues: ["disabled", "false"] ) + let varAttributes = OptionDescriptor( + argumentName: "varattributes", + displayName: "Var Attributes", + help: "Property @attributes: \"preserve\", \"prev-line\", or \"same-line\"", + deprecationMessage: "Use with `--storedvarattrs` or `--computedvarattrs` instead.", + keyPath: \.varAttributes + ) // MARK: - RENAMED diff --git a/Sources/Options.swift b/Sources/Options.swift index 230660be0..3b22e0f1f 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -56,6 +56,8 @@ public enum WrapMode: String, CaseIterable { case beforeFirst = "before-first" case afterFirst = "after-first" case preserve + case auto + case always case disabled case `default` @@ -71,6 +73,10 @@ public enum WrapMode: String, CaseIterable { self = .disabled case "default": self = .default + case "auto": + self = .auto + case "always": + self = .always default: return nil } @@ -197,6 +203,19 @@ public struct Version: RawRepresentable, Comparable, ExpressibleByStringLiteral, } } +public enum ReplacementKey: String, CaseIterable { + case fileName = "file" + case currentYear = "year" + case createdDate = "created" + case createdName = "created.name" + case createdEmail = "created.email" + case createdYear = "created.year" + case followedCreatedDate = "created.follow" + case followedCreatedName = "created.name.follow" + case followedCreatedEmail = "created.email.follow" + case followedCreatedYear = "created.year.follow" +} + /// Argument type for stripping public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStringLiteral { case ignore @@ -239,24 +258,129 @@ public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStrin return string.isEmpty ? "strip" : string.replacingOccurrences(of: "\n", with: "\\n") } } + + public func hasTemplateKey(_ keys: ReplacementKey...) -> Bool { + guard case let .replace(str) = self else { + return false + } + + return keys.contains(where: { str.contains("{\($0.rawValue)}") }) + } + + public var needsGitInfo: Bool { + hasTemplateKey(.createdDate, .createdYear, + .createdName, .createdEmail) + } + + public var needsFollowGitInfo: Bool { + hasTemplateKey(.followedCreatedDate, .followedCreatedYear, + .followedCreatedName, .followedCreatedEmail) + } +} + +public struct ReplacementOptions { + var dateFormat: DateFormat + var timeZone: FormatTimeZone + + init(dateFormat: DateFormat, timeZone: FormatTimeZone) { + self.dateFormat = dateFormat + self.timeZone = timeZone + } + + init(_ options: FormatOptions) { + self.init(dateFormat: options.dateFormat, timeZone: options.timeZone) + } +} + +public enum ReplacementType: Equatable { + case constant(String) + case dynamic((FileInfo, ReplacementOptions) -> String?) + + init?(_ value: String?) { + guard let val = value else { return nil } + self = .constant(val) + } + + public static func == (lhs: ReplacementType, rhs: ReplacementType) -> Bool { + switch (lhs, rhs) { + case let (.constant(lhsVal), .constant(rhsVal)): + return lhsVal == rhsVal + case let (.dynamic(lhsClosure), .dynamic(rhsClosure)): + return lhsClosure as AnyObject === rhsClosure as AnyObject + default: + return false + } + } + + public func resolve(_ info: FileInfo, _ options: ReplacementOptions) -> String? { + switch self { + case let .constant(value): + return value + case let .dynamic(fn): + return fn(info, options) + } + } } /// File info, used for constructing header comments public struct FileInfo: Equatable, CustomStringConvertible { - var filePath: String? + static var defaultReplacements: [ReplacementKey: ReplacementType] = [ + .createdDate: .dynamic { info, options in + info.creationDate?.format(with: options.dateFormat, + timeZone: options.timeZone) + }, + .createdYear: .dynamic { info, _ in info.creationDate?.yearString }, + .followedCreatedDate: .dynamic { info, options in + info.followedCreationDate?.format(with: options.dateFormat, + timeZone: options.timeZone) + }, + .followedCreatedYear: .dynamic { info, _ in + info.followedCreationDate?.yearString + }, + .currentYear: .constant(Date.currentYear), + ] + + let filePath: String? var creationDate: Date? + var followedCreationDate: Date? + var replacements: [ReplacementKey: ReplacementType] = Self.defaultReplacements var fileName: String? { filePath.map { URL(fileURLWithPath: $0).lastPathComponent } } - public init(filePath: String? = nil, creationDate: Date? = nil) { + public init( + filePath: String? = nil, + creationDate: Date? = nil, + followedCreationDate: Date? = nil, + replacements: [ReplacementKey: ReplacementType] = [:] + ) { self.filePath = filePath self.creationDate = creationDate + self.followedCreationDate = followedCreationDate + + self.replacements.merge(replacements, uniquingKeysWith: { $1 }) + + if let fileName = fileName { + self.replacements[.fileName] = .constant(fileName) + } } public var description: String { - "\(fileName ?? "");\(creationDate.map { "\($0)" } ?? "")" + replacements.enumerated() + .map { "\($0)=\($1)" } + .joined(separator: ";") + } + + public func hasReplacement(for key: ReplacementKey, options: FormatOptions) -> Bool { + switch replacements[key] { + case nil: + return false + case .constant: + return true + case let .dynamic(fn): + return fn(self, ReplacementOptions(options)) != nil + } } } @@ -348,6 +472,104 @@ public enum EnumNamespacesMode: String, CaseIterable { case structsOnly = "structs-only" } +/// Whether or not to add spacing around data type delimiter +public enum SpaceAroundDelimiter: String, CaseIterable { + case trailing + case leadingTrailing = "leading-trailing" +} + +/// Format to use when printing dates +public enum DateFormat: Equatable, RawRepresentable, CustomStringConvertible { + case dayMonthYear + case iso + case monthDayYear + case system + case custom(String) + + public init?(rawValue: String) { + switch rawValue { + case "dmy": + self = .dayMonthYear + case "iso": + self = .iso + case "mdy": + self = .monthDayYear + case "system": + self = .system + default: + self = .custom(rawValue) + } + } + + public var rawValue: String { + switch self { + case .dayMonthYear: + return "dmy" + case .iso: + return "iso" + case .monthDayYear: + return "mdy" + case .system: + return "system" + case let .custom(str): + return str + } + } + + public var description: String { + rawValue + } +} + +/// Timezone to use when printing dates +public enum FormatTimeZone: Equatable, RawRepresentable, CustomStringConvertible { + case system + case abbreviation(String) + case identifier(String) + + static let utcNames = ["utc", "gmt"] + + public init?(rawValue: String) { + if Self.utcNames.contains(rawValue.lowercased()) { + self = .identifier("UTC") + } else if TimeZone.knownTimeZoneIdentifiers.contains(rawValue) { + self = .identifier(rawValue) + } else if TimeZone.abbreviationDictionary.keys.contains(rawValue) { + self = .abbreviation(rawValue) + } else if rawValue == Self.system.rawValue { + self = .system + } else { + return nil + } + } + + public var rawValue: String { + switch self { + case .system: + return "system" + case let .abbreviation(abbreviation): + return abbreviation + case let .identifier(identifier): + return identifier + } + } + + public var timeZone: TimeZone? { + switch self { + case .system: + return TimeZone.current + case let .abbreviation(abbreviation): + return TimeZone(abbreviation: abbreviation) + case let .identifier(identifier): + return TimeZone(identifier: identifier) + } + } + + public var description: String { + rawValue + } +} + /// When initializing an optional value type, /// is it necessary to explicitly declare a default value public enum NilInitType: String, CaseIterable { @@ -382,9 +604,11 @@ public struct FormatOptions: CustomStringConvertible { public var wrapTypealiases: WrapMode public var wrapEnumCases: WrapEnumCases public var closingParenOnSameLine: Bool + public var closingCallSiteParenOnSameLine: Bool public var wrapReturnType: WrapReturnType public var wrapConditions: WrapMode public var wrapTernaryOperators: TernaryOperatorWrapMode + public var conditionsWrap: WrapMode public var uppercaseHex: Bool public var uppercaseExponent: Bool public var decimalGrouping: Grouping @@ -417,6 +641,10 @@ public struct FormatOptions: CustomStringConvertible { public var funcAttributes: AttributeMode public var typeAttributes: AttributeMode public var varAttributes: AttributeMode + public var storedVarAttributes: AttributeMode + public var computedVarAttributes: AttributeMode + public var complexAttributes: AttributeMode + public var complexAttributesExceptions: Set public var markTypes: MarkMode public var typeMarkComment: String public var markExtensions: MarkMode @@ -434,6 +662,8 @@ public struct FormatOptions: CustomStringConvertible { public var yodaSwap: YodaMode public var extensionACLPlacement: ExtensionACLPlacement public var redundantType: RedundantType + public var preserveSymbols: Set + public var inferredTypesInConditionalExpressions: Bool public var emptyBracesSpacing: EmptyBracesSpacing public var acronyms: Set public var indentStrings: Bool @@ -446,6 +676,11 @@ public struct FormatOptions: CustomStringConvertible { public var preserveAnonymousForEach: Bool public var preserveSingleLineForEach: Bool public var preserveDocComments: Bool + public var conditionalAssignmentOnlyAfterNewProperties: Bool + public var spaceAroundDelimiter: SpaceAroundDelimiter + public var initCoderNil: Bool + public var dateFormat: DateFormat + public var timeZone: FormatTimeZone public var nilInitType: NilInitType /// Deprecated @@ -486,9 +721,11 @@ public struct FormatOptions: CustomStringConvertible { wrapTypealiases: WrapMode = .preserve, wrapEnumCases: WrapEnumCases = .always, closingParenOnSameLine: Bool = false, + closingCallSiteParenOnSameLine: Bool = false, wrapReturnType: WrapReturnType = .preserve, wrapConditions: WrapMode = .preserve, wrapTernaryOperators: TernaryOperatorWrapMode = .default, + conditionsWrap: WrapMode = .disabled, uppercaseHex: Bool = true, uppercaseExponent: Bool = false, decimalGrouping: Grouping = .group(3, 6), @@ -521,6 +758,10 @@ public struct FormatOptions: CustomStringConvertible { funcAttributes: AttributeMode = .preserve, typeAttributes: AttributeMode = .preserve, varAttributes: AttributeMode = .preserve, + storedVarAttributes: AttributeMode = .preserve, + computedVarAttributes: AttributeMode = .preserve, + complexAttributes: AttributeMode = .preserve, + complexAttributesExceptions: Set = [], markTypes: MarkMode = .always, typeMarkComment: String = "MARK: - %t", markExtensions: MarkMode = .always, @@ -538,6 +779,8 @@ public struct FormatOptions: CustomStringConvertible { yodaSwap: YodaMode = .always, extensionACLPlacement: ExtensionACLPlacement = .onExtension, redundantType: RedundantType = .inferLocalsOnly, + preserveSymbols: Set = [], + inferredTypesInConditionalExpressions: Bool = false, emptyBracesSpacing: EmptyBracesSpacing = .noSpace, acronyms: Set = ["ID", "URL", "UUID"], indentStrings: Bool = false, @@ -550,6 +793,11 @@ public struct FormatOptions: CustomStringConvertible { preserveAnonymousForEach: Bool = false, preserveSingleLineForEach: Bool = true, preserveDocComments: Bool = false, + conditionalAssignmentOnlyAfterNewProperties: Bool = true, + spaceAroundDelimiter: SpaceAroundDelimiter = .trailing, + initCoderNil: Bool = false, + dateFormat: DateFormat = .system, + timeZone: FormatTimeZone = .system, nilInitType: NilInitType = .remove, // Doesn't really belong here, but hard to put elsewhere fragment: Bool = false, @@ -580,9 +828,11 @@ public struct FormatOptions: CustomStringConvertible { self.wrapTypealiases = wrapTypealiases self.wrapEnumCases = wrapEnumCases self.closingParenOnSameLine = closingParenOnSameLine + self.closingCallSiteParenOnSameLine = closingCallSiteParenOnSameLine self.wrapReturnType = wrapReturnType self.wrapConditions = wrapConditions self.wrapTernaryOperators = wrapTernaryOperators + self.conditionsWrap = conditionsWrap self.uppercaseHex = uppercaseHex self.uppercaseExponent = uppercaseExponent self.decimalGrouping = decimalGrouping @@ -615,6 +865,10 @@ public struct FormatOptions: CustomStringConvertible { self.funcAttributes = funcAttributes self.typeAttributes = typeAttributes self.varAttributes = varAttributes + self.storedVarAttributes = storedVarAttributes + self.computedVarAttributes = computedVarAttributes + self.complexAttributes = complexAttributes + self.complexAttributesExceptions = complexAttributesExceptions self.markTypes = markTypes self.typeMarkComment = typeMarkComment self.markExtensions = markExtensions @@ -632,6 +886,8 @@ public struct FormatOptions: CustomStringConvertible { self.yodaSwap = yodaSwap self.extensionACLPlacement = extensionACLPlacement self.redundantType = redundantType + self.preserveSymbols = preserveSymbols + self.inferredTypesInConditionalExpressions = inferredTypesInConditionalExpressions self.emptyBracesSpacing = emptyBracesSpacing self.acronyms = acronyms self.indentStrings = indentStrings @@ -644,6 +900,11 @@ public struct FormatOptions: CustomStringConvertible { self.preserveAnonymousForEach = preserveAnonymousForEach self.preserveSingleLineForEach = preserveSingleLineForEach self.preserveDocComments = preserveDocComments + self.conditionalAssignmentOnlyAfterNewProperties = conditionalAssignmentOnlyAfterNewProperties + self.spaceAroundDelimiter = spaceAroundDelimiter + self.initCoderNil = initCoderNil + self.dateFormat = dateFormat + self.timeZone = timeZone self.nilInitType = nilInitType // Doesn't really belong here, but hard to put elsewhere self.fragment = fragment diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index e18599886..8e467ebc6 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -632,13 +632,19 @@ extension Formatter { var i = i while let token = token(at: i) { switch token { - case .keyword("in"), .keyword("throws"), .keyword("rethrows"): + case .keyword("in"), .keyword("throws"), .keyword("rethrows"), .identifier("async"): guard let scopeIndex = index(of: .startOfScope, before: i, if: { $0 == .startOfScope("{") - }) else { + }), isStartOfClosure(at: scopeIndex) else { return false } - return isStartOfClosure(at: scopeIndex) + if token != .keyword("in"), + let arrowIndex = index(of: .operator("->", .infix), after: i), + next(.keyword, after: arrowIndex) != .keyword("in") + { + return false + } + return true case .startOfScope("("), .startOfScope("["), .startOfScope("<"), .endOfScope(")"), .endOfScope("]"), .endOfScope(">"), .keyword where token.isAttribute, _ where token.isComment: @@ -844,6 +850,91 @@ extension Formatter { } } + /// Whether or not this property at the given introducer index (either `var` or `let`) + /// is a stored property or a computed property. + func isStoredProperty(atIntroducerIndex introducerIndex: Int) -> Bool { + assert(["let", "var"].contains(tokens[introducerIndex].string)) + + var parseIndex = introducerIndex + + // All properties have the property name after the introducer + if let propertyNameIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: parseIndex), + tokens[propertyNameIndex].isIdentifierOrKeyword + { + parseIndex = propertyNameIndex + } + + // Properties have an optional `: TypeName` component + if let typeAnnotationStartIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: parseIndex), + tokens[typeAnnotationStartIndex] == .delimiter(":"), + let startOfTypeIndex = index(of: .nonSpaceOrComment, after: typeAnnotationStartIndex), + let typeRange = parseType(at: startOfTypeIndex)?.range + { + parseIndex = typeRange.upperBound + } + + // Properties have an optional `= expression` component + if let assignmentIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: parseIndex), + tokens[assignmentIndex] == .operator("=", .infix) + { + // If the type has an assignment operator, it's guaranteed to be a stored property. + return true + } + + // Finally, properties have an optional `{` body + if let startOfBody = index(of: .nonSpaceOrCommentOrLinebreak, after: parseIndex), + tokens[startOfBody] == .startOfScope("{") + { + // If this property has a body, then its a stored property if and only if the body + // has a `didSet` or `willSet` keyword, based on the grammar for a variable declaration. + if let nextToken = next(.nonSpaceOrCommentOrLinebreak, after: startOfBody), + [.identifier("willSet"), .identifier("didSet")].contains(nextToken) + { + return true + } else { + return false + } + } + + // If the property declaration isn't followed by a `{ ... }` block, + // then it's definitely a stored property and not a computed property. + else { + return true + } + } + + /// Whether or not the attribute starting at the given index is complex. That is, has: + /// - any named arguments + /// - more than one unnamed argument + func isComplexAttribute(at attributeIndex: Int) -> Bool { + assert(tokens[attributeIndex].string.hasPrefix("@")) + + guard let startOfScopeIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: attributeIndex), + tokens[startOfScopeIndex] == .startOfScope("("), + let firstTokenInBody = index(of: .nonSpaceOrCommentOrLinebreak, after: startOfScopeIndex), + let endOfScopeIndex = endOfScope(at: startOfScopeIndex), + firstTokenInBody != endOfScopeIndex + else { return false } + + // If the first argument is named with a parameter label, then this is a complex attribute: + if tokens[firstTokenInBody].isIdentifierOrKeyword, + let followingToken = index(of: .nonSpaceOrCommentOrLinebreak, after: firstTokenInBody), + tokens[followingToken] == .delimiter(":") + { + return true + } + + // If there are any commas in the attribute body, then this attribute has + // multiple arguments and is thus complex: + for index in startOfScopeIndex ... endOfScopeIndex { + if tokens[index] == .delimiter(","), startOfScope(at: index) == startOfScopeIndex { + return true + } + } + + return false + } + /// Determine if next line after this token should be indented func isEndOfStatement(at i: Int, in scope: Token? = nil) -> Bool { guard let token = token(at: i) else { return true } @@ -1226,8 +1317,13 @@ extension Formatter { /// - `borrowing ...` /// - `consuming ...` /// - `(type).(type)` - func parseType(at startOfTypeIndex: Int) -> (name: String, range: ClosedRange)? { - guard let baseType = parseNonOptionalType(at: startOfTypeIndex) else { return nil } + func parseType( + at startOfTypeIndex: Int, + excludeLowercaseIdentifiers: Bool = false + ) + -> (name: String, range: ClosedRange)? + { + guard let baseType = parseNonOptionalType(at: startOfTypeIndex, excludeLowercaseIdentifiers: excludeLowercaseIdentifiers) else { return nil } // Any type can be optional, so check for a trailing `?` or `!` if let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: baseType.range.upperBound), @@ -1241,7 +1337,7 @@ extension Formatter { if let nextTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: baseType.range.upperBound), tokens[nextTokenIndex] == .operator(".", .infix), let followingToken = index(of: .nonSpaceOrCommentOrLinebreak, after: nextTokenIndex), - let followingType = parseType(at: followingToken) + let followingType = parseType(at: followingToken, excludeLowercaseIdentifiers: excludeLowercaseIdentifiers) { let typeRange = startOfTypeIndex ... followingType.range.upperBound return (name: tokens[typeRange].string, range: typeRange) @@ -1250,10 +1346,33 @@ extension Formatter { return baseType } - private func parseNonOptionalType(at startOfTypeIndex: Int) -> (name: String, range: ClosedRange)? { - // Parse types of the form `[...]` + private func parseNonOptionalType( + at startOfTypeIndex: Int, + excludeLowercaseIdentifiers: Bool + ) + -> (name: String, range: ClosedRange)? + { let startToken = tokens[startOfTypeIndex] + + // Parse types of the form `[...]` if startToken == .startOfScope("["), let endOfScope = endOfScope(at: startOfTypeIndex) { + // Validate that the inner type is also valid + guard let innerTypeStartIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: startOfTypeIndex), + let innerType = parseType(at: innerTypeStartIndex, excludeLowercaseIdentifiers: excludeLowercaseIdentifiers), + let indexAfterType = index(of: .nonSpaceOrCommentOrLinebreak, after: innerType.range.upperBound) + else { return nil } + + // This is either an array type of the form `[Element]`, + // or a dictionary type of the form `[Key: Value]`. + if indexAfterType != endOfScope { + guard tokens[indexAfterType] == .delimiter(":"), + let secondTypeIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: indexAfterType), + let secondType = parseType(at: secondTypeIndex, excludeLowercaseIdentifiers: excludeLowercaseIdentifiers), + let indexAfterSecondType = index(of: .nonSpaceOrCommentOrLinebreak, after: secondType.range.upperBound), + indexAfterSecondType == endOfScope + else { return nil } + } + let typeRange = startOfTypeIndex ... endOfScope return (name: tokens[typeRange].string, range: typeRange) } @@ -1295,6 +1414,14 @@ extension Formatter { // Otherwise this is just a single identifier if startToken.isIdentifier || startToken.isKeywordOrAttribute, startToken != .identifier("init") { + let firstCharacter = startToken.string.first.flatMap(String.init) ?? "" + let isLowercaseIdentifier = firstCharacter.uppercased() != firstCharacter + + guard !(excludeLowercaseIdentifiers && isLowercaseIdentifier), + // Don't parse macro invocations or `#selector` as a type. + !["#"].contains(firstCharacter) + else { return nil } + return (name: startToken.string, range: startOfTypeIndex ... startOfTypeIndex) } @@ -1330,17 +1457,24 @@ extension Formatter { /// - `[...]` (array or dictionary) /// - `{ ... }` (closure) /// - `#selector(...)` / macro invocations + /// - An `if/switch` expression (only allowed if this is the only expression in + /// a code block or if following an assignment `=` operator). /// - Any value can be preceded by a prefix operator /// - Any value can be preceded by `try`, `try?`, `try!`, or `await` /// - Any value can be followed by a postfix operator /// - Any value can be followed by an infix operator plus a right-hand-side expression. /// - Any value can be followed by an arbitrary number of method calls `(...)`, subscripts `[...]`, or generic arguments `<...>`. /// - Any value can be followed by a `.identifier` - func parseExpressionRange(startingAt startIndex: Int) -> ClosedRange? { + func parseExpressionRange( + startingAt startIndex: Int, + allowConditionalExpressions: Bool = false + ) + -> ClosedRange? + { // Any expression can start with a prefix operator, or `await` if tokens[startIndex].isOperator(ofType: .prefix) || tokens[startIndex].string == "await", let nextTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: startIndex), - let followingExpression = parseExpressionRange(startingAt: nextTokenIndex) + let followingExpression = parseExpressionRange(startingAt: nextTokenIndex, allowConditionalExpressions: allowConditionalExpressions) { return startIndex ... followingExpression.upperBound } @@ -1356,7 +1490,7 @@ extension Formatter { nextTokenAfterTry = nextTokenAfterTryOperator } - if let followingExpression = parseExpressionRange(startingAt: nextTokenAfterTry) { + if let followingExpression = parseExpressionRange(startingAt: nextTokenAfterTry, allowConditionalExpressions: allowConditionalExpressions) { return startIndex ... followingExpression.upperBound } } @@ -1382,10 +1516,16 @@ extension Formatter { // #selector() and macro expansions like #macro() are parsed into keyword tokens. endOfExpression = startIndex + case .keyword("if"), .keyword("switch"): + guard allowConditionalExpressions, + let conditionalBranches = conditionalBranches(at: startIndex), + let lastBranch = conditionalBranches.last + else { return nil } + endOfExpression = lastBranch.endOfBranch + default: return nil } - while let nextTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: endOfExpression), let nextToken = token(at: nextTokenIndex) { @@ -1402,7 +1542,7 @@ extension Formatter { endOfExpression = endOfScope /// Any value can be followed by a `.identifier` - case .delimiter("."): + case .delimiter("."), .operator(".", _): guard let nextIdentifierIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: nextTokenIndex), tokens[nextIdentifierIndex].isIdentifier else { return startIndex ... endOfExpression } @@ -1480,6 +1620,73 @@ extension Formatter { } } + /// A property of the format `(let|var) identifier: Type = expression`. + /// - `: Type` and `= expression` elements are optional + struct PropertyDeclaration { + let introducerIndex: Int + let identifier: String + let identifierIndex: Int + let type: (colonIndex: Int, name: String, range: ClosedRange)? + let value: (assignmentIndex: Int, expressionRange: ClosedRange)? + + var range: ClosedRange { + if let value = value { + return introducerIndex ... value.expressionRange.upperBound + } else if let type = type { + return introducerIndex ... type.range.upperBound + } else { + return introducerIndex ... identifierIndex + } + } + } + + /// Parses a property of the format `(let|var) identifier: Type = expression` + /// starting at the given introducer index (the `let` / `var` keyword). + func parsePropertyDeclaration(atIntroducerIndex introducerIndex: Int) -> PropertyDeclaration? { + assert(["let", "var"].contains(tokens[introducerIndex].string)) + + guard let propertyIdentifierIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: introducerIndex), + let propertyIdentifier = token(at: propertyIdentifierIndex), + propertyIdentifier.isIdentifier + else { return nil } + + var typeInformation: (colonIndex: Int, name: String, range: ClosedRange)? + + if let colonIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: propertyIdentifierIndex), + tokens[colonIndex] == .delimiter(":"), + let startOfTypeIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), + let type = parseType(at: startOfTypeIndex) + { + typeInformation = ( + colonIndex: colonIndex, + name: type.name, + range: type.range + ) + } + + let endOfTypeOrIdentifier = typeInformation?.range.upperBound ?? propertyIdentifierIndex + var valueInformation: (assignmentIndex: Int, expressionRange: ClosedRange)? + + if let assignmentIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: endOfTypeOrIdentifier), + tokens[assignmentIndex] == .operator("=", .infix), + let startOfExpression = index(of: .nonSpaceOrCommentOrLinebreak, after: assignmentIndex), + let expressionRange = parseExpressionRange(startingAt: startOfExpression, allowConditionalExpressions: true) + { + valueInformation = ( + assignmentIndex: assignmentIndex, + expressionRange: expressionRange + ) + } + + return PropertyDeclaration( + introducerIndex: introducerIndex, + identifier: propertyIdentifier.string, + identifierIndex: propertyIdentifierIndex, + type: typeInformation, + value: valueInformation + ) + } + /// Shared import rules implementation func parseImports() -> [[ImportRange]] { var importStack = [[ImportRange]]() @@ -1984,7 +2191,7 @@ extension Formatter { case type /// The declaration is within some local scope, - /// like a function body. + /// like a function body or closure. case local } @@ -1995,7 +2202,7 @@ extension Formatter { let typeDeclarations = Set(["class", "actor", "struct", "enum", "extension"]) // Declarations which have `DeclarationScope.local` - let localDeclarations = Set(["let", "var", "func", "subscript", "init", "deinit"]) + let localDeclarations = Set(["let", "var", "func", "subscript", "init", "deinit", "get", "set", "willSet", "didSet"]) let allDeclarationScopes = typeDeclarations.union(localDeclarations) @@ -2530,24 +2737,6 @@ extension Formatter { } extension _FormatRules { - /// Short date formatter. Used by fileHeader rule - static var shortDateFormatter: (Date) -> String = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .none - return { formatter.string(from: $0) } - }() - - /// Year formatter. Used by fileHeader rule - static var yearFormatter: (Date) -> String = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy" - return { formatter.string(from: $0) } - }() - - /// Current year. Used by fileHeader rule - static var currentYear: String = yearFormatter(Date()) - /// Swiftlint semantic modifier groups static let semanticModifierGroups = ["acl", "setteracl", "mutators", "typemethods", "owned"] diff --git a/Sources/Rules.swift b/Sources/Rules.swift index beb19fa59..58b7e1a1d 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -494,7 +494,7 @@ public struct _FormatRules { /// preceded by a space, unless it appears at the beginning of a line. public let spaceAroundOperators = FormatRule( help: "Add or remove space around operators or delimiters.", - options: ["operatorfunc", "nospaceoperators", "ranges"] + options: ["operatorfunc", "nospaceoperators", "ranges", "typedelimiter"] ) { formatter in formatter.forEachToken { i, token in switch token { @@ -601,11 +601,15 @@ public struct _FormatRules { // Ensure there is a space after the token formatter.insert(.space(" "), at: i + 1) } - if formatter.token(at: i - 1)?.isSpace == true, - formatter.token(at: i - 2)?.isLinebreak == false - { + + let spaceBeforeToken = formatter.token(at: i - 1)?.isSpace == true + && formatter.token(at: i - 2)?.isLinebreak == false + + if spaceBeforeToken, formatter.options.spaceAroundDelimiter == .trailing { // Remove space before the token formatter.removeToken(at: i - 1) + } else if !spaceBeforeToken, formatter.options.spaceAroundDelimiter == .leadingTrailing { + formatter.insertSpace(" ", at: i) } default: break @@ -1650,7 +1654,6 @@ public struct _FormatRules { linewrapStack.append(false) scopeStack.append(.operator("=", .infix)) scopeStartLineIndexes.append(lineIndex) - default: // If this is the final `endOfScope` in a conditional assignment, // we have to end the scope introduced by that assignment operator. @@ -2108,7 +2111,6 @@ public struct _FormatRules { if linewrapped, shouldIndentNextLine(at: i) { indentStack[indentStack.count - 1] += formatter.options.indent } - default: break } @@ -2157,7 +2159,7 @@ public struct _FormatRules { Add `@available(*, unavailable)` attribute to required `init(coder:)` when it hasn't been implemented. """, - options: [], + options: ["initcodernil"], sharedOptions: ["linebreaks"] ) { formatter in let unavailableTokens = tokenize("@available(*, unavailable)") @@ -2179,10 +2181,17 @@ public struct _FormatRules { else { return } // make sure the implementation is empty or fatalError - guard let firstToken = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: braceIndex, if: { + guard let firstTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: braceIndex, if: { [.endOfScope("}"), .identifier("fatalError")].contains($0) }) else { return } + if formatter.options.initCoderNil, + formatter.token(at: firstTokenIndex) == .identifier("fatalError"), + let fatalParenEndOfScope = formatter.index(of: .endOfScope, after: firstTokenIndex + 1) + { + formatter.replaceTokens(in: firstTokenIndex ... fatalParenEndOfScope, with: [.identifier("nil")]) + } + // avoid adding attribute if it's already there if formatter.modifiersForDeclaration(at: i, contains: "@available") { return } @@ -2737,10 +2746,10 @@ public struct _FormatRules { }) == nil, let scopeIndex = formatter.startOfScope(at: i) { - isClosure = formatter.isStartOfClosure(at: scopeIndex) + isClosure = formatter.isStartOfClosure(at: scopeIndex) && formatter.isInClosureArguments(at: i) } if !isClosure, nextToken != .keyword("in") { - return // It's a closure type or function declaration + return // It's a closure type, function declaration or for loop } case .operator: if case let .operator(inner, _)? = formatter.last(.nonSpace, before: closingIndex), @@ -3873,8 +3882,9 @@ public struct _FormatRules { public let wrap = FormatRule( help: "Wrap lines that exceed the specified maximum width.", options: ["maxwidth", "nowrapoperators", "assetliterals", "wrapternary"], - sharedOptions: ["wraparguments", "wrapparameters", "wrapcollections", "closingparen", "indent", - "trimwhitespace", "linebreaks", "tabwidth", "maxwidth", "smarttabs", "wrapreturntype", "wrapconditions", "wraptypealiases", "wrapternary", "wrapeffects"] + sharedOptions: ["wraparguments", "wrapparameters", "wrapcollections", "closingparen", "callsiteparen", "indent", + "trimwhitespace", "linebreaks", "tabwidth", "maxwidth", "smarttabs", "wrapreturntype", + "wrapconditions", "wraptypealiases", "wrapternary", "wrapeffects", "conditionswrap"] ) { formatter in let maxWidth = formatter.options.maxWidth guard maxWidth > 0 else { return } @@ -3930,8 +3940,9 @@ public struct _FormatRules { public let wrapArguments = FormatRule( help: "Align wrapped function arguments or collection elements.", orderAfter: ["wrap"], - options: ["wraparguments", "wrapparameters", "wrapcollections", "closingparen", - "wrapreturntype", "wrapconditions", "wraptypealiases", "wrapeffects"], + options: ["wraparguments", "wrapparameters", "wrapcollections", "closingparen", "callsiteparen", + "wrapreturntype", "wrapconditions", "wraptypealiases", "wrapeffects", + "conditionswrap"], sharedOptions: ["indent", "trimwhitespace", "linebreaks", "tabwidth", "maxwidth", "smarttabs", "assetliterals", "wrapternary"] ) { formatter in @@ -4322,7 +4333,7 @@ public struct _FormatRules { public let fileHeader = FormatRule( help: "Use specified source file header template for all files.", runOnceOnly: true, - options: ["header"], + options: ["header", "dateformat", "timezone"], sharedOptions: ["linebreaks"] ) { formatter in var headerTokens = [Token]() @@ -4331,23 +4342,18 @@ public struct _FormatRules { case .ignore: return case var .replace(string): - if let range = string.range(of: "{file}"), - let file = formatter.options.fileInfo.fileName - { - string.replaceSubrange(range, with: file) - } - if let range = string.range(of: "{year}") { - string.replaceSubrange(range, with: currentYear) - } - if let range = string.range(of: "{created}"), - let date = formatter.options.fileInfo.creationDate - { - string.replaceSubrange(range, with: shortDateFormatter(date)) - } - if let range = string.range(of: "{created.year}"), - let date = formatter.options.fileInfo.creationDate - { - string.replaceSubrange(range, with: yearFormatter(date)) + let file = formatter.options.fileInfo + let options = ReplacementOptions( + dateFormat: formatter.options.dateFormat, + timeZone: formatter.options.timeZone + ) + + for (key, replacement) in formatter.options.fileInfo.replacements { + if let replacementStr = replacement.resolve(file, options) { + while let range = string.range(of: "{\(key.rawValue)}") { + string.replaceSubrange(range, with: replacementStr) + } + } } headerTokens = tokenize(string) directives = headerTokens.compactMap { @@ -4420,7 +4426,8 @@ public struct _FormatRules { /// Strip redundant `.init` from type instantiations public let redundantInit = FormatRule( - help: "Remove explicit `init` if not required." + help: "Remove explicit `init` if not required.", + orderAfter: ["propertyType"] ) { formatter in formatter.forEach(.identifier("init")) { initIndex, _ in guard let dotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: initIndex, if: { @@ -5468,7 +5475,7 @@ public struct _FormatRules { public let wrapAttributes = FormatRule( help: "Wrap @attributes onto a separate line, or keep them on the same line.", - options: ["funcattributes", "typeattributes", "varattributes"], + options: ["funcattributes", "typeattributes", "varattributes", "storedvarattrs", "computedvarattrs", "complexattrs", "noncomplexattrs"], sharedOptions: ["linebreaks", "maxwidth"] ) { formatter in formatter.forEach(.attribute) { i, _ in @@ -5490,18 +5497,41 @@ public struct _FormatRules { } // Check which `AttributeMode` option to use - let attributeMode: AttributeMode + var attributeMode: AttributeMode switch formatter.tokens[keywordIndex].string { case "func", "init", "subscript": attributeMode = formatter.options.funcAttributes case "class", "actor", "struct", "enum", "protocol", "extension": attributeMode = formatter.options.typeAttributes case "var", "let": - attributeMode = formatter.options.varAttributes + let storedOrComputedAttributeMode: AttributeMode + if formatter.isStoredProperty(atIntroducerIndex: keywordIndex) { + storedOrComputedAttributeMode = formatter.options.storedVarAttributes + } else { + storedOrComputedAttributeMode = formatter.options.computedVarAttributes + } + + // If the relevant `storedvarattrs` or `computedvarattrs` option hasn't been configured, + // fall back to the previous (now deprecated) `varattributes` option. + if storedOrComputedAttributeMode == .preserve { + attributeMode = formatter.options.varAttributes + } else { + attributeMode = storedOrComputedAttributeMode + } default: return } + // If the complexAttributes option is configured, it takes precedence over other options + // if this is a complex attributes with arguments. + let attributeName = formatter.tokens[i].string + let isComplexAttribute = formatter.isComplexAttribute(at: i) + && !formatter.options.complexAttributesExceptions.contains(attributeName) + + if isComplexAttribute, formatter.options.complexAttributes != .preserve { + attributeMode = formatter.options.complexAttributes + } + // Apply the `AttributeMode` switch attributeMode { case .preserve: @@ -7102,31 +7132,35 @@ public struct _FormatRules { public let conditionalAssignment = FormatRule( help: "Assign properties using if / switch expressions.", - orderAfter: ["redundantReturn"] + orderAfter: ["redundantReturn"], + options: ["condassignment"] ) { formatter in // If / switch expressions were added in Swift 5.9 (SE-0380) guard formatter.options.swiftVersion >= "5.9" else { return } - formatter.forEach(.keyword) { introducerIndex, introducerToken in - // Look for declarations of the pattern: - // - // let foo: Foo - // if/switch... - // - guard ["let", "var"].contains(introducerToken.string), - let identifierIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: introducerIndex), - let identifier = formatter.token(at: identifierIndex), - identifier.isIdentifier, - let colonIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: identifierIndex), - formatter.tokens[colonIndex] == .delimiter(":"), - let startOfTypeIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex) + formatter.forEach(.keyword) { startOfConditional, keywordToken in + // Look for an if/switch expression where the first branch starts with `identifier =` + guard ["if", "switch"].contains(keywordToken.string), + let conditionalBranches = formatter.conditionalBranches(at: startOfConditional), + var startOfFirstBranch = conditionalBranches.first?.startOfBranch else { return } - guard let (typeName, typeRange) = formatter.parseType(at: startOfTypeIndex), - let startOfConditional = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: typeRange.upperBound), - let conditionalBranches = formatter.conditionalBranches(at: startOfConditional) + // Traverse any nested if/switch branches until we find the first code branch + while let firstTokenInBranch = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: startOfFirstBranch), + ["if", "switch"].contains(formatter.tokens[firstTokenInBranch].string), + let nestedConditionalBranches = formatter.conditionalBranches(at: firstTokenInBranch), + let startOfNestedBranch = nestedConditionalBranches.first?.startOfBranch + { + startOfFirstBranch = startOfNestedBranch + } + + // Check if the first branch starts with the pattern `lvalue =`. + guard let firstTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: startOfFirstBranch), + let lvalueRange = formatter.parseExpressionRange(startingAt: firstTokenIndex), + let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: lvalueRange.upperBound), + formatter.tokens[equalsIndex] == .operator("=", .infix) else { return } // Whether or not the conditional statement that starts at the given index @@ -7156,7 +7190,7 @@ public struct _FormatRules { // Whether or not the given conditional branch body qualifies as a single statement // that assigns a value to `identifier`. This is either: - // 1. a single assignment to `identifier =` + // 1. a single assignment to `lvalue =` // 2. a single `if` or `switch` statement where each of the branches also qualify, // and the statement is exhaustive. func isExhaustiveSingleStatementAssignment(_ branch: Formatter.ConditionalBranch) -> Bool { @@ -7183,9 +7217,10 @@ public struct _FormatRules { && isExhaustive } - // Otherwise we expect this to be of the pattern `identifier = (statement)` - else if formatter.tokens[firstTokenIndex] == identifier, - let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstTokenIndex), + // Otherwise we expect this to be of the pattern `lvalue = (statement)` + else if let firstExpressionRange = formatter.parseExpressionRange(startingAt: firstTokenIndex), + formatter.tokens[firstExpressionRange] == formatter.tokens[lvalueRange], + let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstExpressionRange.upperBound), formatter.tokens[equalsIndex] == .operator("=", .infix), let valueStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex) { @@ -7233,42 +7268,102 @@ public struct _FormatRules { return } - // Remove the `identifier =` from each conditional branch, - formatter.forEachRecursiveConditionalBranch(in: conditionalBranches) { branch in - guard let firstTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: branch.startOfBranch), - let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstTokenIndex), - formatter.tokens[equalsIndex] == .operator("=", .infix), - let valueStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex) - else { return } + // Removes the `identifier =` from each conditional branch + func removeAssignmentFromAllBranches() { + formatter.forEachRecursiveConditionalBranch(in: conditionalBranches) { branch in + guard let firstTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: branch.startOfBranch), + let firstExpressionRange = formatter.parseExpressionRange(startingAt: firstTokenIndex), + let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstExpressionRange.upperBound), + formatter.tokens[equalsIndex] == .operator("=", .infix), + let valueStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex) + else { return } + + formatter.removeTokens(in: firstTokenIndex ..< valueStartIndex) + } + } + + // If this expression follows a property like `let identifier: Type`, we just + // have to insert an `=` between property and the conditional. + // - Find the introducer (let/var), parse the property, and verify that the identifier + // matches the identifier assigned on each conditional branch. + if let introducerIndex = formatter.indexOfLastSignificantKeyword(at: startOfConditional, excluding: ["if", "switch"]), + ["let", "var"].contains(formatter.tokens[introducerIndex].string), + let property = formatter.parsePropertyDeclaration(atIntroducerIndex: introducerIndex), + formatter.tokens[lvalueRange.lowerBound].string == property.identifier, + property.value == nil, + let typeRange = property.type?.range, + let nextTokenAfterProperty = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: typeRange.upperBound), + nextTokenAfterProperty == startOfConditional + { + removeAssignmentFromAllBranches() + + let rangeBetweenTypeAndConditional = (typeRange.upperBound + 1) ..< startOfConditional + + // If there are no comments between the type and conditional, + // we reformat it from: + // + // let foo: Foo\n + // if condition { + // + // to: + // + // let foo: Foo = if condition { + // + if formatter.tokens[rangeBetweenTypeAndConditional].allSatisfy(\.isSpaceOrLinebreak) { + formatter.replaceTokens(in: rangeBetweenTypeAndConditional, with: [ + .space(" "), + .operator("=", .infix), + .space(" "), + ]) + } - formatter.removeTokens(in: firstTokenIndex ..< valueStartIndex) + // But if there are comments, then we shouldn't just delete them. + // Instead we just insert `= ` after the type. + else { + formatter.insert([.operator("=", .infix), .space(" ")], at: startOfConditional) + } } - // Lastly we have to insert an `=` between the type and the conditional - let rangeBetweenTypeAndConditional = (typeRange.upperBound + 1) ..< startOfConditional + // Otherwise we insert an `identifier =` before the if/switch expression + else if !formatter.options.conditionalAssignmentOnlyAfterNewProperties { + // In this case we should only apply the conversion if this is a top-level condition, + // and not nested in some parent condition. In large complex if/switch conditions + // with multiple layers of nesting, for example, this prevents us from making any + // changes unless the entire set of nested conditions can be converted as a unit. + // - First attempt to find and parse a parent if / switch condition. + var startOfParentScope = formatter.startOfScope(at: startOfConditional) - // If there are no comments between the type and conditional, - // we reformat it from: - // - // let foo: Foo\n - // if condition { - // - // to: - // - // let foo: Foo = if condition { - // - if formatter.tokens[rangeBetweenTypeAndConditional].allSatisfy(\.isSpaceOrLinebreak) { - formatter.replaceTokens(in: rangeBetweenTypeAndConditional, with: [ + // If we're inside a switch case, expand to look at the whole switch statement + while let currentStartOfParentScope = startOfParentScope, + formatter.tokens[currentStartOfParentScope] == .startOfScope(":"), + let caseToken = formatter.index(of: .endOfScope("case"), before: currentStartOfParentScope) + { + startOfParentScope = formatter.startOfScope(at: caseToken) + } + + if let startOfParentScope = startOfParentScope, + let mostRecentIfOrSwitch = formatter.index(of: .keyword, before: startOfParentScope, if: { ["if", "switch"].contains($0.string) }), + let conditionalBranches = formatter.conditionalBranches(at: mostRecentIfOrSwitch), + let startOfFirstParentBranch = conditionalBranches.first?.startOfBranch, + let endOfLastParentBranch = conditionalBranches.last?.endOfBranch, + // If this condition is contained within a parent condition, do nothing. + // We should only convert the entire set of nested conditions together as a unit. + (startOfFirstParentBranch ... endOfLastParentBranch).contains(startOfConditional) + { return } + + let lvalueTokens = formatter.tokens[lvalueRange] + + // Now we can remove the `identifier =` from each branch, + // and instead add it before the if / switch expression. + removeAssignmentFromAllBranches() + + let identifierEqualsTokens = lvalueTokens + [ .space(" "), .operator("=", .infix), .space(" "), - ]) - } + ] - // But if there are comments, then we shouldn't just delete them. - // Instead we just insert `= ` after the type. - else { - formatter.insert([.operator("=", .infix), .space(" ")], at: startOfConditional) + formatter.insert(identifierEqualsTokens, at: startOfConditional) } } } @@ -7748,35 +7843,13 @@ public struct _FormatRules { orderAfter: ["conditionalAssignment"], sharedOptions: ["linebreaks"] ) { formatter in - formatter.forEach(.keyword) { introducerIndex, introducerToken in - guard [.keyword("let"), .keyword("var")].contains(introducerToken), - let identifierIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: introducerIndex), - let identifier = formatter.token(at: identifierIndex), - identifier.isIdentifier + formatter.forEach(.keyword) { startOfCondition, keywordToken in + guard [.keyword("if"), .keyword("switch")].contains(keywordToken), + let assignmentIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: startOfCondition), + formatter.tokens[assignmentIndex] == .operator("=", .infix), + let endOfPropertyDefinition = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: assignmentIndex) else { return } - // Find the `=` index for this variable, if present - let assignmentIndex: Int - if let colonIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: identifierIndex), - formatter.tokens[colonIndex] == .delimiter(":"), - let startOfTypeIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), - let typeRange = formatter.parseType(at: startOfTypeIndex)?.range, - let tokenAfterType = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: typeRange.upperBound), - formatter.tokens[tokenAfterType] == .operator("=", .infix) - { - assignmentIndex = tokenAfterType - } - - else if let tokenAfterIdentifier = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: identifierIndex), - formatter.tokens[tokenAfterIdentifier] == .operator("=", .infix) - { - assignmentIndex = tokenAfterIdentifier - } - - else { - return - } - // Verify the RHS of the assignment is an if/switch expression guard let startOfConditionalExpression = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: assignmentIndex), ["if", "switch"].contains(formatter.tokens[startOfConditionalExpression].string), @@ -7789,11 +7862,11 @@ public struct _FormatRules { return } - // The `=` should be on the same line as the `let`/`var` introducer - if !formatter.onSameLine(introducerIndex, assignmentIndex), + // The `=` should be on the same line as the rest of the property + if !formatter.onSameLine(endOfPropertyDefinition, assignmentIndex), formatter.last(.nonSpaceOrComment, before: assignmentIndex)?.isLinebreak == true, let previousToken = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: assignmentIndex), - formatter.onSameLine(introducerIndex, previousToken) + formatter.onSameLine(endOfPropertyDefinition, previousToken) { // Move the assignment operator to follow the previous token. // Also remove any trailing space after the previous position @@ -7812,4 +7885,310 @@ public struct _FormatRules { } } } + + public let blankLineAfterMultilineSwitchCase = FormatRule( + help: """ + Insert a blank line after multiline switch cases (excluding the last case, + which is followed by a closing brace). + """, + disabledByDefault: true, + orderAfter: ["redundantBreak"] + ) { formatter in + formatter.forEach(.keyword("switch")) { switchIndex, _ in + guard let switchCases = formatter.switchStatementBranchesWithSpacingInfo(at: switchIndex) else { return } + + for switchCase in switchCases.reversed() { + // Any switch statement that spans multiple lines should be followed by a blank line + // (excluding the last case, which is followed by a closing brace). + if switchCase.spansMultipleLines, + !switchCase.isLastCase, + !switchCase.isFollowedByBlankLine + { + switchCase.insertTrailingBlankLine(using: formatter) + } + + // The last case should never be followed by a blank line, since it's + // already followed by a closing brace. + if switchCase.isLastCase, + switchCase.isFollowedByBlankLine + { + switchCase.removeTrailingBlankLine(using: formatter) + } + } + } + } + + public let consistentSwitchStatementSpacing = FormatRule( + help: "Ensures consistent spacing among all of the cases in a switch statement.", + orderAfter: ["blankLineAfterMultilineSwitchCase"] + ) { formatter in + formatter.forEach(.keyword("switch")) { switchIndex, _ in + guard let switchCases = formatter.switchStatementBranchesWithSpacingInfo(at: switchIndex) else { return } + + // When counting the switch cases, exclude the last case (which should never have a trailing blank line). + let countWithTrailingBlankLine = switchCases.filter { $0.isFollowedByBlankLine && !$0.isLastCase }.count + let countWithoutTrailingBlankLine = switchCases.filter { !$0.isFollowedByBlankLine && !$0.isLastCase }.count + + // We want the spacing to be consistent for all switch cases, + // so use whichever formatting is used for the majority of cases. + var allCasesShouldHaveBlankLine = countWithTrailingBlankLine >= countWithoutTrailingBlankLine + + // When the `blankLinesBetweenChainedFunctions` rule is enabled, and there is a switch case + // that is required to span multiple lines, then all cases must span multiple lines. + // (Since if this rule removed the blank line from that case, it would contradict the other rule) + if formatter.options.enabledRules.contains(FormatRules.blankLineAfterMultilineSwitchCase.name), + switchCases.contains(where: { $0.spansMultipleLines && !$0.isLastCase }) + { + allCasesShouldHaveBlankLine = true + } + + for switchCase in switchCases.reversed() { + if !switchCase.isFollowedByBlankLine, allCasesShouldHaveBlankLine, !switchCase.isLastCase { + switchCase.insertTrailingBlankLine(using: formatter) + } + + if switchCase.isFollowedByBlankLine, !allCasesShouldHaveBlankLine || switchCase.isLastCase { + switchCase.removeTrailingBlankLine(using: formatter) + } + } + } + } + + public let propertyType = FormatRule( + help: "Convert property declarations to use inferred types (`let foo = Foo()`) or explicit types (`let foo: Foo = .init()`).", + disabledByDefault: true, + orderAfter: ["redundantType"], + options: ["inferredtypes", "preservesymbols"], + sharedOptions: ["redundanttype"] + ) { formatter in + formatter.forEach(.operator("=", .infix)) { equalsIndex, _ in + // Preserve all properties in conditional statements like `if let foo = Bar() { ... }` + guard !formatter.isConditionalStatement(at: equalsIndex) else { return } + + // Determine whether the type should use the inferred syntax (`let foo = Foo()`) + // of the explicit syntax (`let foo: Foo = .init()`). + let useInferredType: Bool + switch formatter.options.redundantType { + case .inferred: + useInferredType = true + + case .explicit: + useInferredType = false + + case .inferLocalsOnly: + switch formatter.declarationScope(at: equalsIndex) { + case .global, .type: + useInferredType = false + case .local: + useInferredType = true + } + } + + guard let introducerIndex = formatter.indexOfLastSignificantKeyword(at: equalsIndex), + ["var", "let"].contains(formatter.tokens[introducerIndex].string), + let property = formatter.parsePropertyDeclaration(atIntroducerIndex: introducerIndex), + let rhsExpressionRange = property.value?.expressionRange + else { return } + + let rhsStartIndex = rhsExpressionRange.lowerBound + + if useInferredType { + guard let type = property.type else { return } + let typeTokens = formatter.tokens[type.range] + + // Preserve the existing formatting if the LHS type is optional. + // - `let foo: Foo? = .foo` is valid, but `let foo = Foo?.foo` + // is invalid if `.foo` is defined on `Foo` but not `Foo?`. + guard !["?", "!"].contains(typeTokens.last?.string ?? "") else { return } + + // Preserve the existing formatting if the LHS type is an existential (indicated with `any`). + // - The `extension MyProtocol where Self == MyType { ... }` syntax + // creates static members where `let foo: any MyProtocol = .myType` + // is valid, but `let foo = (any MyProtocol).myType` isn't. + guard typeTokens.first?.string != "any" else { return } + + // Preserve the existing formatting if the RHS expression has a top-level infix operator. + // - `let value: ClosedRange = .zero ... 10` would not be valid to convert to + // `let value = ClosedRange.zero ... 10`. + if let nextInfixOperatorIndex = formatter.index(after: rhsStartIndex, where: { token in + token.isOperator(ofType: .infix) && token != .operator(".", .infix) + }), + rhsExpressionRange.contains(nextInfixOperatorIndex) + { + return + } + + // Preserve the formatting as-is if the type is manually excluded + if formatter.options.preserveSymbols.contains(type.name) { + return + } + + // If the RHS starts with a leading dot, then we know its accessing some static member on this type. + if formatter.tokens[rhsStartIndex].isOperator(".") { + // Preserve the formatting as-is if the identifier is manually excluded + if let identifierAfterDot = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: rhsStartIndex), + formatter.options.preserveSymbols.contains(formatter.tokens[identifierAfterDot].string) + { return } + + // Update the . token from a prefix operator to an infix operator. + formatter.replaceToken(at: rhsStartIndex, with: .operator(".", .infix)) + + // Insert a copy of the type on the RHS before the dot + formatter.insert(typeTokens, at: rhsStartIndex) + } + + // If the RHS is an if/switch expression, check that each branch starts with a leading dot + else if formatter.options.inferredTypesInConditionalExpressions, + ["if", "switch"].contains(formatter.tokens[rhsStartIndex].string), + let conditonalBranches = formatter.conditionalBranches(at: rhsStartIndex) + { + var hasInvalidConditionalBranch = false + formatter.forEachRecursiveConditionalBranch(in: conditonalBranches) { branch in + guard let firstTokenInBranch = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: branch.startOfBranch) else { + hasInvalidConditionalBranch = true + return + } + + if !formatter.tokens[firstTokenInBranch].isOperator(".") { + hasInvalidConditionalBranch = true + } + + // Preserve the formatting as-is if the identifier is manually excluded + if let identifierAfterDot = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: rhsStartIndex), + formatter.options.preserveSymbols.contains(formatter.tokens[identifierAfterDot].string) + { + hasInvalidConditionalBranch = true + } + } + + guard !hasInvalidConditionalBranch else { return } + + // Insert a copy of the type on the RHS before the dot in each branch + formatter.forEachRecursiveConditionalBranch(in: conditonalBranches) { branch in + guard let dotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: branch.startOfBranch) else { return } + + // Update the . token from a prefix operator to an infix operator. + formatter.replaceToken(at: dotIndex, with: .operator(".", .infix)) + + // Insert a copy of the type on the RHS before the dot + formatter.insert(typeTokens, at: dotIndex) + } + } + + else { + return + } + + // Remove the colon and explicit type before the equals token + formatter.removeTokens(in: type.colonIndex ... type.range.upperBound) + } + + // If using explicit types, convert properties to the format `let foo: Foo = .init()`. + else { + guard // When parsing the type, exclude lowercase identifiers so `foo` isn't parsed as a type, + // and so `Foo.init` is parsed as `Foo` instead of `Foo.init`. + let rhsType = formatter.parseType(at: rhsStartIndex, excludeLowercaseIdentifiers: true), + property.type == nil, + let indexAfterIdentifier = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: property.identifierIndex), + formatter.tokens[indexAfterIdentifier] != .delimiter(":"), + let indexAfterType = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: rhsType.range.upperBound), + [.operator(".", .infix), .startOfScope("(")].contains(formatter.tokens[indexAfterType]), + !rhsType.name.contains(".") + else { return } + + // Preserve the existing formatting if the RHS expression has a top-level operator. + // - `let foo = Foo.foo.bar` would not be valid to convert to `let foo: Foo = .foo.bar`. + let operatorSearchIndex = formatter.tokens[indexAfterType].isStartOfScope ? (indexAfterType - 1) : indexAfterType + if let nextInfixOperatorIndex = formatter.index(after: operatorSearchIndex, where: { token in + token.isOperator(ofType: .infix) + }), + rhsExpressionRange.contains(nextInfixOperatorIndex) + { + return + } + + // Preserve any types that have been manually excluded. + // Preserve any `Void` types and tuples, since they're special and don't support things like `.init` + guard !(formatter.options.preserveSymbols + ["Void"]).contains(rhsType.name), + !rhsType.name.hasPrefix("(") + else { return } + + // A type name followed by a `(` is an implicit `.init(`. Insert a `.init` + // so that the init call stays valid after we move the type to the LHS. + if formatter.tokens[indexAfterType] == .startOfScope("(") { + // Preserve the existing format if `init` is manually excluded + if formatter.options.preserveSymbols.contains("init") { + return + } + + formatter.insert([.operator(".", .prefix), .identifier("init")], at: indexAfterType) + } + + // If the type name is followed by an infix `.` operator, convert it to a prefix operator. + else if formatter.tokens[indexAfterType] == .operator(".", .infix) { + // Exclude types with dots followed by a member access. + // - For example with something like `Color.Theme.themeColor`, we don't know + // if the property is `static var themeColor: Color` or `static var themeColor: Color.Theme`. + // - This isn't a problem with something like `Color.Theme()`, which we can reasonably assume + // is an initializer + if rhsType.name.contains(".") { return } + + // Preserve the formatting as-is if the identifier is manually excluded. + // Don't convert `let foo = Foo.self` to `let foo: Foo = .self`, since `.self` returns the metatype + let symbolsToExclude = formatter.options.preserveSymbols + ["self"] + if let indexAfterDot = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: indexAfterType), + symbolsToExclude.contains(formatter.tokens[indexAfterDot].string) + { return } + + formatter.replaceToken(at: indexAfterType, with: .operator(".", .prefix)) + } + + // Move the type name to the LHS of the property, followed by a colon + let typeTokens = formatter.tokens[rhsType.range] + formatter.removeTokens(in: rhsType.range) + formatter.insert([.delimiter(":"), .space(" ")] + typeTokens, at: property.identifierIndex + 1) + } + } + } + + public let redundantProperty = FormatRule( + help: "Simplifies redundant property definitions that are immediately returned.", + disabledByDefault: true, + orderAfter: ["propertyType"] + ) { formatter in + formatter.forEach(.keyword) { introducerIndex, introducerToken in + // Find properties like `let identifier = value` followed by `return identifier` + guard ["let", "var"].contains(introducerToken.string), + let property = formatter.parsePropertyDeclaration(atIntroducerIndex: introducerIndex), + let (assignmentIndex, expressionRange) = property.value, + let returnIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: expressionRange.upperBound), + formatter.tokens[returnIndex] == .keyword("return"), + let returnedValueIndex = formatter.index(of: .nonSpaceOrComment, after: returnIndex), + let returnedExpression = formatter.parseExpressionRange(startingAt: returnedValueIndex, allowConditionalExpressions: true), + formatter.tokens[returnedExpression] == [.identifier(property.identifier)] + else { return } + + let returnRange = formatter.startOfLine(at: returnIndex) ... formatter.endOfLine(at: returnedExpression.upperBound) + let propertyRange = introducerIndex ... expressionRange.upperBound + + guard !propertyRange.overlaps(returnRange) else { return } + + // Remove the line with the `return identifier` statement. + formatter.removeTokens(in: returnRange) + + // If there's nothing but whitespace between the end of the expression + // and the return statement, we can remove all of it. But if there's a comment, + // we should preserve it. + let rangeBetweenExpressionAndReturn = (expressionRange.upperBound + 1) ..< (returnRange.lowerBound - 1) + if formatter.tokens[rangeBetweenExpressionAndReturn].allSatisfy(\.isSpaceOrLinebreak) { + formatter.removeTokens(in: rangeBetweenExpressionAndReturn) + } + + // Replace the `let identifier = value` with `return value` + formatter.replaceTokens( + in: introducerIndex ..< expressionRange.lowerBound, + with: [.keyword("return"), .space(" ")] + ) + } + } } diff --git a/Sources/ShellHelpers.swift b/Sources/ShellHelpers.swift new file mode 100644 index 000000000..fdddfeada --- /dev/null +++ b/Sources/ShellHelpers.swift @@ -0,0 +1,63 @@ +// +// ShellHelpers.swift +// SwiftFormat +// +// Created by Hampus Tågerud on 2023-08-08. +// Copyright 2023 Nick Lockwood and the SwiftFormat project authors +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/nicklockwood/SwiftFormat +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +extension String { + func shellOutput(cwd: URL? = nil) -> String? { + let process = Process() + let pipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", self] + process.standardOutput = pipe + process.standardError = pipe + + if let safeCWD = cwd { + process.currentDirectoryURL = safeCWD + } + + let file = pipe.fileHandleForReading + + do { try process.run() } + catch { return nil } + + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return nil + } + + let outputData = file.readDataToEndOfFile() + return String(data: outputData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/SwiftFormat.swift b/Sources/SwiftFormat.swift index feef4e95a..7490a1479 100644 --- a/Sources/SwiftFormat.swift +++ b/Sources/SwiftFormat.swift @@ -180,10 +180,33 @@ public func enumerateFiles(withInputURL inputURL: URL, let fileOptions = options.fileOptions ?? .default if resourceValues.isRegularFile == true { if fileOptions.supportedFileExtensions.contains(inputURL.pathExtension) { + let fileHeaderRuleEnabled = options.rules?.contains(FormatRules.fileHeader.name) ?? false + let shouldGetGitInfo = fileHeaderRuleEnabled && + options.formatOptions?.fileHeader.needsGitInfo == true + + let shouldGetFollowGitInfo = fileHeaderRuleEnabled && + options.formatOptions?.fileHeader.needsFollowGitInfo == true + + let gitInfo = shouldGetGitInfo + ? GitHelpers.fileInfo(inputURL) + : nil + + let followedGitInfo = shouldGetFollowGitInfo + ? GitHelpers.fileInfo(inputURL, follow: true) + : nil + let fileInfo = FileInfo( filePath: resourceValues.path, - creationDate: resourceValues.creationDate + creationDate: gitInfo?.createdAt ?? resourceValues.creationDate, + followedCreationDate: followedGitInfo?.createdAt, + replacements: [ + .createdName: ReplacementType(gitInfo?.createdByName), + .createdEmail: ReplacementType(gitInfo?.createdByEmail), + .followedCreatedName: ReplacementType(followedGitInfo?.createdByName), + .followedCreatedEmail: ReplacementType(followedGitInfo?.createdByEmail), + ].compactMapValues { $0 } ) + var options = options options.formatOptions?.fileInfo = fileInfo do { @@ -488,19 +511,15 @@ private func applyRules( // Check if required FileInfo is available if rules.contains(FormatRules.fileHeader) { - if options.fileHeader.rawValue.contains("{created"), - options.fileInfo.creationDate == nil - { - throw FormatError.options( - "Failed to apply {created} template in file header as file info is unavailable" - ) - } - if options.fileHeader.rawValue.contains("{file"), - options.fileInfo.fileName == nil - { - throw FormatError.options( - "Failed to apply {file} template in file header as file name was not provided" - ) + let header = options.fileHeader + let fileInfo = options.fileInfo + + for key in ReplacementKey.allCases { + if !fileInfo.hasReplacement(for: key, options: options), header.hasTemplateKey(key) { + throw FormatError.options( + "Failed to apply {\(key.rawValue)} template in file header as required info is unavailable" + ) + } } } diff --git a/SwiftFormat.xcodeproj/project.pbxproj b/SwiftFormat.xcodeproj/project.pbxproj index 399472b2a..d76df272f 100644 --- a/SwiftFormat.xcodeproj/project.pbxproj +++ b/SwiftFormat.xcodeproj/project.pbxproj @@ -98,6 +98,14 @@ A3DF48252620E03600F45A5F /* JSONReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF48242620E03600F45A5F /* JSONReporter.swift */; }; A3DF48262620E03600F45A5F /* JSONReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF48242620E03600F45A5F /* JSONReporter.swift */; }; B9C4F55C2387FA3E0088DBEE /* SupportedContentUTIs.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9C4F55B2387FA3E0088DBEE /* SupportedContentUTIs.swift */; }; + D52F6A642A82E04600FE1448 /* GitHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A632A82E04600FE1448 /* GitHelpers.swift */; }; + D52F6A652A82E04600FE1448 /* GitHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A632A82E04600FE1448 /* GitHelpers.swift */; }; + D52F6A662A82E04600FE1448 /* GitHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A632A82E04600FE1448 /* GitHelpers.swift */; }; + D52F6A672A82E04600FE1448 /* GitHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A632A82E04600FE1448 /* GitHelpers.swift */; }; + D52F6A692A82E0DD00FE1448 /* ShellHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A682A82E0DD00FE1448 /* ShellHelpers.swift */; }; + D52F6A6A2A82E0DD00FE1448 /* ShellHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A682A82E0DD00FE1448 /* ShellHelpers.swift */; }; + D52F6A6B2A82E0DD00FE1448 /* ShellHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A682A82E0DD00FE1448 /* ShellHelpers.swift */; }; + D52F6A6C2A82E0DD00FE1448 /* ShellHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52F6A682A82E0DD00FE1448 /* ShellHelpers.swift */; }; DD9AD39E2999FCC8001C2C0E /* GithubActionsLogReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9AD39C2999FCC8001C2C0E /* GithubActionsLogReporter.swift */; }; DD9AD39F2999FCC8001C2C0E /* GithubActionsLogReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9AD39C2999FCC8001C2C0E /* GithubActionsLogReporter.swift */; }; DD9AD3A32999FCC8001C2C0E /* Reporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9AD39D2999FCC8001C2C0E /* Reporter.swift */; }; @@ -244,6 +252,8 @@ 90F16AFA1DA5ED9A00EB4EA1 /* CommandErrors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandErrors.swift; sourceTree = ""; }; A3DF48242620E03600F45A5F /* JSONReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONReporter.swift; sourceTree = ""; }; B9C4F55B2387FA3E0088DBEE /* SupportedContentUTIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedContentUTIs.swift; sourceTree = ""; }; + D52F6A632A82E04600FE1448 /* GitHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHelpers.swift; sourceTree = ""; }; + D52F6A682A82E0DD00FE1448 /* ShellHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellHelpers.swift; sourceTree = ""; }; DD9AD39C2999FCC8001C2C0E /* GithubActionsLogReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GithubActionsLogReporter.swift; sourceTree = ""; }; DD9AD39D2999FCC8001C2C0E /* Reporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reporter.swift; sourceTree = ""; }; E41CB5BE2025761D00C1BEDE /* UserSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelection.swift; sourceTree = ""; }; @@ -368,6 +378,8 @@ 01A0EAC41D5DB54A00A0A8E3 /* SwiftFormat.swift */, 01A0EABF1D5DB4F700A0A8E3 /* Tokenizer.swift */, 01BBD85821DAA2A000457380 /* Globs.swift */, + D52F6A632A82E04600FE1448 /* GitHelpers.swift */, + D52F6A682A82E0DD00FE1448 /* ShellHelpers.swift */, ); path = Sources; sourceTree = ""; @@ -779,6 +791,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D52F6A642A82E04600FE1448 /* GitHelpers.swift in Sources */, 01045A992119979400D2BE3D /* Arguments.swift in Sources */, 01567D2F225B2BFD00B22D41 /* ParsingHelpers.swift in Sources */, DD9AD39E2999FCC8001C2C0E /* GithubActionsLogReporter.swift in Sources */, @@ -786,6 +799,7 @@ DD9AD3A32999FCC8001C2C0E /* Reporter.swift in Sources */, E4FABAD5202FEF060065716E /* OptionDescriptor.swift in Sources */, 01BBD85921DAA2A000457380 /* Globs.swift in Sources */, + D52F6A692A82E0DD00FE1448 /* ShellHelpers.swift in Sources */, 01ACAE05220CD90F003F3CCF /* Examples.swift in Sources */, 01D3B28624E9C9C700888DE0 /* FormattingHelpers.swift in Sources */, E4E4D3C92033F17C000D7CB1 /* EnumAssociable.swift in Sources */, @@ -845,9 +859,11 @@ DD9AD39F2999FCC8001C2C0E /* GithubActionsLogReporter.swift in Sources */, E4E4D3CA2033F17C000D7CB1 /* EnumAssociable.swift in Sources */, 01BBD85A21DAA2A600457380 /* Globs.swift in Sources */, + D52F6A6A2A82E0DD00FE1448 /* ShellHelpers.swift in Sources */, 01045A92211988F100D2BE3D /* Inference.swift in Sources */, 01F3DF8D1DB9FD3F00454944 /* Options.swift in Sources */, E4FABAD6202FEF060065716E /* OptionDescriptor.swift in Sources */, + D52F6A652A82E04600FE1448 /* GitHelpers.swift in Sources */, A3DF48262620E03600F45A5F /* JSONReporter.swift in Sources */, 01A8320724EC7F7600A9D0EB /* FormattingHelpers.swift in Sources */, 01F17E831E25870700DCD359 /* CommandLine.swift in Sources */, @@ -872,6 +888,7 @@ 01045A9B2119979400D2BE3D /* Arguments.swift in Sources */, E4872114201D3B8C0014845E /* Tokenizer.swift in Sources */, 015243E32B04B0A600F65221 /* Singularize.swift in Sources */, + D52F6A6B2A82E0DD00FE1448 /* ShellHelpers.swift in Sources */, E4872112201D3B860014845E /* Rules.swift in Sources */, E4962DE0203F3CD500A02013 /* OptionsStore.swift in Sources */, 01ACAE07220CD915003F3CCF /* Examples.swift in Sources */, @@ -885,6 +902,7 @@ E41CB5BF2025761D00C1BEDE /* UserSelection.swift in Sources */, E4872111201D3B830014845E /* Options.swift in Sources */, 01A95BD3225BEDE400744931 /* ParsingHelpers.swift in Sources */, + D52F6A662A82E04600FE1448 /* GitHelpers.swift in Sources */, 90C4B6CD1DA4B04A009EB000 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -902,6 +920,7 @@ E4E4D3CC2033F17C000D7CB1 /* EnumAssociable.swift in Sources */, E4FABAD8202FEF060065716E /* OptionDescriptor.swift in Sources */, 015243E42B04B0A700F65221 /* Singularize.swift in Sources */, + D52F6A6C2A82E0DD00FE1448 /* ShellHelpers.swift in Sources */, 9028F7841DA4B435009FE5B4 /* Tokenizer.swift in Sources */, 90F16AFB1DA5ED9A00EB4EA1 /* CommandErrors.swift in Sources */, 01A8320924EC7F7800A9D0EB /* FormattingHelpers.swift in Sources */, @@ -915,6 +934,7 @@ 90C4B6E71DA4B059009EB000 /* FormatSelectionCommand.swift in Sources */, 90F16AF81DA5EB4600EB4EA1 /* FormatFileCommand.swift in Sources */, 01ACAE08220CD916003F3CCF /* Examples.swift in Sources */, + D52F6A672A82E04600FE1448 /* GitHelpers.swift in Sources */, 9028F7861DA4B435009FE5B4 /* Rules.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/MetadataTests.swift b/Tests/MetadataTests.swift index fb7a04ad1..25ad904ab 100644 --- a/Tests/MetadataTests.swift +++ b/Tests/MetadataTests.swift @@ -86,8 +86,11 @@ class MetadataTests: XCTestCase { if !rule.options.isEmpty { result += "\n\nOption | Description\n--- | ---" for option in rule.options { - let help = Descriptors.byName[option]!.help - result += "\n`--\(option)` | \(help)" + let descriptor = Descriptors.byName[option]! + guard !descriptor.isDeprecated else { + continue + } + result += "\n`--\(option)` | \(descriptor.help)" } } if let examples = rule.examples { @@ -195,10 +198,11 @@ class MetadataTests: XCTestCase { case .identifier("wrapCollectionsAndArguments"): referencedOptions += [ Descriptors.wrapArguments, Descriptors.wrapParameters, Descriptors.wrapCollections, - Descriptors.closingParenOnSameLine, Descriptors.linebreak, Descriptors.truncateBlankLines, + Descriptors.closingParenOnSameLine, Descriptors.closingCallSiteParenOnSameLine, + Descriptors.linebreak, Descriptors.truncateBlankLines, Descriptors.indent, Descriptors.tabWidth, Descriptors.smartTabs, Descriptors.maxWidth, Descriptors.assetLiteralWidth, Descriptors.wrapReturnType, Descriptors.wrapEffects, - Descriptors.wrapConditions, Descriptors.wrapTypealiases, Descriptors.wrapTernaryOperators, + Descriptors.wrapConditions, Descriptors.wrapTypealiases, Descriptors.wrapTernaryOperators, Descriptors.conditionsWrap, ] case .identifier("wrapStatementBody"): referencedOptions += [Descriptors.indent, Descriptors.linebreak] @@ -236,6 +240,7 @@ class MetadataTests: XCTestCase { continue } } + for option in referencedOptions { XCTAssert(ruleOptions.contains(option.argumentName) || option.isDeprecated, "\(option.argumentName) not listed in \(name) rule") @@ -271,7 +276,10 @@ class MetadataTests: XCTestCase { func testArgumentNamesAreValidLength() { let arguments = Set(commandLineArguments).subtracting(deprecatedArguments) for argument in arguments { - XCTAssert(argument.count <= Options.maxArgumentNameLength) + XCTAssert( + argument.count <= Options.maxArgumentNameLength, + "\"\(argument)\" (length=\(argument.count)) longer than maximum allowed argument name length \(Options.maxArgumentNameLength)" + ) } } diff --git a/Tests/OptionDescriptorTests.swift b/Tests/OptionDescriptorTests.swift index 36ae5426f..86cd37728 100644 --- a/Tests/OptionDescriptorTests.swift +++ b/Tests/OptionDescriptorTests.swift @@ -117,14 +117,20 @@ class OptionDescriptorTests: XCTestCase { func testAllDescriptorsHaveProperty() { let allProperties = Set(FormatOptions.default.allOptions.keys) for descriptor in Descriptors.all where !descriptor.isDeprecated { - XCTAssert(allProperties.contains(descriptor.propertyName)) + XCTAssert( + allProperties.contains(descriptor.propertyName), + "FormatOptions doesn't have property named \(descriptor.propertyName)." + ) } } func testAllPropertiesHaveDescriptor() { let allDescriptors = Set(Descriptors.all.map { $0.propertyName }) for property in FormatOptions.default.allOptions.keys { - XCTAssert(allDescriptors.contains(property)) + XCTAssert( + allDescriptors.contains(property), + "Missing OptionDescriptor for FormatOptions.\(property) option." + ) } } diff --git a/Tests/ParsingHelpersTests.swift b/Tests/ParsingHelpersTests.swift index 2450c7d76..d8767768b 100644 --- a/Tests/ParsingHelpersTests.swift +++ b/Tests/ParsingHelpersTests.swift @@ -1549,6 +1549,10 @@ class ParsingHelpersTests: XCTestCase { } let instanceMember3 = Bar() + + let instanceMemberClosure = Foo { + let localMember2 = Bar() + } } """ @@ -1561,6 +1565,8 @@ class ParsingHelpersTests: XCTestCase { XCTAssertEqual(formatter.declarationScope(at: 42), .type) // instanceMethod XCTAssertEqual(formatter.declarationScope(at: 51), .local) // localMember1 XCTAssertEqual(formatter.declarationScope(at: 66), .type) // instanceMember3 + XCTAssertEqual(formatter.declarationScope(at: 78), .type) // instanceMemberClosure + XCTAssertEqual(formatter.declarationScope(at: 89), .local) // localMember2 } // MARK: spaceEquivalentToWidth @@ -1686,6 +1692,48 @@ class ParsingHelpersTests: XCTestCase { XCTAssertEqual(formatter.parseType(at: 5)?.name, "Foo!") } + func testDoesntParseMacroInvocationAsType() { + let formatter = Formatter(tokenize(""" + let foo = #colorLiteral(1, 2, 3) + """)) + XCTAssertNil(formatter.parseType(at: 6)) + } + + func testDoesntParseSelectorAsType() { + let formatter = Formatter(tokenize(""" + let foo = #selector(Foo.bar) + """)) + XCTAssertNil(formatter.parseType(at: 6)) + } + + func testDoesntParseArrayAsType() { + let formatter = Formatter(tokenize(""" + let foo = [foo, bar].member() + """)) + XCTAssertNil(formatter.parseType(at: 6)) + } + + func testDoesntParseDictionaryAsType() { + let formatter = Formatter(tokenize(""" + let foo = [foo: bar, baaz: quux].member() + """)) + XCTAssertNil(formatter.parseType(at: 6)) + } + + func testParsesArrayAsType() { + let formatter = Formatter(tokenize(""" + let foo = [Foo]() + """)) + XCTAssertEqual(formatter.parseType(at: 6)?.name, "[Foo]") + } + + func testParsesDictionaryAsType() { + let formatter = Formatter(tokenize(""" + let foo = [Foo: Bar]() + """)) + XCTAssertEqual(formatter.parseType(at: 6)?.name, "[Foo: Bar]") + } + func testParseGenericType() { let formatter = Formatter(tokenize(""" let foo: Foo = .init() @@ -1770,6 +1818,13 @@ class ParsingHelpersTests: XCTestCase { XCTAssertEqual(formatter.parseType(at: 5)?.name, "Foo.Bar.Baaz") } + func testDoesntParseLeadingDotAsType() { + let formatter = Formatter(tokenize(""" + let foo: Foo = .Bar.baaz + """)) + XCTAssertEqual(formatter.parseType(at: 9)?.name, nil) + } + func testParseCompoundGenericType() { let formatter = Formatter(tokenize(""" let foo: Foo.Bar.Baaz @@ -1827,7 +1882,10 @@ class ParsingHelpersTests: XCTestCase { // MARK: - parseExpressionRange func testParseIndividualExpressions() { + XCTAssert(isSingleExpression(#"Foo()"#)) XCTAssert(isSingleExpression(#"Foo("bar")"#)) + XCTAssert(isSingleExpression(#"Foo.init()"#)) + XCTAssert(isSingleExpression(#"Foo.init("bar")"#)) XCTAssert(isSingleExpression(#"foo.bar"#)) XCTAssert(isSingleExpression(#"foo .bar"#)) XCTAssert(isSingleExpression(#"foo["bar"]("baaz")"#)) @@ -1881,6 +1939,29 @@ class ParsingHelpersTests: XCTestCase { XCTAssert(isSingleExpression(#"try await { try await printAsyncThrows(foo) }()"#)) XCTAssert(isSingleExpression(#"Foo()"#)) XCTAssert(isSingleExpression(#"Foo(quux: quux)"#)) + XCTAssert(!isSingleExpression(#"if foo { "foo" } else { "bar" }"#)) + + XCTAssert(isSingleExpression( + #"if foo { "foo" } else { "bar" }"#, + allowConditionalExpressions: true + )) + + XCTAssert(isSingleExpression(""" + if foo { + "foo" + } else { + "bar" + } + """, allowConditionalExpressions: true)) + + XCTAssert(isSingleExpression(""" + switch foo { + case true: + "foo" + case false: + "bar" + } + """, allowConditionalExpressions: true)) XCTAssert(isSingleExpression(""" foo @@ -1991,9 +2072,9 @@ class ParsingHelpersTests: XCTestCase { XCTAssertEqual(parseExpressions(input), expectedExpressions) } - func isSingleExpression(_ string: String) -> Bool { + func isSingleExpression(_ string: String, allowConditionalExpressions: Bool = false) -> Bool { let formatter = Formatter(tokenize(string)) - guard let expressionRange = formatter.parseExpressionRange(startingAt: 0) else { return false } + guard let expressionRange = formatter.parseExpressionRange(startingAt: 0, allowConditionalExpressions: allowConditionalExpressions) else { return false } return expressionRange.upperBound == formatter.tokens.indices.last! } @@ -2015,4 +2096,48 @@ class ParsingHelpersTests: XCTestCase { return expressions } + + // MARK: isStoredProperty + + func testIsStoredProperty() { + XCTAssertTrue(isStoredProperty("var foo: String")) + XCTAssertTrue(isStoredProperty("let foo = 42")) + XCTAssertTrue(isStoredProperty("let foo: Int = 42")) + XCTAssertTrue(isStoredProperty("var foo: Int = 42")) + XCTAssertTrue(isStoredProperty("@Environment(\\.myEnvironmentProperty) var foo", at: 7)) + + XCTAssertTrue(isStoredProperty(""" + var foo: String { + didSet { + print(newValue) + } + } + """)) + + XCTAssertTrue(isStoredProperty(""" + var foo: String { + willSet { + print(newValue) + } + } + """)) + + XCTAssertFalse(isStoredProperty(""" + var foo: String { + "foo" + } + """)) + + XCTAssertFalse(isStoredProperty(""" + var foo: String { + get { "foo" } + set { print(newValue} } + } + """)) + } + + func isStoredProperty(_ input: String, at index: Int = 0) -> Bool { + let formatter = Formatter(tokenize(input)) + return formatter.isStoredProperty(atIntroducerIndex: index) + } } diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index 80bff5fc8..d868c34ed 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -9,6 +9,23 @@ import XCTest @testable import SwiftFormat +private enum TestDateFormat: String { + case basic = "yyyy-MM-dd" + case time = "HH:mmZZZZZ" + case timestamp = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" +} + +private func createTestDate( + _ input: String, + _ format: TestDateFormat = .basic +) -> Date { + let formatter = DateFormatter() + formatter.dateFormat = format.rawValue + formatter.timeZone = .current + + return formatter.date(from: input)! +} + class GeneralTests: RulesTests { // MARK: - initCoderUnavailable @@ -28,12 +45,12 @@ class GeneralTests: RulesTests { exclude: ["unusedArguments"]) } - func testInitCoderUnavailableFatalError() { + func testInitCoderUnavailableFatalErrorNilDisabled() { let input = """ extension Module { final class A: UIView { required init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } } @@ -43,12 +60,37 @@ class GeneralTests: RulesTests { final class A: UIView { @available(*, unavailable) required init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } } """ - testFormatting(for: input, output, rule: FormatRules.initCoderUnavailable) + let options = FormatOptions(initCoderNil: false) + testFormatting(for: input, output, rule: FormatRules.initCoderUnavailable, options: options) + } + + func testInitCoderUnavailableFatalErrorNilEnabled() { + let input = """ + extension Module { + final class A: UIView { + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + } + """ + let output = """ + extension Module { + final class A: UIView { + @available(*, unavailable) + required init?(coder _: NSCoder) { + nil + } + } + } + """ + let options = FormatOptions(initCoderNil: true) + testFormatting(for: input, output, rule: FormatRules.initCoderUnavailable, options: options) } func testInitCoderUnavailableAlreadyPresent() { @@ -82,7 +124,7 @@ class GeneralTests: RulesTests { let input = """ class Foo: UIView { public required init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } """ @@ -90,7 +132,7 @@ class GeneralTests: RulesTests { class Foo: UIView { @available(*, unavailable) public required init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } """ @@ -101,7 +143,7 @@ class GeneralTests: RulesTests { let input = """ class Foo: UIView { required public init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } """ @@ -109,12 +151,13 @@ class GeneralTests: RulesTests { class Foo: UIView { @available(*, unavailable) required public init?(coder _: NSCoder) { - fatalError() + nil } } """ + let options = FormatOptions(initCoderNil: true) testFormatting(for: input, output, rule: FormatRules.initCoderUnavailable, - exclude: ["modifierOrder"]) + options: options, exclude: ["modifierOrder"]) } // MARK: - trailingCommas @@ -234,7 +277,7 @@ class GeneralTests: RulesTests { Int ]).self """ - testFormatting(for: input, rule: FormatRules.trailingCommas) + testFormatting(for: input, rule: FormatRules.trailingCommas, exclude: ["propertyType"]) } func testTrailingCommaNotAddedToTypeDeclaration() { @@ -281,7 +324,7 @@ class GeneralTests: RulesTests { String: Int ]]() """ - testFormatting(for: input, rule: FormatRules.trailingCommas) + testFormatting(for: input, rule: FormatRules.trailingCommas, exclude: ["propertyType"]) } func testTrailingCommaNotAddedToTypeDeclaration6() { @@ -294,7 +337,7 @@ class GeneralTests: RulesTests { ]) ]]() """ - testFormatting(for: input, rule: FormatRules.trailingCommas) + testFormatting(for: input, rule: FormatRules.trailingCommas, exclude: ["propertyType"]) } func testTrailingCommaNotAddedToTypeDeclaration7() { @@ -564,6 +607,25 @@ class GeneralTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } + func testFileHeaderCreatorReplacement() { + let name = "Test User" + let email = "test@email.com" + let input = "let foo = bar" + let output = "// Created by \(name) \(email)\n\nlet foo = bar" + let fileInfo = FileInfo(replacements: [.createdName: .constant(name), .createdEmail: .constant(email)]) + let options = FormatOptions(fileHeader: "// Created by {created.name} {created.email}", fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + + func testFileHeaderMultipleReplacement() { + let name = "Test User" + let input = "let foo = bar" + let output = "// Copyright © \(name)\n// Created by \(name)\n\nlet foo = bar" + let fileInfo = FileInfo(replacements: [.createdName: .constant(name)]) + let options = FormatOptions(fileHeader: "// Copyright © {created.name}\n// Created by {created.name}", fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + func testFileHeaderCreationDateReplacement() { let input = "let foo = bar" let date = Date(timeIntervalSince1970: 0) @@ -578,6 +640,127 @@ class GeneralTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } + func testFileHeaderDateFormattingIso() { + let date = createTestDate("2023-08-09") + + let input = "let foo = bar" + let output = "// 2023-08-09\n\nlet foo = bar" + let fileInfo = FileInfo(creationDate: date) + let options = FormatOptions(fileHeader: "// {created}", dateFormat: .iso, fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + + func testFileHeaderDateFormattingDayMonthYear() { + let date = createTestDate("2023-08-09") + + let input = "let foo = bar" + let output = "// 09/08/2023\n\nlet foo = bar" + let fileInfo = FileInfo(creationDate: date) + let options = FormatOptions(fileHeader: "// {created}", dateFormat: .dayMonthYear, fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + + func testFileHeaderDateFormattingMonthDayYear() { + let date = createTestDate("2023-08-09") + + let input = "let foo = bar" + let output = "// 08/09/2023\n\nlet foo = bar" + let fileInfo = FileInfo(creationDate: date) + let options = FormatOptions(fileHeader: "// {created}", + dateFormat: .monthDayYear, + fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + + func testFileHeaderDateFormattingCustom() { + let date = createTestDate("2023-08-09T12:59:30.345Z", .timestamp) + + let input = "let foo = bar" + let output = "// 23.08.09-12.59.30.345\n\nlet foo = bar" + let fileInfo = FileInfo(creationDate: date) + let options = FormatOptions(fileHeader: "// {created}", + dateFormat: .custom("yy.MM.dd-HH.mm.ss.SSS"), + timeZone: .identifier("UTC"), + fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + + private func testTimeZone( + timeZone: FormatTimeZone, + tests: [String: String] + ) { + for (input, expected) in tests { + let date = createTestDate(input, .time) + let input = "let foo = bar" + let output = "// \(expected)\n\nlet foo = bar" + + let fileInfo = FileInfo(creationDate: date) + + let options = FormatOptions( + fileHeader: "// {created}", + dateFormat: .custom("HH:mm"), + timeZone: timeZone, + fileInfo: fileInfo + ) + + testFormatting(for: input, output, + rule: FormatRules.fileHeader, + options: options) + } + } + + func testFileHeaderDateTimeZoneSystem() { + let baseDate = createTestDate("15:00Z", .time) + let offset = TimeZone.current.secondsFromGMT(for: baseDate) + + let date = baseDate.addingTimeInterval(Double(offset)) + + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + + let expected = formatter.string(from: date) + + testTimeZone(timeZone: .system, tests: [ + "15:00Z": expected, + "16:00+1": expected, + "01:00+10": expected, + "16:30+0130": expected, + ]) + } + + func testFileHeaderDateTimeZoneAbbreviations() { + // GMT+0530 + testTimeZone(timeZone: FormatTimeZone(rawValue: "IST")!, tests: [ + "15:00Z": "20:30", + "16:00+1": "20:30", + "01:00+10": "20:30", + "16:30+0130": "20:30", + ]) + } + + func testFileHeaderDateTimeZoneIdentifiers() { + // GMT+0845 + testTimeZone(timeZone: FormatTimeZone(rawValue: "Australia/Eucla")!, tests: [ + "15:00Z": "23:45", + "16:00+1": "23:45", + "01:00+10": "23:45", + "16:30+0130": "23:45", + ]) + } + + func testGitHelpersReturnsInfo() { + let info = GitHelpers.fileInfo(URL(fileURLWithPath: #file), follow: false) + XCTAssertNotNil(info?.createdByName) + XCTAssertNotNil(info?.createdByEmail) + XCTAssertNotNil(info?.createdAt) + + let followInfo = GitHelpers.fileInfo(URL(fileURLWithPath: #file), follow: true) + XCTAssertNotNil(followInfo?.createdByName) + XCTAssertNotNil(followInfo?.createdByEmail) + XCTAssertNotNil(followInfo?.createdAt) + } + func testFileHeaderRuleThrowsIfCreationDateUnavailable() { let input = "let foo = bar" let options = FormatOptions(fileHeader: "// Created by Nick Lockwood on {created}.", fileInfo: FileInfo()) diff --git a/Tests/RulesTests+Indentation.swift b/Tests/RulesTests+Indentation.swift index f3cf7ac31..cacc9a0a3 100644 --- a/Tests/RulesTests+Indentation.swift +++ b/Tests/RulesTests+Indentation.swift @@ -88,7 +88,7 @@ class IndentTests: RulesTests { paymentFormURL: .paymentForm) """ let options = FormatOptions(wrapParameters: .preserve) - testFormatting(for: input, rule: FormatRules.indent, options: options) + testFormatting(for: input, rule: FormatRules.indent, options: options, exclude: ["propertyType"]) } func testIndentPreservedForNestedWrappedParameters2() { @@ -99,7 +99,7 @@ class IndentTests: RulesTests { paymentFormURL: .paymentForm)) """ let options = FormatOptions(wrapParameters: .preserve) - testFormatting(for: input, rule: FormatRules.indent, options: options) + testFormatting(for: input, rule: FormatRules.indent, options: options, exclude: ["propertyType"]) } func testIndentPreservedForNestedWrappedParameters3() { @@ -112,7 +112,7 @@ class IndentTests: RulesTests { ) """ let options = FormatOptions(wrapParameters: .preserve) - testFormatting(for: input, rule: FormatRules.indent, options: options) + testFormatting(for: input, rule: FormatRules.indent, options: options, exclude: ["propertyType"]) } func testIndentTrailingClosureInParensContainingUnwrappedArguments() { @@ -346,7 +346,7 @@ class IndentTests: RulesTests { return x + y } """ - testFormatting(for: input, rule: FormatRules.indent) + testFormatting(for: input, rule: FormatRules.indent, exclude: ["propertyType"]) } func testIndentWrappedClosureCaptureListWithUnwrappedParameters() { @@ -373,7 +373,7 @@ class IndentTests: RulesTests { } """ let options = FormatOptions(closingParenOnSameLine: true) - testFormatting(for: input, rule: FormatRules.indent, options: options) + testFormatting(for: input, rule: FormatRules.indent, options: options, exclude: ["propertyType"]) } func testIndentAllmanTrailingClosureArguments() { @@ -389,7 +389,7 @@ class IndentTests: RulesTests { } """ let options = FormatOptions(allmanBraces: true) - testFormatting(for: input, rule: FormatRules.indent, options: options) + testFormatting(for: input, rule: FormatRules.indent, options: options, exclude: ["propertyType"]) } func testIndentAllmanTrailingClosureArguments2() { @@ -448,7 +448,7 @@ class IndentTests: RulesTests { } """ testFormatting(for: input, rule: FormatRules.indent, - exclude: ["braces", "wrapMultilineStatementBraces"]) + exclude: ["braces", "wrapMultilineStatementBraces", "redundantProperty"]) } func testIndentLineAfterIndentedInlineClosure() { @@ -460,7 +460,7 @@ class IndentTests: RulesTests { return viewController } """ - testFormatting(for: input, rule: FormatRules.indent) + testFormatting(for: input, rule: FormatRules.indent, exclude: ["redundantProperty"]) } func testIndentLineAfterNonIndentedClosure() { @@ -473,7 +473,7 @@ class IndentTests: RulesTests { return viewController } """ - testFormatting(for: input, rule: FormatRules.indent) + testFormatting(for: input, rule: FormatRules.indent, exclude: ["redundantProperty"]) } func testIndentMultilineStatementDoesntFailToTerminate() { @@ -553,18 +553,18 @@ class IndentTests: RulesTests { func testIndentSwitchAfterRangeCase() { let input = "switch x {\ncase 0 ..< 2:\n switch y {\n default:\n break\n }\ndefault:\n break\n}" - testFormatting(for: input, rule: FormatRules.indent) + testFormatting(for: input, rule: FormatRules.indent, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testIndentEnumDeclarationInsideSwitchCase() { let input = "switch x {\ncase y:\nenum Foo {\ncase z\n}\nbar()\ndefault: break\n}" let output = "switch x {\ncase y:\n enum Foo {\n case z\n }\n bar()\ndefault: break\n}" - testFormatting(for: input, output, rule: FormatRules.indent) + testFormatting(for: input, output, rule: FormatRules.indent, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testIndentEnumCaseBodyAfterWhereClause() { let input = "switch foo {\ncase _ where baz < quux:\n print(1)\n print(2)\ndefault:\n break\n}" - testFormatting(for: input, rule: FormatRules.indent) + testFormatting(for: input, rule: FormatRules.indent, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testIndentSwitchCaseCommentsCorrectly() { @@ -590,7 +590,7 @@ class IndentTests: RulesTests { break } """ - testFormatting(for: input, output, rule: FormatRules.indent) + testFormatting(for: input, output, rule: FormatRules.indent, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testIndentMultilineSwitchCaseCommentsCorrectly() { @@ -1193,7 +1193,7 @@ class IndentTests: RulesTests { func testNoIndentAfterDefaultAsIdentifier() { let input = "let foo = FileManager.default\n/// Comment\nlet bar = 0" - testFormatting(for: input, rule: FormatRules.indent) + testFormatting(for: input, rule: FormatRules.indent, exclude: ["propertyType"]) } func testIndentClosureStartingOnIndentedLine() { @@ -1589,7 +1589,7 @@ class IndentTests: RulesTests { } """ let options = FormatOptions(wrapArguments: .disabled, closingParenOnSameLine: true) - testFormatting(for: input, rule: FormatRules.indent, options: options) + testFormatting(for: input, rule: FormatRules.indent, options: options, exclude: ["propertyType"]) } func testSingleIndentTrailingClosureBodyThatStartsOnFollowingLine() { @@ -1732,7 +1732,7 @@ class IndentTests: RulesTests { .bar .baz """ - testFormatting(for: input, rule: FormatRules.indent) + testFormatting(for: input, rule: FormatRules.indent, exclude: ["propertyType"]) } func testIndentChainedPropertiesAfterFunctionCallWithXcodeIndentation() { @@ -1744,7 +1744,7 @@ class IndentTests: RulesTests { .baz """ let options = FormatOptions(xcodeIndentation: true) - testFormatting(for: input, rule: FormatRules.indent, options: options) + testFormatting(for: input, rule: FormatRules.indent, options: options, exclude: ["propertyType"]) } func testIndentChainedPropertiesAfterFunctionCall2() { @@ -1756,7 +1756,7 @@ class IndentTests: RulesTests { .baz """ testFormatting(for: input, rule: FormatRules.indent, - exclude: ["trailingClosures"]) + exclude: ["trailingClosures", "propertyType"]) } func testIndentChainedPropertiesAfterFunctionCallWithXcodeIndentation2() { @@ -1769,7 +1769,7 @@ class IndentTests: RulesTests { """ let options = FormatOptions(xcodeIndentation: true) testFormatting(for: input, rule: FormatRules.indent, options: options, - exclude: ["trailingClosures"]) + exclude: ["trailingClosures", "propertyType"]) } func testIndentChainedMethodsAfterTrailingClosure() { @@ -2949,14 +2949,14 @@ class IndentTests: RulesTests { let input = "switch foo {\ncase .bar:\n#if x\nbar()\n#endif\nbaz()\ncase .baz: break\n}" let output = "switch foo {\ncase .bar:\n #if x\n bar()\n #endif\n baz()\ncase .baz: break\n}" let options = FormatOptions(indentCase: false) - testFormatting(for: input, output, rule: FormatRules.indent, options: options) + testFormatting(for: input, output, rule: FormatRules.indent, options: options, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testSwitchIfEndifInsideCaseIndenting2() { let input = "switch foo {\ncase .bar:\n#if x\nbar()\n#endif\nbaz()\ncase .baz: break\n}" let output = "switch foo {\n case .bar:\n #if x\n bar()\n #endif\n baz()\n case .baz: break\n}" let options = FormatOptions(indentCase: true) - testFormatting(for: input, output, rule: FormatRules.indent, options: options) + testFormatting(for: input, output, rule: FormatRules.indent, options: options, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testIfUnknownCaseEndifIndenting() { @@ -3191,14 +3191,14 @@ class IndentTests: RulesTests { let input = "switch foo {\ncase .bar:\n#if x\nbar()\n#endif\nbaz()\ncase .baz: break\n}" let output = "switch foo {\ncase .bar:\n #if x\n bar()\n #endif\n baz()\ncase .baz: break\n}" let options = FormatOptions(indentCase: false, ifdefIndent: .noIndent) - testFormatting(for: input, output, rule: FormatRules.indent, options: options) + testFormatting(for: input, output, rule: FormatRules.indent, options: options, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testIfEndifInsideCaseNoIndenting2() { let input = "switch foo {\ncase .bar:\n#if x\nbar()\n#endif\nbaz()\ncase .baz: break\n}" let output = "switch foo {\n case .bar:\n #if x\n bar()\n #endif\n baz()\n case .baz: break\n}" let options = FormatOptions(indentCase: true, ifdefIndent: .noIndent) - testFormatting(for: input, output, rule: FormatRules.indent, options: options) + testFormatting(for: input, output, rule: FormatRules.indent, options: options, exclude: ["blankLineAfterMultilineSwitchCase"]) } func testSwitchCaseInIfEndif() { @@ -3888,7 +3888,7 @@ class IndentTests: RulesTests { } """ - testFormatting(for: input, output, rule: FormatRules.indent) + testFormatting(for: input, output, rule: FormatRules.indent, exclude: ["redundantProperty"]) } func testIndentNestedSwitchExpressionAssignment() { diff --git a/Tests/RulesTests+Organization.swift b/Tests/RulesTests+Organization.swift index 78170c48d..120c682f3 100644 --- a/Tests/RulesTests+Organization.swift +++ b/Tests/RulesTests+Organization.swift @@ -874,7 +874,7 @@ class OrganizationTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.organizeDeclarations, options: FormatOptions(ifdefIndent: .noIndent), - exclude: ["blankLinesAtStartOfScope"]) + exclude: ["blankLinesAtStartOfScope", "propertyType"]) } func testOrganizesTypeBelowSymbolImport() { @@ -1508,7 +1508,8 @@ class OrganizationTests: RulesTests { testFormatting( for: input, output, rule: FormatRules.extensionAccessControl, - options: FormatOptions(extensionACLPlacement: .onDeclarations, swiftVersion: "4") + options: FormatOptions(extensionACLPlacement: .onDeclarations, swiftVersion: "4"), + exclude: ["propertyType"] ) } @@ -2790,6 +2791,7 @@ class OrganizationTests: RulesTests { case .value: print("value") } + case .failure: guard self.bar else { print(self.bar) @@ -3351,7 +3353,7 @@ class OrganizationTests: RulesTests { testFormatting(for: input, [output], rules: [FormatRules.sortDeclarations, FormatRules.consecutiveBlankLines], - exclude: ["blankLinesBetweenScopes"]) + exclude: ["blankLinesBetweenScopes", "propertyType"]) } func testSortBetweenDirectiveCommentsInType() { diff --git a/Tests/RulesTests+Parens.swift b/Tests/RulesTests+Parens.swift index 819db1b53..da7c1e265 100644 --- a/Tests/RulesTests+Parens.swift +++ b/Tests/RulesTests+Parens.swift @@ -776,12 +776,12 @@ class ParensTests: RulesTests { func testParensNotRemovedInGenericInstantiation() { let input = "let foo = Foo()" - testFormatting(for: input, rule: FormatRules.redundantParens) + testFormatting(for: input, rule: FormatRules.redundantParens, exclude: ["propertyType"]) } func testParensNotRemovedInGenericInstantiation2() { let input = "let foo = Foo(bar)" - testFormatting(for: input, rule: FormatRules.redundantParens) + testFormatting(for: input, rule: FormatRules.redundantParens, exclude: ["propertyType"]) } func testRedundantParensRemovedAfterGenerics() { @@ -1037,4 +1037,13 @@ class ParensTests: RulesTests { """ testFormatting(for: input, rule: FormatRules.redundantParens) } + + func testRequiredParensNotRemovedInAsyncLet2() { + let input = """ + Task { + let processURL: (URL) async throws -> Void = { _ in } + } + """ + testFormatting(for: input, rule: FormatRules.redundantParens) + } } diff --git a/Tests/RulesTests+Redundancy.swift b/Tests/RulesTests+Redundancy.swift index f1646040e..be734be6c 100644 --- a/Tests/RulesTests+Redundancy.swift +++ b/Tests/RulesTests+Redundancy.swift @@ -226,7 +226,7 @@ class RedundancyTests: RulesTests { let kFoo = Foo().foo """ let options = FormatOptions(swiftVersion: "4") - testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options) + testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options, exclude: ["propertyType"]) } func testFileprivateVarNotChangedToPrivateIfAccessedFromAVar() { @@ -382,7 +382,7 @@ class RedundancyTests: RulesTests { let foo = Foo() """ let options = FormatOptions(swiftVersion: "4") - testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options) + testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options, exclude: ["propertyType"]) } func testFileprivateInitNotChangedToPrivateIfConstructorCalledOutsideType2() { @@ -396,7 +396,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(swiftVersion: "4") - testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options) + testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options, exclude: ["propertyType"]) } func testFileprivateStructMemberNotChangedToPrivateIfConstructorCalledOutsideType() { @@ -408,7 +408,7 @@ class RedundancyTests: RulesTests { let foo = Foo(bar: "test") """ let options = FormatOptions(swiftVersion: "4") - testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options) + testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options, exclude: ["propertyType"]) } func testFileprivateClassMemberChangedToPrivateEvenIfConstructorCalledOutsideType() { @@ -427,7 +427,7 @@ class RedundancyTests: RulesTests { let foo = Foo() """ let options = FormatOptions(swiftVersion: "4") - testFormatting(for: input, output, rule: FormatRules.redundantFileprivate, options: options) + testFormatting(for: input, output, rule: FormatRules.redundantFileprivate, options: options, exclude: ["propertyType"]) } func testFileprivateExtensionFuncNotChangedToPrivateIfPartOfProtocolConformance() { @@ -531,7 +531,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(swiftVersion: "4") - testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options) + testFormatting(for: input, rule: FormatRules.redundantFileprivate, options: options, exclude: ["propertyType"]) } func testFileprivateInitNotChangedToPrivateWhenUsingTrailingClosureInit() { @@ -829,7 +829,7 @@ class RedundancyTests: RulesTests { let Foo = Foo.self let foo = Foo.init() """ - testFormatting(for: input, rule: FormatRules.redundantInit) + testFormatting(for: input, rule: FormatRules.redundantInit, exclude: ["propertyType"]) } func testNoRemoveInitForLocalLetType2() { @@ -885,7 +885,7 @@ class RedundancyTests: RulesTests { let tupleArray = [(key: String, value: Int)]() let dictionary = [String: Int]() """ - testFormatting(for: input, output, rule: FormatRules.redundantInit) + testFormatting(for: input, output, rule: FormatRules.redundantInit, exclude: ["propertyType"]) } func testPreservesInitAfterTypeOfCall() { @@ -906,7 +906,7 @@ class RedundancyTests: RulesTests { // (String!.init("Foo") isn't valid Swift code, so we don't test for it) """ - testFormatting(for: input, output, rule: FormatRules.redundantInit) + testFormatting(for: input, output, rule: FormatRules.redundantInit, exclude: ["propertyType"]) } func testPreservesTryBeforeInit() { @@ -931,7 +931,7 @@ class RedundancyTests: RulesTests { let atomicDictionary = Atomic<[String: Int]>() """ - testFormatting(for: input, output, rule: FormatRules.redundantInit, exclude: ["typeSugar"]) + testFormatting(for: input, output, rule: FormatRules.redundantInit, exclude: ["typeSugar", "propertyType"]) } // MARK: - redundantLetError @@ -1399,7 +1399,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(redundantType: .explicit, swiftVersion: "5.9") - testFormatting(for: input, output, rule: FormatRules.redundantType, options: options, exclude: ["wrapMultilineConditionalAssignment"]) + testFormatting(for: input, output, rule: FormatRules.redundantType, options: options, exclude: ["wrapMultilineConditionalAssignment", "propertyType"]) } func testRedundantTypeWithNestedIfExpression_inferred() { @@ -1412,6 +1412,7 @@ class RedundancyTests: RulesTests { } else { Foo("bar") } + case false: Foo("baaz") } @@ -1428,6 +1429,7 @@ class RedundancyTests: RulesTests { } else { Foo("bar") } + case false: Foo("baaz") } @@ -1449,6 +1451,7 @@ class RedundancyTests: RulesTests { } else { Foo("bar") } + case false: Foo("baaz") } @@ -1465,6 +1468,7 @@ class RedundancyTests: RulesTests { } else { .init("bar") } + case false: .init("baaz") } @@ -1473,7 +1477,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(redundantType: .explicit, swiftVersion: "5.9") - testFormatting(for: input, output, rule: FormatRules.redundantType, options: options, exclude: ["wrapMultilineConditionalAssignment"]) + testFormatting(for: input, output, rule: FormatRules.redundantType, options: options, exclude: ["wrapMultilineConditionalAssignment", "propertyType"]) } func testRedundantTypeWithLiteralsInIfExpression() { @@ -1502,7 +1506,7 @@ class RedundancyTests: RulesTests { let output = "var view: UIView = .init()" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testVarRedundantTypeRemovalExplicitType2() { @@ -1510,7 +1514,7 @@ class RedundancyTests: RulesTests { let output = "var view: UIView = .init /* foo */()" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["spaceAroundComments"]) + options: options, exclude: ["spaceAroundComments", "propertyType"]) } func testLetRedundantGenericTypeRemovalExplicitType() { @@ -1518,7 +1522,7 @@ class RedundancyTests: RulesTests { let output = "let relay: BehaviourRelay = .init(value: nil)" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testLetRedundantGenericTypeRemovalExplicitTypeIfValueOnNextLine() { @@ -1526,7 +1530,7 @@ class RedundancyTests: RulesTests { let output = "let relay: Foo = \n .default" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["trailingSpace"]) + options: options, exclude: ["trailingSpace", "propertyType"]) } func testVarNonRedundantTypeDoesNothingExplicitType() { @@ -1540,7 +1544,7 @@ class RedundancyTests: RulesTests { let output = "let view: UIView = .init()" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovedIfValueOnNextLineExplicitType() { @@ -1554,7 +1558,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovedIfValueOnNextLine2ExplicitType() { @@ -1568,7 +1572,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithCommentExplicitType() { @@ -1576,7 +1580,7 @@ class RedundancyTests: RulesTests { let output = "var view: UIView /* view */ = .init()" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithComment2ExplicitType() { @@ -1584,7 +1588,7 @@ class RedundancyTests: RulesTests { let output = "var view: UIView = /* view */ .init()" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithStaticMember() { @@ -1606,7 +1610,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithStaticFunc() { @@ -1628,13 +1632,13 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeDoesNothingWithChainedMember() { let input = "let session: URLSession = URLSession.default.makeCopy()" let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantRedundantChainedMemberTypeRemovedOnSwift5_4() { @@ -1642,44 +1646,44 @@ class RedundancyTests: RulesTests { let output = "let session: URLSession = .default.makeCopy()" let options = FormatOptions(redundantType: .explicit, swiftVersion: "5.4") testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeDoesNothingWithChainedMember2() { let input = "let color: UIColor = UIColor.red.withAlphaComponent(0.5)" let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantTypeDoesNothingWithChainedMember3() { let input = "let url: URL = URL(fileURLWithPath: #file).deletingLastPathComponent()" let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovedWithChainedMemberOnSwift5_4() { let input = "let url: URL = URL(fileURLWithPath: #file).deletingLastPathComponent()" let output = "let url: URL = .init(fileURLWithPath: #file).deletingLastPathComponent()" let options = FormatOptions(redundantType: .explicit, swiftVersion: "5.4") - testFormatting(for: input, output, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, output, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantTypeDoesNothingIfLet() { let input = "if let foo: Foo = Foo() {}" let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantTypeDoesNothingGuardLet() { let input = "guard let foo: Foo = Foo() else {}" let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantTypeDoesNothingIfLetAfterComma() { let input = "if check == true, let foo: Foo = Foo() {}" let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantTypeWorksAfterIf() { @@ -1693,7 +1697,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeIfVoid() { @@ -1701,7 +1705,7 @@ class RedundancyTests: RulesTests { let output = "let foo: [Void] = .init()" let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantTypeWithIntegerLiteralNotMangled() { @@ -1783,7 +1787,7 @@ class RedundancyTests: RulesTests { let options = FormatOptions(redundantType: .inferLocalsOnly) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["propertyType"]) } // MARK: - redundantNilInit @@ -2184,6 +2188,7 @@ class RedundancyTests: RulesTests { case .bar: var foo: String? Text(foo ?? "") + default: EmptyView() } @@ -2286,6 +2291,7 @@ class RedundancyTests: RulesTests { let _ = { foo = "\\(max)" }() + default: EmptyView() } @@ -2810,7 +2816,7 @@ class RedundancyTests: RulesTests { return bar }() """ - testFormatting(for: input, rule: FormatRules.redundantReturn) + testFormatting(for: input, rule: FormatRules.redundantReturn, exclude: ["redundantProperty"]) } func testNoRemoveReturnInForWhereLoop() { @@ -2933,7 +2939,7 @@ class RedundancyTests: RulesTests { } """ testFormatting(for: input, rule: FormatRules.redundantReturn, - options: FormatOptions(swiftVersion: "5.1")) + options: FormatOptions(swiftVersion: "5.1"), exclude: ["redundantProperty"]) } func testDisableNextRedundantReturn() { @@ -3111,7 +3117,7 @@ class RedundancyTests: RulesTests { }() """ let options = FormatOptions(swiftVersion: "5.9") - testFormatting(for: input, rule: FormatRules.redundantClosure, options: options, exclude: ["redundantReturn"]) + testFormatting(for: input, rule: FormatRules.redundantClosure, options: options, exclude: ["redundantReturn", "propertyType"]) } func testNonRedundantSwitchStatementReturnInFunction() { @@ -3161,6 +3167,7 @@ class RedundancyTests: RulesTests { case true: // foo return "foo" + default: /* bar */ return "bar" @@ -3173,6 +3180,7 @@ class RedundancyTests: RulesTests { case true: // foo "foo" + default: /* bar */ "bar" @@ -3243,6 +3251,7 @@ class RedundancyTests: RulesTests { return "baaz" } } + case false: return "quux" } @@ -3262,6 +3271,7 @@ class RedundancyTests: RulesTests { "baaz" } } + case false: "quux" } @@ -3494,6 +3504,58 @@ class RedundancyTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.redundantReturn, options: options) } + func testRemovesRedundantReturnBeforeIfExpression() { + let input = """ + func foo() -> Foo { + return if condition { + Foo.foo() + } else { + Foo.bar() + } + } + """ + + let output = """ + func foo() -> Foo { + if condition { + Foo.foo() + } else { + Foo.bar() + } + } + """ + + let options = FormatOptions(swiftVersion: "5.9") + testFormatting(for: input, output, rule: FormatRules.redundantReturn, options: options) + } + + func testRemovesRedundantReturnBeforeSwitchExpression() { + let input = """ + func foo() -> Foo { + return switch condition { + case true: + Foo.foo() + case false: + Foo.bar() + } + } + """ + + let output = """ + func foo() -> Foo { + switch condition { + case true: + Foo.foo() + case false: + Foo.bar() + } + } + """ + + let options = FormatOptions(swiftVersion: "5.9") + testFormatting(for: input, output, rule: FormatRules.redundantReturn, options: options) + } + // MARK: - redundantBackticks func testRemoveRedundantBackticksInLet() { @@ -3598,19 +3660,19 @@ class RedundancyTests: RulesTests { let input = "var type = Foo.`true`" let output = "var type = Foo.true" let options = FormatOptions(swiftVersion: "4") - testFormatting(for: input, output, rule: FormatRules.redundantBackticks, options: options) + testFormatting(for: input, output, rule: FormatRules.redundantBackticks, options: options, exclude: ["propertyType"]) } func testRemoveBackticksAroundProperty() { let input = "var type = Foo.`bar`" let output = "var type = Foo.bar" - testFormatting(for: input, output, rule: FormatRules.redundantBackticks) + testFormatting(for: input, output, rule: FormatRules.redundantBackticks, exclude: ["propertyType"]) } func testRemoveBackticksAroundKeywordProperty() { let input = "var type = Foo.`default`" let output = "var type = Foo.default" - testFormatting(for: input, output, rule: FormatRules.redundantBackticks) + testFormatting(for: input, output, rule: FormatRules.redundantBackticks, exclude: ["propertyType"]) } func testRemoveBackticksAroundKeypathProperty() { @@ -3634,7 +3696,7 @@ class RedundancyTests: RulesTests { func testNoRemoveBackticksAroundInitPropertyInSwift5() { let input = "let foo: Foo = .`init`" let options = FormatOptions(swiftVersion: "5") - testFormatting(for: input, rule: FormatRules.redundantBackticks, options: options) + testFormatting(for: input, rule: FormatRules.redundantBackticks, options: options, exclude: ["propertyType"]) } func testNoRemoveBackticksAroundAnyProperty() { @@ -4206,7 +4268,7 @@ class RedundancyTests: RulesTests { let vc = UIHostingController(rootView: InspectionView(inspection: self.inspection)) """ let options = FormatOptions(selfRequired: ["InspectionView"]) - testFormatting(for: input, rule: FormatRules.redundantSelf, options: options) + testFormatting(for: input, rule: FormatRules.redundantSelf, options: options, exclude: ["propertyType"]) } func testNoMistakeProtocolClassModifierForClassFunction() { @@ -5408,7 +5470,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(swiftVersion: "5.4") - testFormatting(for: input, rule: FormatRules.redundantSelf, options: options) + testFormatting(for: input, rule: FormatRules.redundantSelf, options: options, exclude: ["redundantProperty"]) } func testDisableRedundantSelfDirective() { @@ -5422,7 +5484,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(swiftVersion: "5.4") - testFormatting(for: input, rule: FormatRules.redundantSelf, options: options) + testFormatting(for: input, rule: FormatRules.redundantSelf, options: options, exclude: ["redundantProperty"]) } func testDisableRedundantSelfDirective2() { @@ -5437,7 +5499,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(swiftVersion: "5.4") - testFormatting(for: input, rule: FormatRules.redundantSelf, options: options) + testFormatting(for: input, rule: FormatRules.redundantSelf, options: options, exclude: ["redundantProperty"]) } func testSelfInsertDirective() { @@ -5451,7 +5513,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(swiftVersion: "5.4") - testFormatting(for: input, rule: FormatRules.redundantSelf, options: options) + testFormatting(for: input, rule: FormatRules.redundantSelf, options: options, exclude: ["redundantProperty"]) } func testNoRemoveVariableShadowedLaterInScopeInOlderSwiftVersions() { @@ -6572,6 +6634,7 @@ class RedundancyTests: RulesTests { print(self.bar) } } + case .failure: if self.bar { print(self.bar) @@ -6599,6 +6662,7 @@ class RedundancyTests: RulesTests { } } self.method() + case .failure: break } @@ -6629,6 +6693,7 @@ class RedundancyTests: RulesTests { case .value: print("value") } + case .failure: guard self.bar else { print(self.bar) @@ -7099,7 +7164,7 @@ class RedundancyTests: RulesTests { case networkOnly case cacheFirst - static let defaultCacheAge: TimeInterval = .minutes(5) + static let defaultCacheAge = TimeInterval.minutes(5) func requestStrategy() -> SingleRequestStrategy { switch self { @@ -7111,7 +7176,7 @@ class RedundancyTests: RulesTests { } } """ - testFormatting(for: input, rule: FormatRules.redundantStaticSelf) + testFormatting(for: input, rule: FormatRules.redundantStaticSelf, exclude: ["propertyType"]) } func testPreserveStaticSelfInInstanceFunction() { @@ -7488,7 +7553,7 @@ class RedundancyTests: RulesTests { return parser } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty", "propertyType"]) } func testShadowedClosureArgument2() { @@ -7498,7 +7563,7 @@ class RedundancyTests: RulesTests { return input } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testUnusedPropertyWrapperArgument() { @@ -7899,7 +7964,7 @@ class RedundancyTests: RulesTests { return bar } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testTryAwaitArgumentNotMarkedUnused() { @@ -7910,7 +7975,7 @@ class RedundancyTests: RulesTests { return bar } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testTypedTryAwaitArgumentNotMarkedUnused() { @@ -7921,7 +7986,7 @@ class RedundancyTests: RulesTests { return bar } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testConditionalIfLetMarkedAsUnused() { @@ -8453,7 +8518,7 @@ class RedundancyTests: RulesTests { lazy var bar = Bar() """ - testFormatting(for: input, output, rule: FormatRules.redundantClosure) + testFormatting(for: input, output, rule: FormatRules.redundantClosure, exclude: ["propertyType"]) } func testRemoveRedundantClosureInMultiLinePropertyDeclarationWithString() { @@ -8490,7 +8555,7 @@ class RedundancyTests: RulesTests { """ testFormatting(for: input, [output], rules: [FormatRules.redundantReturn, FormatRules.redundantClosure, - FormatRules.semicolons]) + FormatRules.semicolons], exclude: ["propertyType"]) } func testRemoveRedundantClosureInWrappedPropertyDeclaration_beforeFirst() { @@ -8511,7 +8576,7 @@ class RedundancyTests: RulesTests { let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: true) testFormatting(for: input, [output], rules: [FormatRules.redundantClosure, FormatRules.wrapArguments], - options: options) + options: options, exclude: ["propertyType"]) } func testRemoveRedundantClosureInWrappedPropertyDeclaration_afterFirst() { @@ -8530,7 +8595,7 @@ class RedundancyTests: RulesTests { let options = FormatOptions(wrapArguments: .afterFirst, closingParenOnSameLine: true) testFormatting(for: input, [output], rules: [FormatRules.redundantClosure, FormatRules.wrapArguments], - options: options) + options: options, exclude: ["propertyType"]) } func testRedundantClosureKeepsMultiStatementClosureThatSetsProperty() { @@ -8698,7 +8763,7 @@ class RedundancyTests: RulesTests { lazy var foo = Foo(handle: { fatalError() }) """ - testFormatting(for: input, output, rule: FormatRules.redundantClosure) + testFormatting(for: input, output, rule: FormatRules.redundantClosure, exclude: ["propertyType"]) } func testPreservesClosureWithMultipleVoidMethodCalls() { @@ -9284,7 +9349,7 @@ class RedundancyTests: RulesTests { let options = FormatOptions(swiftVersion: "5.9") testFormatting(for: input, output, rule: FormatRules.redundantClosure, options: options, - exclude: ["redundantReturn", "blankLinesBetweenScopes"]) + exclude: ["redundantReturn", "blankLinesBetweenScopes", "propertyType"]) } func testRedundantClosureWithSwitchExpressionDoesntBreakBuildWithRedundantReturnRuleDisabled() { @@ -9321,7 +9386,7 @@ class RedundancyTests: RulesTests { let options = FormatOptions(swiftVersion: "5.9") testFormatting(for: input, [output], rules: [FormatRules.redundantReturn, FormatRules.redundantClosure], - options: options, exclude: ["indent", "blankLinesBetweenScopes", "wrapMultilineConditionalAssignment"]) + options: options, exclude: ["indent", "blankLinesBetweenScopes", "wrapMultilineConditionalAssignment", "propertyType"]) } func testRemovesRedundantClosureWithGenericExistentialTypes() { @@ -9715,4 +9780,160 @@ class RedundancyTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.noExplicitOwnership) } + + // MARK: - redundantProperty + + func testRemovesRedundantProperty() { + let input = """ + func foo() -> Foo { + let foo = Foo(bar: bar, baaz: baaz) + return foo + } + """ + + let output = """ + func foo() -> Foo { + return Foo(bar: bar, baaz: baaz) + } + """ + + testFormatting(for: input, output, rule: FormatRules.redundantProperty, exclude: ["redundantReturn"]) + } + + func testRemovesRedundantPropertyWithIfExpression() { + let input = """ + func foo() -> Foo { + let foo = + if condition { + Foo.foo() + } else { + Foo.bar() + } + + return foo + } + """ + + let output = """ + func foo() -> Foo { + if condition { + Foo.foo() + } else { + Foo.bar() + } + } + """ + + let options = FormatOptions(swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.redundantProperty, FormatRules.redundantReturn, FormatRules.indent], options: options) + } + + func testRemovesRedundantPropertyWithSwitchExpression() { + let input = """ + func foo() -> Foo { + let foo: Foo + switch condition { + case true: + foo = Foo(bar) + case false: + foo = Foo(baaz) + } + + return foo + } + """ + + let output = """ + func foo() -> Foo { + switch condition { + case true: + Foo(bar) + case false: + Foo(baaz) + } + } + """ + + let options = FormatOptions(swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.redundantProperty, FormatRules.redundantReturn, FormatRules.indent], options: options) + } + + func testRemovesRedundantPropertyWithPreferInferredType() { + let input = """ + func bar() -> Bar { + let bar: Bar = .init(baaz: baaz, quux: quux) + return bar + } + """ + + let output = """ + func bar() -> Bar { + return Bar(baaz: baaz, quux: quux) + } + """ + + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantProperty, FormatRules.redundantInit], exclude: ["redundantReturn"]) + } + + func testRemovesRedundantPropertyWithComments() { + let input = """ + func foo() -> Foo { + // There's a comment before this property + let foo = Foo(bar: bar, baaz: baaz) + // And there's a comment after the property + return foo + } + """ + + let output = """ + func foo() -> Foo { + // There's a comment before this property + return Foo(bar: bar, baaz: baaz) + // And there's a comment after the property + } + """ + + testFormatting(for: input, output, rule: FormatRules.redundantProperty, exclude: ["redundantReturn"]) + } + + func testRemovesRedundantPropertyFollowingOtherProperty() { + let input = """ + func foo() -> Foo { + let bar = Bar(baaz: baaz) + let foo = Foo(bar: bar) + return foo + } + """ + + let output = """ + func foo() -> Foo { + let bar = Bar(baaz: baaz) + return Foo(bar: bar) + } + """ + + testFormatting(for: input, output, rule: FormatRules.redundantProperty) + } + + func testPreservesPropertyWhereReturnIsNotRedundant() { + let input = """ + func foo() -> Foo { + let foo = Foo(bar: bar, baaz: baaz) + return foo.with(quux: quux) + } + + func bar() -> Foo { + let bar = Bar(baaz: baaz) + return bar.baaz + } + + func baaz() -> Foo { + let bar = Bar(baaz: baaz) + print(bar) + return bar + } + """ + + testFormatting(for: input, rule: FormatRules.redundantProperty) + } } diff --git a/Tests/RulesTests+Spacing.swift b/Tests/RulesTests+Spacing.swift index d34a51712..a9e7306ef 100644 --- a/Tests/RulesTests+Spacing.swift +++ b/Tests/RulesTests+Spacing.swift @@ -1235,6 +1235,63 @@ class SpacingTests: RulesTests { testFormatting(for: input, rule: FormatRules.spaceAroundOperators, options: options) } + func testSpaceAroundDataTypeDelimiterLeadingAdded() { + let input = "class Implementation: ImplementationProtocol {}" + let output = "class Implementation : ImplementationProtocol {}" + let options = FormatOptions(spaceAroundDelimiter: .leadingTrailing) + testFormatting( + for: input, + output, + rule: FormatRules.spaceAroundOperators, + options: options + ) + } + + func testSpaceAroundDataTypeDelimiterLeadingTrailingAdded() { + let input = "class Implementation:ImplementationProtocol {}" + let output = "class Implementation : ImplementationProtocol {}" + let options = FormatOptions(spaceAroundDelimiter: .leadingTrailing) + testFormatting( + for: input, + output, + rule: FormatRules.spaceAroundOperators, + options: options + ) + } + + func testSpaceAroundDataTypeDelimiterLeadingTrailingNotModified() { + let input = "class Implementation : ImplementationProtocol {}" + let options = FormatOptions(spaceAroundDelimiter: .leadingTrailing) + testFormatting( + for: input, + rule: FormatRules.spaceAroundOperators, + options: options + ) + } + + func testSpaceAroundDataTypeDelimiterTrailingAdded() { + let input = "class Implementation:ImplementationProtocol {}" + let output = "class Implementation: ImplementationProtocol {}" + + let options = FormatOptions(spaceAroundDelimiter: .trailing) + testFormatting( + for: input, + output, + rule: FormatRules.spaceAroundOperators, + options: options + ) + } + + func testSpaceAroundDataTypeDelimiterLeadingNotAdded() { + let input = "class Implementation: ImplementationProtocol {}" + let options = FormatOptions(spaceAroundDelimiter: .trailing) + testFormatting( + for: input, + rule: FormatRules.spaceAroundOperators, + options: options + ) + } + // MARK: - spaceAroundComments func testSpaceAroundCommentInParens() { @@ -1564,4 +1621,524 @@ class SpacingTests: RulesTests { let options = FormatOptions(emptyBracesSpacing: .linebreak) testFormatting(for: input, rule: FormatRules.emptyBraces, options: options) } + + // MARK: - blankLineAfterMultilineSwitchCase + + func testAddsBlankLineAfterMultilineSwitchCases() { + let input = """ + func handle(_ action: SpaceshipAction) { + switch action { + // The warp drive can be engaged by pressing a button on the control panel + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + // Triggered automatically whenever we detect an energy blast was fired in our direction + case .handleIncomingEnergyBlast: + await energyShields.prepare() + energyShields.engage() + } + } + """ + + let output = """ + func handle(_ action: SpaceshipAction) { + switch action { + // The warp drive can be engaged by pressing a button on the control panel + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + // Triggered automatically whenever we detect an energy blast was fired in our direction + case .handleIncomingEnergyBlast: + await energyShields.prepare() + energyShields.engage() + } + } + """ + testFormatting(for: input, output, rule: FormatRules.blankLineAfterMultilineSwitchCase) + } + + func testRemovesBlankLineAfterLastSwitchCase() { + let input = """ + func handle(_ action: SpaceshipAction) { + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArticialLife() + + case .handleIncomingEnergyBlast: + await energyShields.prepare() + energyShields.engage() + + } + } + """ + + let output = """ + func handle(_ action: SpaceshipAction) { + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArticialLife() + + case .handleIncomingEnergyBlast: + await energyShields.prepare() + energyShields.engage() + } + } + """ + testFormatting(for: input, output, rule: FormatRules.blankLineAfterMultilineSwitchCase) + } + + func testDoesntAddBlankLineAfterSingleLineSwitchCase() { + let input = """ + var planetType: PlanetType { + switch self { + case .mercury, .venus, .earth, .mars: + // The terrestrial planets are smaller and have a solid, rocky surface + .terrestrial + case .jupiter, .saturn, .uranus, .neptune: + // The gas giants are huge and lack a solid surface + .gasGiant + } + } + + var planetType: PlanetType { + switch self { + // The terrestrial planets are smaller and have a solid, rocky surface + case .mercury, .venus, .earth, .mars: + .terrestrial + // The gas giants are huge and lack a solid surface + case .jupiter, .saturn, .uranus, .neptune: + .gasGiant + } + } + + var name: PlanetType { + switch self { + // The planet closest to the sun + case .mercury: + "Mercury" + case .venus: + "Venus" + // The best planet, where everything cool happens + case .earth: + "Earth" + // This planet is entirely inhabited by robots. + // There are cool landers, rovers, and even a helicopter. + case .mars: + "Mars" + case .jupiter: + "Jupiter" + case .saturn: + // Other planets have rings, but satun's are the best. + // It's rings are the only once that are usually visible in photos. + "Saturn" + case .uranus: + /* + * The pronunciation of this planet's name is subject of scholarly debate + */ + "Uranus" + case .neptune: + "Neptune" + } + } + """ + + testFormatting(for: input, rule: FormatRules.blankLineAfterMultilineSwitchCase, exclude: ["sortSwitchCases", "wrapSwitchCases", "blockComments"]) + } + + func testMixedSingleLineAndMultiLineCases() { + let input = """ + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArtificialLife() + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + + let output = """ + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArtificialLife() + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + testFormatting(for: input, output, rule: FormatRules.blankLineAfterMultilineSwitchCase, exclude: ["consistentSwitchStatementSpacing"]) + } + + func testAllowsBlankLinesAfterSingleLineCases() { + let input = """ + switch action { + case .engageWarpDrive: + warpDrive.engage() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + + case let .scanPlanet(planet): + scanner.scan(planet) + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + + testFormatting(for: input, rule: FormatRules.blankLineAfterMultilineSwitchCase) + } + + // MARK: - consistentSwitchStatementSpacing + + func testInsertsBlankLinesToMakeSwitchStatementSpacingConsistent1() { + let input = """ + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArtificialLife() + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + + let output = """ + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + + case let .scanPlanet(planet): + scanner.target = planet + scanner.scanAtmosphere() + scanner.scanBiosphere() + scanner.scanForArtificialLife() + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + testFormatting(for: input, output, rule: FormatRules.consistentSwitchStatementSpacing) + } + + func testInsertsBlankLinesToMakeSwitchStatementSpacingConsistent2() { + let input = """ + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + + let output = """ + switch action { + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + testFormatting(for: input, output, rule: FormatRules.consistentSwitchStatementSpacing) + } + + func testInsertsBlankLinesToMakeSwitchStatementSpacingConsistent3() { + let input = """ + var name: PlanetType { + switch self { + // The planet closest to the sun + case .mercury: + "Mercury" + // Similar to Earth but way more deadly + case .venus: + "Venus" + + // The best planet, where everything cool happens + case .earth: + "Earth" + + // This planet is entirely inhabited by robots. + // There are cool landers, rovers, and even a helicopter. + case .mars: + "Mars" + + // The biggest planet with the most moons + case .jupiter: + "Jupiter" + + // Other planets have rings, but satun's are the best. + case .saturn: + "Saturn" + case .uranus: + "Uranus" + case .neptune: + "Neptune" + } + } + """ + + let output = """ + var name: PlanetType { + switch self { + // The planet closest to the sun + case .mercury: + "Mercury" + + // Similar to Earth but way more deadly + case .venus: + "Venus" + + // The best planet, where everything cool happens + case .earth: + "Earth" + + // This planet is entirely inhabited by robots. + // There are cool landers, rovers, and even a helicopter. + case .mars: + "Mars" + + // The biggest planet with the most moons + case .jupiter: + "Jupiter" + + // Other planets have rings, but satun's are the best. + case .saturn: + "Saturn" + + case .uranus: + "Uranus" + + case .neptune: + "Neptune" + } + } + """ + testFormatting(for: input, output, rule: FormatRules.consistentSwitchStatementSpacing) + } + + func testRemovesBlankLinesToMakeSwitchStatementConsistent() { + let input = """ + var name: PlanetType { + switch self { + // The planet closest to the sun + case .mercury: + "Mercury" + + case .venus: + "Venus" + // The best planet, where everything cool happens + case .earth: + "Earth" + // This planet is entirely inhabited by robots. + // There are cool landers, rovers, and even a helicopter. + case .mars: + "Mars" + case .jupiter: + "Jupiter" + // Other planets have rings, but satun's are the best. + case .saturn: + "Saturn" + case .uranus: + "Uranus" + case .neptune: + "Neptune" + } + } + """ + + let output = """ + var name: PlanetType { + switch self { + // The planet closest to the sun + case .mercury: + "Mercury" + case .venus: + "Venus" + // The best planet, where everything cool happens + case .earth: + "Earth" + // This planet is entirely inhabited by robots. + // There are cool landers, rovers, and even a helicopter. + case .mars: + "Mars" + case .jupiter: + "Jupiter" + // Other planets have rings, but satun's are the best. + case .saturn: + "Saturn" + case .uranus: + "Uranus" + case .neptune: + "Neptune" + } + } + """ + + testFormatting(for: input, output, rule: FormatRules.consistentSwitchStatementSpacing) + } + + func testSingleLineAndMultiLineSwitchCase1() { + let input = """ + switch planetType { + case .terrestrial: + if options.treatPlutoAsPlanet { + [.mercury, .venus, .earth, .mars, .pluto] + } else { + [.mercury, .venus, .earth, .mars] + } + case .gasGiant: + [.jupiter, .saturn, .uranus, .neptune] + } + """ + + let output = """ + switch planetType { + case .terrestrial: + if options.treatPlutoAsPlanet { + [.mercury, .venus, .earth, .mars, .pluto] + } else { + [.mercury, .venus, .earth, .mars] + } + + case .gasGiant: + [.jupiter, .saturn, .uranus, .neptune] + } + """ + + testFormatting(for: input, [output], rules: [FormatRules.blankLineAfterMultilineSwitchCase, FormatRules.consistentSwitchStatementSpacing]) + } + + func testSingleLineAndMultiLineSwitchCase2() { + let input = """ + switch planetType { + case .gasGiant: + [.jupiter, .saturn, .uranus, .neptune] + case .terrestrial: + if options.treatPlutoAsPlanet { + [.mercury, .venus, .earth, .mars, .pluto] + } else { + [.mercury, .venus, .earth, .mars] + } + } + """ + + testFormatting(for: input, rule: FormatRules.consistentSwitchStatementSpacing) + } + + func testSwitchStatementWithSingleMultilineCase_blankLineAfterMultilineSwitchCaseEnabled() { + let input = """ + switch action { + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + case let .scanPlanet(planet): + scanner.scan(planet) + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + + let output = """ + switch action { + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + + case let .scanPlanet(planet): + scanner.scan(planet) + + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + + testFormatting(for: input, [output], rules: [FormatRules.consistentSwitchStatementSpacing, FormatRules.blankLineAfterMultilineSwitchCase]) + } + + func testSwitchStatementWithSingleMultilineCase_blankLineAfterMultilineSwitchCaseDisabled() { + let input = """ + switch action { + case .enableArtificialGravity: + artificialGravityEngine.enable(strength: .oneG) + case .engageWarpDrive: + navigationComputer.destination = targetedDestination + await warpDrive.spinUp() + warpDrive.activate() + case let .scanPlanet(planet): + scanner.scan(planet) + case .handleIncomingEnergyBlast: + energyShields.engage() + } + """ + + testFormatting(for: input, rule: FormatRules.consistentSwitchStatementSpacing, exclude: ["blankLineAfterMultilineSwitchCase"]) + } } diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index c127c3b2d..a8cdd2ab3 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -1803,17 +1803,17 @@ class SyntaxTests: RulesTests { func testAvoidSwiftParserBugWithClosuresInsideArrays() { let input = "var foo = Array<(_ image: Data?) -> Void>()" - testFormatting(for: input, rule: FormatRules.typeSugar, options: FormatOptions(shortOptionals: .always)) + testFormatting(for: input, rule: FormatRules.typeSugar, options: FormatOptions(shortOptionals: .always), exclude: ["propertyType"]) } func testAvoidSwiftParserBugWithClosuresInsideDictionaries() { let input = "var foo = Dictionary Void>()" - testFormatting(for: input, rule: FormatRules.typeSugar, options: FormatOptions(shortOptionals: .always)) + testFormatting(for: input, rule: FormatRules.typeSugar, options: FormatOptions(shortOptionals: .always), exclude: ["propertyType"]) } func testAvoidSwiftParserBugWithClosuresInsideOptionals() { let input = "var foo = Optional<(_ image: Data?) -> Void>()" - testFormatting(for: input, rule: FormatRules.typeSugar, options: FormatOptions(shortOptionals: .always)) + testFormatting(for: input, rule: FormatRules.typeSugar, options: FormatOptions(shortOptionals: .always), exclude: ["propertyType"]) } func testDontOverApplyBugWorkaround() { @@ -1841,21 +1841,21 @@ class SyntaxTests: RulesTests { let input = "var foo = Array<(image: Data?) -> Void>()" let output = "var foo = [(image: Data?) -> Void]()" let options = FormatOptions(shortOptionals: .always) - testFormatting(for: input, output, rule: FormatRules.typeSugar, options: options) + testFormatting(for: input, output, rule: FormatRules.typeSugar, options: options, exclude: ["propertyType"]) } func testDontOverApplyBugWorkaround5() { let input = "var foo = Array<(Data?) -> Void>()" let output = "var foo = [(Data?) -> Void]()" let options = FormatOptions(shortOptionals: .always) - testFormatting(for: input, output, rule: FormatRules.typeSugar, options: options) + testFormatting(for: input, output, rule: FormatRules.typeSugar, options: options, exclude: ["propertyType"]) } func testDontOverApplyBugWorkaround6() { let input = "var foo = Dictionary Void>>()" let output = "var foo = [Int: Array<(_ image: Data?) -> Void>]()" let options = FormatOptions(shortOptionals: .always) - testFormatting(for: input, output, rule: FormatRules.typeSugar, options: options) + testFormatting(for: input, output, rule: FormatRules.typeSugar, options: options, exclude: ["propertyType"]) } // MARK: - preferKeyPath @@ -2050,7 +2050,7 @@ class SyntaxTests: RulesTests { struct ScreenID {} """ - testFormatting(for: input, output, rule: FormatRules.acronyms) + testFormatting(for: input, output, rule: FormatRules.acronyms, exclude: ["propertyType"]) } func testUppercaseCustomAcronym() { @@ -3195,7 +3195,7 @@ class SyntaxTests: RulesTests { """ testFormatting(for: input, output, rule: FormatRules.docComments, - exclude: ["spaceInsideComments"]) + exclude: ["spaceInsideComments", "propertyType"]) } func testConvertDocCommentsToComments() { @@ -3270,7 +3270,7 @@ class SyntaxTests: RulesTests { """ testFormatting(for: input, output, rule: FormatRules.docComments, - exclude: ["spaceInsideComments"]) + exclude: ["spaceInsideComments", "redundantProperty", "propertyType"]) } func testPreservesDocComments() { @@ -3347,7 +3347,7 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(preserveDocComments: true) - testFormatting(for: input, output, rule: FormatRules.docComments, options: options, exclude: ["spaceInsideComments"]) + testFormatting(for: input, output, rule: FormatRules.docComments, options: options, exclude: ["spaceInsideComments", "redundantProperty", "propertyType"]) } func testDoesntConvertCommentBeforeConsecutivePropertiesToDocComment() { @@ -3628,6 +3628,7 @@ class SyntaxTests: RulesTests { case .bar: // bar let bar = baz() + default: // baz let baz = quux() @@ -3762,10 +3763,12 @@ class SyntaxTests: RulesTests { } else { foo = Foo("bar") } + case false: switch condition { case true: foo = Foo("baaz") + case false: if condition { foo = Foo("quux") @@ -3783,10 +3786,12 @@ class SyntaxTests: RulesTests { } else { Foo("bar") } + case false: switch condition { case true: Foo("baaz") + case false: if condition { Foo("quux") @@ -3902,6 +3907,7 @@ class SyntaxTests: RulesTests { case true: foo = Foo("foo") print("Multi-statement") + case false: foo = Foo("bar") } @@ -3939,6 +3945,7 @@ class SyntaxTests: RulesTests { foo = Foo("baaz") } print("Multi-statement") + case false: foo = Foo("bar") } @@ -4262,6 +4269,233 @@ class SyntaxTests: RulesTests { testFormatting(for: input, rule: FormatRules.conditionalAssignment, options: options) } + func testConvertsIfStatementNotFollowingPropertyDefinition() { + let input = """ + if condition { + property = Foo("foo") + } else { + property = Foo("bar") + } + """ + + let output = """ + property = + if condition { + Foo("foo") + } else { + Foo("bar") + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testPreservesIfStatementNotFollowingPropertyDefinitionWithInvalidBranch() { + let input = """ + if condition { + property = Foo("foo") + } else { + property = Foo("bar") + print("A second expression on this branch") + } + + if condition { + property = Foo("foo") + } else { + if otherCondition { + property = Foo("foo") + } + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testPreservesNonExhaustiveIfStatementNotFollowingPropertyDefinition() { + let input = """ + if condition { + property = Foo("foo") + } + + if condition { + property = Foo("foo") + } else if otherCondition { + property = Foo("foo") + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testConvertsSwitchStatementNotFollowingPropertyDefinition() { + let input = """ + switch condition { + case true: + property = Foo("foo") + case false: + property = Foo("bar") + } + """ + + let output = """ + property = + switch condition { + case true: + Foo("foo") + case false: + Foo("bar") + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testConvertsSwitchStatementWithComplexLValueNotFollowingPropertyDefinition() { + let input = """ + switch condition { + case true: + property?.foo!.bar["baaz"] = Foo("foo") + case false: + property?.foo!.bar["baaz"] = Foo("bar") + } + """ + + let output = """ + property?.foo!.bar["baaz"] = + switch condition { + case true: + Foo("foo") + case false: + Foo("bar") + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testDoesntMergePropertyWithUnrelatedCondition() { + let input = """ + let differentProperty: Foo + switch condition { + case true: + property = Foo("foo") + case false: + property = Foo("bar") + } + """ + + let output = """ + let differentProperty: Foo + property = + switch condition { + case true: + Foo("foo") + case false: + Foo("bar") + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testConvertsNestedIfSwitchStatementNotFollowingPropertyDefinition() { + let input = """ + switch firstCondition { + case true: + if secondCondition { + property = Foo("foo") + } else { + property = Foo("bar") + } + + case false: + if thirdCondition { + property = Foo("baaz") + } else { + property = Foo("quux") + } + } + """ + + let output = """ + property = + switch firstCondition { + case true: + if secondCondition { + Foo("foo") + } else { + Foo("bar") + } + + case false: + if thirdCondition { + Foo("baaz") + } else { + Foo("quux") + } + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testPreservesSwitchConditionWithIneligibleBranch() { + let input = """ + switch firstCondition { + case true: + // Even though this condition is eligible to be converted, + // we leave it as-is because it's nested in an ineligible condition. + if secondCondition { + property = Foo("foo") + } else { + property = Foo("bar") + } + + case false: + if thirdCondition { + property = Foo("baaz") + } else { + property = Foo("quux") + print("A second expression on this branch") + } + } + """ + + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") + testFormatting(for: input, rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + + func testPreservesIfConditionWithIneligibleBranch() { + let input = """ + if firstCondition { + // Even though this condition is eligible to be converted, + // we leave it as-is because it's nested in an ineligible condition. + if secondCondition { + property = Foo("foo") + } else { + property = Foo("bar") + } + } else { + if thirdCondition { + property = Foo("baaz") + } else { + property = Foo("quux") + print("A second expression on this branch") + } + } + """ + + let options = FormatOptions(swiftVersion: "5.9") + testFormatting(for: input, rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + // MARK: - preferForLoop func testConvertSimpleForEachToForLoop() { @@ -4620,4 +4854,554 @@ class SyntaxTests: RulesTests { """ testFormatting(for: input, rule: FormatRules.preferForLoop) } + + // MARK: propertyType + + func testConvertsExplicitTypeToInferredType() { + let input = """ + let foo: Foo = .init() + let bar: Bar = .staticBar + let baaz: Baaz = .Example.default + let quux: Quux = .quuxBulder(foo: .foo, bar: .bar) + + let dictionary: [Foo: Bar] = .init() + let array: [Foo] = .init() + let genericType: MyGenericType = .init() + """ + + let output = """ + let foo = Foo() + let bar = Bar.staticBar + let baaz = Baaz.Example.default + let quux = Quux.quuxBulder(foo: .foo, bar: .bar) + + let dictionary = [Foo: Bar]() + let array = [Foo]() + let genericType = MyGenericType() + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantInit], options: options) + } + + func testConvertsInferredTypeToExplicitType() { + let input = """ + let foo = Foo() + let bar = Bar.staticBar + let quux = Quux.quuxBulder(foo: .foo, bar: .bar) + + let dictionary = [Foo: Bar]() + let array = [Foo]() + let genericType = MyGenericType() + """ + + let output = """ + let foo: Foo = .init() + let bar: Bar = .staticBar + let quux: Quux = .quuxBulder(foo: .foo, bar: .bar) + + let dictionary: [Foo: Bar] = .init() + let array: [Foo] = .init() + let genericType: MyGenericType = .init() + """ + + let options = FormatOptions(redundantType: .explicit) + testFormatting(for: input, output, rule: FormatRules.propertyType, options: options) + } + + func testConvertsTypeMembersToExplicitType() { + let input = """ + struct Foo { + let foo = Foo() + let bar = Bar.staticBar + let quux = Quux.quuxBulder(foo: .foo, bar: .bar) + + let dictionary = [Foo: Bar]() + let array = [Foo]() + let genericType = MyGenericType() + } + """ + + let output = """ + struct Foo { + let foo: Foo = .init() + let bar: Bar = .staticBar + let quux: Quux = .quuxBulder(foo: .foo, bar: .bar) + + let dictionary: [Foo: Bar] = .init() + let array: [Foo] = .init() + let genericType: MyGenericType = .init() + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly) + testFormatting(for: input, output, rule: FormatRules.propertyType, options: options) + } + + func testConvertsLocalsToImplicitType() { + let input = """ + struct Foo { + let foo = Foo() + + func bar() { + let bar: Bar = .staticBar + let quux: Quux = .quuxBulder(foo: .foo, bar: .bar) + + let dictionary: [Foo: Bar] = .init() + let array: [Foo] = .init() + let genericType: MyGenericType = .init() + } + } + """ + + let output = """ + struct Foo { + let foo: Foo = .init() + + func bar() { + let bar = Bar.staticBar + let quux = Quux.quuxBulder(foo: .foo, bar: .bar) + + let dictionary = [Foo: Bar]() + let array = [Foo]() + let genericType = MyGenericType() + } + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantInit], options: options) + } + + func testPreservesInferredTypeFollowingTypeWithDots() { + let input = """ + let baaz = Baaz.Example.default + let color = Color.Theme.default + """ + + let options = FormatOptions(redundantType: .explicit) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesExplicitTypeIfNoRHS() { + let input = """ + let foo: Foo + let bar: Bar + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesImplicitTypeIfNoRHSType() { + let input = """ + let foo = foo() + let bar = bar + let int = 24 + let array = ["string"] + """ + + let options = FormatOptions(redundantType: .explicit) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesImplicitForVoidAndTuples() { + let input = """ + let foo = Void() + let foo = (foo: "foo", bar: "bar").foo + let foo = ["bar", "baz"].quux(quuz) + let foo = [bar].first + let foo = [bar, baaz].first + let foo = ["foo": "bar"].first + let foo = [foo: bar].first + """ + + let options = FormatOptions(redundantType: .explicit) + testFormatting(for: input, rule: FormatRules.propertyType, options: options, exclude: ["void"]) + } + + func testPreservesExplicitTypeIfUsingLocalValueOrLiteral() { + let input = """ + let foo: Foo = localFoo + let bar: Bar = localBar + let int: Int64 = 1234 + let number: CGFloat = 12.345 + let array: [String] = [] + let dictionary: [String: Int] = [:] + let tuple: (String, Int) = ("foo", 123) + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.propertyType, options: options, exclude: ["redundantType"]) + } + + func testCompatibleWithRedundantTypeInferred() { + let input = """ + let foo: Foo = Foo() + """ + + let output = """ + let foo = Foo() + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.propertyType], options: options) + } + + func testCompatibleWithRedundantTypeExplicit() { + let input = """ + let foo: Foo = Foo() + """ + + let output = """ + let foo: Foo = .init() + """ + + let options = FormatOptions(redundantType: .explicit) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.propertyType], options: options) + } + + func testCompatibleWithRedundantTypeInferLocalsOnly() { + let input = """ + let foo: Foo = Foo.init() + let foo: Foo = .init() + + func bar() { + let baaz: Baaz = Baaz.init() + let baaz: Baaz = .init() + } + """ + + let output = """ + let foo: Foo = .init() + let foo: Foo = .init() + + func bar() { + let baaz = Baaz() + let baaz = Baaz() + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.propertyType, FormatRules.redundantInit], options: options) + } + + func testPropertyTypeWithIfExpressionDisabledByDefault() { + let input = """ + let foo: SomeTypeWithALongGenrericName = + if condition { + .init(bar) + } else { + .init(baaz) + } + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPropertyTypeWithIfExpression() { + let input = """ + let foo: Foo = + if condition { + .init(bar) + } else { + .init(baaz) + } + """ + + let output = """ + let foo = + if condition { + Foo(bar) + } else { + Foo(baaz) + } + """ + + let options = FormatOptions(redundantType: .inferred, inferredTypesInConditionalExpressions: true) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantInit], options: options) + } + + func testPropertyTypeWithSwitchExpression() { + let input = """ + let foo: Foo = + switch condition { + case true: + .init(bar) + case false: + .init(baaz) + } + """ + + let output = """ + let foo = + switch condition { + case true: + Foo(bar) + case false: + Foo(baaz) + } + """ + + let options = FormatOptions(redundantType: .inferred, inferredTypesInConditionalExpressions: true) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantInit], options: options) + } + + func testPreservesNonMatchingIfExpression() { + let input = """ + let foo: Foo = + if condition { + .init(bar) + } else { + [] // e.g. using ExpressibleByArrayLiteral + } + """ + + let options = FormatOptions(redundantType: .inferred, inferredTypesInConditionalExpressions: true) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesExplicitOptionalType() { + // `let foo = Foo?.foo` doesn't work if `.foo` is defined on `Foo` but not `Foo?` + let input = """ + let optionalFoo1: Foo? = .foo + let optionalFoo2: Foo? = Foo.foo + let optionalFoo3: Foo! = .foo + let optionalFoo4: Foo! = Foo.foo + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesTypeWithSeparateDeclarationAndProperty() { + let input = """ + var foo: Foo! + foo = Foo(afterDelay: { + print(foo) + }) + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesTypeWithExistentialAny() { + let input = """ + protocol ShapeStyle {} + struct MyShapeStyle: ShapeStyle {} + + extension ShapeStyle where Self == MyShapeStyle { + static var myShape: MyShapeStyle { MyShapeStyle() } + } + + /// This compiles + let myShape1: any ShapeStyle = .myShape + + // This would fail with "error: static member 'myShape' cannot be used on protocol metatype '(any ShapeStyle).Type'" + // let myShape2 = (any ShapeStyle).myShape + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesExplicitRightHandSideWithOperator() { + let input = """ + let value: ClosedRange = .zero ... 10 + let dynamicTypeSizeRange: ClosedRange = .large ... .xxxLarge + let dynamicTypeSizeRange: ClosedRange = .large() ... .xxxLarge() + let dynamicTypeSizeRange: ClosedRange = .convertFromLiteral(.large ... .xxxLarge) + """ + + let output = """ + let value: ClosedRange = .zero ... 10 + let dynamicTypeSizeRange: ClosedRange = .large ... .xxxLarge + let dynamicTypeSizeRange: ClosedRange = .large() ... .xxxLarge() + let dynamicTypeSizeRange = ClosedRange.convertFromLiteral(.large ... .xxxLarge) + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, output, rule: FormatRules.propertyType, options: options) + } + + func testPreservesInferredRightHandSideWithOperators() { + let input = """ + let foo = Foo().bar + let foo = Foo.bar.baaz.quux + let foo = Foo.bar ... baaz + """ + + let options = FormatOptions(redundantType: .explicit) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPreservesUserProvidedSymbolTypes() { + let input = """ + class Foo { + let foo = Foo() + let bar = Bar() + + func bar() { + let foo: Foo = .foo + let bar: Bar = .bar + let baaz: Baaz = .baaz + let quux: Quux = .quux + } + } + """ + + let output = """ + class Foo { + let foo = Foo() + let bar: Bar = .init() + + func bar() { + let foo: Foo = .foo + let bar = Bar.bar + let baaz: Baaz = .baaz + let quux: Quux = .quux + } + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly, preserveSymbols: ["Foo", "Baaz", "quux"]) + testFormatting(for: input, output, rule: FormatRules.propertyType, options: options) + } + + func testPreserveInitIfExplicitlyExcluded() { + let input = """ + class Foo { + let foo = Foo() + let bar = Bar.init() + let baaz = Baaz.baaz() + + func bar() { + let foo: Foo = .init() + let bar: Bar = .init() + let baaz: Baaz = .baaz() + } + } + """ + + let output = """ + class Foo { + let foo = Foo() + let bar = Bar.init() + let baaz: Baaz = .baaz() + + func bar() { + let foo: Foo = .init() + let bar: Bar = .init() + let baaz = Baaz.baaz() + } + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly, preserveSymbols: ["init"]) + testFormatting(for: input, output, rule: FormatRules.propertyType, options: options, exclude: ["redundantInit"]) + } + + func testClosureBodyIsConsideredLocal() { + let input = """ + foo { + let bar = Bar() + let baaz: Baaz = .init() + } + + foo(bar: bar, baaz: baaz, quux: { + let bar = Bar() + let baaz: Baaz = .init() + }) + + foo { + let bar = Bar() + let baaz: Baaz = .init() + } bar: { + let bar = Bar() + let baaz: Baaz = .init() + } + + class Foo { + let foo = Foo.bar { + let baaz = Baaz() + let baaz: Baaz = .init() + } + } + """ + + let output = """ + foo { + let bar = Bar() + let baaz = Baaz() + } + + foo(bar: bar, baaz: baaz, quux: { + let bar = Bar() + let baaz = Baaz() + }) + + foo { + let bar = Bar() + let baaz = Baaz() + } bar: { + let bar = Bar() + let baaz = Baaz() + } + + class Foo { + let foo: Foo = .bar { + let baaz = Baaz() + let baaz = Baaz() + } + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantInit], options: options) + } + + func testIfGuardConditionsPreserved() { + let input = """ + if let foo = Foo(bar) { + let foo = Foo(bar) + } else if let foo = Foo(bar) { + let foo = Foo(bar) + } else { + let foo = Foo(bar) + } + + guard let foo = Foo(bar) else { + return + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } + + func testPropertyObserversConsideredLocal() { + let input = """ + class Foo { + var foo: Foo { + get { + let foo = Foo(bar) + } + set { + let foo = Foo(bar) + } + willSet { + let foo = Foo(bar) + } + didSet { + let foo = Foo(bar) + } + } + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) + } } diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index a09f5a144..a6aa62ad9 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -1410,7 +1410,7 @@ class WrappingTests: RulesTests { Thing(), ]) """ - testFormatting(for: input, output, rule: FormatRules.wrapArguments) + testFormatting(for: input, output, rule: FormatRules.wrapArguments, exclude: ["propertyType"]) } func testWrapArgumentsDoesntIndentTrailingComment() { @@ -1474,6 +1474,86 @@ class WrappingTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) } + func testWrapParametersFunctionDeclarationClosingParenOnSameLine() { + let input = """ + func foo( + bar _: Int, + baz _: String + ) {} + """ + let output = """ + func foo( + bar _: Int, + baz _: String) {} + """ + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: true) + testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) + } + + func testWrapParametersFunctionDeclarationClosingParenOnNextLine() { + let input = """ + func foo( + bar _: Int, + baz _: String) {} + """ + let output = """ + func foo( + bar _: Int, + baz _: String + ) {} + """ + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: false) + testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) + } + + func testWrapParametersFunctionDeclarationClosingParenOnSameLineAndForce() { + let input = """ + func foo( + bar _: Int, + baz _: String + ) {} + """ + let output = """ + func foo( + bar _: Int, + baz _: String) {} + """ + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: true, closingCallSiteParenOnSameLine: true) + testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) + } + + func testWrapParametersFunctionDeclarationClosingParenOnNextLineAndForce() { + let input = """ + func foo( + bar _: Int, + baz _: String) {} + """ + let output = """ + func foo( + bar _: Int, + baz _: String + ) {} + """ + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: false, closingCallSiteParenOnSameLine: true) + testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) + } + + func testWrapParametersFunctionCallClosingParenOnNextLineAndForce() { + let input = """ + foo( + bar: 42, + baz: "foo" + ) + """ + let output = """ + foo( + bar: 42, + baz: "foo") + """ + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: false, closingCallSiteParenOnSameLine: true) + testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) + } + func testIndentMultilineStringWhenWrappingArguments() { let input = """ foobar(foo: \"\"" @@ -1513,7 +1593,7 @@ class WrappingTests: RulesTests { } """ let options = FormatOptions(wrapArguments: .beforeFirst) - testFormatting(for: input, rule: FormatRules.wrapArguments, options: options) + testFormatting(for: input, rule: FormatRules.wrapArguments, options: options, exclude: ["propertyType"]) } // MARK: wrapParameters @@ -3229,7 +3309,7 @@ class WrappingTests: RulesTests { wrapReturnType: .ifMultiline ) - testFormatting(for: input, rule: FormatRules.wrapArguments, options: options) + testFormatting(for: input, rule: FormatRules.wrapArguments, options: options, exclude: ["propertyType"]) } func testPreserveReturnOnMultilineFunctionDeclarationByDefault() { @@ -3399,7 +3479,7 @@ class WrappingTests: RulesTests { print("statement body") } """ - testFormatting(for: input, rule: FormatRules.wrapMultilineStatementBraces) + testFormatting(for: input, rule: FormatRules.wrapMultilineStatementBraces, exclude: ["propertyType"]) } func testSingleLineIfBraceOnSameLine() { @@ -3531,7 +3611,7 @@ class WrappingTests: RulesTests { func testMultilineBraceAppliedToGetterBody_wrapBeforeFirst() { let input = """ - var items: Adaptive = .adaptive( + var items = Adaptive.adaptive( compact: Sizes.horizontalPaddingTiny_8, regular: Sizes.horizontalPaddingLarge_64) { didSet { updateAccessoryViewSpacing() } @@ -3539,7 +3619,7 @@ class WrappingTests: RulesTests { """ let output = """ - var items: Adaptive = .adaptive( + var items = Adaptive.adaptive( compact: Sizes.horizontalPaddingTiny_8, regular: Sizes.horizontalPaddingLarge_64) { @@ -3554,7 +3634,7 @@ class WrappingTests: RulesTests { testFormatting(for: input, [output], rules: [ FormatRules.wrapMultilineStatementBraces, FormatRules.indent, - ], options: options) + ], options: options, exclude: ["propertyType"]) } func testMultilineBraceAppliedToTrailingClosure_wrapAfterFirst() { @@ -3583,8 +3663,8 @@ class WrappingTests: RulesTests { func testMultilineBraceAppliedToGetterBody_wrapAfterFirst() { let input = """ - var items: Adaptive = .adaptive(compact: Sizes.horizontalPaddingTiny_8, - regular: Sizes.horizontalPaddingLarge_64) + var items = Adaptive.adaptive(compact: Sizes.horizontalPaddingTiny_8, + regular: Sizes.horizontalPaddingLarge_64) { didSet { updateAccessoryViewSpacing() } } @@ -3597,7 +3677,7 @@ class WrappingTests: RulesTests { testFormatting(for: input, [], rules: [ FormatRules.wrapMultilineStatementBraces, FormatRules.wrapArguments, - ], options: options) + ], options: options, exclude: ["propertyType"]) } func testMultilineBraceAppliedToSubscriptBody() { @@ -3972,7 +4052,8 @@ class WrappingTests: RulesTests { """ testFormatting( for: input, rules: [FormatRules.wrapArguments, FormatRules.indent], - options: FormatOptions(closingParenOnSameLine: true, wrapConditions: .beforeFirst) + options: FormatOptions(closingParenOnSameLine: true, wrapConditions: .beforeFirst), + exclude: ["propertyType"] ) } @@ -4134,6 +4215,286 @@ class WrappingTests: RulesTests { ) } + // MARK: conditionsWrap auto + + func testConditionsWrapAutoForLongGuard() { + let input = """ + guard let foo = foo, let bar = bar, let third = third else {} + """ + + let output = """ + guard let foo = foo, + let bar = bar, + let third = third + else {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 40) + ) + } + + func testConditionsWrapAutoForLongGuardWithoutChanges() { + let input = """ + guard let foo = foo, let bar = bar, let third = third else {} + """ + testFormatting( + for: input, + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 120) + ) + } + + func testConditionsWrapAutoForMultilineGuard() { + let input = """ + guard let foo = foo, + let bar = bar, let third = third else {} + """ + + let output = """ + guard let foo = foo, + let bar = bar, + let third = third + else {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments, FormatRules.indent], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 40) + ) + } + + func testConditionsWrapAutoOptionForGuardStyledAsBeforeArgument() { + let input = """ + guard + let foo = foo, + let bar = bar, + let third = third + else {} + + guard + let foo = foo, + let bar = bar, + let third = third + else {} + """ + + let output = """ + guard let foo = foo, + let bar = bar, + let third = third + else {} + + guard let foo = foo, + let bar = bar, + let third = third + else {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 40) + ) + } + + func testConditionsWrapAutoOptionForGuardWhenElseOnNewLine() { + let input = """ + guard let foo = foo, let bar = bar, let third = third + else {} + """ + + let output = """ + guard let foo = foo, + let bar = bar, + let third = third + else {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 40) + ) + } + + func testConditionsWrapAutoOptionForGuardWhenElseOnNewLineAndNotAligned() { + let input = """ + guard let foo = foo, let bar = bar, let third = third + else {} + + guard let foo = foo, let bar = bar, let third = third + + else {} + """ + + let output = """ + guard let foo = foo, + let bar = bar, + let third = third + else {} + + guard let foo = foo, + let bar = bar, + let third = third + else {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 40) + ) + } + + func testConditionsWrapAutoOptionForGuardInMethod() { + let input = """ + func doSmth() { + let a = smth as? SmthElse + + guard + let foo = foo, + let bar = bar, + let third = third + else { + return nil + } + + let value = a.doSmth() + } + """ + + let output = """ + func doSmth() { + let a = smth as? SmthElse + + guard let foo = foo, + let bar = bar, + let third = third + else { + return nil + } + + let value = a.doSmth() + } + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 120) + ) + } + + func testConditionsWrapAutoOptionForIfInsideMethod() { + let input = """ + func doSmth() { + let a = smth as? SmthElse + + if + let foo = foo, + let bar = bar, + let third = third { + return nil + } + + let value = a.doSmth() + } + """ + + let output = """ + func doSmth() { + let a = smth as? SmthElse + + if let foo = foo, + let bar = bar, + let third = third { + return nil + } + + let value = a.doSmth() + } + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 120), + exclude: ["wrapMultilineStatementBraces"] + ) + } + + func testConditionsWrapAutoOptionForLongIf() { + let input = """ + if let foo = foo, let bar = bar, let third = third {} + """ + + let output = """ + if let foo = foo, + let bar = bar, + let third = third {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments, FormatRules.indent], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 25) + ) + } + + func testConditionsWrapAutoOptionForLongMultilineIf() { + let input = """ + if let foo = foo, + let bar = bar, let third = third {} + """ + + let output = """ + if let foo = foo, + let bar = bar, + let third = third {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments, FormatRules.indent], + options: FormatOptions(indent: " ", conditionsWrap: .auto, maxWidth: 25) + ) + } + + // MARK: conditionsWrap always + + func testConditionWrapAlwaysOptionForLongGuard() { + let input = """ + guard let foo = foo, let bar = bar, let third = third else {} + """ + + let output = """ + guard let foo = foo, + let bar = bar, + let third = third + else {} + """ + + testFormatting( + for: input, + [output], + rules: [FormatRules.wrapArguments], + options: FormatOptions(indent: " ", conditionsWrap: .always, maxWidth: 120) + ) + } + // MARK: - wrapAttributes func testPreserveWrappedFuncAttributeByDefault() { @@ -4365,7 +4726,7 @@ class WrappingTests: RulesTests { let myClass = MyClass() """ let options = FormatOptions(typeAttributes: .prevLine) - testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) } func testClassImportAttributeNotTreatedAsType() { @@ -4376,6 +4737,18 @@ class WrappingTests: RulesTests { testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) } + func testWrapPrivateSetComputedVarAttributes() { + let input = """ + @objc private(set) dynamic var foo = Foo() + """ + let output = """ + @objc + private(set) dynamic var foo = Foo() + """ + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) + } + func testWrapPrivateSetVarAttributes() { let input = """ @objc private(set) dynamic var foo = Foo() @@ -4385,7 +4758,19 @@ class WrappingTests: RulesTests { private(set) dynamic var foo = Foo() """ let options = FormatOptions(varAttributes: .prevLine) - testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) + } + + func testDontWrapPrivateSetVarAttributes() { + let input = """ + @objc + private(set) dynamic var foo = Foo() + """ + let output = """ + @objc private(set) dynamic var foo = Foo() + """ + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) } func testWrapConvenienceInitAttribute() { @@ -4400,7 +4785,7 @@ class WrappingTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } - func testWrapPropertyWrapperAttribute() { + func testWrapPropertyWrapperAttributeVarAttributes() { let input = """ @OuterType.Wrapper var foo: Int """ @@ -4412,6 +4797,30 @@ class WrappingTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } + func testWrapPropertyWrapperAttribute() { + let input = """ + @OuterType.Wrapper var foo: Int + """ + let output = """ + @OuterType.Wrapper + var foo: Int + """ + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + + func testDontWrapPropertyWrapperAttribute() { + let input = """ + @OuterType.Wrapper + var foo: Int + """ + let output = """ + @OuterType.Wrapper var foo: Int + """ + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + func testWrapGenericPropertyWrapperAttribute() { let input = """ @OuterType.Generic var foo: WrappedType @@ -4420,7 +4829,7 @@ class WrappingTests: RulesTests { @OuterType.Generic var foo: WrappedType """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4432,7 +4841,84 @@ class WrappingTests: RulesTests { @OuterType.Generic.Foo var foo: WrappedType """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + + func testAttributeOnComputedProperty() { + let input = """ + extension SectionContainer: ContentProviding where Section: ContentProviding { + @_disfavoredOverload + public var content: Section.Content { + section.content + } + } + """ + + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + + func testWrapAvailableAttributeUnderMaxWidth() { + let input = """ + @available(*, unavailable, message: "This property is deprecated.") + var foo: WrappedType + """ + let output = """ + @available(*, unavailable, message: "This property is deprecated.") var foo: WrappedType + """ + let options = FormatOptions(maxWidth: 100, varAttributes: .prevLine, storedVarAttributes: .sameLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + + func testDoesntWrapAvailableAttributeWithLongMessage() { + // Unwrapping this attribute would just cause it to wrap in a different way: + // + // @available( + // *, + // unavailable, + // message: "This property is deprecated. It has a really long message." + // ) var foo: WrappedType + // + // so instead leave it un-wrapped to preserve the existing formatting. + let input = """ + @available(*, unavailable, message: "This property is deprecated. It has a really long message.") + var foo: WrappedType + """ + let options = FormatOptions(maxWidth: 100, varAttributes: .prevLine, storedVarAttributes: .sameLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + + func testDoesntWrapComplexAttribute() { + let input = """ + @Option( + name: ["myArgument"], + help: "Long help text for my example arg from Swift argument parser") + var foo: WrappedType + """ + let options = FormatOptions(closingParenOnSameLine: true, varAttributes: .prevLine, storedVarAttributes: .sameLine, complexAttributes: .prevLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + + func testDoesntWrapComplexMultilineAttribute() { + let input = """ + @available(*, deprecated, message: "Deprecated!") + var foo: WrappedType + """ + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine, complexAttributes: .prevLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + + func testWrapsComplexAttribute() { + let input = """ + @available(*, deprecated, message: "Deprecated!") var foo: WrappedType + """ + + let output = """ + @available(*, deprecated, message: "Deprecated!") + var foo: WrappedType + """ + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine, complexAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4448,16 +4934,199 @@ class WrappingTests: RulesTests { var foo = Foo() } """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) + } + + func testComplexAttributesException() { + let input = """ + @Environment(\\.myEnvironmentVar) var foo: Foo + + @SomeCustomAttr(argument: true) var foo: Foo + + @available(*, deprecated) var foo: Foo + """ + + let output = """ + @Environment(\\.myEnvironmentVar) var foo: Foo + + @SomeCustomAttr(argument: true) var foo: Foo + + @available(*, deprecated) + var foo: Foo + """ + + let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine, complexAttributes: .prevLine, complexAttributesExceptions: ["@SomeCustomAttr"]) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } + func testMixedComplexAndSimpleAttributes() { + let input = """ + /// Simple attributes stay on a single line: + @State private var warpDriveEnabled: Bool + + @ObservedObject private var lifeSupportService: LifeSupportService + + @Environment(\\.controlPanelStyle) private var controlPanelStyle + + @AppStorage("ControlsConfig") private var controlsConfig: ControlConfiguration + + /// Complex attributes are wrapped: + @AppStorage("ControlPanelState", store: myCustomUserDefaults) private var controlPanelState: ControlPanelState + + @Tweak(name: "Aspect ratio") private var aspectRatio = AspectRatio.stretch + + @available(*, unavailable) var saturn5Builder: Saturn5Builder + + @available(*, unavailable, message: "No longer in production") var saturn5Builder: Saturn5Builder + """ + + let output = """ + /// Simple attributes stay on a single line: + @State private var warpDriveEnabled: Bool + + @ObservedObject private var lifeSupportService: LifeSupportService + + @Environment(\\.controlPanelStyle) private var controlPanelStyle + + @AppStorage("ControlsConfig") private var controlsConfig: ControlConfiguration + + /// Complex attributes are wrapped: + @AppStorage("ControlPanelState", store: myCustomUserDefaults) + private var controlPanelState: ControlPanelState + + @Tweak(name: "Aspect ratio") + private var aspectRatio = AspectRatio.stretch + + @available(*, unavailable) + var saturn5Builder: Saturn5Builder + + @available(*, unavailable, message: "No longer in production") + var saturn5Builder: Saturn5Builder + """ + + let options = FormatOptions(storedVarAttributes: .sameLine, complexAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) + } + + func testEscapingClosureNotMistakenForComplexAttribute() { + let input = """ + func foo(_ fooClosure: @escaping () throws -> Void) { + try fooClosure() + } + """ + + let options = FormatOptions(complexAttributes: .prevLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + + func testEscapingTypedThrowClosureNotMistakenForComplexAttribute() { + let input = """ + func foo(_ fooClosure: @escaping () throws(Foo) -> Void) { + try fooClosure() + } + """ + + let options = FormatOptions(complexAttributes: .prevLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + + func testWrapOrDontWrapMultipleDeclarationsInClass() { + let input = """ + class Foo { + @objc + var foo = Foo() + + @available(*, unavailable) + var bar: Bar + + @available(*, unavailable) + var myComputedFoo: String { + "myComputedFoo" + } + + @Environment(\\.myEnvironmentVar) + var foo + + @State + var myStoredFoo: String = "myStoredFoo" { + didSet { + print(newValue) + } + } + } + """ + let output = """ + class Foo { + @objc var foo = Foo() + + @available(*, unavailable) + var bar: Bar + + @available(*, unavailable) + var myComputedFoo: String { + "myComputedFoo" + } + + @Environment(\\.myEnvironmentVar) var foo + + @State var myStoredFoo: String = "myStoredFoo" { + didSet { + print(newValue) + } + } + } + """ + let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine, complexAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) + } + + func testWrapOrDontAttributesInSwiftUIView() { + let input = """ + struct MyView: View { + @State var textContent: String + + var body: some View { + childView + } + + @ViewBuilder + var childView: some View { + Text(verbatim: textContent) + } + } + """ + + let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + + func testWrapAttributesInSwiftUIView() { + let input = """ + struct MyView: View { + @State var textContent: String + @Environment(\\.myEnvironmentVar) var environmentVar + + var body: some View { + childView + } + + @ViewBuilder var childView: some View { + Text(verbatim: textContent) + } + } + """ + + let options = FormatOptions(varAttributes: .sameLine, complexAttributes: .prevLine) + testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) + } + func testInlineMainActorAttributeNotWrapped() { let input = """ var foo: @MainActor (Foo) -> Void var bar: @MainActor (Bar) -> Void """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) } @@ -4943,4 +5612,71 @@ class WrappingTests: RulesTests { testFormatting(for: input, [output], rules: [FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent]) } + + func testWrapIfAssignmentWithoutIntroducer() { + let input = """ + property = if condition { + Foo("foo") + } else { + Foo("bar") + } + """ + + let output = """ + property = + if condition { + Foo("foo") + } else { + Foo("bar") + } + """ + + testFormatting(for: input, [output], rules: [FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent]) + } + + func testWrapSwitchAssignmentWithoutIntroducer() { + let input = """ + property = switch condition { + case true: + Foo("foo") + case false: + Foo("bar") + } + """ + + let output = """ + property = + switch condition { + case true: + Foo("foo") + case false: + Foo("bar") + } + """ + + testFormatting(for: input, [output], rules: [FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent]) + } + + func testWrapSwitchAssignmentWithComplexLValue() { + let input = """ + property?.foo!.bar["baaz"] = switch condition { + case true: + Foo("foo") + case false: + Foo("bar") + } + """ + + let output = """ + property?.foo!.bar["baaz"] = + switch condition { + case true: + Foo("foo") + case false: + Foo("bar") + } + """ + + testFormatting(for: input, [output], rules: [FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent]) + } } diff --git a/Tests/SwiftFormatTests.swift b/Tests/SwiftFormatTests.swift index 7955ee9fd..54ac5b1a8 100644 --- a/Tests/SwiftFormatTests.swift +++ b/Tests/SwiftFormatTests.swift @@ -67,7 +67,7 @@ class SwiftFormatTests: XCTestCase { return { files.append(inputURL) } } XCTAssertEqual(errors.count, 0) - XCTAssertEqual(files.count, 70) + XCTAssertEqual(files.count, 72) } func testInputFilesMatchOutputFilesForSameOutput() { @@ -78,7 +78,7 @@ class SwiftFormatTests: XCTestCase { return { files.append(inputURL) } } XCTAssertEqual(errors.count, 0) - XCTAssertEqual(files.count, 70) + XCTAssertEqual(files.count, 72) } func testInputFileNotEnumeratedWhenExcluded() { @@ -93,7 +93,7 @@ class SwiftFormatTests: XCTestCase { return { files.append(inputURL) } } XCTAssertEqual(errors.count, 0) - XCTAssertEqual(files.count, 43) + XCTAssertEqual(files.count, 45) } // MARK: format function