Files

100 lines
4.4 KiB
Swift

//
// URLMacro.swift
// SwiftFormat
//
// Created by Manuel Lopez on 6/17/25.
// Copyright © 2024 Nick Lockwood. All rights reserved.
//
import Foundation
public extension FormatRule {
/// Convert force-unwrapped URL initializers to use the #URL(...) macro
static let urlMacro = FormatRule(
help: "Replace force-unwrapped `URL(string:)` initializers with the configured `#URL(_:)` macro.",
disabledByDefault: true,
options: ["url-macro"]
) { formatter in
// Only apply this rule if a URL macro is configured
guard case let .macro(macroName, module: module) = formatter.options.urlMacro else {
return
}
// First collect all indices to process
var indicesToProcess: [(Int, Int, Int, Int)] = [] // (i, firstArgIndex, stringStartIndex, unwrapIndex)
formatter.forEach(.identifier("URL")) { i, _ in
// Look for `URL(string: "...")!` pattern
guard let openParenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: i),
formatter.tokens[openParenIndex] == .startOfScope("("),
let firstArgIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: openParenIndex),
formatter.tokens[firstArgIndex] == .identifier("string"),
let colonIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: firstArgIndex),
formatter.tokens[colonIndex] == .delimiter(":"),
let stringStartIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: colonIndex),
formatter.tokens[stringStartIndex] == .startOfScope("\""),
let stringEndIndex = formatter.index(of: .endOfScope("\""), after: stringStartIndex),
let closeParenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: stringEndIndex),
formatter.tokens[closeParenIndex] == .endOfScope(")"),
let unwrapIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: closeParenIndex),
formatter.tokens[unwrapIndex] == .operator("!", .postfix)
else { return }
// Only convert simple string literals (no interpolation, concatenation, etc.)
// Check if there are any tokens between the string delimiters that indicate non-literal content
var hasNonLiteralContent = false
for tokenIndex in (stringStartIndex + 1) ..< stringEndIndex {
let token = formatter.tokens[tokenIndex]
switch token {
case .stringBody:
// String body is fine - this is the literal content
continue
case .startOfScope("\\("), .endOfScope(")"):
// String interpolation detected
hasNonLiteralContent = true
default:
// Any other tokens between string delimiters suggest complex content
hasNonLiteralContent = true
}
}
// Skip if this is not a simple string literal
guard !hasNonLiteralContent else { return }
indicesToProcess.append((i, firstArgIndex, stringStartIndex, unwrapIndex))
}
// Process changes in reverse order to avoid index shifts
for (i, firstArgIndex, stringStartIndex, unwrapIndex) in indicesToProcess.reversed() {
// Remove the unwrap operator first (working backwards to avoid index shifts)
formatter.removeToken(at: unwrapIndex)
// Remove "string: " argument
formatter.removeTokens(in: firstArgIndex ..< stringStartIndex)
// Replace "URL" with the configured macro name
formatter.replaceToken(at: i, with: .keyword(macroName))
}
// Add the configured module import if any modifications were made
if !indicesToProcess.isEmpty {
formatter.addImports([module])
}
} examples: {
"""
With `--url-macro "#URL,URLFoundation"`:
```diff
- let url = URL(string: "https://example.com")!
+ import URLFoundation
+ let url = #URL("https://example.com")
```
```diff
- return URL(string: "https://api.example.com/users")!
+ import URLFoundation
+ return #URL("https://api.example.com/users")
```
"""
}
}