mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
c7c259822e
Co-authored-by: calda <1811727+calda@users.noreply.github.com>
801 lines
30 KiB
Swift
801 lines
30 KiB
Swift
//
|
|
// SwiftFormat.swift
|
|
// SwiftFormat
|
|
//
|
|
// Created by Nick Lockwood on 12/08/2016.
|
|
// Copyright 2016 Nick Lockwood
|
|
//
|
|
// Distributed under the permissive MIT license
|
|
// Get the latest version from here:
|
|
//
|
|
// https://github.com/nicklockwood/SwiftFormat
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in all
|
|
// copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// The current SwiftFormat version
|
|
let swiftFormatVersion = "0.59.1"
|
|
public let version = swiftFormatVersion
|
|
|
|
/// The standard SwiftFormat config file name
|
|
public let swiftFormatConfigurationFile = ".swiftformat"
|
|
|
|
/// The standard Swift version file name
|
|
public let swiftVersionFile = ".swift-version"
|
|
|
|
/// Supported Swift compiler versions
|
|
public let swiftVersions = [
|
|
"3.x", "4.0", "4.1", "4.2",
|
|
"5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10",
|
|
"6.0", "6.1", "6.2", "6.3", "6.4",
|
|
]
|
|
|
|
/// Supported Swift language modes
|
|
public let languageModes = [
|
|
"4", "4.2", "5", "6",
|
|
]
|
|
|
|
/// The default language mode for the given Swift compiler version
|
|
func defaultLanguageMode(for compilerVersion: Version) -> Version {
|
|
switch compilerVersion {
|
|
case "4.0" ..< "4.2":
|
|
return "4"
|
|
case "4.2":
|
|
return "4.2"
|
|
case "5.0" ..< "6.0":
|
|
return "5"
|
|
case "6.0"...:
|
|
// The default language mode in Swift 6.0 is Swift 5 mode.
|
|
// https://developer.apple.com/documentation/swift/adoptingswift6
|
|
return "5"
|
|
default:
|
|
return .undefined
|
|
}
|
|
}
|
|
|
|
/// An enumeration of the types of error that may be thrown by SwiftFormat
|
|
public enum FormatError: Error, CustomStringConvertible, LocalizedError, CustomNSError {
|
|
case reading(String)
|
|
case writing(String)
|
|
case parsing(String)
|
|
case options(String)
|
|
|
|
static func invalidOption(
|
|
_ option: String,
|
|
for argumentName: String,
|
|
with validOptions: [String]
|
|
) -> Self {
|
|
let message = "Unsupported --\(argumentName) value '\(option)'"
|
|
guard let match = option.bestMatch(in: validOptions) else {
|
|
return .options("\(message). Valid options are \(validOptions.formattedList())")
|
|
}
|
|
return .options("\(message). Did you mean '\(match)'?")
|
|
}
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case let .reading(string),
|
|
let .writing(string),
|
|
let .parsing(string),
|
|
let .options(string):
|
|
return string
|
|
}
|
|
}
|
|
|
|
public var localizedDescription: String {
|
|
"Error: \(description)."
|
|
}
|
|
|
|
public var errorUserInfo: [String: Any] {
|
|
[NSLocalizedDescriptionKey: localizedDescription]
|
|
}
|
|
}
|
|
|
|
/// Callback for enumerateFiles() function
|
|
public typealias FileEnumerationHandler = (
|
|
_ inputURL: URL,
|
|
_ ouputURL: URL,
|
|
_ options: Options
|
|
) throws -> () throws -> Void
|
|
|
|
/// Callback for info-level logging
|
|
public typealias Logger = (String) -> Void
|
|
|
|
/// Enumerate all Swift files at the specified location and (optionally) calculate an output file URL for each.
|
|
/// Ignores the file if any of the excluded file URLs is a prefix of the input file URL.
|
|
///
|
|
/// Files are enumerated concurrently. For convenience, the enumeration block returns a completion block, which
|
|
/// will be executed synchronously on the calling thread once enumeration is complete.
|
|
///
|
|
/// Errors may be thrown by either the enumeration block or the completion block, and are gathered into an
|
|
/// array and returned after enumeration is complete, along with any errors generated by the function itself.
|
|
/// Throwing an error from inside either block does *not* terminate the enumeration.
|
|
public func enumerateFiles(withInputURLs inputURLs: [URL],
|
|
outputURL: URL? = nil,
|
|
options baseOptions: Options = .default,
|
|
concurrent: Bool = true,
|
|
logger: Logger? = nil,
|
|
skipped: FileEnumerationHandler? = nil,
|
|
handler: @escaping FileEnumerationHandler) -> [Error]
|
|
{
|
|
let manager = FileManager.default
|
|
let keys: [URLResourceKey] = [
|
|
.isRegularFileKey, .isDirectoryKey,
|
|
.isAliasFileKey, .isSymbolicLinkKey,
|
|
.creationDateKey, .pathKey,
|
|
]
|
|
|
|
let group = DispatchGroup()
|
|
var completionBlocks = [() throws -> Void]()
|
|
let completionQueue = DispatchQueue(label: "swiftformat.enumeration")
|
|
func onComplete(_ block: @escaping () throws -> Void) {
|
|
completionQueue.async(group: group) {
|
|
completionBlocks.append(block)
|
|
}
|
|
}
|
|
|
|
let queue = concurrent ? DispatchQueue.global(qos: .userInitiated) : completionQueue
|
|
|
|
func resolveInputURL(_ inputURL: URL, options: Options) -> (URL, URLResourceValues, Options)? {
|
|
let fileOptions = options.fileOptions ?? .default
|
|
let inputURL = inputURL.standardizedFileURL
|
|
if options.shouldSkipFile(inputURL) {
|
|
if let handler = skipped {
|
|
do {
|
|
try onComplete(handler(inputURL, inputURL, options))
|
|
} catch {
|
|
onComplete { throw error }
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
do {
|
|
let resourceValues = try getResourceValues(for: inputURL, keys: keys)
|
|
#if os(macOS)
|
|
if resourceValues.isAliasFile == true {
|
|
if fileOptions.followSymlinks {
|
|
guard let resolvedURL = try? URL(resolvingAliasFileAt: inputURL) else {
|
|
throw FormatError.options("Could not resolve alias at \(inputURL.path)")
|
|
}
|
|
return resolveInputURL(resolvedURL, options: options)
|
|
} else {
|
|
if let handler = skipped {
|
|
try onComplete(handler(inputURL, inputURL, options))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
#endif
|
|
if resourceValues.isSymbolicLink == true {
|
|
if fileOptions.followSymlinks {
|
|
let resolvedURL = inputURL.resolvingSymlinksInPath()
|
|
return resolveInputURL(resolvedURL, options: options)
|
|
} else {
|
|
if let handler = skipped {
|
|
try onComplete(handler(inputURL, inputURL, options))
|
|
}
|
|
return nil
|
|
}
|
|
} else {
|
|
return (inputURL, resourceValues, options)
|
|
}
|
|
} catch {
|
|
onComplete { throw error }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func enumerate(inputURL: URL,
|
|
outputURL: URL?,
|
|
options: Options)
|
|
{
|
|
assert(options.formatOptions != nil)
|
|
guard let (inputURL, resourceValues, options) = resolveInputURL(inputURL, options: options) else {
|
|
return
|
|
}
|
|
assert(options.formatOptions != nil)
|
|
let fileOptions = options.fileOptions ?? .default
|
|
if resourceValues.isRegularFile == true {
|
|
if fileOptions.supportedFileExtensions.contains(inputURL.pathExtension) {
|
|
let fileInfo = collectFileInfo(inputURL: inputURL,
|
|
options: options,
|
|
resourceValues: resourceValues)
|
|
|
|
var options = options
|
|
options.formatOptions?.fileInfo = fileInfo
|
|
do {
|
|
try onComplete(handler(inputURL, outputURL ?? inputURL, options))
|
|
} catch {
|
|
onComplete { throw error }
|
|
}
|
|
}
|
|
} else if resourceValues.isDirectory == true {
|
|
var tempOptions = options
|
|
do {
|
|
try processDirectory(inputURL, with: &tempOptions, logger: logger)
|
|
} catch {
|
|
onComplete { throw error }
|
|
return
|
|
}
|
|
let options = tempOptions
|
|
let enumerationOptions: FileManager.DirectoryEnumerationOptions = .skipsHiddenFiles
|
|
guard let files = try? manager.contentsOfDirectory(
|
|
at: inputURL, includingPropertiesForKeys: keys, options: enumerationOptions
|
|
) else {
|
|
onComplete { throw FormatError.reading("Failed to read contents of directory at \(inputURL.path)") }
|
|
return
|
|
}
|
|
for url in files where !url.path.hasPrefix(".") {
|
|
let options = options
|
|
queue.async(group: group) {
|
|
let outputURL = outputURL.map {
|
|
URL(fileURLWithPath: $0.path + url.path[inputURL.path.endIndex ..< url.path.endIndex])
|
|
}
|
|
enumerate(inputURL: url, outputURL: outputURL, options: options)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for inputURL in inputURLs {
|
|
queue.async(group: group) {
|
|
var options = baseOptions
|
|
var inputURL = inputURL
|
|
if options.formatOptions == nil {
|
|
options.formatOptions = .default
|
|
}
|
|
do {
|
|
try gatherOptions(&options, for: inputURL, with: logger)
|
|
guard let (resolvedURL, resourceValues, _) = resolveInputURL(inputURL, options: options) else {
|
|
return
|
|
}
|
|
inputURL = resolvedURL
|
|
let fileOptions = options.fileOptions ?? .default
|
|
if resourceValues.isDirectory == false,
|
|
!fileOptions.supportedFileExtensions.contains(inputURL.pathExtension)
|
|
{
|
|
throw FormatError.options("Unsupported file type: \(inputURL.path)")
|
|
}
|
|
} catch {
|
|
onComplete { throw error }
|
|
return
|
|
}
|
|
enumerate(inputURL: inputURL, outputURL: outputURL, options: options)
|
|
}
|
|
}
|
|
group.wait()
|
|
|
|
var errors = [Error]()
|
|
for block in completionBlocks {
|
|
do {
|
|
try block()
|
|
} catch {
|
|
errors.append(error)
|
|
}
|
|
}
|
|
return errors
|
|
}
|
|
|
|
@available(*, deprecated)
|
|
public func enumerateFiles(withInputURL inputURL: URL,
|
|
outputURL: URL? = nil,
|
|
options baseOptions: Options = .default,
|
|
concurrent: Bool = true,
|
|
logger: Logger? = nil,
|
|
skipped: FileEnumerationHandler? = nil,
|
|
handler: @escaping FileEnumerationHandler) -> [Error]
|
|
{
|
|
enumerateFiles(withInputURLs: [inputURL],
|
|
outputURL: outputURL,
|
|
options: baseOptions,
|
|
concurrent: concurrent,
|
|
logger: logger,
|
|
skipped: skipped,
|
|
handler: handler)
|
|
}
|
|
|
|
func collectFileInfo(inputURL: URL, options: Options, resourceValues: URLResourceValues?) -> FileInfo {
|
|
let fileHeaderRuleEnabled = options.rules?.contains(FormatRule.fileHeader.name) ?? false
|
|
let shouldGetGitInfo = fileHeaderRuleEnabled &&
|
|
options.formatOptions?.fileHeader.needsGitInfo == true
|
|
|
|
let gitInfo = shouldGetGitInfo ? GitFileInfo(url: inputURL) : nil
|
|
|
|
return FileInfo(
|
|
filePath: resourceValues?.path ?? inputURL.path,
|
|
creationDate: gitInfo?.creationDate ?? resourceValues?.creationDate,
|
|
replacements: [
|
|
.author: ReplacementType(gitInfo?.author),
|
|
.authorName: ReplacementType(gitInfo?.authorName),
|
|
.authorEmail: ReplacementType(gitInfo?.authorEmail),
|
|
].compactMapValues { $0 }
|
|
)
|
|
}
|
|
|
|
/// Process configuration in all directories in specified path.
|
|
func gatherOptions(_ options: inout Options, for inputURL: URL, with logger: Logger?) throws {
|
|
var directory = URL(fileURLWithPath: inputURL.pathComponents[0]).standardized
|
|
for part in inputURL.pathComponents.dropFirst().dropLast() {
|
|
directory.appendPathComponent(part)
|
|
if options.shouldSkipFile(directory) {
|
|
return
|
|
}
|
|
try processDirectory(directory, with: &options, logger: logger)
|
|
}
|
|
}
|
|
|
|
/// Process configuration files in specified directory.
|
|
private var configCache = [URL: [[String: String]]]()
|
|
private let configQueue = DispatchQueue(label: "swiftformat.config", qos: .userInteractive)
|
|
private func processDirectory(_ inputURL: URL, with options: inout Options, logger: Logger?) throws {
|
|
if let args = configQueue.sync(execute: { configCache[inputURL] }) {
|
|
try options.addArguments(args, in: inputURL.path)
|
|
return
|
|
}
|
|
var args = [[String: String]]()
|
|
let manager = FileManager.default
|
|
let configFile = inputURL.appendingPathComponent(swiftFormatConfigurationFile)
|
|
if manager.fileExists(atPath: configFile.path) {
|
|
if let configURLs = options.configURLs {
|
|
let standardizedConfigFile = configFile.standardizedFileURL
|
|
if !configURLs.contains(where: { $0.standardizedFileURL == standardizedConfigFile }) {
|
|
logger?("Ignoring config file at \(configFile.path)")
|
|
}
|
|
} else {
|
|
logger?("Reading config file at \(configFile.path)")
|
|
let data = try Data(contentsOf: configFile)
|
|
args = try parseConfigFile(data)
|
|
}
|
|
}
|
|
let versionFile = inputURL.appendingPathComponent(swiftVersionFile)
|
|
if manager.fileExists(atPath: versionFile.path) {
|
|
// Don't read .swift-version from directories that will be excluded (affects no files)
|
|
var tempOptions = options
|
|
try tempOptions.addArguments(args, in: inputURL.standardizedFileURL.path)
|
|
if !tempOptions.shouldSkipFile(inputURL) {
|
|
let versionString = try String(contentsOf: versionFile, encoding: .utf8)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if args.first(where: { $0["swift-version"] != nil }) != nil {
|
|
logger?("Ignoring swift-version file at \(versionFile.path)")
|
|
} else if Version(rawValue: versionString) != nil {
|
|
logger?("Reading swift-version file at \(versionFile.path) (version \(versionString))")
|
|
|
|
if args.isEmpty {
|
|
args = [["swift-version": versionString]]
|
|
} else {
|
|
args = args.map {
|
|
var args = $0
|
|
args["swift-version"] = versionString
|
|
return args
|
|
}
|
|
}
|
|
} else {
|
|
// Don't treat as error, per: https://github.com/nicklockwood/SwiftFormat/issues/639
|
|
// TODO: find a better solution for logging warnings here
|
|
logger?("Unrecognized swift version string '\(versionString)' in \(versionFile.path)")
|
|
}
|
|
}
|
|
}
|
|
configQueue.async {
|
|
configCache[inputURL] = args
|
|
}
|
|
assert(options.formatOptions != nil)
|
|
try options.addArguments(args, in: inputURL.standardizedFileURL.path)
|
|
}
|
|
|
|
/// Line and column offset in source
|
|
/// Note: line and column indexes start at 1
|
|
public struct SourceOffset: Equatable, CustomStringConvertible {
|
|
var line, column: Int
|
|
|
|
public init(line: Int, column: Int) {
|
|
self.line = line
|
|
self.column = column
|
|
}
|
|
|
|
public var description: String {
|
|
"\(line):\(column)"
|
|
}
|
|
}
|
|
|
|
/// Get offset for token
|
|
public func offsetForToken(at index: Int, in tokens: [Token], tabWidth: Int) -> SourceOffset {
|
|
var column = 1
|
|
for token in tokens[..<index].reversed() {
|
|
switch token {
|
|
case let .linebreak(_, line):
|
|
return SourceOffset(line: line + 1, column: column)
|
|
default:
|
|
column += token.columnWidth(tabWidth: tabWidth)
|
|
}
|
|
}
|
|
return SourceOffset(line: 1, column: column)
|
|
}
|
|
|
|
/// Get token index for offset
|
|
public func tokenIndex(for offset: SourceOffset, in tokens: [Token], tabWidth: Int) -> Int {
|
|
var tokenIndex = 0, line = 1
|
|
for index in tokens.indices {
|
|
guard case let .linebreak(_, originalLine) = tokens[index] else {
|
|
continue
|
|
}
|
|
line = originalLine
|
|
guard originalLine < offset.line else {
|
|
break
|
|
}
|
|
tokenIndex = index + 1
|
|
}
|
|
if line < offset.line - 1 {
|
|
return tokens.endIndex
|
|
}
|
|
var column = 1
|
|
while tokenIndex < tokens.endIndex, column < offset.column {
|
|
column += tokens[tokenIndex].columnWidth(tabWidth: tabWidth)
|
|
tokenIndex += 1
|
|
}
|
|
return tokenIndex
|
|
}
|
|
|
|
/// Deprecated
|
|
@available(*, deprecated, message: "Use tokenIndex(for:) instead")
|
|
public func tokenIndexForOffset(_ offset: SourceOffset, in tokens: [Token], tabWidth: Int) -> Int {
|
|
tokenIndex(for: offset, in: tokens, tabWidth: tabWidth)
|
|
}
|
|
|
|
/// Get token index range for line range
|
|
public func tokenRange(forLineRange lineRange: ClosedRange<Int>, in tokens: [Token]) -> Range<Int> {
|
|
let startOffset = SourceOffset(line: lineRange.lowerBound, column: 0)
|
|
let endOffset = SourceOffset(line: lineRange.upperBound + 1, column: 0)
|
|
// NOTE: tab width is not relevant for line-based offsets
|
|
let tokenStart = max(0, tokenIndex(for: startOffset, in: tokens, tabWidth: 1) - 1)
|
|
let tokenEnd = max(tokenStart, tokenIndex(for: endOffset, in: tokens, tabWidth: 1) - 1)
|
|
return tokenStart ..< tokenEnd
|
|
}
|
|
|
|
/// Get new offset for an original offset (before formatting)
|
|
public func newOffset(for offset: SourceOffset, in tokens: [Token], tabWidth: Int) -> SourceOffset {
|
|
var closestLine = 0
|
|
for i in tokens.indices {
|
|
guard case let .linebreak(_, originalLine) = tokens[i] else {
|
|
continue
|
|
}
|
|
closestLine += 1
|
|
guard originalLine >= offset.line else {
|
|
continue
|
|
}
|
|
var lineLength = 0
|
|
for j in (0 ..< i).reversed() {
|
|
let token = tokens[j]
|
|
if token.isLinebreak {
|
|
break
|
|
}
|
|
lineLength += token.columnWidth(tabWidth: tabWidth)
|
|
}
|
|
return SourceOffset(line: closestLine, column: min(offset.column, lineLength + 1))
|
|
}
|
|
let lineLength = tokens.reduce(0) { $0 + $1.columnWidth(tabWidth: tabWidth) }
|
|
return SourceOffset(line: closestLine + 1, column: min(offset.column, lineLength + 1))
|
|
}
|
|
|
|
/// Process parsing errors
|
|
public func parsingError(for tokens: [Token], options: FormatOptions, allowErrorsInFragments: Bool = true) -> FormatError? {
|
|
guard let index = tokens.firstIndex(where: {
|
|
guard (options.fragment && allowErrorsInFragments) || !$0.isError else { return true }
|
|
guard !options.ignoreConflictMarkers, case let .operator(string, _) = $0 else { return false }
|
|
return string.hasPrefix("<<<<<") || string.hasPrefix("=====") || string.hasPrefix(">>>>>")
|
|
}) else {
|
|
return nil
|
|
}
|
|
let message: String
|
|
switch tokens[index] {
|
|
case .error(""):
|
|
message = "Unexpected end of file"
|
|
case let .error(string):
|
|
if string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
message = "Inconsistent whitespace in multi-line string literal"
|
|
} else {
|
|
message = "Unexpected token \(string)"
|
|
}
|
|
case let .operator(string, _):
|
|
message = "Found conflict marker \(string)"
|
|
default:
|
|
preconditionFailure()
|
|
}
|
|
let offset = offsetForToken(at: index, in: tokens, tabWidth: options.tabWidth)
|
|
return .parsing("\(message) at \(offset)")
|
|
}
|
|
|
|
/// Convert a token array back into a string
|
|
public func sourceCode(for tokens: [Token]?) -> String {
|
|
(tokens ?? []).map(\.string).joined()
|
|
}
|
|
|
|
/// Apply specified rules to a token array and optionally capture list of changes
|
|
public func applyRules(
|
|
_ originalRules: [FormatRule],
|
|
to originalTokens: [Token],
|
|
with options: FormatOptions,
|
|
trackChanges: Bool,
|
|
range originalRange: Range<Int>?,
|
|
maxIterations: Int = 10
|
|
) throws -> (tokens: [Token], changes: [Formatter.Change]) {
|
|
precondition(maxIterations > 1)
|
|
|
|
let originalRules = originalRules.sorted()
|
|
var tokens = originalTokens
|
|
var range = originalRange
|
|
|
|
// Ensure rule names have been set
|
|
if originalRules.first?.name == FormatRule.unnamedRule {
|
|
_ = FormatRules.all
|
|
}
|
|
|
|
// Check for parsing errors
|
|
if let error = parsingError(for: originalTokens, options: options) {
|
|
throw error
|
|
}
|
|
|
|
// Infer shared options
|
|
var options = options
|
|
options.enabledRules = Set(originalRules.map(\.name))
|
|
let sharedOptions = FormatRules
|
|
.sharedOptionsForRules(originalRules)
|
|
.compactMap { Descriptors.byName[$0] }
|
|
.filter { $0.defaultArgument == $0.fromOptions(options) }
|
|
.map(\.propertyName)
|
|
|
|
inferFormatOptions(sharedOptions, from: originalTokens, into: &options)
|
|
|
|
// Check if required FileInfo is available
|
|
if originalRules.contains(.fileHeader) {
|
|
let header = options.fileHeader
|
|
let fileInfo = options.fileInfo
|
|
|
|
for key in ReplacementKey.allCases {
|
|
if case let .replace(string) = header,
|
|
!fileInfo.hasReplacement(for: key, options: options),
|
|
string.contains(key.placeholder)
|
|
{
|
|
throw FormatError.options(
|
|
"Failed to apply \(key.placeholder) template in file header as required info is unavailable"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Split tokens into lines
|
|
func getLines(in tokens: [Token], includingLinebreaks: Bool) -> [Int: ArraySlice<Token>] {
|
|
var lines: [Int: ArraySlice<Token>] = [:]
|
|
var startIndex = 0, nextLine = 1
|
|
for (i, token) in tokens.enumerated() {
|
|
if case let .linebreak(_, line) = token {
|
|
let endIndex = i + (includingLinebreaks ? 1 : 0)
|
|
if let existing = lines[line] {
|
|
lines[line] = tokens[existing.startIndex ..< endIndex]
|
|
} else {
|
|
lines[line] = tokens[startIndex ..< endIndex]
|
|
}
|
|
nextLine = line + 1
|
|
startIndex = i + 1
|
|
}
|
|
}
|
|
lines[nextLine] = tokens[startIndex...]
|
|
return lines
|
|
}
|
|
|
|
// Apply trim/indent rule once at start
|
|
var rules = originalRules
|
|
if rules.contains(.indent) {
|
|
rules.insert(.indent, at: 0)
|
|
if rules.contains(.trailingSpace) {
|
|
rules.insert(.trailingSpace, at: 0)
|
|
}
|
|
}
|
|
|
|
// Recursively apply rules until no changes are detected
|
|
let group = DispatchGroup()
|
|
let queue = DispatchQueue(label: "swiftformat.formatting", qos: .userInteractive)
|
|
let timeout = options.timeout + TimeInterval(originalTokens.count) / 1000
|
|
var changes = [Formatter.Change]()
|
|
var lastChanges = [Formatter.Change]()
|
|
for iteration in 0 ..< maxIterations {
|
|
let formatter = Formatter(tokens, options: options,
|
|
trackChanges: trackChanges, range: range)
|
|
for rule in rules {
|
|
queue.async(group: group) {
|
|
rule.apply(with: formatter)
|
|
}
|
|
guard group.wait(timeout: .now() + timeout) != .timedOut else {
|
|
throw FormatError.writing("\(rule.name) rule timed out")
|
|
}
|
|
}
|
|
|
|
// Abort if there are fatal errors
|
|
if let error = formatter.errors.first, !options.fragment {
|
|
throw error
|
|
}
|
|
|
|
// Record changes
|
|
lastChanges = formatter.changes
|
|
changes += lastChanges
|
|
|
|
// Update range and discard unwanted changes
|
|
var newTokens = formatter.tokens
|
|
if let oldRange = range, let newRange = formatter.range {
|
|
// Discard changes outside of specified range
|
|
newTokens = Array(tokens[..<oldRange.lowerBound] + newTokens[newRange] + tokens[oldRange.upperBound...])
|
|
range = oldRange.lowerBound ..< (oldRange.lowerBound + newRange.count)
|
|
}
|
|
|
|
// Terminate early if there were no changes
|
|
if tokens == newTokens {
|
|
if changes.isEmpty {
|
|
return (tokens, [])
|
|
}
|
|
|
|
// Sort changes
|
|
changes.sort(by: {
|
|
if $0.line == $1.line {
|
|
return $0.rule.name < $1.rule.name
|
|
}
|
|
return $0.line < $1.line
|
|
})
|
|
|
|
// Get lines
|
|
let oldLines = getLines(in: originalTokens, includingLinebreaks: true)
|
|
let newLines = getLines(in: tokens, includingLinebreaks: true)
|
|
|
|
// Filter out duplicates and lines that haven't changed
|
|
var last: Formatter.Change?
|
|
changes = changes.filter { change in
|
|
if last == change {
|
|
return false
|
|
}
|
|
last = change
|
|
// Filter out lines that haven't changed from their original line in
|
|
// the input code, unless the change was explicitly marked as a move.
|
|
if !change.isMove, newLines[change.line] == oldLines[change.line] {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
return (tokens, changes)
|
|
}
|
|
|
|
// Update tokens
|
|
tokens = newTokens
|
|
|
|
// Remove rules that should only be run once
|
|
if iteration == 0 {
|
|
rules = originalRules
|
|
rules.removeAll(where: { $0.runOnceOnly })
|
|
}
|
|
}
|
|
|
|
// If we got here, formatting failed to terminate within max iterations
|
|
let rulesApplied = Set(lastChanges.map(\.rule.name)).sorted()
|
|
guard !rulesApplied.isEmpty else {
|
|
// No rules were applied this time (maybe something else went wrong?)
|
|
throw FormatError.writing("Failed to terminate")
|
|
}
|
|
let names = rulesApplied.count == 1 ?
|
|
"\(rulesApplied[0]) rule" :
|
|
"\(rulesApplied.formattedList(lastSeparator: "and")) rules"
|
|
let changeLines = Set(lastChanges.map { "\($0.line)" }).sorted()
|
|
let lines = changeLines.count == 1 ?
|
|
"line \(changeLines[0])" :
|
|
"lines \(changeLines.formattedList(lastSeparator: "and"))"
|
|
throw FormatError.writing("The \(names) failed to terminate at \(lines)")
|
|
}
|
|
|
|
/// Format a pre-parsed token array
|
|
/// Returns the formatted token array
|
|
public func format(
|
|
_ tokens: [Token], rules: [FormatRule] = FormatRules.default,
|
|
options: FormatOptions = .default, range: Range<Int>? = nil
|
|
) throws -> (tokens: [Token], changes: [Formatter.Change]) {
|
|
try applyRules(rules, to: tokens, with: options, trackChanges: true, range: range)
|
|
}
|
|
|
|
/// Format code with specified rules and options
|
|
public func format(
|
|
_ source: String, rules: [FormatRule] = FormatRules.default,
|
|
options: FormatOptions = .default, lineRange: ClosedRange<Int>? = nil
|
|
) throws -> (output: String, changes: [Formatter.Change]) {
|
|
let tokens = tokenize(source)
|
|
let range = lineRange.map { tokenRange(forLineRange: $0, in: tokens) }
|
|
let output = try format(tokens, rules: rules, options: options, range: range)
|
|
return (sourceCode(for: output.tokens), output.changes)
|
|
}
|
|
|
|
/// Lint a pre-parsed token array
|
|
/// Returns the list of edits made
|
|
public func lint(
|
|
_ tokens: [Token], rules: [FormatRule] = FormatRules.default,
|
|
options: FormatOptions = .default, range: Range<Int>? = nil
|
|
) throws -> [Formatter.Change] {
|
|
try applyRules(rules, to: tokens, with: options, trackChanges: true, range: range).changes
|
|
}
|
|
|
|
/// Lint code with specified rules and options
|
|
public func lint(
|
|
_ source: String, rules: [FormatRule] = FormatRules.default,
|
|
options: FormatOptions = .default, lineRange: ClosedRange<Int>? = nil
|
|
) throws -> [Formatter.Change] {
|
|
let tokens = tokenize(source)
|
|
let range = lineRange.map { tokenRange(forLineRange: $0, in: tokens) }
|
|
return try lint(tokens, rules: rules, options: options, range: range)
|
|
}
|
|
|
|
// MARK: Path utilities
|
|
|
|
public func expandPath(_ path: String, in directory: String) -> URL {
|
|
let nsPath: NSString = (path as NSString).expandingTildeInPath as NSString
|
|
if nsPath.isAbsolutePath {
|
|
return URL(fileURLWithPath: nsPath as String).standardized
|
|
}
|
|
return URL(fileURLWithPath: directory, isDirectory: true).appendingPathComponent(path).standardized
|
|
}
|
|
|
|
func getResourceValues(for url: URL, keys: [URLResourceKey]) throws -> URLResourceValues {
|
|
if let resourceValues = try? url.resourceValues(forKeys: Set(keys)) {
|
|
return resourceValues
|
|
}
|
|
if FileManager.default.fileExists(atPath: url.path) {
|
|
throw FormatError.reading("Failed to read attributes for \(url.path)")
|
|
}
|
|
throw FormatError.options("File not found at \(url.path)")
|
|
}
|
|
|
|
// MARK: Documentation utilities
|
|
|
|
/// Strip markdown code-formatting
|
|
func stripMarkdown(_ input: String) -> String {
|
|
var result = ""
|
|
var startCount = 0
|
|
var endCount = 0
|
|
var escaped = false
|
|
for c in input {
|
|
if c == "`" {
|
|
if escaped {
|
|
endCount += 1
|
|
} else {
|
|
startCount += 1
|
|
}
|
|
} else {
|
|
if escaped, endCount > 0 {
|
|
if endCount != startCount {
|
|
result += String(repeating: "`", count: endCount)
|
|
} else {
|
|
escaped = false
|
|
startCount = 0
|
|
}
|
|
endCount = 0
|
|
}
|
|
if startCount > 0 {
|
|
escaped = true
|
|
}
|
|
result.append(c)
|
|
}
|
|
}
|
|
return result
|
|
}
|