Add --regex-rules option

This commit is contained in:
Nick Lockwood
2025-11-18 18:15:40 +00:00
parent 61baa7042c
commit 88698d7c1e
9 changed files with 197 additions and 10 deletions
+6 -7
View File
@@ -301,13 +301,12 @@ func preprocessArguments(_ args: [String], _ names: [String]) throws -> [String:
if hasTrailingComma {
arg = String(arg.dropLast())
}
if let existing = namedArgs[name], !existing.isEmpty,
// TODO: find a more general way to represent merge-able options
["exclude", "unexclude", "disable", "enable", "lint-only", "rules", "config"].contains(name) ||
Descriptors.all.contains(where: {
$0.argumentName == name && $0.isSetType
})
{
if let existing = namedArgs[name], !existing.isEmpty, [
// TODO: find a more general way to represent merge-able options
"exclude", "unexclude", "disable", "enable", "lint-only", "rules", "config", "regex"
].contains(name) || Descriptors.all.contains(where: {
$0.argumentName == name && $0.isSetType
}) {
namedArgs[name] = existing + "," + arg
} else {
namedArgs[name] = arg
+6 -1
View File
@@ -235,6 +235,11 @@ func printHelp(as type: CLI.OutputType) {
--rule-info Display options for a given rule or rules (comma-delimited)
--options Prints a list of all formatting options and their usage
In addition to the built-in rules, you can also provide your own rules using
regular expressions. Multiple regex rules can be provided, separated by commas.
--regex-rules \(stripMarkdown(Descriptors.regexRules.help))
""", as: type)
print("")
}
@@ -1031,7 +1036,7 @@ func applyRules(_ source: String, tokens: [Token]? = nil, options: Options, line
// Get rules
let rulesByName = FormatRules.byName
let ruleNames = Array(options.rules ?? defaultRules).sorted()
let ruleNames = (options.rules ?? defaultRules).sorted()
let rules = ruleNames.compactMap { rulesByName[$0] }
if verbose, let path = options.formatOptions?.fileInfo.filePath {
+34
View File
@@ -78,6 +78,40 @@ public final class FormatRule: Hashable, Comparable, CustomStringConvertible {
self.examples = examples()
}
convenience init(regexRule: RegexRule) {
self.init(help: "") { formatter in
var wasEnabled = true
var start = 0
func apply(upTo index: Int) {
let range = start ..< index
guard wasEnabled, !range.isEmpty else {
return
}
let tokens = formatter.tokens[start ..< index]
let input = sourceCode(for: Array(tokens[range]))
let output = regexRule.apply(to: input)
if output != input {
formatter.replaceTokens(in: range, with: tokenize(output))
}
}
formatter.forEachToken(onlyWhereEnabled: false) { index, _ in
if formatter.isEnabled {
if !wasEnabled {
start = index
wasEnabled = true
}
} else {
apply(upTo: index)
wasEnabled = false
}
}
apply(upTo: formatter.tokens.count)
} examples: {
nil
}
name = regexRule.name
}
public func apply(with formatter: Formatter) {
formatter.currentRule = self
fn(formatter)
+9 -1
View File
@@ -547,6 +547,7 @@ extension _Descriptors {
swiftVersion,
languageMode,
markdownFiles,
regexRules,
]
}
@@ -1477,10 +1478,17 @@ struct _Descriptors {
let markdownFiles = OptionDescriptor(
argumentName: "markdown-files",
displayName: "Markdown Files",
help: "Swift in markdown files:",
help: "Swift in markdown:",
keyPath: \.markdownFiles,
altOptions: ["format-lenient": .lenient, "format-strict": .strict]
)
let regexRules = OptionDescriptor(
argumentName: "regex-rules",
displayName: "Regex Replace",
help: "Regex rules. Format: [name]/pattern/replacement/[,...]",
keyPath: \.regexRules,
validate: { _ = try RegexRule(pattern: $0) }
)
// MARK: - DEPRECATED
+3
View File
@@ -847,6 +847,7 @@ public struct FormatOptions: CustomStringConvertible {
public var languageMode: Version
public var fileInfo: FileInfo
public var markdownFiles: MarkdownFormattingMode
public var regexRules: [String]
public var timeout: TimeInterval
/// Enabled rules - this is a hack used to allow rules to vary their behavior
@@ -987,6 +988,7 @@ public struct FormatOptions: CustomStringConvertible {
languageMode: Version? = nil,
fileInfo: FileInfo = FileInfo(),
markdownFiles: MarkdownFormattingMode = .ignore,
regexRules: [String] = [],
timeout: TimeInterval = 1)
{
self.lineAfterMarks = lineAfterMarks
@@ -1120,6 +1122,7 @@ public struct FormatOptions: CustomStringConvertible {
self.languageMode = languageMode ?? defaultLanguageMode(for: swiftVersion)
self.fileInfo = fileInfo
self.markdownFiles = markdownFiles
self.regexRules = regexRules
self.timeout = timeout
}
+54
View File
@@ -0,0 +1,54 @@
//
// RegexRule.swift
// SwiftFormat
//
// Created by Nick Lockwood on 18/11/2025.
// Copyright © 2025 Nick Lockwood. All rights reserved.
//
import Foundation
final class RegexRule: RawRepresentable {
let rawValue: String
let name: String
private let regex: NSRegularExpression
private let replacement: String
init(pattern: String) throws {
let parts = pattern.components(separatedBy: "/")
guard parts.count == 4 else {
throw FormatError.options("Expected format [name]/pattern/replacement/")
}
guard !parts[1].isEmpty else {
throw FormatError.options("Pattern cannot be empty")
}
guard parts[3].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw FormatError.options("Unexpected token '\(parts[3])' after final slash")
}
rawValue = pattern
let name = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
self.name = name.isEmpty ? "regexReplace" : name
do {
regex = try NSRegularExpression(pattern: parts[1], options: [
.anchorsMatchLines,
.dotMatchesLineSeparators,
])
} catch {
throw FormatError.options("Pattern error: \(error.localizedDescription)")
}
replacement = parts[2]
}
convenience init?(rawValue: String) {
try? self.init(pattern: rawValue)
}
func apply(to input: String) -> String {
regex.stringByReplacingMatches(
in: input,
options: [],
range: NSRange(location: 0, length: input.utf16.count),
withTemplate: replacement
)
}
}
+6 -1
View File
@@ -534,7 +534,12 @@ public func applyRules(
) throws -> (tokens: [Token], changes: [Formatter.Change]) {
precondition(maxIterations > 1)
let originalRules = originalRules.sorted()
let originalRules = originalRules.sorted() + options.regexRules.compactMap {
guard let rule = RegexRule(rawValue: $0) else {
return nil
}
return FormatRule(regexRule: rule)
}
var tokens = originalTokens
var range = originalRange
+10
View File
@@ -151,6 +151,10 @@
E4FABAD6202FEF060065716E /* OptionDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FABAD4202FEF060065716E /* OptionDescriptor.swift */; };
E4FABAD7202FEF060065716E /* OptionDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FABAD4202FEF060065716E /* OptionDescriptor.swift */; };
E4FABAD8202FEF060065716E /* OptionDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FABAD4202FEF060065716E /* OptionDescriptor.swift */; };
EA3403872ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
EA3403882ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
EA3403892ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
EA34038A2ECC86AE00A817DE /* RegexRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3403862ECC86AE00A817DE /* RegexRule.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -287,6 +291,7 @@
E4E4D3C82033F17C000D7CB1 /* EnumAssociable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumAssociable.swift; sourceTree = "<group>"; };
E4E4D3CD2033F1EF000D7CB1 /* EnumAssociableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumAssociableTests.swift; sourceTree = "<group>"; };
E4FABAD4202FEF060065716E /* OptionDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionDescriptor.swift; sourceTree = "<group>"; };
EA3403862ECC86AE00A817DE /* RegexRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegexRule.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -407,6 +412,7 @@
children = (
2E3A24EE2DDD621600407419 /* Rules */,
2E2BAB8B2C57F6B600590239 /* RuleRegistry.generated.swift */,
EA3403862ECC86AE00A817DE /* RegexRule.swift */,
01F17E811E25870700DCD359 /* CommandLine.swift */,
E4E4D3C82033F17C000D7CB1 /* EnumAssociable.swift */,
01B3987C1D763493009ADE61 /* Formatter.swift */,
@@ -904,6 +910,7 @@
2EF8BF1B2D1E0D4F00D6F12F /* DeclarationType.swift in Sources */,
01F17E821E25870700DCD359 /* CommandLine.swift in Sources */,
01F3DF8C1DB9FD3F00454944 /* Options.swift in Sources */,
EA3403872ECC86AE00A817DE /* RegexRule.swift in Sources */,
01A0EAC21D5DB4F700A0A8E3 /* Tokenizer.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -955,6 +962,7 @@
01F17E831E25870700DCD359 /* CommandLine.swift in Sources */,
015243E22B04B0A600F65221 /* Singularize.swift in Sources */,
2EE4007B2E595B7200CF8D5A /* TypeName.swift in Sources */,
EA3403882ECC86AE00A817DE /* RegexRule.swift in Sources */,
6E954FF42E0DEACC004EE019 /* SARIFReporter.swift in Sources */,
C2FFD1832BD13C9E00774F55 /* XMLReporter.swift in Sources */,
01A0EACD1D5DB5F500A0A8E3 /* main.swift in Sources */,
@@ -979,6 +987,7 @@
E4083191202C049200CAF11D /* SwiftFormat.swift in Sources */,
E4FABAD7202FEF060065716E /* OptionDescriptor.swift in Sources */,
2E2611C82DD94FE900FFFE09 /* JSONReporter.swift in Sources */,
EA3403892ECC86AE00A817DE /* RegexRule.swift in Sources */,
2E2611C92DD94FE900FFFE09 /* XMLReporter.swift in Sources */,
2E2611CA2DD94FE900FFFE09 /* GithubActionsLogReporter.swift in Sources */,
2E2611CB2DD94FE900FFFE09 /* Reporter.swift in Sources */,
@@ -1019,6 +1028,7 @@
01045AA0211A1EE300D2BE3D /* Arguments.swift in Sources */,
01BBD85C21DAA2A700457380 /* Globs.swift in Sources */,
2E26108F2DD92CB400FFFE09 /* CommandLine.swift in Sources */,
EA34038A2ECC86AE00A817DE /* RegexRule.swift in Sources */,
01045A9F2119D30D00D2BE3D /* Inference.swift in Sources */,
01A95BD2225BEDE300744931 /* ParsingHelpers.swift in Sources */,
90C4B6E51DA4B059009EB000 /* SourceEditorExtension.swift in Sources */,
+69
View File
@@ -302,4 +302,73 @@ final class SwiftFormatTests: XCTestCase {
let output = "class Foo {\r func bar() {\r }\r\r func baz() {\r }\r}"
XCTAssertEqual(try format(input, rules: [.blankLinesBetweenScopes]).output, output)
}
// MARK: regex
func testRegexReplace() throws {
let input = """
class NetworkService {
private let baseURL = URL(string: "https://api.example.com")!
func makeRequest() {
let url = URL(string: "https://api.example.com/endpoint")!
// Use url...
}
}
"""
let output = """
class NetworkService {
private let baseURL = #URL("https://api.example.com")
func makeRequest() {
let url = #URL("https://api.example.com/endpoint")
// Use url...
}
}
"""
let options = FormatOptions(regexRules: ["urls/URL\\(string: \"([^\\\"]*)\"\\)!/#URL(\"$1\")/"])
XCTAssertEqual(try format(input, rules: [], options: options).output, output)
}
func testRegexRuleDisabled() throws {
let input = """
class NetworkService {
private let baseURL = URL(string: "https://api.example.com")!
func makeRequest() {
// swiftformat:disable:next urls
let url = URL(string: "https://api.example.com/endpoint")!
// Use url...
}
}
"""
let output = """
class NetworkService {
private let baseURL = #URL("https://api.example.com")
func makeRequest() {
// swiftformat:disable:next urls
let url = URL(string: "https://api.example.com/endpoint")!
// Use url...
}
}
"""
let options = FormatOptions(regexRules: ["urls/URL\\(string: \"([^\\\"]*)\"\\)!/#URL(\"$1\")/"])
XCTAssertEqual(try format(input, rules: [], options: options).output, output)
}
func testRegexCaretMatchesStartOfLine() throws {
let input = """
import Foo
import Bar
struct Baz {}
"""
let output = """
struct Baz {}
"""
let options = FormatOptions(regexRules: ["no-imports/^import\\s+[^\\n]*\\n//"])
XCTAssertEqual(try format(input, rules: [], options: options).output, output)
}
}