mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
Add --regex-rules option
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user