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
}
}