From 7e8b3dc39be748a03d9a386a51d58fd795d7d9bf Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Tue, 16 Apr 2024 19:28:20 +0100 Subject: [PATCH 01/38] Fix `redundantParens` regression --- Sources/ParsingHelpers.swift | 12 +++++++++--- Sources/Rules.swift | 4 ++-- Tests/RulesTests+Parens.swift | 9 +++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index e18599886..ee93f87c2 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: diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 32ee205a6..66ed607aa 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -2737,10 +2737,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), diff --git a/Tests/RulesTests+Parens.swift b/Tests/RulesTests+Parens.swift index 819db1b53..4b68457ad 100644 --- a/Tests/RulesTests+Parens.swift +++ b/Tests/RulesTests+Parens.swift @@ -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) + } } From 8189d9679ee520b07edd7073cf5fe2a3c6b0c1ef Mon Sep 17 00:00:00 2001 From: "a.baranouski" Date: Wed, 27 Jan 2021 22:01:45 +0100 Subject: [PATCH 02/38] `--conditionswrap` option to format condition in Xcode 12 style, in case it's too long or multiline --- Rules.md | 12 ++ Sources/Examples.swift | 11 ++ Sources/FormattingHelpers.swift | 108 ++++++++++-- Sources/OptionDescriptor.swift | 6 + Sources/Options.swift | 9 + Sources/Rules.swift | 5 +- Tests/MetadataTests.swift | 3 +- Tests/RulesTests+Wrapping.swift | 280 ++++++++++++++++++++++++++++++++ 8 files changed, 420 insertions(+), 14 deletions(-) diff --git a/Rules.md b/Rules.md index edcf49e93..89246c663 100644 --- a/Rules.md +++ b/Rules.md @@ -2444,6 +2444,7 @@ Option | Description `--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 @@ -2504,6 +2505,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 {} +``` + +

diff --git a/Sources/Examples.swift b/Sources/Examples.swift index af38d9d9e..68bf8d598 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1151,6 +1151,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 = """ diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index efe842138..1be79a7c9 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -467,22 +467,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 +513,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 +613,7 @@ extension Formatter { wrapArgumentsAfterFirst(startOfScope: i, endOfScope: endOfScope, allowGrouping: true) - case .disabled, .default: + case .disabled, .default, .auto, .always: assertionFailure() // Shouldn't happen } @@ -654,7 +669,7 @@ extension Formatter { wrapArgumentsAfterFirst(startOfScope: i, endOfScope: endOfScope, allowGrouping: true) - case .disabled, .default: + case .disabled, .default, .auto, .always: assertionFailure() // Shouldn't happen } } @@ -677,7 +692,7 @@ extension Formatter { lastIndex = i } - // -- wrapconditions + // -- wrapconditions && -- conditionswrap forEach(.keyword) { index, token in let indent: String let endOfConditionsToken: Token @@ -695,16 +710,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 +738,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 +756,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 +897,7 @@ extension Formatter { wrapIndices = andTokenIndices case .beforeFirst: wrapIndices = [equalsIndex] + andTokenIndices - case .default, .disabled, .preserve: + case .default, .disabled, .preserve, .auto, .always: return } diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 67fe709eb..573927b62 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", diff --git a/Sources/Options.swift b/Sources/Options.swift index 6723e7eec..78e912449 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 } @@ -375,6 +381,7 @@ public struct FormatOptions: CustomStringConvertible { 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 @@ -478,6 +485,7 @@ public struct FormatOptions: CustomStringConvertible { wrapReturnType: WrapReturnType = .preserve, wrapConditions: WrapMode = .preserve, wrapTernaryOperators: TernaryOperatorWrapMode = .default, + conditionsWrap: WrapMode = .disabled, uppercaseHex: Bool = true, uppercaseExponent: Bool = false, decimalGrouping: Grouping = .group(3, 6), @@ -571,6 +579,7 @@ public struct FormatOptions: CustomStringConvertible { self.wrapReturnType = wrapReturnType self.wrapConditions = wrapConditions self.wrapTernaryOperators = wrapTernaryOperators + self.conditionsWrap = conditionsWrap self.uppercaseHex = uppercaseHex self.uppercaseExponent = uppercaseExponent self.decimalGrouping = decimalGrouping diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 66ed607aa..eafc0c28b 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -3864,7 +3864,7 @@ public struct _FormatRules { 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"] + "trimwhitespace", "linebreaks", "tabwidth", "maxwidth", "smarttabs", "wrapreturntype", "wrapconditions", "wraptypealiases", "wrapternary", "wrapeffects", "conditionswrap"] ) { formatter in let maxWidth = formatter.options.maxWidth guard maxWidth > 0 else { return } @@ -3921,7 +3921,8 @@ public struct _FormatRules { help: "Align wrapped function arguments or collection elements.", orderAfter: ["wrap"], options: ["wraparguments", "wrapparameters", "wrapcollections", "closingparen", - "wrapreturntype", "wrapconditions", "wraptypealiases", "wrapeffects"], + "wrapreturntype", "wrapconditions", "wraptypealiases", "wrapeffects", + "conditionswrap"], sharedOptions: ["indent", "trimwhitespace", "linebreaks", "tabwidth", "maxwidth", "smarttabs", "assetliterals", "wrapternary"] ) { formatter in diff --git a/Tests/MetadataTests.swift b/Tests/MetadataTests.swift index fb7a04ad1..63fe5e1e7 100644 --- a/Tests/MetadataTests.swift +++ b/Tests/MetadataTests.swift @@ -198,7 +198,7 @@ class MetadataTests: XCTestCase { Descriptors.closingParenOnSameLine, 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 +236,7 @@ class MetadataTests: XCTestCase { continue } } + for option in referencedOptions { XCTAssert(ruleOptions.contains(option.argumentName) || option.isDeprecated, "\(option.argumentName) not listed in \(name) rule") diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index a09f5a144..f751f54f6 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -4134,6 +4134,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() { From 7a57bc845c2018586f866fe15a43b8f0aa4c2318 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Tue, 27 Dec 2022 14:59:19 +0100 Subject: [PATCH 03/38] Add options for spacing around delimiter (#1335) --- Rules.md | 1 + Sources/OptionDescriptor.swift | 6 ++++ Sources/Options.swift | 9 ++++++ Sources/Rules.swift | 12 ++++--- Tests/RulesTests+Spacing.swift | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/Rules.md b/Rules.md index 89246c663..c896790b7 100644 --- a/Rules.md +++ b/Rules.md @@ -2061,6 +2061,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 diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 573927b62..b672d6638 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -698,6 +698,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", diff --git a/Sources/Options.swift b/Sources/Options.swift index 78e912449..e2fb0d305 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -354,6 +354,12 @@ 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" +} + /// Configuration options for formatting. These aren't actually used by the /// Formatter class itself, but it makes them available to the format rules. public struct FormatOptions: CustomStringConvertible { @@ -443,6 +449,7 @@ public struct FormatOptions: CustomStringConvertible { public var preserveAnonymousForEach: Bool public var preserveSingleLineForEach: Bool public var preserveDocComments: Bool + public var spaceAroundDelimiter: SpaceAroundDelimiter /// Deprecated public var indentComments: Bool @@ -547,6 +554,7 @@ public struct FormatOptions: CustomStringConvertible { preserveAnonymousForEach: Bool = false, preserveSingleLineForEach: Bool = true, preserveDocComments: Bool = false, + spaceAroundDelimiter: SpaceAroundDelimiter = .trailing, // Doesn't really belong here, but hard to put elsewhere fragment: Bool = false, ignoreConflictMarkers: Bool = false, @@ -641,6 +649,7 @@ public struct FormatOptions: CustomStringConvertible { self.preserveAnonymousForEach = preserveAnonymousForEach self.preserveSingleLineForEach = preserveSingleLineForEach self.preserveDocComments = preserveDocComments + self.spaceAroundDelimiter = spaceAroundDelimiter // Doesn't really belong here, but hard to put elsewhere self.fragment = fragment self.ignoreConflictMarkers = ignoreConflictMarkers diff --git a/Sources/Rules.swift b/Sources/Rules.swift index eafc0c28b..fe5a66825 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 diff --git a/Tests/RulesTests+Spacing.swift b/Tests/RulesTests+Spacing.swift index d34a51712..31d0b9665 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() { From e52c7e88aac90660b1b06d4f16d489d7641d7d49 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Sun, 21 May 2023 22:55:12 +0200 Subject: [PATCH 04/38] Extend `initCoderUnavailable ` rule (#1442) --- Rules.md | 4 ++ .../UIDesigner/EditViewController.swift | 2 +- Sources/OptionDescriptor.swift | 8 ++++ Sources/Options.swift | 3 ++ Sources/Rules.swift | 11 ++++- Tests/RulesTests+General.swift | 44 +++++++++++++++---- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Rules.md b/Rules.md index c896790b7..62ff91b99 100644 --- a/Rules.md +++ b/Rules.md @@ -955,6 +955,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 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/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index b672d6638..f688c963b 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -961,6 +961,14 @@ struct _Descriptors { trueValues: ["preserve"], falseValues: ["before-declarations", "declarations"] ) + 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"] + ) // MARK: - Internal diff --git a/Sources/Options.swift b/Sources/Options.swift index e2fb0d305..332a60b88 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -450,6 +450,7 @@ public struct FormatOptions: CustomStringConvertible { public var preserveSingleLineForEach: Bool public var preserveDocComments: Bool public var spaceAroundDelimiter: SpaceAroundDelimiter + public var initCoderNil: Bool /// Deprecated public var indentComments: Bool @@ -555,6 +556,7 @@ public struct FormatOptions: CustomStringConvertible { preserveSingleLineForEach: Bool = true, preserveDocComments: Bool = false, spaceAroundDelimiter: SpaceAroundDelimiter = .trailing, + initCoderNil: Bool = false, // Doesn't really belong here, but hard to put elsewhere fragment: Bool = false, ignoreConflictMarkers: Bool = false, @@ -650,6 +652,7 @@ public struct FormatOptions: CustomStringConvertible { self.preserveSingleLineForEach = preserveSingleLineForEach self.preserveDocComments = preserveDocComments self.spaceAroundDelimiter = spaceAroundDelimiter + self.initCoderNil = initCoderNil // Doesn't really belong here, but hard to put elsewhere self.fragment = fragment self.ignoreConflictMarkers = ignoreConflictMarkers diff --git a/Sources/Rules.swift b/Sources/Rules.swift index fe5a66825..b986d2583 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -2161,7 +2161,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)") @@ -2183,10 +2183,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 } diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index 80bff5fc8..697a70291 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -28,12 +28,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 +43,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 +107,7 @@ class GeneralTests: RulesTests { let input = """ class Foo: UIView { public required init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } """ @@ -90,7 +115,7 @@ class GeneralTests: RulesTests { class Foo: UIView { @available(*, unavailable) public required init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } """ @@ -101,7 +126,7 @@ class GeneralTests: RulesTests { let input = """ class Foo: UIView { required public init?(coder _: NSCoder) { - fatalError() + fatalError("init(coder:) has not been implemented") } } """ @@ -109,12 +134,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 From 3535307c34d0bb7595d9c569cf0fa496bf17a773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Wed, 9 Aug 2023 00:09:07 +0200 Subject: [PATCH 05/38] Add `created.name` and `created.email` file header placeholders --- README.md | 4 +- Sources/CommandLine.swift | 5 +- Sources/FormattingHelpers.swift | 25 ++++++++ Sources/GitHelpers.swift | 82 +++++++++++++++++++++++++++ Sources/Options.swift | 40 +++++++++++-- Sources/ParsingHelpers.swift | 18 ------ Sources/Rules.swift | 21 ++----- Sources/ShellHelpers.swift | 59 +++++++++++++++++++ Sources/SwiftFormat.swift | 37 +++++++----- SwiftFormat.xcodeproj/project.pbxproj | 20 +++++++ Tests/ArgumentsTests.swift | 2 +- Tests/RulesTests+General.swift | 24 +++++++- Tests/SwiftFormatTests.swift | 6 +- 13 files changed, 281 insertions(+), 62 deletions(-) create mode 100644 Sources/GitHelpers.swift create mode 100644 Sources/ShellHelpers.swift diff --git a/README.md b/README.md index 2e1fa206c..d79eb59f7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/CommandLine.swift b/Sources/CommandLine.swift index 92f7cf40d..5de576695 100644 --- a/Sources/CommandLine.swift +++ b/Sources/CommandLine.swift @@ -484,7 +484,10 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in var formatOptions = options.formatOptions ?? .default formatOptions.fileInfo = FileInfo( filePath: resourceValues.path, - creationDate: resourceValues.creationDate + replacements: [ + .createdDate: resourceValues.creationDate?.shortString, + .createdYear: resourceValues.creationDate?.yearString, + ].compactMapValues { $0 } ) options.formatOptions = formatOptions } diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index 1be79a7c9..7aea68034 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -3363,3 +3363,28 @@ extension Formatter { isTypeRoot: false, isInit: false) } } + +extension Date { + static var shortDateFormatter: (Date) -> String = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return { date in formatter.string(from: 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) + } + + var shortString: String { + Date.shortDateFormatter(self) + } +} diff --git a/Sources/GitHelpers.swift b/Sources/GitHelpers.swift new file mode 100644 index 000000000..9167ee4bf --- /dev/null +++ b/Sources/GitHelpers.swift @@ -0,0 +1,82 @@ +// +// 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 + +struct GitFileInfo { + var createdByName: String? + var createdByEmail: String? +} + +enum GitHelpers { + private static var inGitRoot: Bool { + // Get current git repository top level directory + guard let root = "git rev-parse --show-toplevel".shellOutput() else { return false } + // Make sure a valid URL was returned + guard let _ = URL(string: root) else { return false } + // Make sure an existing path was returned + return FileManager.default.fileExists(atPath: root) + } + + // If a file has never been committed, default to the local git user for that repository + private static var defaultGitInfo: GitFileInfo? { + guard inGitRoot else { return nil } + + let name = "git config user.name".shellOutput() + let email = "git config user.email".shellOutput() + + guard let safeName = name, let safeEmail = email else { return nil } + + return GitFileInfo(createdByName: safeName, createdByEmail: safeEmail) + } + + private enum FileInfoPart: String { + case email = "ae" + case name = "an" + } + + private static func fileInfoPart(_ inputURL: URL, _ part: FileInfoPart) -> String? { + let value = "git log --diff-filter=A --pretty=%\(part.rawValue) \(inputURL.relativePath)" + .shellOutput() + + guard let safeValue = value, !safeValue.isEmpty else { return nil } + return safeValue + } + + static func fileInfo(_ inputURL: URL) -> GitFileInfo? { + guard inGitRoot else { return nil } + + let name = fileInfoPart(inputURL, .name) ?? defaultGitInfo?.createdByName + let email = fileInfoPart(inputURL, .email) ?? defaultGitInfo?.createdByEmail + + return GitFileInfo(createdByName: name, createdByEmail: email) + } +} diff --git a/Sources/Options.swift b/Sources/Options.swift index 332a60b88..3680de489 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -245,24 +245,54 @@ public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStrin return string.isEmpty ? "strip" : string.replacingOccurrences(of: "\n", with: "\\n") } } + + public func hasTemplateKey(_ keys: FileInfoKey...) -> Bool { + guard case let .replace(str) = self else { + return false + } + + return keys.contains(where: { str.contains("{\($0.rawValue)}") }) + } +} + +public enum FileInfoKey: String, CaseIterable { + case fileName = "file" + case currentYear = "year" + case createdName = "created.name" + case createdEmail = "created.email" + case createdDate = "created" + case createdYear = "created.year" } /// File info, used for constructing header comments public struct FileInfo: Equatable, CustomStringConvertible { - var filePath: String? - var creationDate: Date? + let filePath: String? + var replacements: [FileInfoKey: String] = [:] var fileName: String? { filePath.map { URL(fileURLWithPath: $0).lastPathComponent } } - public init(filePath: String? = nil, creationDate: Date? = nil) { + public init(filePath: String? = nil, replacements: [FileInfoKey: String] = [:]) { self.filePath = filePath - self.creationDate = creationDate + + self.replacements.merge(replacements, uniquingKeysWith: { $1 }) + + if let fileName = fileName { + self.replacements[.fileName] = fileName + } + + self.replacements[.currentYear] = Date.currentYear } public var description: String { - "\(fileName ?? "");\(creationDate.map { "\($0)" } ?? "")" + replacements.enumerated() + .map { "\($0)=\($1)" } + .joined(separator: ";") + } + + public func hasReplacement(for key: FileInfoKey) -> Bool { + replacements[key] != nil } } diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index ee93f87c2..7f791b097 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -2536,24 +2536,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 b986d2583..f42967b3b 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -4333,23 +4333,10 @@ 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)) + for (key, replacement) in formatter.options.fileInfo.replacements { + if let range = string.range(of: "{\(key.rawValue)}") { + string.replaceSubrange(range, with: replacement) + } } headerTokens = tokenize(string) directives = headerTokens.compactMap { diff --git a/Sources/ShellHelpers.swift b/Sources/ShellHelpers.swift new file mode 100644 index 000000000..00d99ef81 --- /dev/null +++ b/Sources/ShellHelpers.swift @@ -0,0 +1,59 @@ +// +// 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() -> String? { + let process = Process() + let pipe = Pipe() + + process.executableURL = URL(string: "/bin/bash") + process.arguments = ["-c", self] + process.standardOutput = pipe + process.standardError = pipe + + 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..75330b1e2 100644 --- a/Sources/SwiftFormat.swift +++ b/Sources/SwiftFormat.swift @@ -180,9 +180,22 @@ public func enumerateFiles(withInputURL inputURL: URL, let fileOptions = options.fileOptions ?? .default if resourceValues.isRegularFile == true { if fileOptions.supportedFileExtensions.contains(inputURL.pathExtension) { + let shouldGetCreatedBy = + options.rules?.contains(FormatRules.fileHeader.name) ?? false && + options.formatOptions?.fileHeader.hasTemplateKey(.createdName, .createdEmail) ?? false + + let gitInfo = shouldGetCreatedBy + ? GitHelpers.fileInfo(inputURL) + : nil + let fileInfo = FileInfo( filePath: resourceValues.path, - creationDate: resourceValues.creationDate + replacements: [ + .createdDate: resourceValues.creationDate?.shortString, + .createdYear: resourceValues.creationDate?.yearString, + .createdName: gitInfo?.createdByName, + .createdEmail: gitInfo?.createdByEmail, + ].compactMapValues { $0 } ) var options = options options.formatOptions?.fileInfo = fileInfo @@ -488,19 +501,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 FileInfoKey.allCases { + if header.hasTemplateKey(key), !fileInfo.hasReplacement(for: 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/ArgumentsTests.swift b/Tests/ArgumentsTests.swift index 7f27fcc29..efc01ef38 100644 --- a/Tests/ArgumentsTests.swift +++ b/Tests/ArgumentsTests.swift @@ -669,7 +669,7 @@ class ArgumentsTests: XCTestCase { } func testAddArgumentsDoesntBreakFileInfo() throws { - let fileInfo = FileInfo(filePath: "~/Foo.swift", creationDate: Date()) + let fileInfo = createFileInfo(filePath: "~/Foo.swift", creationDate: Date()) var options = Options(formatOptions: FormatOptions(fileInfo: fileInfo)) try options.addArguments(["indent": "2"], in: "") guard let formatOptions = options.formatOptions else { diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index 697a70291..d60ce9802 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -9,6 +9,16 @@ import XCTest @testable import SwiftFormat +func createFileInfo(filePath: String? = nil, creationDate: Date? = nil, replacements: [FileInfoKey: String] = [:]) -> FileInfo { + var allReplacements = replacements + allReplacements.merge([ + .createdDate: creationDate?.shortString, + .createdYear: creationDate?.yearString, + ].compactMapValues { $0 }, uniquingKeysWith: { $1 }) + + return FileInfo(filePath: filePath, replacements: allReplacements) +} + class GeneralTests: RulesTests { // MARK: - initCoderUnavailable @@ -585,11 +595,21 @@ class GeneralTests: RulesTests { formatter.dateFormat = "yyyy" return "// Copyright © \(formatter.string(from: date))\n\nlet foo = bar" }() - let fileInfo = FileInfo(creationDate: date) + let fileInfo = createFileInfo(creationDate: date) let options = FormatOptions(fileHeader: "// Copyright © {created.year}", fileInfo: fileInfo) 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 = createFileInfo(replacements: [.createdName: name, .createdEmail: email]) + let options = FormatOptions(fileHeader: "// Created by {created.name} {created.email}", fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + func testFileHeaderCreationDateReplacement() { let input = "let foo = bar" let date = Date(timeIntervalSince1970: 0) @@ -599,7 +619,7 @@ class GeneralTests: RulesTests { formatter.timeStyle = .none return "// Created by Nick Lockwood on \(formatter.string(from: date)).\n\nlet foo = bar" }() - let fileInfo = FileInfo(creationDate: date) + let fileInfo = createFileInfo(creationDate: date) let options = FormatOptions(fileHeader: "// Created by Nick Lockwood on {created}.", fileInfo: fileInfo) testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } 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 From b6727ed13e38f3254a3192b7f8e91a17f86a50b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Wed, 9 Aug 2023 00:18:58 +0200 Subject: [PATCH 06/38] Fix header replacements when placeholder is used multiple times --- Sources/Rules.swift | 2 +- Tests/RulesTests+General.swift | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/Rules.swift b/Sources/Rules.swift index f42967b3b..85c330b14 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -4334,7 +4334,7 @@ public struct _FormatRules { return case var .replace(string): for (key, replacement) in formatter.options.fileInfo.replacements { - if let range = string.range(of: "{\(key.rawValue)}") { + while let range = string.range(of: "{\(key.rawValue)}") { string.replaceSubrange(range, with: replacement) } } diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index d60ce9802..674922acf 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -610,6 +610,15 @@ class GeneralTests: RulesTests { 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 = createFileInfo(replacements: [.createdName: 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) From 48ad8c0c4388e775109e1e943b24cccb692f89d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Wed, 9 Aug 2023 13:23:13 +0200 Subject: [PATCH 07/38] Add option to configure how dates are printed in the file header --- Sources/Arguments.swift | 1 + Sources/CommandLine.swift | 2 +- Sources/FormattingHelpers.swift | 32 ++++++++++++----- Sources/OptionDescriptor.swift | 8 +++++ Sources/Options.swift | 46 ++++++++++++++++++++++++ Sources/SwiftFormat.swift | 2 +- Tests/RulesTests+General.swift | 63 +++++++++++++++++++++++++++++++-- 7 files changed, 141 insertions(+), 13 deletions(-) diff --git a/Sources/Arguments.swift b/Sources/Arguments.swift index 8ebdcd185..bd515dc02 100644 --- a/Sources/Arguments.swift +++ b/Sources/Arguments.swift @@ -672,6 +672,7 @@ let commandLineArguments = [ "version", "options", "ruleinfo", + "dateformat", ] + optionsArguments let deprecatedArguments = Descriptors.all.compactMap { diff --git a/Sources/CommandLine.swift b/Sources/CommandLine.swift index 5de576695..7afee088f 100644 --- a/Sources/CommandLine.swift +++ b/Sources/CommandLine.swift @@ -485,7 +485,7 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in formatOptions.fileInfo = FileInfo( filePath: resourceValues.path, replacements: [ - .createdDate: resourceValues.creationDate?.shortString, + .createdDate: resourceValues.creationDate?.format(with: options.formatOptions?.dateFormat), .createdYear: resourceValues.creationDate?.yearString, ].compactMapValues { $0 } ) diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index 7aea68034..1311e3a97 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -3365,13 +3365,6 @@ extension Formatter { } extension Date { - static var shortDateFormatter: (Date) -> String = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .none - return { date in formatter.string(from: date) } - }() - static var yearFormatter: (Date) -> String = { let formatter = DateFormatter() formatter.dateFormat = "yyyy" @@ -3384,7 +3377,28 @@ extension Date { Date.yearFormatter(self) } - var shortString: String { - Date.shortDateFormatter(self) + func format(with format: DateFormat?) -> String { + let formatter = DateFormatter() + + if format != nil, format != .system { + // Default to UTC + formatter.timeZone = .init(identifier: "UTC") + } + + switch format { + case nil, .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/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index f688c963b..24c743dba 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -969,6 +969,14 @@ struct _Descriptors { 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 } + ) // MARK: - Internal diff --git a/Sources/Options.swift b/Sources/Options.swift index 3680de489..c474373cf 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -390,6 +390,49 @@ public enum SpaceAroundDelimiter: String, CaseIterable { case leadingTrailing = "leading-trailing" } +/// Format for printed 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 + } +} + /// Configuration options for formatting. These aren't actually used by the /// Formatter class itself, but it makes them available to the format rules. public struct FormatOptions: CustomStringConvertible { @@ -481,6 +524,7 @@ public struct FormatOptions: CustomStringConvertible { public var preserveDocComments: Bool public var spaceAroundDelimiter: SpaceAroundDelimiter public var initCoderNil: Bool + public var dateFormat: DateFormat /// Deprecated public var indentComments: Bool @@ -587,6 +631,7 @@ public struct FormatOptions: CustomStringConvertible { preserveDocComments: Bool = false, spaceAroundDelimiter: SpaceAroundDelimiter = .trailing, initCoderNil: Bool = false, + dateFormat: DateFormat = .system, // Doesn't really belong here, but hard to put elsewhere fragment: Bool = false, ignoreConflictMarkers: Bool = false, @@ -683,6 +728,7 @@ public struct FormatOptions: CustomStringConvertible { self.preserveDocComments = preserveDocComments self.spaceAroundDelimiter = spaceAroundDelimiter self.initCoderNil = initCoderNil + self.dateFormat = dateFormat // Doesn't really belong here, but hard to put elsewhere self.fragment = fragment self.ignoreConflictMarkers = ignoreConflictMarkers diff --git a/Sources/SwiftFormat.swift b/Sources/SwiftFormat.swift index 75330b1e2..4037aa87d 100644 --- a/Sources/SwiftFormat.swift +++ b/Sources/SwiftFormat.swift @@ -191,7 +191,7 @@ public func enumerateFiles(withInputURL inputURL: URL, let fileInfo = FileInfo( filePath: resourceValues.path, replacements: [ - .createdDate: resourceValues.creationDate?.shortString, + .createdDate: resourceValues.creationDate?.format(with: options.formatOptions?.dateFormat), .createdYear: resourceValues.creationDate?.yearString, .createdName: gitInfo?.createdByName, .createdEmail: gitInfo?.createdByEmail, diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index 674922acf..050da9850 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -9,16 +9,35 @@ import XCTest @testable import SwiftFormat -func createFileInfo(filePath: String? = nil, creationDate: Date? = nil, replacements: [FileInfoKey: String] = [:]) -> FileInfo { +func createFileInfo( + filePath: String? = nil, + creationDate: Date? = nil, + replacements: [FileInfoKey: String] = [:], + dateFormat: DateFormat? = nil +) -> FileInfo { var allReplacements = replacements allReplacements.merge([ - .createdDate: creationDate?.shortString, + .createdDate: creationDate?.format(with: dateFormat), .createdYear: creationDate?.yearString, ].compactMapValues { $0 }, uniquingKeysWith: { $1 }) return FileInfo(filePath: filePath, replacements: allReplacements) } +private enum TestDateFormat: String { + case basic = "yyyy-MM-dd" + 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 + return formatter.date(from: input)! +} + class GeneralTests: RulesTests { // MARK: - initCoderUnavailable @@ -633,6 +652,46 @@ 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 = createFileInfo(creationDate: date, dateFormat: .iso) + let options = FormatOptions(fileHeader: "// {created}", 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 = createFileInfo(creationDate: date, dateFormat: .dayMonthYear) + let options = FormatOptions(fileHeader: "// {created}", 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 = createFileInfo(creationDate: date, dateFormat: .monthDayYear) + let options = FormatOptions(fileHeader: "// {created}", 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 = createFileInfo(creationDate: date, dateFormat: .custom("yy.MM.dd-HH.mm.ss.SSS")) + let options = FormatOptions(fileHeader: "// {created}", fileInfo: fileInfo) + testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) + } + func testFileHeaderRuleThrowsIfCreationDateUnavailable() { let input = "let foo = bar" let options = FormatOptions(fileHeader: "// Created by Nick Lockwood on {created}.", fileInfo: FileInfo()) From 139c5cdba4b038ab3853fc78fd9e88a1a2687d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Mon, 14 Aug 2023 23:43:57 +0200 Subject: [PATCH 08/38] Add option to configure which timezone dates should be formatted to --- Rules.md | 2 + Sources/Arguments.swift | 1 + Sources/CommandLine.swift | 9 +++- Sources/FormattingHelpers.swift | 9 ++-- Sources/OptionDescriptor.swift | 8 ++++ Sources/Options.swift | 54 +++++++++++++++++++++- Sources/SwiftFormat.swift | 10 +++- Tests/RulesTests+General.swift | 82 +++++++++++++++++++++++++++++++-- 8 files changed, 164 insertions(+), 11 deletions(-) diff --git a/Rules.md b/Rules.md index 62ff91b99..c805edf25 100644 --- a/Rules.md +++ b/Rules.md @@ -737,6 +737,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 diff --git a/Sources/Arguments.swift b/Sources/Arguments.swift index bd515dc02..44efeeb81 100644 --- a/Sources/Arguments.swift +++ b/Sources/Arguments.swift @@ -673,6 +673,7 @@ let commandLineArguments = [ "options", "ruleinfo", "dateformat", + "timezone", ] + optionsArguments let deprecatedArguments = Descriptors.all.compactMap { diff --git a/Sources/CommandLine.swift b/Sources/CommandLine.swift index 7afee088f..857ad80ce 100644 --- a/Sources/CommandLine.swift +++ b/Sources/CommandLine.swift @@ -482,10 +482,17 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in keys: [.creationDateKey, .pathKey] ) var formatOptions = options.formatOptions ?? .default + + var createdDate: String? + if let creationDate = resourceValues.creationDate { + createdDate = creationDate.format(with: formatOptions.dateFormat, + timeZone: formatOptions.timeZone) + } + formatOptions.fileInfo = FileInfo( filePath: resourceValues.path, replacements: [ - .createdDate: resourceValues.creationDate?.format(with: options.formatOptions?.dateFormat), + .createdDate: createdDate, .createdYear: resourceValues.creationDate?.yearString, ].compactMapValues { $0 } ) diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index 1311e3a97..2e4b46abd 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -3377,16 +3377,15 @@ extension Date { Date.yearFormatter(self) } - func format(with format: DateFormat?) -> String { + func format(with format: DateFormat, timeZone: FormatTimeZone) -> String { let formatter = DateFormatter() - if format != nil, format != .system { - // Default to UTC - formatter.timeZone = .init(identifier: "UTC") + if let chosenTimeZone = timeZone.timeZone { + formatter.timeZone = chosenTimeZone } switch format { - case nil, .system: + case .system: formatter.dateStyle = .short formatter.timeStyle = .none case .dayMonthYear: diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 24c743dba..4088f528e 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -977,6 +977,14 @@ struct _Descriptors { 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 diff --git a/Sources/Options.swift b/Sources/Options.swift index c474373cf..758dbe8f8 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -390,7 +390,7 @@ public enum SpaceAroundDelimiter: String, CaseIterable { case leadingTrailing = "leading-trailing" } -/// Format for printed dates +/// Format to use when printing dates public enum DateFormat: Equatable, RawRepresentable, CustomStringConvertible { case dayMonthYear case iso @@ -433,6 +433,55 @@ public enum DateFormat: Equatable, RawRepresentable, CustomStringConvertible { } } +/// 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 + } +} + /// Configuration options for formatting. These aren't actually used by the /// Formatter class itself, but it makes them available to the format rules. public struct FormatOptions: CustomStringConvertible { @@ -525,6 +574,7 @@ public struct FormatOptions: CustomStringConvertible { public var spaceAroundDelimiter: SpaceAroundDelimiter public var initCoderNil: Bool public var dateFormat: DateFormat + public var timeZone: FormatTimeZone /// Deprecated public var indentComments: Bool @@ -632,6 +682,7 @@ public struct FormatOptions: CustomStringConvertible { spaceAroundDelimiter: SpaceAroundDelimiter = .trailing, initCoderNil: Bool = false, dateFormat: DateFormat = .system, + timeZone: FormatTimeZone = .system, // Doesn't really belong here, but hard to put elsewhere fragment: Bool = false, ignoreConflictMarkers: Bool = false, @@ -729,6 +780,7 @@ public struct FormatOptions: CustomStringConvertible { self.spaceAroundDelimiter = spaceAroundDelimiter self.initCoderNil = initCoderNil self.dateFormat = dateFormat + self.timeZone = timeZone // Doesn't really belong here, but hard to put elsewhere self.fragment = fragment self.ignoreConflictMarkers = ignoreConflictMarkers diff --git a/Sources/SwiftFormat.swift b/Sources/SwiftFormat.swift index 4037aa87d..479fe61c7 100644 --- a/Sources/SwiftFormat.swift +++ b/Sources/SwiftFormat.swift @@ -188,10 +188,18 @@ public func enumerateFiles(withInputURL inputURL: URL, ? GitHelpers.fileInfo(inputURL) : nil + var createdDate: String? + if let creationDate = resourceValues.creationDate, + let formatOptions = options.formatOptions + { + createdDate = creationDate.format(with: formatOptions.dateFormat, + timeZone: formatOptions.timeZone) + } + let fileInfo = FileInfo( filePath: resourceValues.path, replacements: [ - .createdDate: resourceValues.creationDate?.format(with: options.formatOptions?.dateFormat), + .createdDate: createdDate, .createdYear: resourceValues.creationDate?.yearString, .createdName: gitInfo?.createdByName, .createdEmail: gitInfo?.createdByEmail, diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index 050da9850..61aa5d4a3 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -13,11 +13,13 @@ func createFileInfo( filePath: String? = nil, creationDate: Date? = nil, replacements: [FileInfoKey: String] = [:], - dateFormat: DateFormat? = nil + dateFormat: DateFormat = .system, + timeZone: FormatTimeZone = .system ) -> FileInfo { var allReplacements = replacements + allReplacements.merge([ - .createdDate: creationDate?.format(with: dateFormat), + .createdDate: creationDate?.format(with: dateFormat, timeZone: timeZone), .createdYear: creationDate?.yearString, ].compactMapValues { $0 }, uniquingKeysWith: { $1 }) @@ -26,6 +28,7 @@ func createFileInfo( private enum TestDateFormat: String { case basic = "yyyy-MM-dd" + case time = "HH:mmZZZZZ" case timestamp = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" } @@ -35,6 +38,8 @@ private func createTestDate( ) -> Date { let formatter = DateFormatter() formatter.dateFormat = format.rawValue + formatter.timeZone = .current + return formatter.date(from: input)! } @@ -687,11 +692,82 @@ class GeneralTests: RulesTests { let input = "let foo = bar" let output = "// 23.08.09-12.59.30.345\n\nlet foo = bar" - let fileInfo = createFileInfo(creationDate: date, dateFormat: .custom("yy.MM.dd-HH.mm.ss.SSS")) + let fileInfo = createFileInfo( + creationDate: date, + dateFormat: .custom("yy.MM.dd-HH.mm.ss.SSS"), + timeZone: .identifier("UTC") + ) let options = FormatOptions(fileHeader: "// {created}", 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 = createFileInfo( + creationDate: date, + dateFormat: .custom("HH:mm"), + timeZone: timeZone + ) + + let options = FormatOptions( + fileHeader: "// {created}", + 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 testFileHeaderRuleThrowsIfCreationDateUnavailable() { let input = "let foo = bar" let options = FormatOptions(fileHeader: "// Created by Nick Lockwood on {created}.", fileInfo: FileInfo()) From bfb53e51b3301be1e8f1e2dddc2c42bcd9d841a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Mon, 14 Aug 2023 23:44:40 +0200 Subject: [PATCH 09/38] Add option to configure which timezone dates should be formatted to --- Rules.md | 65 ++++++++++++++++++++++++++++++++++++++++++ Sources/Examples.swift | 65 ++++++++++++++++++++++++++++++++++++++++++ Sources/Rules.swift | 2 +- 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/Rules.md b/Rules.md index c805edf25..ada618af2 100644 --- a/Rules.md +++ b/Rules.md @@ -750,6 +750,8 @@ 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 **Example**: @@ -765,6 +767,69 @@ Token | Description + // ``` +**Note**: `{created.name}` and `{created.email}` requires the project +to be version controlled by git. + +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 +``` +

diff --git a/Sources/Examples.swift b/Sources/Examples.swift index 68bf8d598..fe45a65a8 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1573,6 +1573,8 @@ 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 **Example**: @@ -1587,6 +1589,69 @@ private struct Examples { + // Copyright © 2023 CompanyName. + // ``` + + **Note**: `{created.name}` and `{created.email}` requires the project + to be version controlled by git. + + 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 = """ diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 85c330b14..9fa781732 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -4324,7 +4324,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]() From 8f505738bb2f338437c73d85183505f95690841e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Tue, 15 Aug 2023 02:07:16 +0200 Subject: [PATCH 10/38] Integrate date formatting options in file header rule --- Sources/CommandLine.swift | 11 +--- Sources/Options.swift | 98 ++++++++++++++++++++++++++++------ Sources/Rules.swift | 12 ++++- Sources/SwiftFormat.swift | 19 ++----- Tests/ArgumentsTests.swift | 2 +- Tests/RulesTests+General.swift | 57 +++++++------------- 6 files changed, 119 insertions(+), 80 deletions(-) diff --git a/Sources/CommandLine.swift b/Sources/CommandLine.swift index 857ad80ce..93edd1893 100644 --- a/Sources/CommandLine.swift +++ b/Sources/CommandLine.swift @@ -483,18 +483,9 @@ func processArguments(_ args: [String], environment: [String: String] = [:], in ) var formatOptions = options.formatOptions ?? .default - var createdDate: String? - if let creationDate = resourceValues.creationDate { - createdDate = creationDate.format(with: formatOptions.dateFormat, - timeZone: formatOptions.timeZone) - } - formatOptions.fileInfo = FileInfo( filePath: resourceValues.path, - replacements: [ - .createdDate: createdDate, - .createdYear: resourceValues.creationDate?.yearString, - ].compactMapValues { $0 } + creationDate: resourceValues.creationDate ) options.formatOptions = formatOptions } diff --git a/Sources/Options.swift b/Sources/Options.swift index 758dbe8f8..5ec02a595 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -203,6 +203,15 @@ 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" +} + /// Argument type for stripping public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStringLiteral { case ignore @@ -246,7 +255,7 @@ public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStrin } } - public func hasTemplateKey(_ keys: FileInfoKey...) -> Bool { + public func hasTemplateKey(_ keys: ReplacementKey...) -> Bool { guard case let .replace(str) = self else { return false } @@ -255,34 +264,82 @@ public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStrin } } -public enum FileInfoKey: String, CaseIterable { - case fileName = "file" - case currentYear = "year" - case createdName = "created.name" - case createdEmail = "created.email" - case createdDate = "created" - case createdYear = "created.year" +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 { + static let defaultReplacements: [ReplacementKey: ReplacementType] = [ + .createdDate: .dynamic { info, options in + info.creationDate?.format(with: options.dateFormat, + timeZone: options.timeZone) + }, + .createdYear: .dynamic { info, _ in info.creationDate?.yearString }, + .currentYear: .constant(Date.currentYear), + ] + let filePath: String? - var replacements: [FileInfoKey: String] = [:] + var creationDate: Date? + var replacements: [ReplacementKey: ReplacementType] = Self.defaultReplacements var fileName: String? { filePath.map { URL(fileURLWithPath: $0).lastPathComponent } } - public init(filePath: String? = nil, replacements: [FileInfoKey: String] = [:]) { + public init( + filePath: String? = nil, + creationDate: Date? = nil, + replacements: [ReplacementKey: ReplacementType] = [:] + ) { self.filePath = filePath + self.creationDate = creationDate self.replacements.merge(replacements, uniquingKeysWith: { $1 }) if let fileName = fileName { - self.replacements[.fileName] = fileName + self.replacements[.fileName] = .constant(fileName) } - - self.replacements[.currentYear] = Date.currentYear } public var description: String { @@ -291,8 +348,19 @@ public struct FileInfo: Equatable, CustomStringConvertible { .joined(separator: ";") } - public func hasReplacement(for key: FileInfoKey) -> Bool { - replacements[key] != nil + public func hasReplacement(for key: ReplacementKey, options: FormatOptions) -> Bool { + switch replacements[key] { + case nil: + return false + case .constant: + return true + case let .dynamic(fn): + guard let date = creationDate else { + return false + } + + return fn(self, ReplacementOptions(options)) != nil + } } } diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 9fa781732..cfd98495e 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -4333,9 +4333,17 @@ public struct _FormatRules { case .ignore: return case var .replace(string): + let file = formatter.options.fileInfo + let options = ReplacementOptions( + dateFormat: formatter.options.dateFormat, + timeZone: formatter.options.timeZone + ) + for (key, replacement) in formatter.options.fileInfo.replacements { - while let range = string.range(of: "{\(key.rawValue)}") { - string.replaceSubrange(range, with: replacement) + if let replacementStr = replacement.resolve(file, options) { + while let range = string.range(of: "{\(key.rawValue)}") { + string.replaceSubrange(range, with: replacementStr) + } } } headerTokens = tokenize(string) diff --git a/Sources/SwiftFormat.swift b/Sources/SwiftFormat.swift index 479fe61c7..0c0db1c27 100644 --- a/Sources/SwiftFormat.swift +++ b/Sources/SwiftFormat.swift @@ -188,21 +188,12 @@ public func enumerateFiles(withInputURL inputURL: URL, ? GitHelpers.fileInfo(inputURL) : nil - var createdDate: String? - if let creationDate = resourceValues.creationDate, - let formatOptions = options.formatOptions - { - createdDate = creationDate.format(with: formatOptions.dateFormat, - timeZone: formatOptions.timeZone) - } - let fileInfo = FileInfo( filePath: resourceValues.path, + creationDate: resourceValues.creationDate, replacements: [ - .createdDate: createdDate, - .createdYear: resourceValues.creationDate?.yearString, - .createdName: gitInfo?.createdByName, - .createdEmail: gitInfo?.createdByEmail, + .createdName: .init(gitInfo?.createdByName), + .createdEmail: .init(gitInfo?.createdByEmail), ].compactMapValues { $0 } ) var options = options @@ -512,8 +503,8 @@ private func applyRules( let header = options.fileHeader let fileInfo = options.fileInfo - for key in FileInfoKey.allCases { - if header.hasTemplateKey(key), !fileInfo.hasReplacement(for: key) { + 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/Tests/ArgumentsTests.swift b/Tests/ArgumentsTests.swift index efc01ef38..7f27fcc29 100644 --- a/Tests/ArgumentsTests.swift +++ b/Tests/ArgumentsTests.swift @@ -669,7 +669,7 @@ class ArgumentsTests: XCTestCase { } func testAddArgumentsDoesntBreakFileInfo() throws { - let fileInfo = createFileInfo(filePath: "~/Foo.swift", creationDate: Date()) + let fileInfo = FileInfo(filePath: "~/Foo.swift", creationDate: Date()) var options = Options(formatOptions: FormatOptions(fileInfo: fileInfo)) try options.addArguments(["indent": "2"], in: "") guard let formatOptions = options.formatOptions else { diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index 61aa5d4a3..a4e9996cb 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -9,23 +9,6 @@ import XCTest @testable import SwiftFormat -func createFileInfo( - filePath: String? = nil, - creationDate: Date? = nil, - replacements: [FileInfoKey: String] = [:], - dateFormat: DateFormat = .system, - timeZone: FormatTimeZone = .system -) -> FileInfo { - var allReplacements = replacements - - allReplacements.merge([ - .createdDate: creationDate?.format(with: dateFormat, timeZone: timeZone), - .createdYear: creationDate?.yearString, - ].compactMapValues { $0 }, uniquingKeysWith: { $1 }) - - return FileInfo(filePath: filePath, replacements: allReplacements) -} - private enum TestDateFormat: String { case basic = "yyyy-MM-dd" case time = "HH:mmZZZZZ" @@ -619,7 +602,7 @@ class GeneralTests: RulesTests { formatter.dateFormat = "yyyy" return "// Copyright © \(formatter.string(from: date))\n\nlet foo = bar" }() - let fileInfo = createFileInfo(creationDate: date) + let fileInfo = FileInfo(creationDate: date) let options = FormatOptions(fileHeader: "// Copyright © {created.year}", fileInfo: fileInfo) testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } @@ -629,7 +612,7 @@ class GeneralTests: RulesTests { let email = "test@email.com" let input = "let foo = bar" let output = "// Created by \(name) \(email)\n\nlet foo = bar" - let fileInfo = createFileInfo(replacements: [.createdName: name, .createdEmail: email]) + 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) } @@ -638,7 +621,7 @@ class GeneralTests: RulesTests { let name = "Test User" let input = "let foo = bar" let output = "// Copyright © \(name)\n// Created by \(name)\n\nlet foo = bar" - let fileInfo = createFileInfo(replacements: [.createdName: name]) + 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) } @@ -652,7 +635,7 @@ class GeneralTests: RulesTests { formatter.timeStyle = .none return "// Created by Nick Lockwood on \(formatter.string(from: date)).\n\nlet foo = bar" }() - let fileInfo = createFileInfo(creationDate: date) + let fileInfo = FileInfo(creationDate: date) let options = FormatOptions(fileHeader: "// Created by Nick Lockwood on {created}.", fileInfo: fileInfo) testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } @@ -662,8 +645,8 @@ class GeneralTests: RulesTests { let input = "let foo = bar" let output = "// 2023-08-09\n\nlet foo = bar" - let fileInfo = createFileInfo(creationDate: date, dateFormat: .iso) - let options = FormatOptions(fileHeader: "// {created}", fileInfo: fileInfo) + let fileInfo = FileInfo(creationDate: date) + let options = FormatOptions(fileHeader: "// {created}", dateFormat: .iso, fileInfo: fileInfo) testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } @@ -672,8 +655,8 @@ class GeneralTests: RulesTests { let input = "let foo = bar" let output = "// 09/08/2023\n\nlet foo = bar" - let fileInfo = createFileInfo(creationDate: date, dateFormat: .dayMonthYear) - let options = FormatOptions(fileHeader: "// {created}", fileInfo: fileInfo) + let fileInfo = FileInfo(creationDate: date) + let options = FormatOptions(fileHeader: "// {created}", dateFormat: .dayMonthYear, fileInfo: fileInfo) testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } @@ -682,8 +665,10 @@ class GeneralTests: RulesTests { let input = "let foo = bar" let output = "// 08/09/2023\n\nlet foo = bar" - let fileInfo = createFileInfo(creationDate: date, dateFormat: .monthDayYear) - let options = FormatOptions(fileHeader: "// {created}", fileInfo: fileInfo) + let fileInfo = FileInfo(creationDate: date) + let options = FormatOptions(fileHeader: "// {created}", + dateFormat: .monthDayYear, + fileInfo: fileInfo) testFormatting(for: input, output, rule: FormatRules.fileHeader, options: options) } @@ -692,12 +677,11 @@ class GeneralTests: RulesTests { let input = "let foo = bar" let output = "// 23.08.09-12.59.30.345\n\nlet foo = bar" - let fileInfo = createFileInfo( - creationDate: date, - dateFormat: .custom("yy.MM.dd-HH.mm.ss.SSS"), - timeZone: .identifier("UTC") - ) - let options = FormatOptions(fileHeader: "// {created}", fileInfo: fileInfo) + 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) } @@ -710,14 +694,11 @@ class GeneralTests: RulesTests { let input = "let foo = bar" let output = "// \(expected)\n\nlet foo = bar" - let fileInfo = createFileInfo( - creationDate: date, - dateFormat: .custom("HH:mm"), - timeZone: timeZone - ) + let fileInfo = FileInfo(creationDate: date) let options = FormatOptions( fileHeader: "// {created}", + dateFormat: .custom("HH:mm"), timeZone: timeZone, fileInfo: fileInfo ) From 9c7b5278a1f3b30b88a815ce7d4abc3a5534bd0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Tue, 15 Aug 2023 00:16:03 +0200 Subject: [PATCH 11/38] Fix crash in the shell helper function --- Sources/ShellHelpers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ShellHelpers.swift b/Sources/ShellHelpers.swift index 00d99ef81..01611c090 100644 --- a/Sources/ShellHelpers.swift +++ b/Sources/ShellHelpers.swift @@ -36,7 +36,7 @@ extension String { let process = Process() let pipe = Pipe() - process.executableURL = URL(string: "/bin/bash") + process.executableURL = URL(fileURLWithPath: "/bin/bash") process.arguments = ["-c", self] process.standardOutput = pipe process.standardError = pipe From bc44fb12d1aea994e474720d3936282922ef0cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Tue, 15 Aug 2023 20:30:22 +0200 Subject: [PATCH 12/38] Add test for GitFileInfo --- Sources/GitHelpers.swift | 33 +++++++++++++++++++++++---------- Sources/ShellHelpers.swift | 6 +++++- Tests/RulesTests+General.swift | 11 +++++++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Sources/GitHelpers.swift b/Sources/GitHelpers.swift index 9167ee4bf..6c8eb43a5 100644 --- a/Sources/GitHelpers.swift +++ b/Sources/GitHelpers.swift @@ -36,22 +36,31 @@ struct GitFileInfo { var createdByEmail: String? } -enum GitHelpers { - private static var inGitRoot: Bool { +struct GitHelpers { + var currentWorkingDirectory: URL? + + init(cwd: URL?) { + currentWorkingDirectory = cwd + } + + private var inGitRoot: Bool { // Get current git repository top level directory - guard let root = "git rev-parse --show-toplevel".shellOutput() else { return false } + guard let root = "git rev-parse --show-toplevel" + .shellOutput(cwd: currentWorkingDirectory) else { return false } // Make sure a valid URL was returned guard let _ = URL(string: root) else { return false } // Make sure an existing path was returned return FileManager.default.fileExists(atPath: root) } - // If a file has never been committed, default to the local git user for that repository - private static var defaultGitInfo: GitFileInfo? { + // If a file has never been committed, defaults to the local git user for that repository + private var defaultGitInfo: GitFileInfo? { guard inGitRoot else { return nil } - let name = "git config user.name".shellOutput() - let email = "git config user.email".shellOutput() + let name = "git config user.name" + .shellOutput(cwd: currentWorkingDirectory) + let email = "git config user.email" + .shellOutput(cwd: currentWorkingDirectory) guard let safeName = name, let safeEmail = email else { return nil } @@ -63,15 +72,15 @@ enum GitHelpers { case name = "an" } - private static func fileInfoPart(_ inputURL: URL, _ part: FileInfoPart) -> String? { + private func fileInfoPart(_ inputURL: URL, _ part: FileInfoPart) -> String? { let value = "git log --diff-filter=A --pretty=%\(part.rawValue) \(inputURL.relativePath)" - .shellOutput() + .shellOutput(cwd: currentWorkingDirectory) guard let safeValue = value, !safeValue.isEmpty else { return nil } return safeValue } - static func fileInfo(_ inputURL: URL) -> GitFileInfo? { + func fileInfo(_ inputURL: URL) -> GitFileInfo? { guard inGitRoot else { return nil } let name = fileInfoPart(inputURL, .name) ?? defaultGitInfo?.createdByName @@ -79,4 +88,8 @@ enum GitHelpers { return GitFileInfo(createdByName: name, createdByEmail: email) } + + static func fileInfo(_ inputURL: URL, cwd: URL? = nil) -> GitFileInfo? { + GitHelpers(cwd: cwd).fileInfo(inputURL) + } } diff --git a/Sources/ShellHelpers.swift b/Sources/ShellHelpers.swift index 01611c090..fdddfeada 100644 --- a/Sources/ShellHelpers.swift +++ b/Sources/ShellHelpers.swift @@ -32,7 +32,7 @@ import Foundation extension String { - func shellOutput() -> String? { + func shellOutput(cwd: URL? = nil) -> String? { let process = Process() let pipe = Pipe() @@ -41,6 +41,10 @@ extension String { process.standardOutput = pipe process.standardError = pipe + if let safeCWD = cwd { + process.currentDirectoryURL = safeCWD + } + let file = pipe.fileHandleForReading do { try process.run() } diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index a4e9996cb..a8b0f730e 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -749,6 +749,17 @@ class GeneralTests: RulesTests { ]) } + func testGitHelpersReturnsInfo() { + let projectDirectory = URL(fileURLWithPath: #file) + .deletingLastPathComponent().deletingLastPathComponent() + + let info = GitHelpers(cwd: projectDirectory) + .fileInfo(URL(fileURLWithPath: #file)) + + XCTAssertEqual(info?.createdByName, "Nick Lockwood") + XCTAssertEqual(info?.createdByEmail, "nick@charcoaldesign.co.uk") + } + func testFileHeaderRuleThrowsIfCreationDateUnavailable() { let input = "let foo = bar" let options = FormatOptions(fileHeader: "// Created by Nick Lockwood on {created}.", fileInfo: FileInfo()) From 54b047521e3468af81be646acbe5df90927f8776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Tue, 15 Aug 2023 22:33:31 +0200 Subject: [PATCH 13/38] Add support for following the file across renames in git --- Rules.md | 10 +++++--- Sources/Examples.swift | 10 +++++--- Sources/GitHelpers.swift | 42 ++++++++++++++++++++++++++-------- Sources/Options.swift | 36 +++++++++++++++++++---------- Sources/SwiftFormat.swift | 26 +++++++++++++++++---- Tests/RulesTests+General.swift | 23 +++++++++++++++++-- 6 files changed, 113 insertions(+), 34 deletions(-) diff --git a/Rules.md b/Rules.md index ada618af2..71de34ab9 100644 --- a/Rules.md +++ b/Rules.md @@ -753,6 +753,13 @@ Token | Description `{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**: @@ -767,9 +774,6 @@ Token | Description + // ``` -**Note**: `{created.name}` and `{created.email}` requires the project -to be version controlled by git. - You can use the following built-in formats for `--dateformat`: Token | Description diff --git a/Sources/Examples.swift b/Sources/Examples.swift index fe45a65a8..df8ff0bb6 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1576,6 +1576,13 @@ private struct Examples { `{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**: @@ -1590,9 +1597,6 @@ private struct Examples { + // ``` - **Note**: `{created.name}` and `{created.email}` requires the project - to be version controlled by git. - You can use the following built-in formats for `--dateformat`: Token | Description diff --git a/Sources/GitHelpers.swift b/Sources/GitHelpers.swift index 6c8eb43a5..950986a0d 100644 --- a/Sources/GitHelpers.swift +++ b/Sources/GitHelpers.swift @@ -34,6 +34,7 @@ import Foundation struct GitFileInfo { var createdByName: String? var createdByEmail: String? + var createdAt: Date? } struct GitHelpers { @@ -70,26 +71,49 @@ struct GitHelpers { private enum FileInfoPart: String { case email = "ae" case name = "an" + case createdAt = "at" } - private func fileInfoPart(_ inputURL: URL, _ part: FileInfoPart) -> String? { - let value = "git log --diff-filter=A --pretty=%\(part.rawValue) \(inputURL.relativePath)" + private func fileInfoPart(_ inputURL: URL, + _ part: FileInfoPart, + follow: Bool) -> String? + { + // --follow to keep tracking the file across renames + let follow = follow ? "--follow" : "" + let format = part.rawValue + let path = inputURL.relativePath + + let value = "git log \(follow) --diff-filter=A --pretty=%\(format) \(path)" .shellOutput(cwd: currentWorkingDirectory) guard let safeValue = value, !safeValue.isEmpty else { return nil } return safeValue } - func fileInfo(_ inputURL: URL) -> GitFileInfo? { + func fileInfo(_ inputURL: URL, follow: Bool) -> GitFileInfo? { guard inGitRoot else { return nil } - let name = fileInfoPart(inputURL, .name) ?? defaultGitInfo?.createdByName - let email = fileInfoPart(inputURL, .email) ?? defaultGitInfo?.createdByEmail - - return GitFileInfo(createdByName: name, createdByEmail: email) + let name = fileInfoPart(inputURL, .name, follow: follow) ?? + defaultGitInfo?.createdByName + let email = fileInfoPart(inputURL, .email, follow: follow) ?? + defaultGitInfo?.createdByEmail + + var date: Date? + if let createdAtString = fileInfoPart(inputURL, .createdAt, follow: follow), + let interval = TimeInterval(createdAtString) + { + date = Date(timeIntervalSince1970: interval) + } + + return GitFileInfo(createdByName: name, + createdByEmail: email, + createdAt: date) } - static func fileInfo(_ inputURL: URL, cwd: URL? = nil) -> GitFileInfo? { - GitHelpers(cwd: cwd).fileInfo(inputURL) + static func fileInfo(_ inputURL: URL, + cwd: URL? = nil, + follow: Bool = false) -> GitFileInfo? + { + GitHelpers(cwd: cwd).fileInfo(inputURL, follow: follow) } } diff --git a/Sources/Options.swift b/Sources/Options.swift index 5ec02a595..3e15278f7 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -210,6 +210,10 @@ public enum ReplacementKey: String, CaseIterable { 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 @@ -310,17 +314,27 @@ public enum ReplacementType: Equatable { /// File info, used for constructing header comments public struct FileInfo: Equatable, CustomStringConvertible { - static let defaultReplacements: [ReplacementKey: ReplacementType] = [ - .createdDate: .dynamic { info, options in - info.creationDate?.format(with: options.dateFormat, - timeZone: options.timeZone) - }, - .createdYear: .dynamic { info, _ in info.creationDate?.yearString }, - .currentYear: .constant(Date.currentYear), - ] + 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? { @@ -330,10 +344,12 @@ public struct FileInfo: Equatable, CustomStringConvertible { 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 }) @@ -355,10 +371,6 @@ public struct FileInfo: Equatable, CustomStringConvertible { case .constant: return true case let .dynamic(fn): - guard let date = creationDate else { - return false - } - return fn(self, ReplacementOptions(options)) != nil } } diff --git a/Sources/SwiftFormat.swift b/Sources/SwiftFormat.swift index 0c0db1c27..8ba4fef22 100644 --- a/Sources/SwiftFormat.swift +++ b/Sources/SwiftFormat.swift @@ -180,20 +180,36 @@ public func enumerateFiles(withInputURL inputURL: URL, let fileOptions = options.fileOptions ?? .default if resourceValues.isRegularFile == true { if fileOptions.supportedFileExtensions.contains(inputURL.pathExtension) { - let shouldGetCreatedBy = + let shouldGetGitInfo = options.rules?.contains(FormatRules.fileHeader.name) ?? false && - options.formatOptions?.fileHeader.hasTemplateKey(.createdName, .createdEmail) ?? false - - let gitInfo = shouldGetCreatedBy + options.formatOptions?.fileHeader.hasTemplateKey( + .createdName, + .createdEmail, + .createdDate, + .createdYear, + .followedCreatedName, + .followedCreatedEmail, + .followedCreatedDate, + .followedCreatedYear + ) ?? false + + let gitInfo = shouldGetGitInfo ? GitHelpers.fileInfo(inputURL) : nil + let followedGitInfo = shouldGetGitInfo && gitInfo != nil + ? 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: .init(gitInfo?.createdByName), .createdEmail: .init(gitInfo?.createdByEmail), + .followedCreatedName: .init(followedGitInfo?.createdByName), + .followedCreatedEmail: .init(followedGitInfo?.createdByEmail), ].compactMapValues { $0 } ) var options = options diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index a8b0f730e..3937ef30d 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -749,15 +749,34 @@ class GeneralTests: RulesTests { ]) } - func testGitHelpersReturnsInfo() { + func testGitHelpersReturnsNoFollowInfo() { let projectDirectory = URL(fileURLWithPath: #file) .deletingLastPathComponent().deletingLastPathComponent() + let dateFormat = DateFormat.custom("yyyy-MM-dd HH:mm:ss ZZZZZ") let info = GitHelpers(cwd: projectDirectory) - .fileInfo(URL(fileURLWithPath: #file)) + .fileInfo(URL(fileURLWithPath: #file), follow: false) XCTAssertEqual(info?.createdByName, "Nick Lockwood") XCTAssertEqual(info?.createdByEmail, "nick@charcoaldesign.co.uk") + let formattedDate = info?.createdAt?.format(with: dateFormat, + timeZone: .identifier("UTC")) + XCTAssertEqual(formattedDate, "2021-09-28 14:23:05 Z") + } + + func testGitHelpersReturnsFollowInfo() { + let projectDirectory = URL(fileURLWithPath: #file) + .deletingLastPathComponent().deletingLastPathComponent() + let dateFormat = DateFormat.custom("yyyy-MM-dd HH:mm:ss ZZZZZ") + + let info = GitHelpers(cwd: projectDirectory) + .fileInfo(URL(fileURLWithPath: #file), follow: true) + + XCTAssertEqual(info?.createdByName, "Nick Lockwood") + XCTAssertEqual(info?.createdByEmail, "nick@charcoaldesign.co.uk") + let formattedDate = info?.createdAt?.format(with: dateFormat, + timeZone: .identifier("UTC")) + XCTAssertEqual(formattedDate, "2016-08-22 19:41:41 Z") } func testFileHeaderRuleThrowsIfCreationDateUnavailable() { From d5a249b7344f2f470a7a8005d2c2f0b77acf00d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Wed, 16 Aug 2023 22:40:32 +0200 Subject: [PATCH 14/38] Reduce number of shell commands to get git info and improve reliability --- Sources/GitHelpers.swift | 158 ++++++++++++++++++++------------- Sources/Options.swift | 42 +++++---- Sources/SwiftFormat.swift | 29 +++--- Tests/RulesTests+General.swift | 38 +++----- 4 files changed, 145 insertions(+), 122 deletions(-) diff --git a/Sources/GitHelpers.swift b/Sources/GitHelpers.swift index 950986a0d..a4d6e2600 100644 --- a/Sources/GitHelpers.swift +++ b/Sources/GitHelpers.swift @@ -31,89 +31,127 @@ 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? } -struct GitHelpers { - var currentWorkingDirectory: URL? +enum GitHelpers { + static let getGitRoot: (URL) -> URL? = memoize({ $0.relativePath }) { url in + let dir = "git rev-parse --show-toplevel".shellOutput(cwd: url) - init(cwd: URL?) { - currentWorkingDirectory = cwd - } + guard let root = dir, FileManager.default.fileExists(atPath: root) else { + return nil + } - private var inGitRoot: Bool { - // Get current git repository top level directory - guard let root = "git rev-parse --show-toplevel" - .shellOutput(cwd: currentWorkingDirectory) else { return false } - // Make sure a valid URL was returned - guard let _ = URL(string: root) else { return false } - // Make sure an existing path was returned - return FileManager.default.fileExists(atPath: root) + return URL(fileURLWithPath: root, isDirectory: true) } - // If a file has never been committed, defaults to the local git user for that repository - private var defaultGitInfo: GitFileInfo? { - guard inGitRoot else { return nil } - - let name = "git config user.name" - .shellOutput(cwd: currentWorkingDirectory) - let email = "git config user.email" - .shellOutput(cwd: currentWorkingDirectory) + // 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 enum FileInfoPart: String { - case email = "ae" - case name = "an" - case createdAt = "at" - } - - private func fileInfoPart(_ inputURL: URL, - _ part: FileInfoPart, - follow: Bool) -> String? - { - // --follow to keep tracking the file across renames - let follow = follow ? "--follow" : "" - let format = part.rawValue - let path = inputURL.relativePath - - let value = "git log \(follow) --diff-filter=A --pretty=%\(format) \(path)" - .shellOutput(cwd: currentWorkingDirectory) + 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) + } + } - guard let safeValue = value, !safeValue.isEmpty else { return nil } return safeValue } - func fileInfo(_ inputURL: URL, follow: Bool) -> GitFileInfo? { - guard inGitRoot else { return nil } + 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) + } + ) - let name = fileInfoPart(inputURL, .name, follow: follow) ?? - defaultGitInfo?.createdByName - let email = fileInfoPart(inputURL, .email, follow: follow) ?? - defaultGitInfo?.createdByEmail + static func fileInfo(_ url: URL, follow: Bool = false) -> GitFileInfo? { + let dir = url.deletingLastPathComponent() + guard let gitRoot = getGitRoot(dir) else { return nil } - var date: Date? - if let createdAtString = fileInfoPart(inputURL, .createdAt, follow: follow), - let interval = TimeInterval(createdAtString) - { - date = Date(timeIntervalSince1970: interval) + guard let commitHash = getGitCommit(url, root: gitRoot, follow: follow) else { + return nil } - return GitFileInfo(createdByName: name, - createdByEmail: email, - createdAt: date) - } - - static func fileInfo(_ inputURL: URL, - cwd: URL? = nil, - follow: Bool = false) -> GitFileInfo? - { - GitHelpers(cwd: cwd).fileInfo(inputURL, follow: follow) + return getCommitInfo((commitHash, gitRoot)) } } diff --git a/Sources/Options.swift b/Sources/Options.swift index 3e15278f7..9eb066e2f 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -266,6 +266,16 @@ public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStrin 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 { @@ -314,23 +324,21 @@ public enum ReplacementType: Equatable { /// File info, used for constructing header comments public struct FileInfo: Equatable, CustomStringConvertible { - 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), - ] - } + 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? diff --git a/Sources/SwiftFormat.swift b/Sources/SwiftFormat.swift index 8ba4fef22..7490a1479 100644 --- a/Sources/SwiftFormat.swift +++ b/Sources/SwiftFormat.swift @@ -180,24 +180,18 @@ public func enumerateFiles(withInputURL inputURL: URL, let fileOptions = options.fileOptions ?? .default if resourceValues.isRegularFile == true { if fileOptions.supportedFileExtensions.contains(inputURL.pathExtension) { - let shouldGetGitInfo = - options.rules?.contains(FormatRules.fileHeader.name) ?? false && - options.formatOptions?.fileHeader.hasTemplateKey( - .createdName, - .createdEmail, - .createdDate, - .createdYear, - .followedCreatedName, - .followedCreatedEmail, - .followedCreatedDate, - .followedCreatedYear - ) ?? false + 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 = shouldGetGitInfo && gitInfo != nil + let followedGitInfo = shouldGetFollowGitInfo ? GitHelpers.fileInfo(inputURL, follow: true) : nil @@ -206,12 +200,13 @@ public func enumerateFiles(withInputURL inputURL: URL, creationDate: gitInfo?.createdAt ?? resourceValues.creationDate, followedCreationDate: followedGitInfo?.createdAt, replacements: [ - .createdName: .init(gitInfo?.createdByName), - .createdEmail: .init(gitInfo?.createdByEmail), - .followedCreatedName: .init(followedGitInfo?.createdByName), - .followedCreatedEmail: .init(followedGitInfo?.createdByEmail), + .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 { diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index 3937ef30d..b259129ce 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -749,34 +749,16 @@ class GeneralTests: RulesTests { ]) } - func testGitHelpersReturnsNoFollowInfo() { - let projectDirectory = URL(fileURLWithPath: #file) - .deletingLastPathComponent().deletingLastPathComponent() - let dateFormat = DateFormat.custom("yyyy-MM-dd HH:mm:ss ZZZZZ") - - let info = GitHelpers(cwd: projectDirectory) - .fileInfo(URL(fileURLWithPath: #file), follow: false) - - XCTAssertEqual(info?.createdByName, "Nick Lockwood") - XCTAssertEqual(info?.createdByEmail, "nick@charcoaldesign.co.uk") - let formattedDate = info?.createdAt?.format(with: dateFormat, - timeZone: .identifier("UTC")) - XCTAssertEqual(formattedDate, "2021-09-28 14:23:05 Z") - } - - func testGitHelpersReturnsFollowInfo() { - let projectDirectory = URL(fileURLWithPath: #file) - .deletingLastPathComponent().deletingLastPathComponent() - let dateFormat = DateFormat.custom("yyyy-MM-dd HH:mm:ss ZZZZZ") - - let info = GitHelpers(cwd: projectDirectory) - .fileInfo(URL(fileURLWithPath: #file), follow: true) - - XCTAssertEqual(info?.createdByName, "Nick Lockwood") - XCTAssertEqual(info?.createdByEmail, "nick@charcoaldesign.co.uk") - let formattedDate = info?.createdAt?.format(with: dateFormat, - timeZone: .identifier("UTC")) - XCTAssertEqual(formattedDate, "2016-08-22 19:41:41 Z") + 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() { From 8f0f236442bd023f531bf56fd684bf40006b01dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hampus=20Ta=CC=8Agerud?= Date: Thu, 17 Aug 2023 02:52:32 +0200 Subject: [PATCH 15/38] Update workflow to support running git commands from tests --- .github/workflows/build.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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: From 62b5630722ec00501eb082e0a177f731171c3c32 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Thu, 12 Oct 2023 15:31:10 +0200 Subject: [PATCH 16/38] Add option for forcing closing paren on same line of function calls --- Sources/FormattingHelpers.swift | 20 ++++++++- Sources/OptionDescriptor.swift | 8 ++++ Sources/Options.swift | 3 ++ Tests/RulesTests+Wrapping.swift | 80 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index 2e4b46abd..d1ec35f70 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -396,7 +396,25 @@ extension Formatter { keepParameterLabelsOnSameLine(startOfScope: i, endOfScope: &endOfScope) - if endOfScopeOnSameLine { + let isFunctionCall: Bool = { + if let openingParenIndex = self.index(of: .startOfScope("("), before: i + 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 + }() + + if isFunctionCall, options.forceClosingParenOnSameLineForFunctionCalls { + removeLinebreakBeforeEndOfScope(at: &endOfScope) + } else if endOfScopeOnSameLine { removeLinebreakBeforeEndOfScope(at: &endOfScope) } else { // Insert linebreak before closing paren diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 4088f528e..c4c210794 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -510,6 +510,14 @@ struct _Descriptors { trueValues: ["same-line"], falseValues: ["balanced"] ) + let forceClosingParenOnSameLineForFunctionCalls = OptionDescriptor( + argumentName: "closingparenfcall", + displayName: "Force Closing Paren on same line for function calls", + help: "Force the closingParenOnSameLine option specifically for function calls: \"inherit\" (default) or \"force-same-line\"", + keyPath: \.forceClosingParenOnSameLineForFunctionCalls, + trueValues: ["force-same-line", "same-line"], + falseValues: ["inherit"] + ) let uppercaseHex = OptionDescriptor( argumentName: "hexliteralcase", displayName: "Hex Literal Case", diff --git a/Sources/Options.swift b/Sources/Options.swift index 9eb066e2f..4fc2b915a 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -594,6 +594,7 @@ public struct FormatOptions: CustomStringConvertible { public var wrapTypealiases: WrapMode public var wrapEnumCases: WrapEnumCases public var closingParenOnSameLine: Bool + public var forceClosingParenOnSameLineForFunctionCalls: Bool public var wrapReturnType: WrapReturnType public var wrapConditions: WrapMode public var wrapTernaryOperators: TernaryOperatorWrapMode @@ -702,6 +703,7 @@ public struct FormatOptions: CustomStringConvertible { wrapTypealiases: WrapMode = .preserve, wrapEnumCases: WrapEnumCases = .always, closingParenOnSameLine: Bool = false, + forceClosingParenOnSameLineForFunctionCalls: Bool = false, wrapReturnType: WrapReturnType = .preserve, wrapConditions: WrapMode = .preserve, wrapTernaryOperators: TernaryOperatorWrapMode = .default, @@ -800,6 +802,7 @@ public struct FormatOptions: CustomStringConvertible { self.wrapTypealiases = wrapTypealiases self.wrapEnumCases = wrapEnumCases self.closingParenOnSameLine = closingParenOnSameLine + self.forceClosingParenOnSameLineForFunctionCalls = forceClosingParenOnSameLineForFunctionCalls self.wrapReturnType = wrapReturnType self.wrapConditions = wrapConditions self.wrapTernaryOperators = wrapTernaryOperators diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index f751f54f6..7f4a5040b 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -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, forceClosingParenOnSameLineForFunctionCalls: 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, forceClosingParenOnSameLineForFunctionCalls: 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, forceClosingParenOnSameLineForFunctionCalls: true) + testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) + } + func testIndentMultilineStringWhenWrappingArguments() { let input = """ foobar(foo: \"\"" From d6dbc4a15863b5c71043b1cb0052ed31c591c0ef Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Thu, 12 Oct 2023 15:57:24 +0200 Subject: [PATCH 17/38] Extract helper isFunctionCall(at:) --- Sources/FormattingHelpers.swift | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index d1ec35f70..1616e22a8 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -396,23 +396,7 @@ extension Formatter { keepParameterLabelsOnSameLine(startOfScope: i, endOfScope: &endOfScope) - let isFunctionCall: Bool = { - if let openingParenIndex = self.index(of: .startOfScope("("), before: i + 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 - }() - - if isFunctionCall, options.forceClosingParenOnSameLineForFunctionCalls { + if options.forceClosingParenOnSameLineForFunctionCalls, isFunctionCall(at: i) { removeLinebreakBeforeEndOfScope(at: &endOfScope) } else if endOfScopeOnSameLine { removeLinebreakBeforeEndOfScope(at: &endOfScope) @@ -1464,6 +1448,23 @@ extension Formatter { return allSatisfy } + /// 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 From 72a8cd3e0dae98e4a7d6f6e4e88cb412f010b00c Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 13 Oct 2023 11:08:41 +0200 Subject: [PATCH 18/38] Support inference of options.forceClosingParenOnSameLineForFunctionCalls --- Sources/Inference.swift | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/Sources/Inference.swift b/Sources/Inference.swift index 1952c1f85..7f99edf52 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 forceClosingParenOnSameLineForFunctionCalls + if functionCallSameLine > functionCallBalanced && functionDeclarationBalanced > functionDeclarationSameLine { + options.forceClosingParenOnSameLineForFunctionCalls = true + } else { + options.forceClosingParenOnSameLineForFunctionCalls = false + } + + // If forceClosingParenOnSameLineForFunctionCalls is true, trust only the declarations to infer closingParenOnSameLine + if options.forceClosingParenOnSameLineForFunctionCalls { + options.closingParenOnSameLine = functionDeclarationSameLine > functionDeclarationBalanced + } else { + let balanced = functionDeclarationBalanced + functionCallBalanced + let sameLine = functionDeclarationSameLine + functionCallSameLine + options.closingParenOnSameLine = sameLine > balanced + } } let uppercaseHex = OptionInferrer { formatter, options in From 2f070bfc0283d29bcf00d4e994f75355c260d8a1 Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Sat, 14 Oct 2023 09:17:38 +0100 Subject: [PATCH 19/38] Rename `closingparenfcall` to `callsiteparen` --- Rules.md | 1 + Sources/FormattingHelpers.swift | 2 +- Sources/Inference.swift | 10 +++++----- Sources/OptionDescriptor.swift | 12 ++++++------ Sources/Options.swift | 6 +++--- Sources/Rules.swift | 7 ++++--- Tests/MetadataTests.swift | 3 ++- Tests/RulesTests+Wrapping.swift | 6 +++--- 8 files changed, 25 insertions(+), 22 deletions(-) diff --git a/Rules.md b/Rules.md index 71de34ab9..0b4a16ac9 100644 --- a/Rules.md +++ b/Rules.md @@ -2516,6 +2516,7 @@ 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" diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index 1616e22a8..5d3895543 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -396,7 +396,7 @@ extension Formatter { keepParameterLabelsOnSameLine(startOfScope: i, endOfScope: &endOfScope) - if options.forceClosingParenOnSameLineForFunctionCalls, isFunctionCall(at: i) { + if options.closingCallSiteParenOnSameLine, isFunctionCall(at: i) { removeLinebreakBeforeEndOfScope(at: &endOfScope) } else if endOfScopeOnSameLine { removeLinebreakBeforeEndOfScope(at: &endOfScope) diff --git a/Sources/Inference.swift b/Sources/Inference.swift index 7f99edf52..9fa9fec2f 100644 --- a/Sources/Inference.swift +++ b/Sources/Inference.swift @@ -407,15 +407,15 @@ private struct Inference { } } - // Decide on forceClosingParenOnSameLineForFunctionCalls + // Decide on closingCallSiteParenOnSameLine if functionCallSameLine > functionCallBalanced && functionDeclarationBalanced > functionDeclarationSameLine { - options.forceClosingParenOnSameLineForFunctionCalls = true + options.closingCallSiteParenOnSameLine = true } else { - options.forceClosingParenOnSameLineForFunctionCalls = false + options.closingCallSiteParenOnSameLine = false } - // If forceClosingParenOnSameLineForFunctionCalls is true, trust only the declarations to infer closingParenOnSameLine - if options.forceClosingParenOnSameLineForFunctionCalls { + // If closingCallSiteParenOnSameLine is true, trust only the declarations to infer closingParenOnSameLine + if options.closingCallSiteParenOnSameLine { options.closingParenOnSameLine = functionDeclarationSameLine > functionDeclarationBalanced } else { let balanced = functionDeclarationBalanced + functionCallBalanced diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index c4c210794..ebf0223a4 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -510,12 +510,12 @@ struct _Descriptors { trueValues: ["same-line"], falseValues: ["balanced"] ) - let forceClosingParenOnSameLineForFunctionCalls = OptionDescriptor( - argumentName: "closingparenfcall", - displayName: "Force Closing Paren on same line for function calls", - help: "Force the closingParenOnSameLine option specifically for function calls: \"inherit\" (default) or \"force-same-line\"", - keyPath: \.forceClosingParenOnSameLineForFunctionCalls, - trueValues: ["force-same-line", "same-line"], + 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( diff --git a/Sources/Options.swift b/Sources/Options.swift index 4fc2b915a..744f510de 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -594,7 +594,7 @@ public struct FormatOptions: CustomStringConvertible { public var wrapTypealiases: WrapMode public var wrapEnumCases: WrapEnumCases public var closingParenOnSameLine: Bool - public var forceClosingParenOnSameLineForFunctionCalls: Bool + public var closingCallSiteParenOnSameLine: Bool public var wrapReturnType: WrapReturnType public var wrapConditions: WrapMode public var wrapTernaryOperators: TernaryOperatorWrapMode @@ -703,7 +703,7 @@ public struct FormatOptions: CustomStringConvertible { wrapTypealiases: WrapMode = .preserve, wrapEnumCases: WrapEnumCases = .always, closingParenOnSameLine: Bool = false, - forceClosingParenOnSameLineForFunctionCalls: Bool = false, + closingCallSiteParenOnSameLine: Bool = false, wrapReturnType: WrapReturnType = .preserve, wrapConditions: WrapMode = .preserve, wrapTernaryOperators: TernaryOperatorWrapMode = .default, @@ -802,7 +802,7 @@ public struct FormatOptions: CustomStringConvertible { self.wrapTypealiases = wrapTypealiases self.wrapEnumCases = wrapEnumCases self.closingParenOnSameLine = closingParenOnSameLine - self.forceClosingParenOnSameLineForFunctionCalls = forceClosingParenOnSameLineForFunctionCalls + self.closingCallSiteParenOnSameLine = closingCallSiteParenOnSameLine self.wrapReturnType = wrapReturnType self.wrapConditions = wrapConditions self.wrapTernaryOperators = wrapTernaryOperators diff --git a/Sources/Rules.swift b/Sources/Rules.swift index cfd98495e..a3539476a 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -3874,8 +3874,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", "conditionswrap"] + 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 } @@ -3931,7 +3932,7 @@ public struct _FormatRules { public let wrapArguments = FormatRule( help: "Align wrapped function arguments or collection elements.", orderAfter: ["wrap"], - options: ["wraparguments", "wrapparameters", "wrapcollections", "closingparen", + options: ["wraparguments", "wrapparameters", "wrapcollections", "closingparen", "callsiteparen", "wrapreturntype", "wrapconditions", "wraptypealiases", "wrapeffects", "conditionswrap"], sharedOptions: ["indent", "trimwhitespace", "linebreaks", diff --git a/Tests/MetadataTests.swift b/Tests/MetadataTests.swift index 63fe5e1e7..a0854c145 100644 --- a/Tests/MetadataTests.swift +++ b/Tests/MetadataTests.swift @@ -195,7 +195,8 @@ 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.conditionsWrap, diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index 7f4a5040b..4477c0e5f 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -1518,7 +1518,7 @@ class WrappingTests: RulesTests { bar _: Int, baz _: String) {} """ - let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: true, forceClosingParenOnSameLineForFunctionCalls: true) + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: true, closingCallSiteParenOnSameLine: true) testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) } @@ -1534,7 +1534,7 @@ class WrappingTests: RulesTests { baz _: String ) {} """ - let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: false, forceClosingParenOnSameLineForFunctionCalls: true) + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: false, closingCallSiteParenOnSameLine: true) testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) } @@ -1550,7 +1550,7 @@ class WrappingTests: RulesTests { bar: 42, baz: "foo") """ - let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: false, forceClosingParenOnSameLineForFunctionCalls: true) + let options = FormatOptions(wrapArguments: .beforeFirst, closingParenOnSameLine: false, closingCallSiteParenOnSameLine: true) testFormatting(for: input, output, rule: FormatRules.wrapArguments, options: options) } From 9f2c2d1bb5c08187b6cb1d3589eb7667e06c5635 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Mon, 20 Nov 2023 17:18:38 -0800 Subject: [PATCH 20/38] Support configuring wrapAttributes rule to apply differently to computed properties vs stored properties --- Rules.md | 3 +- Sources/OptionDescriptor.swift | 8 +- Sources/Options.swift | 3 + Sources/ParsingHelpers.swift | 53 ++++++++++++ Sources/Rules.swift | 8 +- Tests/MetadataTests.swift | 5 +- Tests/ParsingHelpersTests.swift | 44 ++++++++++ Tests/RulesTests+Wrapping.swift | 149 ++++++++++++++++++++++++++++++-- 8 files changed, 262 insertions(+), 11 deletions(-) diff --git a/Rules.md b/Rules.md index 0b4a16ac9..dc374bd79 100644 --- a/Rules.md +++ b/Rules.md @@ -2604,7 +2604,8 @@ 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" +`--varattributes` | Computed property @attributes: "preserve", "prev-line", or "same-line" +`--storedvarattrs` | Stored property @attributes: "preserve", "prev-line", or "same-line"
Examples diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index ebf0223a4..f9cd2a6fc 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -868,9 +868,15 @@ struct _Descriptors { let varAttributes = OptionDescriptor( argumentName: "varattributes", displayName: "Var Attributes", - help: "Property @attributes: \"preserve\", \"prev-line\", or \"same-line\"", + help: "Computed property @attributes: \"preserve\", \"prev-line\", or \"same-line\"", keyPath: \.varAttributes ) + let storedVarAttributes = OptionDescriptor( + argumentName: "storedvarattrs", + displayName: "Stored Var Attributes", + help: "Stored property @attributes: \"preserve\", \"prev-line\", or \"same-line\"", + keyPath: \.storedVarAttributes + ) let yodaSwap = OptionDescriptor( argumentName: "yodaswap", displayName: "Yoda Swap", diff --git a/Sources/Options.swift b/Sources/Options.swift index 744f510de..ad804baf1 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -631,6 +631,7 @@ public struct FormatOptions: CustomStringConvertible { public var funcAttributes: AttributeMode public var typeAttributes: AttributeMode public var varAttributes: AttributeMode + public var storedVarAttributes: AttributeMode public var markTypes: MarkMode public var typeMarkComment: String public var markExtensions: MarkMode @@ -740,6 +741,7 @@ public struct FormatOptions: CustomStringConvertible { funcAttributes: AttributeMode = .preserve, typeAttributes: AttributeMode = .preserve, varAttributes: AttributeMode = .preserve, + storedVarAttributes: AttributeMode = .preserve, markTypes: MarkMode = .always, typeMarkComment: String = "MARK: - %t", markExtensions: MarkMode = .always, @@ -839,6 +841,7 @@ public struct FormatOptions: CustomStringConvertible { self.funcAttributes = funcAttributes self.typeAttributes = typeAttributes self.varAttributes = varAttributes + self.storedVarAttributes = storedVarAttributes self.markTypes = markTypes self.typeMarkComment = typeMarkComment self.markExtensions = markExtensions diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index 7f791b097..5eede93d7 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -850,6 +850,59 @@ 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 + } + } + /// 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 } diff --git a/Sources/Rules.swift b/Sources/Rules.swift index a3539476a..b1cd39495 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -5466,7 +5466,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"], sharedOptions: ["linebreaks", "maxwidth"] ) { formatter in formatter.forEach(.attribute) { i, _ in @@ -5495,7 +5495,11 @@ public struct _FormatRules { case "class", "actor", "struct", "enum", "protocol", "extension": attributeMode = formatter.options.typeAttributes case "var", "let": - attributeMode = formatter.options.varAttributes + if formatter.isStoredProperty(atIntroducerIndex: keywordIndex) { + attributeMode = formatter.options.storedVarAttributes + } else { + attributeMode = formatter.options.varAttributes + } default: return } diff --git a/Tests/MetadataTests.swift b/Tests/MetadataTests.swift index a0854c145..2998a6eae 100644 --- a/Tests/MetadataTests.swift +++ b/Tests/MetadataTests.swift @@ -273,7 +273,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/ParsingHelpersTests.swift b/Tests/ParsingHelpersTests.swift index 2450c7d76..96521a02a 100644 --- a/Tests/ParsingHelpersTests.swift +++ b/Tests/ParsingHelpersTests.swift @@ -2015,4 +2015,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+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index 4477c0e5f..6eb7742dc 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -4744,7 +4744,19 @@ class WrappingTests: RulesTests { @objc private(set) dynamic var foo = Foo() """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + + 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) } @@ -4768,7 +4780,19 @@ class WrappingTests: RulesTests { @OuterType.Wrapper var foo: Int """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .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) } @@ -4780,7 +4804,7 @@ class WrappingTests: RulesTests { @OuterType.Generic var foo: WrappedType """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4792,10 +4816,54 @@ class WrappingTests: RulesTests { @OuterType.Generic.Foo var foo: WrappedType """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .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 testWrapAttributesIndentsLineCorrectly() { let input = """ class Foo { @@ -4808,16 +4876,85 @@ class WrappingTests: RulesTests { var foo = Foo() } """ - let options = FormatOptions(varAttributes: .prevLine) + let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) testFormatting(for: input, output, 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: .prevLine, storedVarAttributes: .sameLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + + 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: .prevLine, storedVarAttributes: .sameLine) + 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(varAttributes: .prevLine, storedVarAttributes: .prevLine) testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) } From a8f8192073cd0585ee959a0c527f49fd32c77861 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Mon, 20 Nov 2023 18:50:09 -0800 Subject: [PATCH 21/38] Deprecate `--varattributes` in favor of `--storedvarattrs` and `--computedvarattrs` --- Rules.md | 5 +-- Sources/OptionDescriptor.swift | 23 ++++++++----- Sources/Options.swift | 3 ++ Sources/Rules.swift | 13 ++++++-- Tests/RulesTests+Wrapping.swift | 59 ++++++++++++++++++++++++++++----- 5 files changed, 83 insertions(+), 20 deletions(-) diff --git a/Rules.md b/Rules.md index dc374bd79..974cb066f 100644 --- a/Rules.md +++ b/Rules.md @@ -2604,8 +2604,9 @@ Option | Description --- | --- `--funcattributes` | Function @attributes: "preserve", "prev-line", or "same-line" `--typeattributes` | Type @attributes: "preserve", "prev-line", or "same-line" -`--varattributes` | Computed property @attributes: "preserve", "prev-line", or "same-line" -`--storedvarattrs` | Stored property @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"
Examples diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index f9cd2a6fc..09f1928e9 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -865,18 +865,18 @@ struct _Descriptors { help: "Type @attributes: \"preserve\", \"prev-line\", or \"same-line\"", keyPath: \.typeAttributes ) - let varAttributes = OptionDescriptor( - argumentName: "varattributes", - displayName: "Var Attributes", - help: "Computed property @attributes: \"preserve\", \"prev-line\", or \"same-line\"", - keyPath: \.varAttributes - ) let storedVarAttributes = OptionDescriptor( argumentName: "storedvarattrs", - displayName: "Stored Var Attributes", - help: "Stored property @attributes: \"preserve\", \"prev-line\", or \"same-line\"", + 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 yodaSwap = OptionDescriptor( argumentName: "yodaswap", displayName: "Yoda Swap", @@ -1063,6 +1063,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 ad804baf1..a2f9eaab1 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -632,6 +632,7 @@ public struct FormatOptions: CustomStringConvertible { public var typeAttributes: AttributeMode public var varAttributes: AttributeMode public var storedVarAttributes: AttributeMode + public var computedVarAttributes: AttributeMode public var markTypes: MarkMode public var typeMarkComment: String public var markExtensions: MarkMode @@ -742,6 +743,7 @@ public struct FormatOptions: CustomStringConvertible { typeAttributes: AttributeMode = .preserve, varAttributes: AttributeMode = .preserve, storedVarAttributes: AttributeMode = .preserve, + computedVarAttributes: AttributeMode = .preserve, markTypes: MarkMode = .always, typeMarkComment: String = "MARK: - %t", markExtensions: MarkMode = .always, @@ -842,6 +844,7 @@ public struct FormatOptions: CustomStringConvertible { self.typeAttributes = typeAttributes self.varAttributes = varAttributes self.storedVarAttributes = storedVarAttributes + self.computedVarAttributes = computedVarAttributes self.markTypes = markTypes self.typeMarkComment = typeMarkComment self.markExtensions = markExtensions diff --git a/Sources/Rules.swift b/Sources/Rules.swift index b1cd39495..8d4a32828 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -5466,7 +5466,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", "storedvarattrs"], + options: ["funcattributes", "typeattributes", "varattributes", "storedvarattrs", "computedvarattrs"], sharedOptions: ["linebreaks", "maxwidth"] ) { formatter in formatter.forEach(.attribute) { i, _ in @@ -5495,10 +5495,19 @@ public struct _FormatRules { case "class", "actor", "struct", "enum", "protocol", "extension": attributeMode = formatter.options.typeAttributes case "var", "let": + let storedOrComputedAttributeMode: AttributeMode if formatter.isStoredProperty(atIntroducerIndex: keywordIndex) { - attributeMode = formatter.options.storedVarAttributes + 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 diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index 6eb7742dc..db5a086fb 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -4736,6 +4736,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) + } + func testWrapPrivateSetVarAttributes() { let input = """ @objc private(set) dynamic var foo = Foo() @@ -4744,7 +4756,7 @@ class WrappingTests: RulesTests { @objc private(set) dynamic var foo = Foo() """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) + let options = FormatOptions(varAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4772,6 +4784,18 @@ class WrappingTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } + func testWrapPropertyWrapperAttributeVarAttributes() { + let input = """ + @OuterType.Wrapper var foo: Int + """ + let output = """ + @OuterType.Wrapper + var foo: Int + """ + let options = FormatOptions(varAttributes: .prevLine) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + func testWrapPropertyWrapperAttribute() { let input = """ @OuterType.Wrapper var foo: Int @@ -4780,7 +4804,7 @@ class WrappingTests: RulesTests { @OuterType.Wrapper var foo: Int """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4804,7 +4828,7 @@ class WrappingTests: RulesTests { @OuterType.Generic var foo: WrappedType """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4816,7 +4840,7 @@ class WrappingTests: RulesTests { @OuterType.Generic.Foo var foo: WrappedType """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4876,7 +4900,7 @@ class WrappingTests: RulesTests { var foo = Foo() } """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4925,7 +4949,7 @@ class WrappingTests: RulesTests { } } """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine) + let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4945,7 +4969,26 @@ class WrappingTests: RulesTests { } """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine) + 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 + + var body: some View { + childView + } + + @ViewBuilder var childView: some View { + Text(verbatim: textContent) + } + } + """ + + let options = FormatOptions(varAttributes: .sameLine) testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) } @@ -4954,7 +4997,7 @@ class WrappingTests: RulesTests { var foo: @MainActor (Foo) -> Void var bar: @MainActor (Bar) -> Void """ - let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .prevLine) + let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) } From 4e607e57e32fb217ac520c0a015b1c6632c51236 Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Sun, 26 Nov 2023 15:56:48 +0000 Subject: [PATCH 22/38] Exclude deprecated rule options from Rules.md --- Rules.md | 1 - Tests/MetadataTests.swift | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Rules.md b/Rules.md index 974cb066f..cb9648ecd 100644 --- a/Rules.md +++ b/Rules.md @@ -2604,7 +2604,6 @@ 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" diff --git a/Tests/MetadataTests.swift b/Tests/MetadataTests.swift index 2998a6eae..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 { From d62a31d49522e5cbd2c0b63562f60f58bb0758a3 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Tue, 28 Nov 2023 14:53:33 -0800 Subject: [PATCH 23/38] Add option to wrap complex attributes with arguments differently than simple attributes without arguments --- Rules.md | 2 + Sources/OptionDescriptor.swift | 12 +++++ Sources/Options.swift | 6 +++ Sources/Rules.swift | 14 +++++- Tests/OptionDescriptorTests.swift | 10 ++++- Tests/RulesTests+Wrapping.swift | 74 +++++++++++++++++++++++++++++-- 6 files changed, 111 insertions(+), 7 deletions(-) diff --git a/Rules.md b/Rules.md index cb9648ecd..9b53fb9d9 100644 --- a/Rules.md +++ b/Rules.md @@ -2606,6 +2606,8 @@ Option | Description `--typeattributes` | Type @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/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 09f1928e9..6ebe996f7 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -877,6 +877,18 @@ struct _Descriptors { 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", displayName: "Yoda Swap", diff --git a/Sources/Options.swift b/Sources/Options.swift index a2f9eaab1..06e509533 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -633,6 +633,8 @@ public struct FormatOptions: CustomStringConvertible { 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 @@ -744,6 +746,8 @@ public struct FormatOptions: CustomStringConvertible { varAttributes: AttributeMode = .preserve, storedVarAttributes: AttributeMode = .preserve, computedVarAttributes: AttributeMode = .preserve, + complexAttributes: AttributeMode = .preserve, + complexAttributesExceptions: Set = [], markTypes: MarkMode = .always, typeMarkComment: String = "MARK: - %t", markExtensions: MarkMode = .always, @@ -845,6 +849,8 @@ public struct FormatOptions: CustomStringConvertible { self.varAttributes = varAttributes self.storedVarAttributes = storedVarAttributes self.computedVarAttributes = computedVarAttributes + self.complexAttributes = complexAttributes + self.complexAttributesExceptions = complexAttributesExceptions self.markTypes = markTypes self.typeMarkComment = typeMarkComment self.markExtensions = markExtensions diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 8d4a32828..5d926ff91 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -5466,7 +5466,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", "storedvarattrs", "computedvarattrs"], + options: ["funcattributes", "typeattributes", "varattributes", "storedvarattrs", "computedvarattrs", "complexattrs", "noncomplexattrs"], sharedOptions: ["linebreaks", "maxwidth"] ) { formatter in formatter.forEach(.attribute) { i, _ in @@ -5488,7 +5488,7 @@ 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 @@ -5513,6 +5513,16 @@ public struct _FormatRules { return } + // If the complexAttriubtes 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.next(.nonSpaceOrCommentOrLinebreak, after: i) == .startOfScope("(") + && !formatter.options.complexAttributesExceptions.contains(attributeName) + + if isComplexAttribute, formatter.options.complexAttributes != .preserve { + attributeMode = formatter.options.complexAttributes + } + // Apply the `AttributeMode` switch attributeMode { case .preserve: 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/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index db5a086fb..e5623f314 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -4888,6 +4888,39 @@ class WrappingTests: RulesTests { 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) + } + func testWrapAttributesIndentsLineCorrectly() { let input = """ class Foo { @@ -4904,6 +4937,39 @@ class WrappingTests: RulesTests { testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } + 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: ["@Environment", "@SomeCustomAttr"]) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + } + + 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 testWrapOrDontWrapMultipleDeclarationsInClass() { let input = """ class Foo { @@ -4933,7 +4999,8 @@ class WrappingTests: RulesTests { class Foo { @objc var foo = Foo() - @available(*, unavailable) var bar: Bar + @available(*, unavailable) + var bar: Bar @available(*, unavailable) var myComputedFoo: String { @@ -4949,7 +5016,7 @@ class WrappingTests: RulesTests { } } """ - let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine) + let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine, complexAttributes: .prevLine, complexAttributesExceptions: ["@Environment"]) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -4977,6 +5044,7 @@ class WrappingTests: RulesTests { let input = """ struct MyView: View { @State var textContent: String + @Environment(\\.myEnvironmentVar) var environmentVar var body: some View { childView @@ -4988,7 +5056,7 @@ class WrappingTests: RulesTests { } """ - let options = FormatOptions(varAttributes: .sameLine) + let options = FormatOptions(varAttributes: .sameLine, complexAttributes: .prevLine, complexAttributesExceptions: ["@Environment"]) testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) } From 1967c0728735ac61895a64aabbaa6ead2a90a861 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Tue, 28 Nov 2023 19:17:12 -0800 Subject: [PATCH 24/38] Include @Environment as non-complex attribute by default --- Sources/Options.swift | 2 +- Tests/RulesTests+Wrapping.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Options.swift b/Sources/Options.swift index 06e509533..b80c7deb5 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -747,7 +747,7 @@ public struct FormatOptions: CustomStringConvertible { storedVarAttributes: AttributeMode = .preserve, computedVarAttributes: AttributeMode = .preserve, complexAttributes: AttributeMode = .preserve, - complexAttributesExceptions: Set = [], + complexAttributesExceptions: Set = ["@Environment"], markTypes: MarkMode = .always, typeMarkComment: String = "MARK: - %t", markExtensions: MarkMode = .always, diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index e5623f314..8bfeaee6c 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -5016,7 +5016,7 @@ class WrappingTests: RulesTests { } } """ - let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine, complexAttributes: .prevLine, complexAttributesExceptions: ["@Environment"]) + let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine, complexAttributes: .prevLine) testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) } @@ -5056,7 +5056,7 @@ class WrappingTests: RulesTests { } """ - let options = FormatOptions(varAttributes: .sameLine, complexAttributes: .prevLine, complexAttributesExceptions: ["@Environment"]) + let options = FormatOptions(varAttributes: .sameLine, complexAttributes: .prevLine) testFormatting(for: input, rule: FormatRules.wrapAttributes, options: options) } From 870f847f1221d70bc9db28e87033ec5662e4efa1 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Tue, 12 Dec 2023 12:17:23 -0800 Subject: [PATCH 25/38] Update complex attributes check to exclude attributes with a single unnamed argument --- Sources/Options.swift | 2 +- Sources/ParsingHelpers.swift | 32 +++++++++++++++++ Sources/Rules.swift | 4 +-- Tests/RulesTests+Wrapping.swift | 62 ++++++++++++++++++++++++++++++++- 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/Sources/Options.swift b/Sources/Options.swift index b80c7deb5..06e509533 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -747,7 +747,7 @@ public struct FormatOptions: CustomStringConvertible { storedVarAttributes: AttributeMode = .preserve, computedVarAttributes: AttributeMode = .preserve, complexAttributes: AttributeMode = .preserve, - complexAttributesExceptions: Set = ["@Environment"], + complexAttributesExceptions: Set = [], markTypes: MarkMode = .always, typeMarkComment: String = "MARK: - %t", markExtensions: MarkMode = .always, diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index 5eede93d7..19ff4ff3d 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -903,6 +903,38 @@ extension Formatter { } } + /// 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 } diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 5d926ff91..fa26080c8 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -5513,10 +5513,10 @@ public struct _FormatRules { return } - // If the complexAttriubtes option is configured, it takes precedence over other options + // 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.next(.nonSpaceOrCommentOrLinebreak, after: i) == .startOfScope("(") + let isComplexAttribute = formatter.isComplexAttribute(at: i) && !formatter.options.complexAttributesExceptions.contains(attributeName) if isComplexAttribute, formatter.options.complexAttributes != .preserve { diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index 8bfeaee6c..26367f5ce 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -4955,7 +4955,56 @@ class WrappingTests: RulesTests { var foo: Foo """ - let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine, complexAttributes: .prevLine, complexAttributesExceptions: ["@Environment", "@SomeCustomAttr"]) + 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) } @@ -4970,6 +5019,17 @@ class WrappingTests: RulesTests { 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 { From c861940638aa90a324072df19e5dc75a89354b39 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Sat, 3 Feb 2024 10:01:46 -0800 Subject: [PATCH 26/38] Add new rules related to blank lines in switch statements (#1621) --- Rules.md | 91 +++++ Sources/Examples.swift | 72 ++++ Sources/FormattingHelpers.swift | 101 +++++- Sources/Rules.swift | 69 ++++ Tests/RulesTests+Indentation.swift | 16 +- Tests/RulesTests+Organization.swift | 1 + Tests/RulesTests+Redundancy.swift | 13 + Tests/RulesTests+Spacing.swift | 520 ++++++++++++++++++++++++++++ Tests/RulesTests+Syntax.swift | 7 + 9 files changed, 881 insertions(+), 9 deletions(-) diff --git a/Rules.md b/Rules.md index 9b53fb9d9..9d06a2a88 100644 --- a/Rules.md +++ b/Rules.md @@ -91,8 +91,10 @@ # Opt-in Rules (disabled by default) * [acronyms](#acronyms) +* [blankLineAfterMultilineSwitchCase](#blankLineAfterMultilineSwitchCase) * [blankLinesBetweenImports](#blankLinesBetweenImports) * [blockComments](#blockComments) +* [consistentSwitchStatementSpacing](#consistentSwitchStatementSpacing) * [docComments](#docComments) * [isEmpty](#isEmpty) * [markTypes](#markTypes) @@ -238,6 +240,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. @@ -543,6 +577,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. diff --git a/Sources/Examples.swift b/Sources/Examples.swift index df8ff0bb6..c508ea6a7 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1754,6 +1754,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 { @@ -1769,4 +1792,53 @@ 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" + } + ``` + """# } diff --git a/Sources/FormattingHelpers.swift b/Sources/FormattingHelpers.swift index 5d3895543..ff75c812c 100644 --- a/Sources/FormattingHelpers.swift +++ b/Sources/FormattingHelpers.swift @@ -1346,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 @@ -1448,6 +1448,105 @@ 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) { diff --git a/Sources/Rules.swift b/Sources/Rules.swift index fa26080c8..f7b261fd6 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7833,4 +7833,73 @@ 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.", + disabledByDefault: true, + 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) + } + } + } + } } diff --git a/Tests/RulesTests+Indentation.swift b/Tests/RulesTests+Indentation.swift index f3cf7ac31..f9e2a55a4 100644 --- a/Tests/RulesTests+Indentation.swift +++ b/Tests/RulesTests+Indentation.swift @@ -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() { @@ -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() { diff --git a/Tests/RulesTests+Organization.swift b/Tests/RulesTests+Organization.swift index 78170c48d..ed59a611b 100644 --- a/Tests/RulesTests+Organization.swift +++ b/Tests/RulesTests+Organization.swift @@ -2790,6 +2790,7 @@ class OrganizationTests: RulesTests { case .value: print("value") } + case .failure: guard self.bar else { print(self.bar) diff --git a/Tests/RulesTests+Redundancy.swift b/Tests/RulesTests+Redundancy.swift index 95d28183d..67a3d4eb5 100644 --- a/Tests/RulesTests+Redundancy.swift +++ b/Tests/RulesTests+Redundancy.swift @@ -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") } @@ -1938,6 +1942,7 @@ class RedundancyTests: RulesTests { case .bar: var foo: String? Text(foo ?? "") + default: EmptyView() } @@ -2038,6 +2043,7 @@ class RedundancyTests: RulesTests { let _ = { foo = "\\(max)" }() + default: EmptyView() } @@ -2913,6 +2919,7 @@ class RedundancyTests: RulesTests { case true: // foo return "foo" + default: /* bar */ return "bar" @@ -2925,6 +2932,7 @@ class RedundancyTests: RulesTests { case true: // foo "foo" + default: /* bar */ "bar" @@ -2995,6 +3003,7 @@ class RedundancyTests: RulesTests { return "baaz" } } + case false: return "quux" } @@ -3014,6 +3023,7 @@ class RedundancyTests: RulesTests { "baaz" } } + case false: "quux" } @@ -6324,6 +6334,7 @@ class RedundancyTests: RulesTests { print(self.bar) } } + case .failure: if self.bar { print(self.bar) @@ -6351,6 +6362,7 @@ class RedundancyTests: RulesTests { } } self.method() + case .failure: break } @@ -6381,6 +6393,7 @@ class RedundancyTests: RulesTests { case .value: print("value") } + case .failure: guard self.bar else { print(self.bar) diff --git a/Tests/RulesTests+Spacing.swift b/Tests/RulesTests+Spacing.swift index 31d0b9665..a9e7306ef 100644 --- a/Tests/RulesTests+Spacing.swift +++ b/Tests/RulesTests+Spacing.swift @@ -1621,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..0ce600d8d 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -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") } From 73825b719d06b8c17d134369404a1f06fb41365e Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Sat, 3 Feb 2024 18:58:51 +0000 Subject: [PATCH 27/38] Enable `consistentSwitchStatementSpacing` by default --- Rules.md | 2 +- Sources/Rules.swift | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Rules.md b/Rules.md index 9d06a2a88..718b52b69 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) @@ -94,7 +95,6 @@ * [blankLineAfterMultilineSwitchCase](#blankLineAfterMultilineSwitchCase) * [blankLinesBetweenImports](#blankLinesBetweenImports) * [blockComments](#blockComments) -* [consistentSwitchStatementSpacing](#consistentSwitchStatementSpacing) * [docComments](#docComments) * [isEmpty](#isEmpty) * [markTypes](#markTypes) diff --git a/Sources/Rules.swift b/Sources/Rules.swift index f7b261fd6..96120edd7 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -1654,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. @@ -2112,7 +2111,6 @@ public struct _FormatRules { if linewrapped, shouldIndentNextLine(at: i) { indentStack[indentStack.count - 1] += formatter.options.indent } - default: break } @@ -7868,7 +7866,6 @@ public struct _FormatRules { public let consistentSwitchStatementSpacing = FormatRule( help: "Ensures consistent spacing among all of the cases in a switch statement.", - disabledByDefault: true, orderAfter: ["blankLineAfterMultilineSwitchCase"] ) { formatter in formatter.forEach(.keyword("switch")) { switchIndex, _ in From 9893c1ce00e2e229eb856e8992c5f74beaf70d55 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Thu, 14 Mar 2024 15:37:23 -0700 Subject: [PATCH 28/38] Update conditionalAssignment to also simplify if/switch expressions that don't immediately follow property declaration --- Sources/Rules.swift | 192 ++++++++++++++++++------------ Tests/RulesTests+Syntax.swift | 203 ++++++++++++++++++++++++++++++++ Tests/RulesTests+Wrapping.swift | 44 +++++++ 3 files changed, 365 insertions(+), 74 deletions(-) diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 96120edd7..318c0c887 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7128,24 +7128,28 @@ public struct _FormatRules { 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 `identifier =`. + guard let firstIdentifierIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: startOfFirstBranch), + let identifier = formatter.token(at: firstIdentifierIndex), + identifier.isIdentifier, + let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstIdentifierIndex), + formatter.tokens[equalsIndex] == .operator("=", .infix) else { return } // Whether or not the conditional statement that starts at the given index @@ -7252,42 +7256,104 @@ 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 equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstTokenIndex), + formatter.tokens[equalsIndex] == .operator("=", .infix), + let valueStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex) + else { return } - formatter.removeTokens(in: firstTokenIndex ..< valueStartIndex) + formatter.removeTokens(in: firstTokenIndex ..< valueStartIndex) + } } - // Lastly we have to insert an `=` between the type and the conditional - let rangeBetweenTypeAndConditional = (typeRange.upperBound + 1) ..< startOfConditional + // 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 propertyIdentifierIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: introducerIndex), + let propertyIdentifier = formatter.token(at: propertyIdentifierIndex), + propertyIdentifier.isIdentifier, + propertyIdentifier == identifier, + let colonIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: propertyIdentifierIndex), + formatter.tokens[colonIndex] == .delimiter(":"), + let startOfTypeIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), + let typeRange = formatter.parseType(at: startOfTypeIndex)?.range, + let nextTokenAfterProperty = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: typeRange.upperBound), + nextTokenAfterProperty == startOfConditional + { + removeAssignmentFromAllBranches() - // 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: [ + 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(" "), + ]) + } + + // 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) + } + } + + // Otherwise we insert an `identifier =` before the if/switch expression + else { + // 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 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 } + + // Now we can remove the `identifier =` from each branch, + // and instead add it before the if / switch expression. + removeAssignmentFromAllBranches() + + let identifierEqualsTokens: [Token] = [ + identifier, .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) } } } @@ -7767,35 +7833,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), @@ -7808,11 +7852,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 diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index 0ce600d8d..07dc58a52 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -4269,6 +4269,209 @@ 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(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(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(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(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(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(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(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() { diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index 26367f5ce..d9a369877 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -5611,4 +5611,48 @@ 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]) + } } From a43b50efbde961d6e1451df9cedb6643f466daaf Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Tue, 12 Mar 2024 16:46:14 -0700 Subject: [PATCH 29/38] Add preferInferredTypes rule --- Rules.md | 26 +++++++++++++++ Sources/Examples.swift | 17 ++++++++++ Sources/Rules.swift | 32 ++++++++++++++++++- Tests/RulesTests+Redundancy.swift | 38 +++++++++++----------- Tests/RulesTests+Syntax.swift | 53 +++++++++++++++++++++++++++++++ Tests/RulesTests+Wrapping.swift | 8 ++--- 6 files changed, 150 insertions(+), 24 deletions(-) diff --git a/Rules.md b/Rules.md index 718b52b69..52e340469 100644 --- a/Rules.md +++ b/Rules.md @@ -100,6 +100,7 @@ * [markTypes](#markTypes) * [noExplicitOwnership](#noExplicitOwnership) * [organizeDeclarations](#organizeDeclarations) +* [preferInferredTypes](#preferInferredTypes) * [sortSwitchCases](#sortSwitchCases) * [wrapConditionalBodies](#wrapConditionalBodies) * [wrapEnumCases](#wrapEnumCases) @@ -1447,6 +1448,31 @@ Option | Description

+## preferInferredTypes + +Prefer using inferred types on property definitions (`let foo = Foo()`) rather than explicit types (`let foo: Foo = .init()`). + +
+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 +``` + +
+
+ ## preferKeyPath Convert trivial `map { $0.foo }` closures to keyPath-based syntax. diff --git a/Sources/Examples.swift b/Sources/Examples.swift index c508ea6a7..0eee5e021 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1841,4 +1841,21 @@ private struct Examples { } ``` """# + + let preferInferredTypes = """ + ```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 + ``` + """ } diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 318c0c887..4c7f64a7f 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -4416,7 +4416,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: ["preferInferredTypes"] ) { formatter in formatter.forEach(.identifier("init")) { initIndex, _ in guard let dotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: initIndex, if: { @@ -7943,4 +7944,33 @@ public struct _FormatRules { } } } + + public let preferInferredTypes = FormatRule( + help: "Prefer using inferred types on property definitions (`let foo = Foo()`) rather than explicit types (`let foo: Foo = .init()`).", + disabledByDefault: true, + orderAfter: ["redundantType"] + ) { formatter in + formatter.forEach(.operator("=", .infix)) { equalsIndex, _ in + guard // Parse and validate the LHS of the property declaration. + // It should take the form `(let|var) propertyName: (Type) = .staticMember` + let introducerIndex = formatter.indexOfLastSignificantKeyword(at: equalsIndex), + ["var", "let"].contains(formatter.tokens[introducerIndex].string), + let colonIndex = formatter.index(of: .delimiter(":"), before: equalsIndex), + introducerIndex < colonIndex, + let typeStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), + let type = formatter.parseType(at: typeStartIndex), + // If the RHS starts with a leading dot, then we know its accessing some static member on this type. + let dotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex), + formatter.tokens[dotIndex] == .operator(".", .prefix) + else { return } + + let typeTokens = formatter.tokens[type.range] + + // Insert a copy of the type on the RHS before the dot + formatter.insert(typeTokens, at: dotIndex) + + // Remove the colon and explicit type before the equals token + formatter.removeTokens(in: colonIndex ... type.range.upperBound) + } + } } diff --git a/Tests/RulesTests+Redundancy.swift b/Tests/RulesTests+Redundancy.swift index 67a3d4eb5..499cecec6 100644 --- a/Tests/RulesTests+Redundancy.swift +++ b/Tests/RulesTests+Redundancy.swift @@ -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: ["preferInferredTypes"]) } func testFileprivateInitNotChangedToPrivateWhenUsingTrailingClosureInit() { @@ -1506,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: ["preferInferredTypes"]) } func testVarRedundantTypeRemovalExplicitType2() { @@ -1514,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", "preferInferredTypes"]) } func testLetRedundantGenericTypeRemovalExplicitType() { @@ -1522,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: ["preferInferredTypes"]) } func testLetRedundantGenericTypeRemovalExplicitTypeIfValueOnNextLine() { @@ -1530,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", "preferInferredTypes"]) } func testVarNonRedundantTypeDoesNothingExplicitType() { @@ -1544,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: ["preferInferredTypes"]) } func testRedundantTypeRemovedIfValueOnNextLineExplicitType() { @@ -1558,7 +1558,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["preferInferredTypes"]) } func testRedundantTypeRemovedIfValueOnNextLine2ExplicitType() { @@ -1572,7 +1572,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["preferInferredTypes"]) } func testRedundantTypeRemovalWithCommentExplicitType() { @@ -1580,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: ["preferInferredTypes"]) } func testRedundantTypeRemovalWithComment2ExplicitType() { @@ -1588,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: ["preferInferredTypes"]) } func testRedundantTypeRemovalWithStaticMember() { @@ -1610,7 +1610,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["preferInferredTypes"]) } func testRedundantTypeRemovalWithStaticFunc() { @@ -1632,7 +1632,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["preferInferredTypes"]) } func testRedundantTypeDoesNothingWithChainedMember() { @@ -1646,7 +1646,7 @@ 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: ["preferInferredTypes"]) } func testRedundantTypeDoesNothingWithChainedMember2() { @@ -1665,7 +1665,7 @@ class RedundancyTests: RulesTests { 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: ["preferInferredTypes"]) } func testRedundantTypeDoesNothingIfLet() { @@ -1697,7 +1697,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["preferInferredTypes"]) } func testRedundantTypeIfVoid() { @@ -1705,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: ["preferInferredTypes"]) } func testRedundantTypeWithIntegerLiteralNotMangled() { @@ -1787,7 +1787,7 @@ class RedundancyTests: RulesTests { let options = FormatOptions(redundantType: .inferLocalsOnly) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options) + options: options, exclude: ["preferInferredTypes"]) } // MARK: - redundantNilInit @@ -3396,7 +3396,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: ["preferInferredTypes"]) } func testNoRemoveBackticksAroundAnyProperty() { @@ -6864,7 +6864,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 { diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index 07dc58a52..6d13bf690 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -4830,4 +4830,57 @@ class SyntaxTests: RulesTests { """ testFormatting(for: input, rule: FormatRules.preferForLoop) } + + // MARK: preferInferredTypes + + func testConvertsExplicitTypeToImplicitType() { + 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 optional: String? = .init("Foo") + """ + + let output = """ + 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 optional = String?.init("Foo") + """ + + testFormatting(for: input, output, rule: FormatRules.preferInferredTypes, exclude: ["redundantInit"]) + } + + func testPreservesExplicitTypeIfNoRHS() { + let input = """ + let foo: Foo + let bar: Bar + """ + + testFormatting(for: input, rule: FormatRules.preferInferredTypes) + } + + 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) + """ + + testFormatting(for: input, rule: FormatRules.preferInferredTypes) + } } diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index d9a369877..01a750062 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -3611,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() } @@ -3619,7 +3619,7 @@ class WrappingTests: RulesTests { """ let output = """ - var items: Adaptive = .adaptive( + var items = Adaptive.adaptive( compact: Sizes.horizontalPaddingTiny_8, regular: Sizes.horizontalPaddingLarge_64) { @@ -3663,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() } } From 2d43f27da0dc52543744ca5b9416a441c4c39b82 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Fri, 15 Mar 2024 16:08:19 -0700 Subject: [PATCH 30/38] Make preferInferredTypes rule more compatible with redundantType rule --- Sources/Rules.swift | 12 +++++-- Tests/RulesTests+Redundancy.swift | 19 +++++----- Tests/RulesTests+Syntax.swift | 60 +++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 4c7f64a7f..df3de66d0 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -779,7 +779,9 @@ public struct _FormatRules { isInferred = true declarationKeywordIndex = nil case .explicit: - isInferred = false + // If the `preferInferredTypes` rule is also enabled, it takes precedence + // over the `--redundanttype explicit` option. + isInferred = formatter.options.enabledRules.contains("preferInferredTypes") declarationKeywordIndex = formatter.declarationIndexAndScope(at: equalsIndex).index case .inferLocalsOnly: let (index, scope) = formatter.declarationIndexAndScope(at: equalsIndex) @@ -7948,7 +7950,8 @@ public struct _FormatRules { public let preferInferredTypes = FormatRule( help: "Prefer using inferred types on property definitions (`let foo = Foo()`) rather than explicit types (`let foo: Foo = .init()`).", disabledByDefault: true, - orderAfter: ["redundantType"] + orderAfter: ["redundantType"], + sharedOptions: ["redundanttype"] ) { formatter in formatter.forEach(.operator("=", .infix)) { equalsIndex, _ in guard // Parse and validate the LHS of the property declaration. @@ -7964,6 +7967,11 @@ public struct _FormatRules { formatter.tokens[dotIndex] == .operator(".", .prefix) else { return } + // Respect the `.inferLocalsOnly` option if enabled + if formatter.options.redundantType == .inferLocalsOnly, + formatter.declarationScope(at: equalsIndex) != .local + { return } + let typeTokens = formatter.tokens[type.range] // Insert a copy of the type on the RHS before the dot diff --git a/Tests/RulesTests+Redundancy.swift b/Tests/RulesTests+Redundancy.swift index 499cecec6..5d42475ac 100644 --- a/Tests/RulesTests+Redundancy.swift +++ b/Tests/RulesTests+Redundancy.swift @@ -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", "preferInferredTypes"]) } func testRedundantTypeWithNestedIfExpression_inferred() { @@ -1477,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", "preferInferredTypes"]) } func testRedundantTypeWithLiteralsInIfExpression() { @@ -1638,7 +1638,7 @@ class RedundancyTests: RulesTests { 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: ["preferInferredTypes"]) } func testRedundantRedundantChainedMemberTypeRemovedOnSwift5_4() { @@ -1652,13 +1652,13 @@ class RedundancyTests: RulesTests { 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: ["preferInferredTypes"]) } 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: ["preferInferredTypes"]) } func testRedundantTypeRemovedWithChainedMemberOnSwift5_4() { @@ -1671,19 +1671,19 @@ class RedundancyTests: RulesTests { 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: ["preferInferredTypes"]) } 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: ["preferInferredTypes"]) } 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: ["preferInferredTypes"]) } func testRedundantTypeWorksAfterIf() { @@ -1745,7 +1745,8 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, + exclude: ["preferInferredTypes"]) } // --redundanttype infer-locals-only diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index 6d13bf690..aadf4130d 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -4858,7 +4858,8 @@ class SyntaxTests: RulesTests { let optional = String?.init("Foo") """ - testFormatting(for: input, output, rule: FormatRules.preferInferredTypes, exclude: ["redundantInit"]) + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, output, rule: FormatRules.preferInferredTypes, options: options, exclude: ["redundantInit"]) } func testPreservesExplicitTypeIfNoRHS() { @@ -4867,7 +4868,8 @@ class SyntaxTests: RulesTests { let bar: Bar """ - testFormatting(for: input, rule: FormatRules.preferInferredTypes) + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) } func testPreservesExplicitTypeIfUsingLocalValueOrLiteral() { @@ -4881,6 +4883,58 @@ class SyntaxTests: RulesTests { let tuple: (String, Int) = ("foo", 123) """ - testFormatting(for: input, rule: FormatRules.preferInferredTypes) + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.preferInferredTypes, 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.preferInferredTypes], options: options) + } + + func testCompatibleWithRedundantTypeExplicit() { + let input = """ + let foo: Foo = Foo() + """ + + let output = """ + let foo = Foo() + """ + + let options = FormatOptions(redundantType: .explicit) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.preferInferredTypes], 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.init() + let baaz = Baaz.init() + } + """ + + let options = FormatOptions(redundantType: .inferLocalsOnly) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.preferInferredTypes], options: options, exclude: ["redundantInit"]) } } From a07c75aa343702c2c5d497141cbe2354a0916af7 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Mon, 18 Mar 2024 14:08:40 -0700 Subject: [PATCH 31/38] Update conditionalAssignment rule to support more complex lvalues --- Rules.md | 12 +++++++++++- Sources/Examples.swift | 12 +++++++++++- Sources/Rules.swift | 29 ++++++++++++++++------------- Tests/RulesTests+Syntax.swift | 24 ++++++++++++++++++++++++ Tests/RulesTests+Wrapping.swift | 23 +++++++++++++++++++++++ 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/Rules.md b/Rules.md index 52e340469..acdbcfc49 100644 --- a/Rules.md +++ b/Rules.md @@ -520,7 +520,6 @@ Assign properties using if / switch expressions. - bar = "bar" + "bar" } -``` ```diff - let foo: String @@ -535,6 +534,17 @@ Assign properties using if / switch expressions. } ``` +- switch condition { ++ foo.bar = switch condition { + case true: +- foo.bar = "baaz" ++ "baaz" + case false: +- foo.bar = "quux" ++ "quux" + } +``` +

diff --git a/Sources/Examples.swift b/Sources/Examples.swift index 0eee5e021..c8e9e5977 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1669,7 +1669,6 @@ private struct Examples { - bar = "bar" + "bar" } - ``` ```diff - let foo: String @@ -1683,6 +1682,17 @@ private struct Examples { + "bar" } ``` + + - switch condition { + + foo.bar = switch condition { + case true: + - foo.bar = "baaz" + + "baaz" + case false: + - foo.bar = "quux" + + "quux" + } + ``` """ let sortTypealiases = """ diff --git a/Sources/Rules.swift b/Sources/Rules.swift index df3de66d0..b361ddd92 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7147,11 +7147,10 @@ public struct _FormatRules { startOfFirstBranch = startOfNestedBranch } - // Check if the first branch starts with the pattern `identifier =`. - guard let firstIdentifierIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: startOfFirstBranch), - let identifier = formatter.token(at: firstIdentifierIndex), - identifier.isIdentifier, - let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstIdentifierIndex), + // 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 } @@ -7182,7 +7181,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 { @@ -7209,9 +7208,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) { @@ -7263,7 +7263,8 @@ public struct _FormatRules { func removeAssignmentFromAllBranches() { formatter.forEachRecursiveConditionalBranch(in: conditionalBranches) { branch in guard let firstTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: branch.startOfBranch), - let equalsIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstTokenIndex), + 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 } @@ -7281,7 +7282,8 @@ public struct _FormatRules { let propertyIdentifierIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: introducerIndex), let propertyIdentifier = formatter.token(at: propertyIdentifierIndex), propertyIdentifier.isIdentifier, - propertyIdentifier == identifier, + formatter.tokens[lvalueRange.lowerBound] == propertyIdentifier, + lvalueRange.count == 1, let colonIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: propertyIdentifierIndex), formatter.tokens[colonIndex] == .delimiter(":"), let startOfTypeIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), @@ -7345,12 +7347,13 @@ public struct _FormatRules { (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: [Token] = [ - identifier, + let identifierEqualsTokens = lvalueTokens + [ .space(" "), .operator("=", .infix), .space(" "), diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index aadf4130d..a39857cb6 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -4354,6 +4354,30 @@ class SyntaxTests: RulesTests { 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(swiftVersion: "5.9") + testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) + } + func testDoesntMergePropertyWithUnrelatedCondition() { let input = """ let differentProperty: Foo diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index 01a750062..d49090278 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -5655,4 +5655,27 @@ class WrappingTests: RulesTests { 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]) + } } From 963085e637f84538d63001d0cccb87b88aef0667 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Mon, 18 Mar 2024 15:18:25 -0700 Subject: [PATCH 32/38] Add --condassignment after-property option --- Rules.md | 5 +++++ Sources/Examples.swift | 1 + Sources/OptionDescriptor.swift | 8 ++++++++ Sources/Options.swift | 3 +++ Sources/Rules.swift | 5 +++-- Tests/RulesTests+Syntax.swift | 16 ++++++++-------- 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Rules.md b/Rules.md index acdbcfc49..a19864ea0 100644 --- a/Rules.md +++ b/Rules.md @@ -507,6 +507,10 @@ Option | Description Assign properties using if / switch expressions. +Option | Description +--- | --- +`--condassignment` | Use cond. assignment: "after-property" (default) or "always". +
Examples @@ -534,6 +538,7 @@ Assign properties using if / switch expressions. } ``` +// With --condassignment always (disabled by default) - switch condition { + foo.bar = switch condition { case true: diff --git a/Sources/Examples.swift b/Sources/Examples.swift index c8e9e5977..daff20786 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1683,6 +1683,7 @@ private struct Examples { } ``` + // With --condassignment always (disabled by default) - switch condition { + foo.bar = switch condition { case true: diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 6ebe996f7..8d16c0e20 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -987,6 +987,14 @@ 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", diff --git a/Sources/Options.swift b/Sources/Options.swift index 06e509533..86e2f9529 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -664,6 +664,7 @@ 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 @@ -777,6 +778,7 @@ 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, @@ -880,6 +882,7 @@ 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 diff --git a/Sources/Rules.swift b/Sources/Rules.swift index b361ddd92..55fb4f561 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7124,7 +7124,8 @@ 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 { @@ -7321,7 +7322,7 @@ public struct _FormatRules { } // Otherwise we insert an `identifier =` before the if/switch expression - else { + 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 diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index a39857cb6..2fb16c00d 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -4287,7 +4287,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } @@ -4309,7 +4309,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } @@ -4326,7 +4326,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } @@ -4350,7 +4350,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } @@ -4374,7 +4374,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } @@ -4400,7 +4400,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } @@ -4442,7 +4442,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, [output], rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } @@ -4468,7 +4468,7 @@ class SyntaxTests: RulesTests { } """ - let options = FormatOptions(swiftVersion: "5.9") + let options = FormatOptions(conditionalAssignmentOnlyAfterNewProperties: false, swiftVersion: "5.9") testFormatting(for: input, rules: [FormatRules.conditionalAssignment, FormatRules.wrapMultilineConditionalAssignment, FormatRules.indent], options: options) } From 65e9e64df54c48226989d4c27f7f25e7d44b9764 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Sat, 23 Mar 2024 19:45:58 -0700 Subject: [PATCH 33/38] Extend preferInferredTypes rule to support if/switch expressions --- Rules.md | 15 ++++++ Sources/Examples.swift | 11 ++++ Sources/OptionDescriptor.swift | 8 +++ Sources/Options.swift | 3 ++ Sources/Rules.swift | 53 +++++++++++++++---- Tests/RulesTests+Syntax.swift | 94 ++++++++++++++++++++++++++++++---- 6 files changed, 165 insertions(+), 19 deletions(-) diff --git a/Rules.md b/Rules.md index a19864ea0..e01309b83 100644 --- a/Rules.md +++ b/Rules.md @@ -1467,6 +1467,10 @@ Option | Description Prefer using inferred types on property definitions (`let foo = Foo()`) rather than explicit types (`let foo: Foo = .init()`). +Option | Description +--- | --- +`--inferredtypes` | "exclude-cond-exprs" (default) or "always" +
Examples @@ -1483,6 +1487,17 @@ Prefer using inferred types on property definitions (`let foo = Foo()`) rather t 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) + } ```
diff --git a/Sources/Examples.swift b/Sources/Examples.swift index daff20786..52cecf01c 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1867,6 +1867,17 @@ private struct Examples { 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) + } ``` """ } diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index 8d16c0e20..d7d883ca1 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -907,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", diff --git a/Sources/Options.swift b/Sources/Options.swift index 86e2f9529..de6acdcf0 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -652,6 +652,7 @@ public struct FormatOptions: CustomStringConvertible { public var yodaSwap: YodaMode public var extensionACLPlacement: ExtensionACLPlacement public var redundantType: RedundantType + public var inferredTypesInConditionalExpressions: Bool public var emptyBracesSpacing: EmptyBracesSpacing public var acronyms: Set public var indentStrings: Bool @@ -766,6 +767,7 @@ public struct FormatOptions: CustomStringConvertible { yodaSwap: YodaMode = .always, extensionACLPlacement: ExtensionACLPlacement = .onExtension, redundantType: RedundantType = .inferLocalsOnly, + inferredTypesInConditionalExpressions: Bool = false, emptyBracesSpacing: EmptyBracesSpacing = .noSpace, acronyms: Set = ["ID", "URL", "UUID"], indentStrings: Bool = false, @@ -870,6 +872,7 @@ public struct FormatOptions: CustomStringConvertible { self.yodaSwap = yodaSwap self.extensionACLPlacement = extensionACLPlacement self.redundantType = redundantType + self.inferredTypesInConditionalExpressions = inferredTypesInConditionalExpressions self.emptyBracesSpacing = emptyBracesSpacing self.acronyms = acronyms self.indentStrings = indentStrings diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 55fb4f561..44a2ac7b6 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7955,9 +7955,15 @@ public struct _FormatRules { help: "Prefer using inferred types on property definitions (`let foo = Foo()`) rather than explicit types (`let foo: Foo = .init()`).", disabledByDefault: true, orderAfter: ["redundantType"], + options: ["inferredtypes"], sharedOptions: ["redundanttype"] ) { formatter in formatter.forEach(.operator("=", .infix)) { equalsIndex, _ in + // Respect the `.inferLocalsOnly` option if enabled + if formatter.options.redundantType == .inferLocalsOnly, + formatter.declarationScope(at: equalsIndex) != .local + { return } + guard // Parse and validate the LHS of the property declaration. // It should take the form `(let|var) propertyName: (Type) = .staticMember` let introducerIndex = formatter.indexOfLastSignificantKeyword(at: equalsIndex), @@ -7966,20 +7972,47 @@ public struct _FormatRules { introducerIndex < colonIndex, let typeStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), let type = formatter.parseType(at: typeStartIndex), - // If the RHS starts with a leading dot, then we know its accessing some static member on this type. - let dotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex), - formatter.tokens[dotIndex] == .operator(".", .prefix) + let rhsStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex) else { return } - // Respect the `.inferLocalsOnly` option if enabled - if formatter.options.redundantType == .inferLocalsOnly, - formatter.declarationScope(at: equalsIndex) != .local - { return } - let typeTokens = formatter.tokens[type.range] - // Insert a copy of the type on the RHS before the dot - formatter.insert(typeTokens, at: dotIndex) + // If the RHS starts with a leading dot, then we know its accessing some static member on this type. + if formatter.tokens[rhsStartIndex].isOperator(".") { + // 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 + } + } + + 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 } + + formatter.insert(typeTokens, at: dotIndex) + } + } + + else { + return + } // Remove the colon and explicit type before the equals token formatter.removeTokens(in: colonIndex ... type.range.upperBound) diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index 2fb16c00d..38886cfb0 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -4871,19 +4871,19 @@ class SyntaxTests: RulesTests { """ let output = """ - let foo = Foo.init() + let foo = Foo() 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 optional = String?.init("Foo") + let dictionary = [Foo: Bar]() + let array = [Foo]() + let genericType = MyGenericType() + let optional = String?("Foo") """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, output, rule: FormatRules.preferInferredTypes, options: options, exclude: ["redundantInit"]) + testFormatting(for: input, [output], rules: [FormatRules.preferInferredTypes, FormatRules.redundantInit], options: options) } func testPreservesExplicitTypeIfNoRHS() { @@ -4953,12 +4953,88 @@ class SyntaxTests: RulesTests { let foo: Foo = .init() func bar() { - let baaz = Baaz.init() - let baaz = Baaz.init() + let baaz = Baaz() + let baaz = Baaz() } """ let options = FormatOptions(redundantType: .inferLocalsOnly) - testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.preferInferredTypes], options: options, exclude: ["redundantInit"]) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.preferInferredTypes, FormatRules.redundantInit], options: options) + } + + func testPreferInferredTypesWithIfExpressionDisabledByDefault() { + let input = """ + let foo: SomeTypeWithALongGenrericName = + if condition { + .init(bar) + } else { + .init(baaz) + } + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + } + + func testPreferInferredTypesWithIfExpression() { + 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.preferInferredTypes, FormatRules.redundantInit], options: options) + } + + func testPreferInferredTypesWithSwitchExpression() { + 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.preferInferredTypes, 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.preferInferredTypes, options: options) } } From 8816977eca6a8dda03d112c73f59ddf8fef74822 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Sat, 23 Mar 2024 15:05:08 -0700 Subject: [PATCH 34/38] Add redundantProperty rule --- Rules.md | 19 +++ Sources/Examples.swift | 10 ++ Sources/ParsingHelpers.swift | 90 ++++++++++- Sources/Rules.swift | 64 ++++++-- Tests/ParsingHelpersTests.swift | 30 +++- Tests/RulesTests+Indentation.swift | 8 +- Tests/RulesTests+Redundancy.swift | 230 +++++++++++++++++++++++++++-- Tests/RulesTests+Syntax.swift | 4 +- 8 files changed, 416 insertions(+), 39 deletions(-) diff --git a/Rules.md b/Rules.md index e01309b83..6e3bf0bc9 100644 --- a/Rules.md +++ b/Rules.md @@ -101,6 +101,7 @@ * [noExplicitOwnership](#noExplicitOwnership) * [organizeDeclarations](#organizeDeclarations) * [preferInferredTypes](#preferInferredTypes) +* [redundantProperty](#redundantProperty) * [sortSwitchCases](#sortSwitchCases) * [wrapConditionalBodies](#wrapConditionalBodies) * [wrapEnumCases](#wrapEnumCases) @@ -1853,6 +1854,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. diff --git a/Sources/Examples.swift b/Sources/Examples.swift index 52cecf01c..d39696c0c 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1880,4 +1880,14 @@ private struct Examples { } ``` """ + + let redundantProperty = """ + ```diff + func foo() -> Foo { + - let foo = Foo() + - return foo + + return Foo() + } + ``` + """ } diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index 19ff4ff3d..acde9a611 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -1421,17 +1421,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 } @@ -1447,7 +1454,7 @@ extension Formatter { nextTokenAfterTry = nextTokenAfterTryOperator } - if let followingExpression = parseExpressionRange(startingAt: nextTokenAfterTry) { + if let followingExpression = parseExpressionRange(startingAt: nextTokenAfterTry, allowConditionalExpressions: allowConditionalExpressions) { return startIndex ... followingExpression.upperBound } } @@ -1473,10 +1480,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) { @@ -1493,7 +1506,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 } @@ -1571,6 +1584,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]]() diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 44a2ac7b6..6a61b4671 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7280,15 +7280,10 @@ public struct _FormatRules { // 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 propertyIdentifierIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: introducerIndex), - let propertyIdentifier = formatter.token(at: propertyIdentifierIndex), - propertyIdentifier.isIdentifier, - formatter.tokens[lvalueRange.lowerBound] == propertyIdentifier, - lvalueRange.count == 1, - let colonIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: propertyIdentifierIndex), - formatter.tokens[colonIndex] == .delimiter(":"), - let startOfTypeIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), - let typeRange = formatter.parseType(at: startOfTypeIndex)?.range, + 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 { @@ -7968,11 +7963,9 @@ public struct _FormatRules { // It should take the form `(let|var) propertyName: (Type) = .staticMember` let introducerIndex = formatter.indexOfLastSignificantKeyword(at: equalsIndex), ["var", "let"].contains(formatter.tokens[introducerIndex].string), - let colonIndex = formatter.index(of: .delimiter(":"), before: equalsIndex), - introducerIndex < colonIndex, - let typeStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex), - let type = formatter.parseType(at: typeStartIndex), - let rhsStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: equalsIndex) + let property = formatter.parsePropertyDeclaration(atIntroducerIndex: introducerIndex), + let type = property.type, + let rhsStartIndex = property.value?.expressionRange.lowerBound else { return } let typeTokens = formatter.tokens[type.range] @@ -8015,7 +8008,48 @@ public struct _FormatRules { } // Remove the colon and explicit type before the equals token - formatter.removeTokens(in: colonIndex ... type.range.upperBound) + formatter.removeTokens(in: type.colonIndex ... type.range.upperBound) + } + } + + public let redundantProperty = FormatRule( + help: "Simplifies redundant property definitions that are immediately returned.", + disabledByDefault: true, + orderAfter: ["preferInferredTypes"] + ) { 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/Tests/ParsingHelpersTests.swift b/Tests/ParsingHelpersTests.swift index 96521a02a..49d27c9a9 100644 --- a/Tests/ParsingHelpersTests.swift +++ b/Tests/ParsingHelpersTests.swift @@ -1827,7 +1827,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 +1884,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 +2017,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! } diff --git a/Tests/RulesTests+Indentation.swift b/Tests/RulesTests+Indentation.swift index f9e2a55a4..f146299df 100644 --- a/Tests/RulesTests+Indentation.swift +++ b/Tests/RulesTests+Indentation.swift @@ -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() { @@ -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+Redundancy.swift b/Tests/RulesTests+Redundancy.swift index 5d42475ac..72984dbec 100644 --- a/Tests/RulesTests+Redundancy.swift +++ b/Tests/RulesTests+Redundancy.swift @@ -2569,7 +2569,7 @@ class RedundancyTests: RulesTests { return bar }() """ - testFormatting(for: input, rule: FormatRules.redundantReturn) + testFormatting(for: input, rule: FormatRules.redundantReturn, exclude: ["redundantProperty"]) } func testNoRemoveReturnInForWhereLoop() { @@ -2692,7 +2692,7 @@ class RedundancyTests: RulesTests { } """ testFormatting(for: input, rule: FormatRules.redundantReturn, - options: FormatOptions(swiftVersion: "5.1")) + options: FormatOptions(swiftVersion: "5.1"), exclude: ["redundantProperty"]) } func testDisableNextRedundantReturn() { @@ -3257,6 +3257,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() { @@ -5171,7 +5223,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() { @@ -5185,7 +5237,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() { @@ -5200,7 +5252,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() { @@ -5214,7 +5266,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() { @@ -7254,7 +7306,7 @@ class RedundancyTests: RulesTests { return parser } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testShadowedClosureArgument2() { @@ -7264,7 +7316,7 @@ class RedundancyTests: RulesTests { return input } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testUnusedPropertyWrapperArgument() { @@ -7665,7 +7717,7 @@ class RedundancyTests: RulesTests { return bar } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testTryAwaitArgumentNotMarkedUnused() { @@ -7676,7 +7728,7 @@ class RedundancyTests: RulesTests { return bar } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testTypedTryAwaitArgumentNotMarkedUnused() { @@ -7687,7 +7739,7 @@ class RedundancyTests: RulesTests { return bar } """ - testFormatting(for: input, rule: FormatRules.unusedArguments) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) } func testConditionalIfLetMarkedAsUnused() { @@ -9481,4 +9533,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.preferInferredTypes, 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+Syntax.swift b/Tests/RulesTests+Syntax.swift index 38886cfb0..f04838a12 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -3270,7 +3270,7 @@ class SyntaxTests: RulesTests { """ testFormatting(for: input, output, rule: FormatRules.docComments, - exclude: ["spaceInsideComments"]) + exclude: ["spaceInsideComments", "redundantProperty"]) } 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"]) } func testDoesntConvertCommentBeforeConsecutivePropertiesToDocComment() { From a3c93631cfd419bbca9c508caac032f8b6a7fb12 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Thu, 28 Mar 2024 10:47:37 -0700 Subject: [PATCH 35/38] Fix issue where preferInferredTypes would cause build failure if property has optional type --- README.md | 2 ++ Sources/Rules.swift | 14 ++++++++++++++ Tests/RulesTests+Syntax.swift | 27 +++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d79eb59f7..72a05ffa9 100644 --- a/README.md +++ b/README.md @@ -953,6 +953,8 @@ Known issues * 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`). +* The `preferInferredTypes` rule can cause a build failure in cases where there are multiple static overloads with the same name but different return types. + Tip Jar ----------- diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 6a61b4671..e67a3d592 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7970,8 +7970,18 @@ public struct _FormatRules { 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 + } + // If the RHS starts with a leading dot, then we know its accessing some static member on this type. if formatter.tokens[rhsStartIndex].isOperator(".") { + // 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) } @@ -7999,6 +8009,10 @@ public struct _FormatRules { 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) } } diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index f04838a12..25f6d0fb7 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -4867,7 +4867,6 @@ class SyntaxTests: RulesTests { let dictionary: [Foo: Bar] = .init() let array: [Foo] = .init() let genericType: MyGenericType = .init() - let optional: String? = .init("Foo") """ let output = """ @@ -4879,7 +4878,6 @@ class SyntaxTests: RulesTests { let dictionary = [Foo: Bar]() let array = [Foo]() let genericType = MyGenericType() - let optional = String?("Foo") """ let options = FormatOptions(redundantType: .inferred) @@ -5037,4 +5035,29 @@ class SyntaxTests: RulesTests { let options = FormatOptions(redundantType: .inferred, inferredTypesInConditionalExpressions: true) testFormatting(for: input, rule: FormatRules.preferInferredTypes, 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.preferInferredTypes, options: options) + } + + func testPreservesTypeWithSeparateDeclarationAndProperty() { + let input = """ + var foo: Foo! + foo = Foo(afterDelay: { + print(foo) + }) + """ + + let options = FormatOptions(redundantType: .inferred) + testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + } } From c6ebee595ce1bd60c04e1ba806190d558bfbfc95 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Thu, 28 Mar 2024 13:31:25 -0700 Subject: [PATCH 36/38] Fix issue where preferInferredTypes could cause a build failure if the property's type is an existential, or if the RHS value has an infix operator --- README.md | 4 +++- Sources/Rules.swift | 20 ++++++++++++++++-- Tests/RulesTests+Syntax.swift | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 72a05ffa9..b6326d159 100644 --- a/README.md +++ b/README.md @@ -953,7 +953,9 @@ Known issues * 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`). -* The `preferInferredTypes` rule can cause a build failure in cases where there are multiple static overloads with the same name but different return types. +* The `preferInferredTypes` 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, disable the rule with `// swiftformat:next:disable preferInferredTypes` or rename the overloads to no longer conflict. + +* The `preferInferredTypes` 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. Tip Jar diff --git a/Sources/Rules.swift b/Sources/Rules.swift index e67a3d592..5eabe5210 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -7965,15 +7965,31 @@ public struct _FormatRules { ["var", "let"].contains(formatter.tokens[introducerIndex].string), let property = formatter.parsePropertyDeclaration(atIntroducerIndex: introducerIndex), let type = property.type, - let rhsStartIndex = property.value?.expressionRange.lowerBound + let rhsExpressionRange = property.value?.expressionRange else { return } + let rhsStartIndex = rhsExpressionRange.lowerBound 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 { + 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 } diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index 25f6d0fb7..587d1bfb5 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -5060,4 +5060,43 @@ class SyntaxTests: RulesTests { let options = FormatOptions(redundantType: .inferred) testFormatting(for: input, rule: FormatRules.preferInferredTypes, 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.preferInferredTypes, options: options) + } + + func testPreservesRightHandSideWithOperator() { + 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.preferInferredTypes, options: options) + } } From 9556b8a35c0f5da360e6662b4b87e31cc7161261 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Fri, 29 Mar 2024 12:39:40 -0700 Subject: [PATCH 37/38] Update preferInferedTypes rule to propertyType, add support for explicit option --- README.md | 8 +- Rules.md | 43 ++-- Sources/Examples.swift | 2 +- Sources/OptionDescriptor.swift | 6 + Sources/Options.swift | 3 + Sources/ParsingHelpers.swift | 50 +++- Sources/Rules.swift | 249 +++++++++++++------ Tests/ParsingHelpersTests.swift | 55 +++++ Tests/RulesTests+General.swift | 6 +- Tests/RulesTests+Indentation.swift | 24 +- Tests/RulesTests+Organization.swift | 7 +- Tests/RulesTests+Parens.swift | 4 +- Tests/RulesTests+Redundancy.swift | 103 ++++---- Tests/RulesTests+Syntax.swift | 367 +++++++++++++++++++++++++--- Tests/RulesTests+Wrapping.swift | 29 +-- 15 files changed, 732 insertions(+), 224 deletions(-) diff --git a/README.md b/README.md index b6326d159..7afb3fad7 100644 --- a/README.md +++ b/README.md @@ -951,11 +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 `preferInferredTypes` 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, disable the rule with `// swiftformat:next:disable preferInferredTypes` or rename the overloads to no longer conflict. +* 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 `preferInferredTypes` 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. +* 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 6e3bf0bc9..1a4be3c02 100644 --- a/Rules.md +++ b/Rules.md @@ -100,7 +100,7 @@ * [markTypes](#markTypes) * [noExplicitOwnership](#noExplicitOwnership) * [organizeDeclarations](#organizeDeclarations) -* [preferInferredTypes](#preferInferredTypes) +* [propertyType](#propertyType) * [redundantProperty](#redundantProperty) * [sortSwitchCases](#sortSwitchCases) * [wrapConditionalBodies](#wrapConditionalBodies) @@ -1464,13 +1464,32 @@ Option | Description

-## preferInferredTypes +## preferKeyPath + +Convert trivial `map { $0.foo }` closures to keyPath-based syntax. + +
+Examples + +```diff +- let barArray = fooArray.map { $0.bar } ++ let barArray = fooArray.map(\.bar) + +- let barArray = fooArray.compactMap { $0.optionalBar } ++ let barArray = fooArray.compactMap(\.optionalBar) +``` + +
+
+ +## propertyType -Prefer using inferred types on property definitions (`let foo = Foo()`) rather than explicit types (`let foo: Foo = .init()`). +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 @@ -1504,24 +1523,6 @@ Option | Description

-## preferKeyPath - -Convert trivial `map { $0.foo }` closures to keyPath-based syntax. - -
-Examples - -```diff -- let barArray = fooArray.map { $0.bar } -+ let barArray = fooArray.map(\.bar) - -- let barArray = fooArray.compactMap { $0.optionalBar } -+ let barArray = fooArray.compactMap(\.optionalBar) -``` - -
-
- ## redundantBackticks Remove redundant backticks around identifiers. diff --git a/Sources/Examples.swift b/Sources/Examples.swift index d39696c0c..1c7b1e161 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1853,7 +1853,7 @@ private struct Examples { ``` """# - let preferInferredTypes = """ + let propertyType = """ ```diff - let foo: Foo = .init() + let foo: Foo = .init() diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index d7d883ca1..a19a32672 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -1052,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 diff --git a/Sources/Options.swift b/Sources/Options.swift index de6acdcf0..f4bc487d3 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -652,6 +652,7 @@ 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 @@ -767,6 +768,7 @@ 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"], @@ -872,6 +874,7 @@ 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 diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index acde9a611..8e467ebc6 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -1317,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), @@ -1332,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) @@ -1341,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) } @@ -1386,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) } @@ -2155,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 } @@ -2166,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) diff --git a/Sources/Rules.swift b/Sources/Rules.swift index 5eabe5210..9496266a8 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -779,9 +779,7 @@ public struct _FormatRules { isInferred = true declarationKeywordIndex = nil case .explicit: - // If the `preferInferredTypes` rule is also enabled, it takes precedence - // over the `--redundanttype explicit` option. - isInferred = formatter.options.enabledRules.contains("preferInferredTypes") + isInferred = false declarationKeywordIndex = formatter.declarationIndexAndScope(at: equalsIndex).index case .inferLocalsOnly: let (index, scope) = formatter.declarationIndexAndScope(at: equalsIndex) @@ -4419,7 +4417,7 @@ public struct _FormatRules { /// Strip redundant `.init` from type instantiations public let redundantInit = FormatRule( help: "Remove explicit `init` if not required.", - orderAfter: ["preferInferredTypes"] + orderAfter: ["propertyType"] ) { formatter in formatter.forEach(.identifier("init")) { initIndex, _ in guard let dotIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, before: initIndex, if: { @@ -7946,106 +7944,207 @@ public struct _FormatRules { } } - public let preferInferredTypes = FormatRule( - help: "Prefer using inferred types on property definitions (`let foo = Foo()`) rather than explicit types (`let foo: Foo = .init()`).", + 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"], + options: ["inferredtypes", "preservesymbols"], sharedOptions: ["redundanttype"] ) { formatter in formatter.forEach(.operator("=", .infix)) { equalsIndex, _ in - // Respect the `.inferLocalsOnly` option if enabled - if formatter.options.redundantType == .inferLocalsOnly, - formatter.declarationScope(at: equalsIndex) != .local - { return } - - guard // Parse and validate the LHS of the property declaration. - // It should take the form `(let|var) propertyName: (Type) = .staticMember` - let introducerIndex = formatter.indexOfLastSignificantKeyword(at: equalsIndex), - ["var", "let"].contains(formatter.tokens[introducerIndex].string), - let property = formatter.parsePropertyDeclaration(atIntroducerIndex: introducerIndex), - let type = property.type, - let rhsExpressionRange = property.value?.expressionRange - else { return } + // Preserve all properties in conditional statements like `if let foo = Bar() { ... }` + guard !formatter.isConditionalStatement(at: equalsIndex) else { return } - let rhsStartIndex = rhsExpressionRange.lowerBound - 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 - } + // 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 - // If the RHS starts with a leading dot, then we know its accessing some static member on this type. - if formatter.tokens[rhsStartIndex].isOperator(".") { - // Update the . token from a prefix operator to an infix operator. - formatter.replaceToken(at: rhsStartIndex, with: .operator(".", .infix)) + case .explicit: + useInferredType = false - // Insert a copy of the type on the RHS before the dot - formatter.insert(typeTokens, at: rhsStartIndex) + case .inferLocalsOnly: + switch formatter.declarationScope(at: equalsIndex) { + case .global, .type: + useInferredType = false + case .local: + useInferredType = true + } } - // 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 - } + 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 } - if !formatter.tokens[firstTokenInBranch].isOperator(".") { - hasInvalidConditionalBranch = true - } + 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 } - guard !hasInvalidConditionalBranch else { return } + // Preserve the formatting as-is if the type is manually excluded + if formatter.options.preserveSymbols.contains(type.name) { + 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 } + // 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: dotIndex, with: .operator(".", .infix)) + formatter.replaceToken(at: rhsStartIndex, with: .operator(".", .infix)) // Insert a copy of the type on the RHS before the dot - formatter.insert(typeTokens, at: dotIndex) + 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 { - return - } + 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 } - // Remove the colon and explicit type before the equals token - formatter.removeTokens(in: type.colonIndex ... type.range.upperBound) + // 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: ["preferInferredTypes"] + orderAfter: ["propertyType"] ) { formatter in formatter.forEach(.keyword) { introducerIndex, introducerToken in // Find properties like `let identifier = value` followed by `return identifier` diff --git a/Tests/ParsingHelpersTests.swift b/Tests/ParsingHelpersTests.swift index 49d27c9a9..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 diff --git a/Tests/RulesTests+General.swift b/Tests/RulesTests+General.swift index b259129ce..d868c34ed 100644 --- a/Tests/RulesTests+General.swift +++ b/Tests/RulesTests+General.swift @@ -277,7 +277,7 @@ class GeneralTests: RulesTests { Int ]).self """ - testFormatting(for: input, rule: FormatRules.trailingCommas) + testFormatting(for: input, rule: FormatRules.trailingCommas, exclude: ["propertyType"]) } func testTrailingCommaNotAddedToTypeDeclaration() { @@ -324,7 +324,7 @@ class GeneralTests: RulesTests { String: Int ]]() """ - testFormatting(for: input, rule: FormatRules.trailingCommas) + testFormatting(for: input, rule: FormatRules.trailingCommas, exclude: ["propertyType"]) } func testTrailingCommaNotAddedToTypeDeclaration6() { @@ -337,7 +337,7 @@ class GeneralTests: RulesTests { ]) ]]() """ - testFormatting(for: input, rule: FormatRules.trailingCommas) + testFormatting(for: input, rule: FormatRules.trailingCommas, exclude: ["propertyType"]) } func testTrailingCommaNotAddedToTypeDeclaration7() { diff --git a/Tests/RulesTests+Indentation.swift b/Tests/RulesTests+Indentation.swift index f146299df..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() { @@ -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() { diff --git a/Tests/RulesTests+Organization.swift b/Tests/RulesTests+Organization.swift index ed59a611b..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"] ) } @@ -3352,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 4b68457ad..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() { diff --git a/Tests/RulesTests+Redundancy.swift b/Tests/RulesTests+Redundancy.swift index 72984dbec..f6f28ed78 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, exclude: ["preferInferredTypes"]) + 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", "preferInferredTypes"]) + testFormatting(for: input, output, rule: FormatRules.redundantType, options: options, exclude: ["wrapMultilineConditionalAssignment", "propertyType"]) } func testRedundantTypeWithNestedIfExpression_inferred() { @@ -1477,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", "preferInferredTypes"]) + testFormatting(for: input, output, rule: FormatRules.redundantType, options: options, exclude: ["wrapMultilineConditionalAssignment", "propertyType"]) } func testRedundantTypeWithLiteralsInIfExpression() { @@ -1506,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, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testVarRedundantTypeRemovalExplicitType2() { @@ -1514,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", "preferInferredTypes"]) + options: options, exclude: ["spaceAroundComments", "propertyType"]) } func testLetRedundantGenericTypeRemovalExplicitType() { @@ -1522,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, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testLetRedundantGenericTypeRemovalExplicitTypeIfValueOnNextLine() { @@ -1530,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", "preferInferredTypes"]) + options: options, exclude: ["trailingSpace", "propertyType"]) } func testVarNonRedundantTypeDoesNothingExplicitType() { @@ -1544,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, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovedIfValueOnNextLineExplicitType() { @@ -1558,7 +1558,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovedIfValueOnNextLine2ExplicitType() { @@ -1572,7 +1572,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithCommentExplicitType() { @@ -1580,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, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithComment2ExplicitType() { @@ -1588,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, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithStaticMember() { @@ -1610,7 +1610,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeRemovalWithStaticFunc() { @@ -1632,13 +1632,13 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["preferInferredTypes"]) + 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, exclude: ["preferInferredTypes"]) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantRedundantChainedMemberTypeRemovedOnSwift5_4() { @@ -1646,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, exclude: ["preferInferredTypes"]) + 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, exclude: ["preferInferredTypes"]) + 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, exclude: ["preferInferredTypes"]) + 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, exclude: ["preferInferredTypes"]) + 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, exclude: ["preferInferredTypes"]) + 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, exclude: ["preferInferredTypes"]) + 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, exclude: ["preferInferredTypes"]) + testFormatting(for: input, rule: FormatRules.redundantType, options: options, exclude: ["propertyType"]) } func testRedundantTypeWorksAfterIf() { @@ -1697,7 +1697,7 @@ class RedundancyTests: RulesTests { """ let options = FormatOptions(redundantType: .explicit) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeIfVoid() { @@ -1705,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, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } func testRedundantTypeWithIntegerLiteralNotMangled() { @@ -1745,8 +1745,7 @@ class RedundancyTests: RulesTests { } """ let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, rule: FormatRules.redundantType, options: options, - exclude: ["preferInferredTypes"]) + testFormatting(for: input, rule: FormatRules.redundantType, options: options) } // --redundanttype infer-locals-only @@ -1788,7 +1787,7 @@ class RedundancyTests: RulesTests { let options = FormatOptions(redundantType: .inferLocalsOnly) testFormatting(for: input, output, rule: FormatRules.redundantType, - options: options, exclude: ["preferInferredTypes"]) + options: options, exclude: ["propertyType"]) } // MARK: - redundantNilInit @@ -2870,7 +2869,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() { @@ -3413,19 +3412,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() { @@ -3449,7 +3448,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, exclude: ["preferInferredTypes"]) + testFormatting(for: input, rule: FormatRules.redundantBackticks, options: options, exclude: ["propertyType"]) } func testNoRemoveBackticksAroundAnyProperty() { @@ -4021,7 +4020,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() { @@ -6929,7 +6928,7 @@ class RedundancyTests: RulesTests { } } """ - testFormatting(for: input, rule: FormatRules.redundantStaticSelf) + testFormatting(for: input, rule: FormatRules.redundantStaticSelf, exclude: ["propertyType"]) } func testPreserveStaticSelfInInstanceFunction() { @@ -7306,7 +7305,7 @@ class RedundancyTests: RulesTests { return parser } """ - testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty"]) + testFormatting(for: input, rule: FormatRules.unusedArguments, exclude: ["redundantProperty", "propertyType"]) } func testShadowedClosureArgument2() { @@ -8271,7 +8270,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() { @@ -8308,7 +8307,7 @@ class RedundancyTests: RulesTests { """ testFormatting(for: input, [output], rules: [FormatRules.redundantReturn, FormatRules.redundantClosure, - FormatRules.semicolons]) + FormatRules.semicolons], exclude: ["propertyType"]) } func testRemoveRedundantClosureInWrappedPropertyDeclaration_beforeFirst() { @@ -8329,7 +8328,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() { @@ -8348,7 +8347,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() { @@ -8516,7 +8515,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() { @@ -9102,7 +9101,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() { @@ -9139,7 +9138,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() { @@ -9625,7 +9624,7 @@ class RedundancyTests: RulesTests { } """ - testFormatting(for: input, [output], rules: [FormatRules.preferInferredTypes, FormatRules.redundantProperty, FormatRules.redundantInit], exclude: ["redundantReturn"]) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantProperty, FormatRules.redundantInit], exclude: ["redundantReturn"]) } func testRemovesRedundantPropertyWithComments() { diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index 587d1bfb5..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", "redundantProperty"]) + 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", "redundantProperty"]) + testFormatting(for: input, output, rule: FormatRules.docComments, options: options, exclude: ["spaceInsideComments", "redundantProperty", "propertyType"]) } func testDoesntConvertCommentBeforeConsecutivePropertiesToDocComment() { @@ -4855,9 +4855,9 @@ class SyntaxTests: RulesTests { testFormatting(for: input, rule: FormatRules.preferForLoop) } - // MARK: preferInferredTypes + // MARK: propertyType - func testConvertsExplicitTypeToImplicitType() { + func testConvertsExplicitTypeToInferredType() { let input = """ let foo: Foo = .init() let bar: Bar = .staticBar @@ -4881,7 +4881,106 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, [output], rules: [FormatRules.preferInferredTypes, FormatRules.redundantInit], options: options) + 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() { @@ -4891,7 +4990,34 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + 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() { @@ -4906,7 +5032,7 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options, exclude: ["redundantType"]) + testFormatting(for: input, rule: FormatRules.propertyType, options: options, exclude: ["redundantType"]) } func testCompatibleWithRedundantTypeInferred() { @@ -4919,7 +5045,7 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.preferInferredTypes], options: options) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.propertyType], options: options) } func testCompatibleWithRedundantTypeExplicit() { @@ -4928,11 +5054,11 @@ class SyntaxTests: RulesTests { """ let output = """ - let foo = Foo() + let foo: Foo = .init() """ let options = FormatOptions(redundantType: .explicit) - testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.preferInferredTypes], options: options) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.propertyType], options: options) } func testCompatibleWithRedundantTypeInferLocalsOnly() { @@ -4957,10 +5083,10 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferLocalsOnly) - testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.preferInferredTypes, FormatRules.redundantInit], options: options) + testFormatting(for: input, [output], rules: [FormatRules.redundantType, FormatRules.propertyType, FormatRules.redundantInit], options: options) } - func testPreferInferredTypesWithIfExpressionDisabledByDefault() { + func testPropertyTypeWithIfExpressionDisabledByDefault() { let input = """ let foo: SomeTypeWithALongGenrericName = if condition { @@ -4971,10 +5097,10 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) } - func testPreferInferredTypesWithIfExpression() { + func testPropertyTypeWithIfExpression() { let input = """ let foo: Foo = if condition { @@ -4994,10 +5120,10 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred, inferredTypesInConditionalExpressions: true) - testFormatting(for: input, [output], rules: [FormatRules.preferInferredTypes, FormatRules.redundantInit], options: options) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantInit], options: options) } - func testPreferInferredTypesWithSwitchExpression() { + func testPropertyTypeWithSwitchExpression() { let input = """ let foo: Foo = switch condition { @@ -5019,7 +5145,7 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred, inferredTypesInConditionalExpressions: true) - testFormatting(for: input, [output], rules: [FormatRules.preferInferredTypes, FormatRules.redundantInit], options: options) + testFormatting(for: input, [output], rules: [FormatRules.propertyType, FormatRules.redundantInit], options: options) } func testPreservesNonMatchingIfExpression() { @@ -5033,7 +5159,7 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred, inferredTypesInConditionalExpressions: true) - testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) } func testPreservesExplicitOptionalType() { @@ -5046,7 +5172,7 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) } func testPreservesTypeWithSeparateDeclarationAndProperty() { @@ -5058,7 +5184,7 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) } func testPreservesTypeWithExistentialAny() { @@ -5078,10 +5204,10 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, rule: FormatRules.preferInferredTypes, options: options) + testFormatting(for: input, rule: FormatRules.propertyType, options: options) } - func testPreservesRightHandSideWithOperator() { + func testPreservesExplicitRightHandSideWithOperator() { let input = """ let value: ClosedRange = .zero ... 10 let dynamicTypeSizeRange: ClosedRange = .large ... .xxxLarge @@ -5097,6 +5223,185 @@ class SyntaxTests: RulesTests { """ let options = FormatOptions(redundantType: .inferred) - testFormatting(for: input, output, rule: FormatRules.preferInferredTypes, options: options) + 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 d49090278..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() { @@ -1593,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 @@ -3309,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() { @@ -3479,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() { @@ -3634,7 +3634,7 @@ class WrappingTests: RulesTests { testFormatting(for: input, [output], rules: [ FormatRules.wrapMultilineStatementBraces, FormatRules.indent, - ], options: options) + ], options: options, exclude: ["propertyType"]) } func testMultilineBraceAppliedToTrailingClosure_wrapAfterFirst() { @@ -3677,7 +3677,7 @@ class WrappingTests: RulesTests { testFormatting(for: input, [], rules: [ FormatRules.wrapMultilineStatementBraces, FormatRules.wrapArguments, - ], options: options) + ], options: options, exclude: ["propertyType"]) } func testMultilineBraceAppliedToSubscriptBody() { @@ -4052,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"] ) } @@ -4725,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() { @@ -4745,7 +4746,7 @@ class WrappingTests: RulesTests { private(set) dynamic var foo = Foo() """ let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) - testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) } func testWrapPrivateSetVarAttributes() { @@ -4757,7 +4758,7 @@ 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() { @@ -4769,7 +4770,7 @@ class WrappingTests: RulesTests { @objc private(set) dynamic var foo = Foo() """ let options = FormatOptions(varAttributes: .prevLine, storedVarAttributes: .sameLine) - testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) } func testWrapConvenienceInitAttribute() { @@ -4934,7 +4935,7 @@ class WrappingTests: RulesTests { } """ let options = FormatOptions(storedVarAttributes: .prevLine, computedVarAttributes: .prevLine) - testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) } func testComplexAttributesException() { @@ -5005,7 +5006,7 @@ class WrappingTests: RulesTests { """ let options = FormatOptions(storedVarAttributes: .sameLine, complexAttributes: .prevLine) - testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) } func testEscapingClosureNotMistakenForComplexAttribute() { @@ -5077,7 +5078,7 @@ class WrappingTests: RulesTests { } """ let options = FormatOptions(varAttributes: .sameLine, storedVarAttributes: .sameLine, computedVarAttributes: .prevLine, complexAttributes: .prevLine) - testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options) + testFormatting(for: input, output, rule: FormatRules.wrapAttributes, options: options, exclude: ["propertyType"]) } func testWrapOrDontAttributesInSwiftUIView() { From 4e3bd75938407ab8e443173a4f64d1f6d43ed518 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Tue, 16 Apr 2024 11:20:50 -0700 Subject: [PATCH 38/38] Add --strict option to emit non-zero exit code after applying changes (#1676) --- README.md | 2 +- Sources/Arguments.swift | 1 + Sources/CommandLine.swift | 17 +++++++++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7afb3fad7..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. diff --git a/Sources/Arguments.swift b/Sources/Arguments.swift index 44efeeb81..c6770de1a 100644 --- a/Sources/Arguments.swift +++ b/Sources/Arguments.swift @@ -663,6 +663,7 @@ let commandLineArguments = [ "dryrun", "lint", "lenient", + "strict", "verbose", "quiet", "reporter", diff --git a/Sources/CommandLine.swift b/Sources/CommandLine.swift index 93edd1893..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) @@ -510,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") @@ -704,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 @@ -788,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 @@ -838,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 { @@ -853,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]) {