Skip to content

Commit 1dceb7c

Browse files
committed
Add --regex-rules option
1 parent 2a9a8dd commit 1dceb7c

File tree

9 files changed

+197
-10
lines changed

9 files changed

+197
-10
lines changed

Sources/Arguments.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -301,13 +301,12 @@ func preprocessArguments(_ args: [String], _ names: [String]) throws -> [String:
301301
if hasTrailingComma {
302302
arg = String(arg.dropLast())
303303
}
304-
if let existing = namedArgs[name], !existing.isEmpty,
305-
// TODO: find a more general way to represent merge-able options
306-
["exclude", "unexclude", "disable", "enable", "lint-only", "rules", "config"].contains(name) ||
307-
Descriptors.all.contains(where: {
308-
$0.argumentName == name && $0.isSetType
309-
})
310-
{
304+
if let existing = namedArgs[name], !existing.isEmpty, [
305+
// TODO: find a more general way to represent merge-able options
306+
"exclude", "unexclude", "disable", "enable", "lint-only", "rules", "config", "regex"
307+
].contains(name) || Descriptors.all.contains(where: {
308+
$0.argumentName == name && $0.isSetType
309+
}) {
311310
namedArgs[name] = existing + "," + arg
312311
} else {
313312
namedArgs[name] = arg

Sources/CommandLine.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,11 @@ func printHelp(as type: CLI.OutputType) {
235235
236236
--rule-info Display options for a given rule or rules (comma-delimited)
237237
--options Prints a list of all formatting options and their usage
238+
239+
In addition to the built-in rules, you can also provide your own rules using
240+
regular expressions. Multiple regex rules can be provided, separated by commas.
241+
242+
--regex-rules \(stripMarkdown(Descriptors.regexRules.help))
238243
""", as: type)
239244
print("")
240245
}
@@ -1031,7 +1036,7 @@ func applyRules(_ source: String, tokens: [Token]? = nil, options: Options, line
10311036

10321037
// Get rules
10331038
let rulesByName = FormatRules.byName
1034-
let ruleNames = Array(options.rules ?? defaultRules).sorted()
1039+
let ruleNames = (options.rules ?? defaultRules).sorted()
10351040
let rules = ruleNames.compactMap { rulesByName[$0] }
10361041

10371042
if verbose, let path = options.formatOptions?.fileInfo.filePath {

Sources/FormatRule.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,40 @@ public final class FormatRule: Hashable, Comparable, CustomStringConvertible {
7878
self.examples = examples()
7979
}
8080

81+
convenience init(regexRule: RegexRule) {
82+
self.init(help: "") { formatter in
83+
var wasEnabled = true
84+
var start = 0
85+
func apply(upTo index: Int) {
86+
let range = start ..< index
87+
guard wasEnabled, !range.isEmpty else {
88+
return
89+
}
90+
let tokens = formatter.tokens[start ..< index]
91+
let input = sourceCode(for: Array(tokens[range]))
92+
let output = regexRule.apply(to: input)
93+
if output != input {
94+
formatter.replaceTokens(in: range, with: tokenize(output))
95+
}
96+
}
97+
formatter.forEachToken(onlyWhereEnabled: false) { index, _ in
98+
if formatter.isEnabled {
99+
if !wasEnabled {
100+
start = index
101+
wasEnabled = true
102+
}
103+
} else {
104+
apply(upTo: index)
105+
wasEnabled = false
106+
}
107+
}
108+
apply(upTo: formatter.tokens.count)
109+
} examples: {
110+
nil
111+
}
112+
name = regexRule.name
113+
}
114+
81115
public func apply(with formatter: Formatter) {
82116
formatter.currentRule = self
83117
fn(formatter)

Sources/OptionDescriptor.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ extension _Descriptors {
547547
swiftVersion,
548548
languageMode,
549549
markdownFiles,
550+
regexRules,
550551
]
551552
}
552553

@@ -1477,10 +1478,17 @@ struct _Descriptors {
14771478
let markdownFiles = OptionDescriptor(
14781479
argumentName: "markdown-files",
14791480
displayName: "Markdown Files",
1480-
help: "Swift in markdown files:",
1481+
help: "Swift in markdown:",
14811482
keyPath: \.markdownFiles,
14821483
altOptions: ["format-lenient": .lenient, "format-strict": .strict]
14831484
)
1485+
let regexRules = OptionDescriptor(
1486+
argumentName: "regex-rules",
1487+
displayName: "Regex Replace",
1488+
help: "Regex rules. Format: [name]/pattern/replacement/[,...]",
1489+
keyPath: \.regexRules,
1490+
validate: { _ = try RegexRule(pattern: $0) }
1491+
)
14841492

14851493
// MARK: - DEPRECATED
14861494

Sources/Options.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,7 @@ public struct FormatOptions: CustomStringConvertible {
847847
public var languageMode: Version
848848
public var fileInfo: FileInfo
849849
public var markdownFiles: MarkdownFormattingMode
850+
public var regexRules: [String]
850851
public var timeout: TimeInterval
851852

852853
/// Enabled rules - this is a hack used to allow rules to vary their behavior
@@ -987,6 +988,7 @@ public struct FormatOptions: CustomStringConvertible {
987988
languageMode: Version? = nil,
988989
fileInfo: FileInfo = FileInfo(),
989990
markdownFiles: MarkdownFormattingMode = .ignore,
991+
regexRules: [String] = [],
990992
timeout: TimeInterval = 1)
991993
{
992994
self.lineAfterMarks = lineAfterMarks
@@ -1120,6 +1122,7 @@ public struct FormatOptions: CustomStringConvertible {
11201122
self.languageMode = languageMode ?? defaultLanguageMode(for: swiftVersion)
11211123
self.fileInfo = fileInfo
11221124
self.markdownFiles = markdownFiles
1125+
self.regexRules = regexRules
11231126
self.timeout = timeout
11241127
}
11251128

Sources/RegexRule.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// RegexRule.swift
3+
// SwiftFormat
4+
//
5+
// Created by Nick Lockwood on 18/11/2025.
6+
// Copyright © 2025 Nick Lockwood. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
final class RegexRule: RawRepresentable {
12+
let rawValue: String
13+
let name: String
14+
private let regex: NSRegularExpression
15+
private let replacement: String
16+
17+
init(pattern: String) throws {
18+
let parts = pattern.components(separatedBy: "/")
19+
guard parts.count == 4 else {
20+
throw FormatError.options("Expected format [name]/pattern/replacement/")
21+
}
22+
guard !parts[1].isEmpty else {
23+
throw FormatError.options("Pattern cannot be empty")
24+
}
25+
guard parts[3].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
26+
throw FormatError.options("Unexpected token '\(parts[3])' after final slash")
27+
}
28+
rawValue = pattern
29+
let name = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
30+
self.name = name.isEmpty ? "regexReplace" : name
31+
do {
32+
regex = try NSRegularExpression(pattern: parts[1], options: [
33+
.anchorsMatchLines,
34+
.dotMatchesLineSeparators,
35+
])
36+
} catch {
37+
throw FormatError.options("Pattern error: \(error.localizedDescription)")
38+
}
39+
replacement = parts[2]
40+
}
41+
42+
convenience init?(rawValue: String) {
43+
try? self.init(pattern: rawValue)
44+
}
45+
46+
func apply(to input: String) -> String {
47+
regex.stringByReplacingMatches(
48+
in: input,
49+
options: [],
50+
range: NSRange(location: 0, length: input.utf16.count),
51+
withTemplate: replacement
52+
)
53+
}
54+
}

Sources/SwiftFormat.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,12 @@ public func applyRules(
534534
) throws -> (tokens: [Token], changes: [Formatter.Change]) {
535535
precondition(maxIterations > 1)
536536

537-
let originalRules = originalRules.sorted()
537+
let originalRules = originalRules.sorted() + options.regexRules.compactMap {
538+
guard let rule = RegexRule(rawValue: $0) else {
539+
return nil
540+
}
541+
return FormatRule(regexRule: rule)
542+
}
538543
var tokens = originalTokens
539544
var range = originalRange
540545

SwiftFormat.xcodeproj/project.pbxproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@
151151
E4FABAD6202FEF060065716E /* OptionDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FABAD4202FEF060065716E /* OptionDescriptor.swift */; };
152152
E4FABAD7202FEF060065716E /* OptionDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FABAD4202FEF060065716E /* OptionDescriptor.swift */; };
153153
E4FABAD8202FEF060065716E /* OptionDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FABAD4202FEF060065716E /* OptionDescriptor.swift */; };
154+
EA3403872ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
155+
EA3403882ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
156+
EA3403892ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
157+
EA34038A2ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
154158
/* End PBXBuildFile section */
155159

156160
/* Begin PBXContainerItemProxy section */
@@ -287,6 +291,7 @@
287291
E4E4D3C82033F17C000D7CB1 /* EnumAssociable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumAssociable.swift; sourceTree = "<group>"; };
288292
E4E4D3CD2033F1EF000D7CB1 /* EnumAssociableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumAssociableTests.swift; sourceTree = "<group>"; };
289293
E4FABAD4202FEF060065716E /* OptionDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionDescriptor.swift; sourceTree = "<group>"; };
294+
EA3403862ECC86AE00A817DE /* RegexRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegexRule.swift; sourceTree = "<group>"; };
290295
/* End PBXFileReference section */
291296

292297
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -407,6 +412,7 @@
407412
children = (
408413
2E3A24EE2DDD621600407419 /* Rules */,
409414
2E2BAB8B2C57F6B600590239 /* RuleRegistry.generated.swift */,
415+
EA3403862ECC86AE00A817DE /* RegexRule.swift */,
410416
01F17E811E25870700DCD359 /* CommandLine.swift */,
411417
E4E4D3C82033F17C000D7CB1 /* EnumAssociable.swift */,
412418
01B3987C1D763493009ADE61 /* Formatter.swift */,
@@ -904,6 +910,7 @@
904910
2EF8BF1B2D1E0D4F00D6F12F /* DeclarationType.swift in Sources */,
905911
01F17E821E25870700DCD359 /* CommandLine.swift in Sources */,
906912
01F3DF8C1DB9FD3F00454944 /* Options.swift in Sources */,
913+
EA3403872ECC86AE00A817DE /* RegexRule.swift in Sources */,
907914
01A0EAC21D5DB4F700A0A8E3 /* Tokenizer.swift in Sources */,
908915
);
909916
runOnlyForDeploymentPostprocessing = 0;
@@ -955,6 +962,7 @@
955962
01F17E831E25870700DCD359 /* CommandLine.swift in Sources */,
956963
015243E22B04B0A600F65221 /* Singularize.swift in Sources */,
957964
2EE4007B2E595B7200CF8D5A /* TypeName.swift in Sources */,
965+
EA3403882ECC86AE00A817DE /* RegexRule.swift in Sources */,
958966
6E954FF42E0DEACC004EE019 /* SARIFReporter.swift in Sources */,
959967
C2FFD1832BD13C9E00774F55 /* XMLReporter.swift in Sources */,
960968
01A0EACD1D5DB5F500A0A8E3 /* main.swift in Sources */,
@@ -979,6 +987,7 @@
979987
E4083191202C049200CAF11D /* SwiftFormat.swift in Sources */,
980988
E4FABAD7202FEF060065716E /* OptionDescriptor.swift in Sources */,
981989
2E2611C82DD94FE900FFFE09 /* JSONReporter.swift in Sources */,
990+
EA3403892ECC86AE00A817DE /* RegexRule.swift in Sources */,
982991
2E2611C92DD94FE900FFFE09 /* XMLReporter.swift in Sources */,
983992
2E2611CA2DD94FE900FFFE09 /* GithubActionsLogReporter.swift in Sources */,
984993
2E2611CB2DD94FE900FFFE09 /* Reporter.swift in Sources */,
@@ -1019,6 +1028,7 @@
10191028
01045AA0211A1EE300D2BE3D /* Arguments.swift in Sources */,
10201029
01BBD85C21DAA2A700457380 /* Globs.swift in Sources */,
10211030
2E26108F2DD92CB400FFFE09 /* CommandLine.swift in Sources */,
1031+
EA34038A2ECC86AE00A817DE /* RegexRule.swift in Sources */,
10221032
01045A9F2119D30D00D2BE3D /* Inference.swift in Sources */,
10231033
01A95BD2225BEDE300744931 /* ParsingHelpers.swift in Sources */,
10241034
90C4B6E51DA4B059009EB000 /* SourceEditorExtension.swift in Sources */,

Tests/SwiftFormatTests.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,4 +302,73 @@ final class SwiftFormatTests: XCTestCase {
302302
let output = "class Foo {\r func bar() {\r }\r\r func baz() {\r }\r}"
303303
XCTAssertEqual(try format(input, rules: [.blankLinesBetweenScopes]).output, output)
304304
}
305+
306+
// MARK: regex
307+
308+
func testRegexReplace() throws {
309+
let input = """
310+
class NetworkService {
311+
private let baseURL = URL(string: "https://api.example.com")!
312+
313+
func makeRequest() {
314+
let url = URL(string: "https://api.example.com/endpoint")!
315+
// Use url...
316+
}
317+
}
318+
"""
319+
let output = """
320+
class NetworkService {
321+
private let baseURL = #URL("https://api.example.com")
322+
323+
func makeRequest() {
324+
let url = #URL("https://api.example.com/endpoint")
325+
// Use url...
326+
}
327+
}
328+
"""
329+
let options = FormatOptions(regexRules: ["urls/URL\\(string: \"(.*)\"\\)!/#URL(\"$1\")/"])
330+
XCTAssertEqual(try format(input, rules: [], options: options).output, output)
331+
}
332+
333+
func testRegexRuleDisabled() throws {
334+
let input = """
335+
class NetworkService {
336+
private let baseURL = URL(string: "https://api.example.com")!
337+
338+
func makeRequest() {
339+
// swiftformat:disable:next urls
340+
let url = URL(string: "https://api.example.com/endpoint")!
341+
// Use url...
342+
}
343+
}
344+
"""
345+
let output = """
346+
class NetworkService {
347+
private let baseURL = #URL("https://api.example.com")
348+
349+
func makeRequest() {
350+
// swiftformat:disable:next urls
351+
let url = URL(string: "https://api.example.com/endpoint")!
352+
// Use url...
353+
}
354+
}
355+
"""
356+
let options = FormatOptions(regexRules: ["urls/URL\\(string: \"(.*)\"\\)!/#URL(\"$1\")/"])
357+
XCTAssertEqual(try format(input, rules: [], options: options).output, output)
358+
}
359+
360+
func testRegexCaretMatchesStartOfLine() throws {
361+
let input = """
362+
import Foo
363+
import Bar
364+
365+
struct Baz {}
366+
"""
367+
let output = """
368+
369+
struct Baz {}
370+
"""
371+
let options = FormatOptions(regexRules: ["no-imports/^import\\s+[^\\n]*\\n//"])
372+
XCTAssertEqual(try format(input, rules: [], options: options).output, output)
373+
}
305374
}

0 commit comments

Comments
 (0)