diff --git a/Rules.md b/Rules.md index 5594c840..ebae6b83 100644 --- a/Rules.md +++ b/Rules.md @@ -1,5 +1,6 @@ # Rules +* [acronyms](#acronyms) * [andOperator](#andOperator) * [anyObjectProtocol](#anyObjectProtocol) * [assertionFailures](#assertionFailures) @@ -79,6 +80,32 @@ ---------- +## acronyms + +Capitalizes acronyms when the first character is capitalized. + +Option | Description +--- | --- +`--acronyms` | Acronyms to auto-capitalize. Defaults to "ID,URL,UUID". + +
+Examples + +```diff +- let destinationUrl: URL +- let urlRouter: UrlRouter +- let screenId: String +- let entityUuid: UUID + ++ let destinationURL: URL ++ let urlRouter: URLRouter ++ let screenID: String ++ let entityUUID: UUID +``` + +
+
+ ## andOperator Prefer comma over `&&` in `if`, `guard` or `while` conditions. diff --git a/Sources/Examples.swift b/Sources/Examples.swift index 65e6718b..8ffe8f8a 100644 --- a/Sources/Examples.swift +++ b/Sources/Examples.swift @@ -1260,4 +1260,18 @@ private struct Examples { + preconditionFailure("message", 2, 1) ``` """ + + let acronyms = """ + ```diff + - let destinationUrl: URL + - let urlRouter: UrlRouter + - let screenId: String + - let entityUuid: UUID + + + let destinationURL: URL + + let urlRouter: URLRouter + + let screenID: String + + let entityUUID: UUID + ``` + """ } diff --git a/Sources/Formatter.swift b/Sources/Formatter.swift index 40139284..41729310 100644 --- a/Sources/Formatter.swift +++ b/Sources/Formatter.swift @@ -600,3 +600,30 @@ public extension Formatter { return .linebreak(options.linebreak, lineNumber) } } + +extension String { + /// https://stackoverflow.com/a/32306142 + func ranges(of string: S, options: String.CompareOptions = []) -> [Range] { + var result: [Range] = [] + var startIndex = self.startIndex + while startIndex < endIndex, let range = self[startIndex...].range(of: string, options: options) { + result.append(range) + startIndex = range.lowerBound < range.upperBound ? range.upperBound : + index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex + } + return result + } +} + +// `Swift.Character.isUppercase` isn't available until Swift 5.0+ / Xcode 10.2+ +#if !swift(>=5.0) + extension Character { + var isUppercase: Bool { + return String(self).uppercased() == String(self) + } + + var isWhitespace: Bool { + return String(self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } +#endif diff --git a/Sources/OptionDescriptor.swift b/Sources/OptionDescriptor.swift index fd228657..5d69c66c 100644 --- a/Sources/OptionDescriptor.swift +++ b/Sources/OptionDescriptor.swift @@ -816,6 +816,12 @@ struct _Descriptors { help: "Empty braces: \"no-space\" (default), \"spaced\" or \"linebreak\"", keyPath: \.emptyBracesSpacing ) + let acronyms = OptionDescriptor( + argumentName: "acronyms", + displayName: "Acronyms", + help: "Acronyms to auto-capitalize. Defaults to \"ID,URL,UUID\".", + keyPath: \.acronyms + ) // MARK: - Internal diff --git a/Sources/Options.swift b/Sources/Options.swift index b587eb0b..9ed073a2 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -381,6 +381,7 @@ public struct FormatOptions: CustomStringConvertible { public var extensionACLPlacement: ExtensionACLPlacement public var redundantType: RedundantType public var emptyBracesSpacing: EmptyBracesSpacing + public var acronyms: Set // Deprecated public var indentComments: Bool @@ -461,6 +462,7 @@ public struct FormatOptions: CustomStringConvertible { extensionACLPlacement: ExtensionACLPlacement = .onExtension, redundantType: RedundantType = .inferLocalsOnly, emptyBracesSpacing: EmptyBracesSpacing = .noSpace, + acronyms: Set = ["ID", "URL", "UUID"], // Doesn't really belong here, but hard to put elsewhere fragment: Bool = false, ignoreConflictMarkers: Bool = false, @@ -535,6 +537,7 @@ public struct FormatOptions: CustomStringConvertible { self.extensionACLPlacement = extensionACLPlacement self.redundantType = redundantType self.emptyBracesSpacing = emptyBracesSpacing + self.acronyms = acronyms // 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 d9082eb5..fcb1e933 100644 --- a/Sources/Rules.swift +++ b/Sources/Rules.swift @@ -5782,4 +5782,69 @@ public struct _FormatRules { } } } + + public let acronyms = FormatRule( + help: "Capitalizes acronyms when the first character is capitalized.", + disabledByDefault: true, + options: ["acronyms"] + ) { formatter in + formatter.forEachToken { index, token in + guard token.is(.identifier) || token.isComment else { return } + + var updatedText = token.string + + for acronym in formatter.options.acronyms { + let find = acronym.capitalized + let replace = acronym.uppercased() + + for replaceCandidateRange in token.string.ranges(of: find) { + let acronymShouldBeCapitalized: Bool + + if replaceCandidateRange.upperBound < token.string.indices.last! { + let indexAfterMatch = replaceCandidateRange.upperBound + let characterAfterMatch = token.string[indexAfterMatch] + + // Only treat this as an acronym if the next character is uppercased, + // to prevent "Id" from matching strings like "Identifier". + if characterAfterMatch.isUppercase || characterAfterMatch.isWhitespace { + acronymShouldBeCapitalized = true + } + + // But if the next character is 's', and then the character after the 's' is uppercase, + // allow the acronym to be capitalized (to handle the plural case, `Ids` to `IDs`) + else if characterAfterMatch == Character("s") { + if indexAfterMatch < token.string.indices.last! { + let characterAfterNext = token.string[token.string.index(after: indexAfterMatch)] + acronymShouldBeCapitalized = (characterAfterNext.isUppercase || characterAfterNext.isWhitespace) + } else { + acronymShouldBeCapitalized = true + } + } else { + acronymShouldBeCapitalized = false + } + } else { + acronymShouldBeCapitalized = true + } + + if acronymShouldBeCapitalized { + updatedText.replaceSubrange(replaceCandidateRange, with: replace) + } + } + } + + if token.string != updatedText { + let updatedToken: Token + switch token { + case .identifier: + updatedToken = .identifier(updatedText) + case .commentBody: + updatedToken = .commentBody(updatedText) + default: + return + } + + formatter.replaceToken(at: index, with: updatedToken) + } + } + } } diff --git a/Tests/RulesTests+Syntax.swift b/Tests/RulesTests+Syntax.swift index 0b1149df..b6df0c92 100644 --- a/Tests/RulesTests+Syntax.swift +++ b/Tests/RulesTests+Syntax.swift @@ -2106,4 +2106,72 @@ class SyntaxTests: RulesTests { let output = "preconditionFailure(msg, 0, 1)" testFormatting(for: input, output, rule: FormatRules.assertionFailures) } + + // MARK: - acronyms + + func testUppercaseAcronyms() { + let input = """ + let url: URL + let destinationUrl: URL + let id: ID + let screenId = "screenId" // We intentionally don't change the content of strings + let validUrls: Set + let validUrlschemes: Set + + let uniqueIdentifier = UUID() + + /// Opens Urls based on their scheme + struct UrlRouter {} + + /// The Id of a screen that can be displayed in the app + struct ScreenId {} + """ + + let output = """ + let url: URL + let destinationURL: URL + let id: ID + let screenID = "screenId" // We intentionally don't change the content of strings + let validURLs: Set + let validUrlschemes: Set + + let uniqueIdentifier = UUID() + + /// Opens URLs based on their scheme + struct URLRouter {} + + /// The ID of a screen that can be displayed in the app + struct ScreenID {} + """ + + testFormatting(for: input, output, rule: FormatRules.acronyms) + } + + func testUppercaseCustomAcronym() { + let input = """ + let url: URL + let destinationUrl: URL + let pngData: Data + let imageInPngFormat: UIImage + """ + + let output = """ + let url: URL + let destinationUrl: URL + let pngData: Data + let imageInPNGFormat: UIImage + """ + + testFormatting(for: input, output, rule: FormatRules.acronyms, options: FormatOptions(acronyms: ["png"])) + } + + func testDisableUppercaseAcronym() { + let input = """ + // swiftformat:disable:next acronyms + typeNotOwnedByAuthor.destinationUrl = URL() + typeOwnedByAuthor.destinationURL = URL() + """ + + testFormatting(for: input, rule: FormatRules.acronyms) + } } diff --git a/Tests/RulesTests+Wrapping.swift b/Tests/RulesTests+Wrapping.swift index f6c36011..658e14ba 100644 --- a/Tests/RulesTests+Wrapping.swift +++ b/Tests/RulesTests+Wrapping.swift @@ -2824,7 +2824,7 @@ class WrappingTests: RulesTests { enum CodingKeys: String, CodingKey { case name case type - case categoryId = "category_id" + case categoryID = "category_id" case attributes } }