// // CommandLine.swift // SwiftFormat // // Created by Nick Lockwood on 10/01/2017. // Copyright 2017 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 /// Public interface for the SwiftFormat command-line functions public enum CLI {} public extension CLI { /// Output type for printed content enum OutputType { case info case success case error case warning case content case raw } /// Output handler - override this to intercept output from the CLI static var print: (String, OutputType) -> Void = { _, _ in fatalError("No print hook set.") } /// Input handler - override this to inject input into the CLI /// Injected lines should include the terminating newline character static var readLine: () -> String? = { Swift.readLine(strippingNewline: false) } /// Run the CLI with the specified input arguments static func run(in directory: String, with args: [String] = CommandLine.arguments) -> ExitCode { processArguments(args, environment: ProcessInfo.processInfo.environment, in: directory) } /// Run the CLI with the specified input string (this will be parsed into multiple arguments) static func run(in directory: String, with argumentString: String) -> ExitCode { run(in: directory, with: parseArguments(argumentString)) } } private var quietMode = false private func print(_ message: String, as type: CLI.OutputType = .info) { if !quietMode || [.raw, .content, .error].contains(type) { CLI.print(message, type) } } /// Print warnings and return true if any was an actual error private func printWarnings(_ errors: [Error]) -> Bool { var containsError = false for error in errors { var message = "\(error)" if !".?!".contains(message.last ?? " ") { message += "." } let isError: Bool switch error as? FormatError { case let .writing(string)?: isError = !string.contains(" cache ") case .parsing?, .reading?, .options?: isError = true case nil: isError = true message = error.localizedDescription } if isError { containsError = true print("error: \(message)", as: .error) } else { print("warning: \(message)", as: .warning) } } return containsError } /// Represents the exit codes to the command line. See `man sysexits` for more information. public enum ExitCode: Int32 { case ok = 0 // EX_OK case lintFailure = 1 case error = 70 // EX_SOFTWARE } private func formatOptions(_ options: [OptionDescriptor]) -> String { options.compactMap { guard !$0.isDeprecated else { return nil } var result = "--\($0.argumentName)" let maxNameLengthForSingleLineFormatting = 16 let optionNameColumnWidth = maxNameLengthForSingleLineFormatting + 3 if $0.argumentName.count <= maxNameLengthForSingleLineFormatting { for _ in 0 ..< optionNameColumnWidth - result.count { result += " " } return result + stripMarkdown($0.help) } else { result += "\n" for _ in 0 ..< optionNameColumnWidth { result += " " } return result + stripMarkdown($0.help) } }.sorted().joined(separator: "\n") } func printOptions(_ options: [OptionDescriptor] = Descriptors.formatting, as type: CLI.OutputType) { print("") print(formatOptions(options), as: type) print("") } func printRuleInfo(for name: String, as type: CLI.OutputType) throws { guard let rule = FormatRules.byName[name] else { if name.isEmpty { throw FormatError.options("--rule-info command expects a rule name") } throw FormatError.options("'\(name)' rule does not exist") } print("") print(name, as: type) print("", as: type) print(stripMarkdown(rule.help), as: type) if let message = rule.deprecationMessage { print("", as: type) print("Note: \(rule.name) rule is deprecated. \(message)") print("") return } if !rule.options.isEmpty { print("\nOptions:\n", as: type) print(formatOptions(rule.options.compactMap { guard let descriptor = Descriptors.byName[$0], !descriptor.isDeprecated else { return nil } return descriptor }), as: type) } if var examples = rule.examples { examples = examples .replacingOccurrences(of: "```diff\n", with: "") .replacingOccurrences(of: "```\n", with: "") if examples.hasSuffix("```") { examples = String(examples.dropLast(3)) } print("\nExamples:\n", as: type) print(examples, as: type) } print("") } func printHelp(as type: CLI.OutputType) { print("") print(""" SwiftFormat, version \(version) Copyright (c) 2016 Nick Lockwood --help Print this help page --version Print the currently installed swiftformat version SwiftFormat can operate on files & directories, or directly on input from stdin. Usage: swiftformat [ ...] [--infer-options] [--output path] [...] ... Swift files or directories to be processed, or "stdin" --filelist Path to a file with names of files to process, one per line --stdin-path Path to stdin source file (used for generating header) --script-input Read Xcode SCRIPT_INPUT_FILE* environment variables as files --config Path(s) to configuration file(s) containing rules and options --base-config Like --config, but local .swiftformat files aren't ignored --infer-options Instead of formatting input, use it to infer format options --output Output path for formatted file(s) (defaults to input path) --exclude Comma-delimited list of ignored paths (supports glob syntax) --unexclude Paths to not exclude, even if excluded elsewhere in config --filter Filters a config file to only apply to paths matching a glob. --symlinks How symlinks are handled: "follow" or "ignore" (default) --line-range Range of lines to process within the input file (first, last) --fragment \(stripMarkdown(Descriptors.fragment.help)) --conflict-markers \(stripMarkdown(Descriptors.ignoreConflictMarkers.help)) --swift-version \(stripMarkdown(Descriptors.swiftVersion.help)) --language-mode \(stripMarkdown(Descriptors.languageMode.help)) --unknown-rules How unknown rules are handled: "error" (default) or "ignore" --min-version The minimum SwiftFormat version to be used for these files --cache Path to cache file, or "clear" or "ignore" the default cache --dry-run Run in "dry" mode (without actually changing any files) --lint Return an error for unformatted input, and list violations --report Path to a file where --lint output should be written --reporter Report format: \(Reporters.help) --lenient Suppress errors for unformatted code in --lint mode --strict Emit errors for unformatted code when formatting --verbose Display detailed formatting output and warnings/errors --quiet Disables non-critical output messages and warnings --output-tokens Outputs an array of tokens instead of text when using stdin --markdown-files \(stripMarkdown(Descriptors.markdownFiles.help)) SwiftFormat has a number of rules that can be enabled or disabled. By default most rules are enabled. Use --rules to display all enabled/disabled rules. --rules The list of rules to apply. Pass nothing to print rules list --disable Comma-delimited list of format rules to be disabled, or "all" --enable Comma-delimited list of rules to be enabled, or "all" --lint-only A list of rules to be enabled only when using --lint mode SwiftFormat's rules can be configured using options. A given option may affect multiple rules. Options have no effect if the related rules have been disabled. --rule-info Display options for a given rule or rules (comma-delimited) --options Prints a list of all formatting options and their usage """, as: type) print("") } func timeEvent(block: () throws -> Void) rethrows -> TimeInterval { #if os(macOS) let start = CFAbsoluteTimeGetCurrent() try block() return CFAbsoluteTimeGetCurrent() - start #else let start = Date.timeIntervalSinceReferenceDate try block() return Date.timeIntervalSinceReferenceDate - start #endif } private func formatTime(_ time: TimeInterval) -> String { let time = round(time * 100) / 100 // round to nearest 10ms return String(format: "%gs", time) } private func serializeOptions(_ options: Options, to outputURL: URL?) throws { if let outputURL { let file = serialize(options: options) + "\n" do { try file.write(to: outputURL, atomically: true, encoding: .utf8) } catch { throw FormatError.writing("Failed to write options to \(outputURL.path)") } } else { print(serialize(options: options, excludingDefaults: true, separator: " "), as: .content) print("") } } private func readConfigArg( _ name: String, with args: inout [String: String], filterOptions: inout [Glob: [String: String]], in directory: String ) throws -> URL? { guard let configPath = args[name] else { return nil } if configPath.isEmpty { throw FormatError.options("--\(name) argument expects a value") } let (url, configs) = try processConfigFile(at: configPath, for: name, in: directory) var config = [String: String]() for configArgs in configs { // If the config file has a `--filter` option, store it separately under that glob. if let filterGlob = configArgs["filter"] { for glob in expandGlobs(filterGlob, in: "/") { filterOptions[glob] = configArgs } } else { config = try mergeArguments(configArgs, into: config) } } args = try mergeArguments(args, into: config) return url } private func processConfigFile(at path: String, for argumentName: String, in directory: String) throws -> (URL, [[String: String]]) { let url = try parsePath(path, for: "--\(argumentName)", in: directory) if !FileManager.default.fileExists(atPath: url.path) { throw FormatError.reading("Specified config file does not exist: \(url.path)") } let data: Data do { data = try Data(contentsOf: url) } catch { throw FormatError.reading("Failed to read config file at \(url.path), \(error)") } var configs = try parseConfigFile(data) // Ensure exclude paths in config file are treated as relative to the file itself let configDirectory = url.deletingLastPathComponent().path configs = configs.map { config in var config = config if let exclude = config["exclude"] { let excluded = expandGlobs(exclude, in: configDirectory) if excluded.isEmpty { print("warning: --exclude value '\(exclude)' did not match any files in \(configDirectory).", as: .warning) config["exclude"] = nil } else { config["exclude"] = excluded.map(\.description).sorted().joined(separator: ",") } } if let unexclude = config["unexclude"] { let unexcluded = expandGlobs(unexclude, in: configDirectory) if unexcluded.isEmpty { print("warning: --unexclude value '\(unexclude)' did not match any files in \(configDirectory).", as: .warning) config["unexclude"] = nil } else { config["unexclude"] = unexcluded.map(\.description).sorted().joined(separator: ",") } } return config } return (url, configs) } private func readMultipleConfigArgs( _ name: String, with args: inout [String: String], filterOptions: inout [Glob: [String: String]], in directory: String ) throws -> [URL] { guard let configPaths = args[name] else { return [] } if configPaths.isEmpty { throw FormatError.options("--\(name) argument expects a value") } // Split comma-separated config paths let paths = parseCommaDelimitedList(configPaths) var configURLs: [URL] = [] var mergedConfig: [String: String] = [:] // Process each config file in order (first as base, subsequent override) for (index, path) in paths.enumerated() { let (url, configs) = try processConfigFile(at: path, for: name, in: directory) for config in configs { // For first config file, use it as base; for subsequent files, merge them in. // If the config file has a `--filter` option, store it separately under that glob. if let filterGlob = config["filter"] { for glob in expandGlobs(filterGlob, in: "/") { filterOptions[glob] = config } } else if index == 0 { mergedConfig = config } else { mergedConfig = try mergeArguments(config, into: mergedConfig) } } configURLs.append(url) } // Merge final config into args args = try mergeArguments(args, into: mergedConfig) return configURLs } typealias OutputFlags = ( filesWritten: Int, filesChecked: Int, filesSkipped: Int, filesFailed: Int ) func processArguments(_ args: [String], environment: [String: String] = [:], in directory: String) -> ExitCode { var errors = [Error]() var verbose = false var lenient = false var strict = false quietMode = false defer { // Reset quiet mode on exit to prevent side-effects between unit tests quietMode = false } do { // Get arguments var args = try preprocessArguments(args, commandLineArguments) // Quiet mode quietMode = (args["quiet"] != nil) // Verbose verbose = (args["verbose"] != nil) // Lenient lenient = (args["lenient"] != nil) // Strict strict = (args["strict"] != nil) // Lint let lint = (args["lint"] != nil) // Dry run let dryrun = lint || (args["dry-run"] != nil) // Whether or not to output tokens instead of source code let printTokens = args["output-tokens"] != nil // Warnings for warning in warningsForArguments(args) { print("warning: \(warning)", as: .warning) } // add args to environment let environment = environment.merging(args) { lhs, _ in lhs } // Report URL let reportURL: URL? = try args["report"].map { arg in guard !arg.isEmpty else { throw FormatError.options("--report argument expects a path") } return try parsePath(arg, for: "--output", in: directory) } // Reporter let reporter: Reporter = try args["reporter"].flatMap { identifier in guard let reporter = Reporters.reporter( named: identifier, environment: environment ) else { throw FormatError.invalidOption( identifier, for: "reporter", // swiftformat:disable:next --preferKeyPath with: Reporters.all.map { $0.name } ) } return reporter } ?? reportURL.flatMap { Reporters.reporter(for: $0, environment: environment) } ?? DefaultReporter(environment: environment) // Throw if default reporter is used with explicit url guard reportURL == nil || !(reporter is DefaultReporter) else { throw FormatError.options("--report requires --reporter to be specified") } // Show help if args["help"] != nil { printHelp(as: .content) return .ok } // Show version if args["version"] != nil { print(version, as: .content) return .ok } // Show options if args["options"] != nil { printOptions(as: .content) return .ok } // Show rule info if let names = try args["rule-info"].map({ try parseRules($0, ignoreUnknown: false) }) { let names = names.isEmpty ? allRules.sorted() : names.sorted() for name in names { try printRuleInfo(for: name, as: .content) } return .ok } // Display rules (must be checked before merging config) let showRules: Bool = args["rules"].map { if $0 == "" { args["rules"] = nil return true } return false } ?? false // Config files (support multiple) var filterOptions = [Glob: [String: String]]() let configURLs = try readMultipleConfigArgs("config", with: &args, filterOptions: &filterOptions, in: directory) // FormatOption overrides var overrides = [String: String]() for key in formattingArguments + rulesArguments { overrides[key] = args[key] } // Base config _ = try readConfigArg("base-config", with: &args, filterOptions: &filterOptions, in: directory) // Options var options = try Options(args, filterOptions: filterOptions, in: directory) options.configURLs = configURLs.isEmpty ? nil : configURLs // Show rules if showRules { print("") let rules = options.rules ?? defaultRules for name in Array(allRules).sorted() { let annotation: String if rules.contains(name) { annotation = "" } else if FormatRules.byName[name]?.isDeprecated == true { annotation = " (deprecated)" } else { annotation = " (disabled)" } print(" \(name)\(annotation)", as: .content) } print("") return .ok } // Input path(s) var inputURLs = [URL]() if let fileListPath = args["filelist"] { let fileListURL = try parsePath(fileListPath, for: "filelist", in: directory) if !FileManager.default.fileExists(atPath: fileListURL.path) { throw FormatError.reading("File not found at \(fileListURL.path)") } guard let source = try? String(contentsOf: fileListURL) else { throw FormatError.options("Failed to read file list at \(fileListPath)") } inputURLs += try parseFileList(source, in: fileListURL.deletingLastPathComponent().path) } var useStdin = false while let inputPath = args[String(inputURLs.count + 1)] { if inputPath.lowercased() == "stdin" { useStdin = true inputURLs.append(URL(string: "stdin")!) } else { inputURLs += try parsePaths(inputPath, in: directory) } } if useStdin { if inputURLs.count > 1 { if args["filelist"] != nil { throw FormatError.options("--filelist option cannot be combined with stdin input") } throw FormatError.options("Cannot combine stdin with other file inputs") } inputURLs = [] } if let stdinPath = args["stdin-path"] { if !useStdin { print("warning: --stdin-path option only applies when using stdin", as: .warning) } let stdinURL = try parsePath(stdinPath, for: "stdin-path", in: directory) // Try to get resource values, but if file doesn't exist, just use the path let resourceValues = try? getResourceValues( for: stdinURL.standardizedFileURL, keys: [.creationDateKey, .pathKey] ) var formatOptions = options.formatOptions ?? .default formatOptions.fileInfo = FileInfo( filePath: resourceValues?.path ?? stdinURL.standardizedFileURL.path, creationDate: resourceValues?.creationDate ) options.formatOptions = formatOptions } if args["script-input"] != nil { inputURLs += try parseScriptInput(from: environment) } /// Treat values for arguments that do not take a value as input paths func addInputPaths(for argName: String) throws { guard let arg = args[argName], !arg.isEmpty else { return } guard inputURLs.isEmpty || "/.,".contains(where: { arg.contains($0) }) else { throw FormatError.options("--\(argName) argument does not expect a value") } if arg.lowercased() == "stdin" { useStdin = true } else { inputURLs += try parsePaths(arg, in: directory) } } try addInputPaths(for: "quiet") try addInputPaths(for: "verbose") try addInputPaths(for: "lenient") try addInputPaths(for: "strict") try addInputPaths(for: "dry-run") try addInputPaths(for: "lint") try addInputPaths(for: "infer-options") // Output path var useStdout = false let outputURL = try args["output"].flatMap { arg -> URL? in if arg == "" { throw FormatError.options("--output argument expects a value") } else if inputURLs.count > 1 { throw FormatError.options("--output argument is only valid for a single input file or directory") } else if arg.lowercased() == "stdout" { useStdout = true return URL(string: "stdout") } if args["lint"] != nil { print("warning: --output argument is unused when running in --lint mode", as: .warning) } return try parsePath(arg, for: "--output", in: directory) } guard !useStdout || (reporter is DefaultReporter || reportURL != nil) else { throw FormatError.options("--report file must be specified when --output is stdout") } // Source range let lineRange = try args["line-range"].flatMap { arg -> ClosedRange? in if arg == "" { throw FormatError.options("--line-range argument expects a value") } else if inputURLs.count > 1 { throw FormatError.options("--line-range argument is only valid for a single input file") } let parts = arg.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } guard (1 ... 2).contains(parts.count), let start = parts.first.flatMap(Int.init), let end = parts.last.flatMap(Int.init) else { throw FormatError.options("Unsupported --line-range value '\(arg)'") } return start ... end } // Infer options if args["infer-options"] != nil { guard configURLs.isEmpty else { throw FormatError.options("--infer-options option can't be used along with a config file") } guard args["range"] == nil else { throw FormatError.options("--infer-options option can't be applied to a line range") } if !inputURLs.isEmpty { print("Inferring swiftformat options from source file(s)...", as: .info) var filesParsed = 0, formatOptions = FormatOptions.default, errors = [Error]() let fileOptions = options.fileOptions ?? .default let time = formatTime(timeEvent { (filesParsed, formatOptions, errors) = inferOptions(from: inputURLs, options: fileOptions) }) if printWarnings(errors) || filesParsed == 0 { throw FormatError.parsing("Failed to to infer options") } var filesChecked = filesParsed for case let error as FormatError in errors { switch error { case .parsing, .reading: filesChecked += 1 case .writing: assertionFailure() case .options: break } } print("Options inferred from \(filesParsed)/\(filesChecked) files in \(time).", as: .info) print("") var options = options options.formatOptions = formatOptions try serializeOptions(options, to: outputURL) return .ok } } // Cache path var cacheURL: URL? let defaultCacheFileName = "swiftformat.cache" let manager = FileManager.default func setDefaultCacheURL() { let cacheDirectory = { () -> URL in #if os(macOS) if let cachePath = NSSearchPathForDirectoriesInDomains( .cachesDirectory, .userDomainMask, true ).first { return URL(fileURLWithPath: cachePath) } #endif if #available(macOS 10.12, *) { return FileManager.default.temporaryDirectory } else { return URL(fileURLWithPath: "/var/tmp/") } }().appendingPathComponent("com.charcoaldesign.swiftformat") do { try manager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil) cacheURL = cacheDirectory.appendingPathComponent(defaultCacheFileName) } catch { errors.append(FormatError.writing("Failed to create cache directory at \(cacheDirectory.path)")) } } if let cache = args["cache"] { switch cache { case "": throw FormatError.options("--cache option expects a value") case "ignore": break case "clear": setDefaultCacheURL() if let cacheURL, manager.fileExists(atPath: cacheURL.path) { do { try manager.removeItem(at: cacheURL) } catch { errors.append(FormatError.writing("Failed to delete cache file at \(cacheURL.path)")) } } default: cacheURL = try parsePath(cache, for: "--cache", in: directory) guard cacheURL != nil else { throw FormatError.options("Invalid --cache value '\(cache)'") } var isDirectory: ObjCBool = false if manager.fileExists(atPath: cacheURL!.path, isDirectory: &isDirectory), isDirectory.boolValue { cacheURL = cacheURL!.appendingPathComponent(defaultCacheFileName) } } } else { setDefaultCacheURL() } func printRunningMessage() { print("Running SwiftFormat...", as: .info) if lint { print("(lint mode - no files will be changed.)", as: .info) } else if dryrun { print("(dryrun mode - no files will be changed.)", as: .info) } } enum Status { case idle, started, finished(ExitCode) } var input: String? var status = Status.idle func processFromStdin() { status = .started while let line = CLI.readLine() { input = (input ?? "") + line } guard let input else { status = .finished(.ok) return } do { var options = options if args["infer-options"] != nil { let tokens = tokenize(input) options.formatOptions = inferFormatOptions(from: tokens) try serializeOptions(options, to: outputURL) status = .finished(.ok) } else { printRunningMessage() if let stdinURL = options.formatOptions?.fileInfo.filePath.map(URL.init(fileURLWithPath:)) { try gatherOptions(&options, for: stdinURL, with: { print($0, as: .info) }) if options.shouldSkipFile(stdinURL) { print(input, as: .raw) status = .finished(.ok) return } // Try to get resource values, but allow nil for non-existing files let resourceValues = try? getResourceValues( for: stdinURL.standardizedFileURL, keys: [.creationDateKey, .pathKey] ) let fileInfo = collectFileInfo(inputURL: stdinURL, options: options, resourceValues: resourceValues) options.formatOptions?.fileInfo = fileInfo try options.addFilterArguments(path: stdinURL.path) } let outputTokens = try applyRules( input, options: options, lineRange: lineRange, verbose: verbose, lint: lint, reporter: reporter ) let output = sourceCode(for: outputTokens) if let outputURL, !useStdout { if !dryrun, (try? String(contentsOf: outputURL)) != output { try write(output, to: outputURL) } } else if !lint { // Write to stdout if printTokens { let tokensToPrint = dryrun ? tokenize(input) : outputTokens try print(OutputTokensData.encodedString(for: tokensToPrint), as: .raw) } else { print(dryrun ? input : output, as: .raw) } } else if let reporterOutput = try reporter.write() { if let reportURL { print("Writing report file to \(reportURL.path)", as: .info) try reporterOutput.write(to: reportURL, options: .atomic) } else { print(String(decoding: reporterOutput, as: UTF8.self), as: .raw) } } let exitCode: ExitCode if lint, output != input { print("Source input did not pass lint check.", as: lenient ? .warning : .error) exitCode = lenient ? .ok : .lintFailure } else if strict, output != input { print("Source input was reformatted.", as: .error) exitCode = .lintFailure } else { print("SwiftFormat completed successfully.", as: .success) exitCode = .ok } status = .finished(exitCode) } } catch { if printWarnings([error]) { status = .finished(.error) } else { status = .finished(.ok) } // Ensure input isn't lost print(input, as: .raw) } } if useStdin { processFromStdin() if case let .finished(exitCode) = status { return exitCode } return .ok } else if inputURLs.isEmpty { // If no input file, try stdin DispatchQueue.global(qos: .userInitiated).async { processFromStdin() } // Wait for input while case .idle = status {} let start = NSDate() while input == nil, start.timeIntervalSinceNow > -0.2 {} // If no input received by now, assume none is coming if input != nil { while start.timeIntervalSinceNow > -30 { if case let .finished(exitCode) = status { return exitCode } } } else if args["infer-options"] != nil { throw FormatError.options("--infer-options requires one or more input files") } else { printHelp(as: .info) } return .ok } printRunningMessage() // Format the code var outputFlags: OutputFlags = (0, 0, 0, 0) let time = formatTime(timeEvent { var _errors: [Error] (outputFlags, _errors) = processInput(inputURLs, andWriteToOutput: outputURL, options: options, overrides: overrides, lineRange: lineRange, verbose: verbose, dryrun: dryrun, lint: lint, lenient: lenient, cacheURL: cacheURL, reporter: reporter) errors += _errors }) if printWarnings(errors) { return .error } if outputFlags.filesChecked == 0, outputFlags.filesSkipped == 0 { let inputPaths = inputURLs.map(\.path).formattedList(lastSeparator: "or") print("warning: No eligible files found at \(inputPaths).", as: .warning) } if let reporterOutput = try reporter.write() { if let reportURL { print("Writing report file to \(reportURL.path)", as: .info) try reporterOutput.write(to: reportURL, options: .atomic) } else { print(String(decoding: reporterOutput, as: UTF8.self), as: .raw) } } print("SwiftFormat completed in \(time).", as: .success) return printResult(dryrun, lint, lenient, strict, outputFlags) } catch { _ = printWarnings(errors) // Fatal error var message = "\(error)" if ![".", "?", "!"].contains(message.last ?? " ") { message += "." } print("error: \(message)", as: .error) return .error } } func write(_ output: String, to file: URL) throws { do { let fm = FileManager.default let attributes = try? fm.attributesOfItem(atPath: file.path) try output.write(to: file, atomically: true, encoding: .utf8) if let created = attributes?[.creationDate] { try? fm.setAttributes([.creationDate: created], ofItemAtPath: file.path) } } catch { throw FormatError.writing("Failed to write file \(file.path)") } } func parseFileList(_ source: String, in directory: String) throws -> [URL] { try source .components(separatedBy: .newlines) .map { $0.components(separatedBy: "#")[0].trimmingCharacters(in: .whitespaces) } .flatMap { try parsePaths($0, in: directory) } } func parseScriptInput(from environment: [String: String]) throws -> [URL] { guard let countString = environment["SCRIPT_INPUT_FILE_COUNT"], let count = Int(countString) else { throw FormatError .options("--script-input requires a configured SCRIPT_INPUT_FILE_COUNT integer variable") } return try (0 ..< count).map { index in guard let file = environment["SCRIPT_INPUT_FILE_\(index)"] else { throw FormatError .options("Input file count is \(count), but SCRIPT_INPUT_FILE_\(index) is not present") } return URL(fileURLWithPath: file) } } func printResult(_ dryrun: Bool, _ lint: Bool, _ lenient: Bool, _ strict: Bool, _ flags: OutputFlags) -> ExitCode { let (written, checked, skipped, failed) = flags let ignored = (skipped == 0) ? "" : ", \(skipped) file\(skipped == 1 ? "" : "s") skipped" if checked == 0 { print("0 files formatted\(ignored).", as: .info) } else if lint { if failed > 0 { print("Source input did not pass lint check.", as: .error) } print("\(failed)/\(checked) files require formatting\(ignored).", as: .info) } else if dryrun { print("\(failed)/\(checked) files would have been formatted\(ignored).", as: .info) } else { print("\(written)/\(checked) files formatted\(ignored).", as: .info) } return ((!lenient && lint) || strict) && failed > 0 ? .lintFailure : .ok } func inferOptions(from inputURLs: [URL], options: FileOptions) -> (Int, FormatOptions, [Error]) { var tokens = [Token]() var filesParsed = 0 var options = options // Avoid trying to tokenize markdown files // TODO: need a more robust solution options.supportedFileExtensions.removeAll(where: { $0 == "md" }) let baseOptions = Options(fileOptions: options) let errors = enumerateFiles( withInputURLs: inputURLs, options: baseOptions, logger: { print($0, as: .info) } ) { inputURL, _, _ in guard let input = try? String(contentsOf: inputURL) else { throw FormatError.reading("Failed to read file \(inputURL.path)") } let _tokens = tokenize(input) return { filesParsed += 1 tokens += _tokens } } return (filesParsed, inferFormatOptions(from: tokens), errors) } func computeHash(_ source: String) -> String { var count = 0 var hash: UInt64 = 5381 for byte in source.utf8 { count += 1 hash = 127 &* (hash & 0x00FF_FFFF_FFFF_FFFF) &+ UInt64(byte) } return "\(count)\(hash)" } func applyRules(_ source: String, tokens: [Token]? = nil, options: Options, lineRange: ClosedRange?, verbose: Bool, lint: Bool, reporter: Reporter?) throws -> [Token] { // Parse source var tokens = tokens ?? tokenize(source) // Get rules let rulesByName = FormatRules.byName let ruleNames = Array(options.rules ?? defaultRules).sorted() let rules = ruleNames.compactMap { rulesByName[$0] } if verbose, let path = options.formatOptions?.fileInfo.filePath { print("\(lint ? "Linting" : "Formatting") \(path)", as: .info) } // Apply rules let formatOptions = options.formatOptions ?? .default var changes = [Formatter.Change]() let range = lineRange.map { tokenRange(forLineRange: $0, in: tokens) } (tokens, changes) = try applyRules( rules, to: tokens, with: formatOptions, trackChanges: lint || verbose || reporter != nil, range: range ) // Display info let updatedSource = sourceCode(for: tokens) if lint, updatedSource != source { reporter?.report(changes) } if verbose { let rulesApplied = changes.reduce(into: Set()) { $0.insert($1.rule.name) } if rulesApplied.isEmpty || updatedSource == source { print("-- no changes", as: .success) } else { let sortedNames = Array(rulesApplied).sorted().formattedList(lastSeparator: "and") print("-- rules applied: \(sortedNames)", as: .success) } } // Output return tokens } func processInput(_ inputURLs: [URL], andWriteToOutput outputURL: URL?, options: Options, overrides: [String: String], lineRange: ClosedRange?, verbose: Bool, dryrun: Bool, lint: Bool, lenient _: Bool, cacheURL: URL?, reporter: Reporter?) -> (OutputFlags, [Error]) { // Load cache let cacheDirectory = cacheURL?.deletingLastPathComponent().absoluteURL var cache: [String: String]? if let cacheURL { if let data = try? Data(contentsOf: cacheURL) { cache = try? JSONDecoder().decode([String: String].self, from: data) } cache = cache ?? [:] } // Logging skipped files var outputFlags: OutputFlags = (0, 0, 0, 0) let skippedHandler: FileEnumerationHandler? = verbose ? { inputURL, _, _ in print("Skipping \(inputURL.path)", as: .info) print("-- ignored", as: .warning) return {} } : { _, _, _ in { outputFlags.filesSkipped += 1 } } // Swift version var shownWarnings = Set() var showedConfigurationWarnings = false func showConfigurationWarnings(_ options: Options) { let arguments = argumentsFor(options, excludingDefaults: true) let warnings = warningsForArguments(arguments, ignoreUnusedOptions: true) for warning in warnings where !shownWarnings.contains(warning) { shownWarnings.insert(warning) print("warning: \(warning)", as: .warning) } guard !showedConfigurationWarnings else { return } let formatOptions = options.formatOptions ?? .default if formatOptions.swiftVersion == .undefined { print("warning: No Swift version was specified, so some formatting features were disabled. Specify the version of Swift you are using with the --swift-version option, or by adding a \(swiftVersionFile) file to your project.", as: .warning) } if formatOptions.useTabs, formatOptions.tabWidth <= 0, !formatOptions.smartTabs { print("warning: The --smarttabs option is disabled, but no --tabwidth was specified.", as: .warning) } showedConfigurationWarnings = true } // Format files var errors = enumerateFiles( withInputURLs: inputURLs, outputURL: outputURL, options: options, concurrent: !verbose, logger: { print($0, as: .info) }, skipped: skippedHandler ) { inputURL, outputURL, options in guard let input = try? String(contentsOf: inputURL) else { throw FormatError.reading("Failed to read file \(inputURL.path)") } // Override options var options = options try options.addArguments(overrides, in: "") // No need for directory as overrides are formatOptions only try options.addFilterArguments(path: inputURL.path) let formatOptions = options.formatOptions ?? .default let range = lineRange.map { "\($0.lowerBound),\($0.upperBound);" } ?? "" // Check cache let rules = options.rules ?? defaultRules let configHash = computeHash("\(formatOptions)\(range)\(rules.sorted().joined(separator: ","))") let cachePrefix = "\(version);\(configHash);" let cacheKey: String = { var path = inputURL.absoluteURL.path if let cacheDirectory { let commonPrefix = path.commonPrefix(with: cacheDirectory.path) path = String(path[commonPrefix.endIndex ..< path.endIndex]) } return path }() do { var cacheHash: String? var sourceHash: String? if let cacheEntry = cache?[cacheKey], cacheEntry.hasPrefix(cachePrefix) { cacheHash = String(cacheEntry[cachePrefix.endIndex...]) sourceHash = computeHash(input) } let output: String if let cacheHash, cacheHash == sourceHash { output = input if verbose { print("\(lint ? "Linting" : "Formatting") \(inputURL.path)", as: .info) print("-- no changes (cached)", as: .success) } } else if inputURL.pathExtension == "md" { var markdown = input let swiftCodeBlocks: [MarkdownCodeBlock] do { swiftCodeBlocks = try parseCodeBlocks(fromMarkdown: input, language: "swift") } catch { switch (options.formatOptions ?? .default).markdownFiles { case .strict: throw error case .lenient, .ignore: swiftCodeBlocks = [] } } // Iterate backwards through the code blocks to not invalidate existing indices for swiftCodeBlock in swiftCodeBlocks.reversed() { // Determine the options to use when formatting this block var options = options if swiftCodeBlock.options?.contains("no-format") == true { continue } else if let args = swiftCodeBlock.options?.components(separatedBy: " "), !args.isEmpty { let arguments = try preprocessArguments(args, commandLineArguments) try applyArguments(arguments, lint: lint, to: &options) } // Set fragment mode var formatOptions = options.formatOptions ?? .default if formatOptions.markdownFiles == .ignore { continue } formatOptions.fragment = true options.formatOptions = formatOptions // Update linebreak line numbers to reflect the actual line in the markdown file // rather than only the line within the code block. This makes it easier to // understand printed diagnostics that include line numbers. let inputTokens = tokenize(swiftCodeBlock.text).map { token in if case let .linebreak(string, lineInCodeBlock) = token { return Token.linebreak(string, lineInCodeBlock + swiftCodeBlock.lineStartIndex) } else { return token } } var outputTokens: [Token]? let parsingError = parsingError(for: inputTokens, options: formatOptions, allowErrorsInFragments: false) switch formatOptions.markdownFiles { case .lenient, .ignore: // Ignore code blocks that fail to parse if parsingError == nil { outputTokens = try? applyRules(swiftCodeBlock.text, tokens: inputTokens, options: options, lineRange: lineRange, verbose: verbose, lint: lint, reporter: reporter) } case .strict: if let parsingError { throw parsingError } outputTokens = try applyRules(swiftCodeBlock.text, tokens: inputTokens, options: options, lineRange: lineRange, verbose: verbose, lint: lint, reporter: reporter) } if let outputTokens { assert(markdown[swiftCodeBlock.range] == swiftCodeBlock.text) markdown.replaceSubrange(swiftCodeBlock.range, with: sourceCode(for: outputTokens)) } } output = markdown if markdown != input { sourceHash = nil } } else { // Regular swift file let outputTokens = try applyRules(input, options: options, lineRange: lineRange, verbose: verbose, lint: lint, reporter: reporter) output = sourceCode(for: outputTokens) if output != input { sourceHash = nil } } let cacheValue = cache.map { _ in // Only bother computing this if cache is enabled cachePrefix + (sourceHash ?? computeHash(output)) } if outputURL.path.components(separatedBy: "/").contains("stdout") { if !dryrun { // Write to stdout print(output, as: .raw) return { outputFlags.filesChecked += 1 outputFlags.filesFailed += 1 outputFlags.filesWritten += 1 showConfigurationWarnings(options) } } } else if outputURL != inputURL, (try? String(contentsOf: outputURL)) != output { if !dryrun { do { try FileManager.default.createDirectory(at: outputURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) } catch { throw FormatError.writing("Failed to create directory at \(outputURL.path), \(error)") } } } else if output == input { // No changes needed return { outputFlags.filesChecked += 1 cache?[cacheKey] = cacheValue showConfigurationWarnings(options) } } if dryrun { return { outputFlags.filesChecked += 1 outputFlags.filesFailed += 1 showConfigurationWarnings(options) } } else { if verbose { print("Writing \(outputURL.path)", as: .info) } try write(output, to: outputURL) return { outputFlags.filesChecked += 1 outputFlags.filesFailed += 1 outputFlags.filesWritten += 1 cache?[cacheKey] = cacheValue showConfigurationWarnings(options) } } } catch { if verbose { var message = "\(error)" if !".?!".contains(message.last ?? " ") { message += "." } print("-- error: \(message)", as: .error) } return { outputFlags.filesChecked += 1 showConfigurationWarnings(options) switch error { case let FormatError.parsing(message): if let range = message.range(of: ". Valid options") ?? message.range(of: ". Did you mean") { throw FormatError.parsing(""" \(message[.. 0 { // Replace individual warnings with a generic message, to avoid repetition errors.append(FormatError.writing("\(errorCount) file\(errorCount == 1 ? "" : "s") could not be formatted")) } } // Save cache if outputFlags.filesChecked > 0, let cache, let cacheURL, let cacheDirectory { do { let data = try JSONEncoder().encode(cache) try data.write(to: cacheURL, options: .atomic) } catch { if FileManager.default.fileExists(atPath: cacheDirectory.path) { errors.append(FormatError.writing("Failed to write cache file at \(cacheURL.path)")) } else { errors.append(FormatError.reading("Specified cache file directory does not exist: \(cacheDirectory.path)")) } } } return (outputFlags, errors) } /// The data format used with `--output-tokens` private struct OutputTokensData: Encodable { init(tokens: [Token]) { self.tokens = tokens version = swiftFormatVersion } /// The SwiftFormat version that this data originated from let version: String /// A representation of the output in `Tokens` let tokens: [Token] /// Creates the `OutputTokensData` and encodes it to a JSON string static func encodedString(for tokens: [Token]) throws -> String { let outputData = OutputTokensData(tokens: tokens) let encoder = JSONEncoder() encoder.outputFormatting = .sortedKeys let encodedData = try encoder.encode(outputData) return String(data: encodedData, encoding: .utf8)! } } /// A code block from a markdown file /// /// For example: /// /// ```{language} {{options like `no-format`, `--disable ruleName` can be put here}} /// // This content is returned as text, /// // and its range in the markdown string is returned as range. /// ``` struct MarkdownCodeBlock { let language: String let range: Range let text: String let options: String? let lineStartIndex: Int } /// Parses code blocks of a specific language in the given markdown file. /// /// Any text following the open delimiter on that initial line is returned in `options`. /// /// For example: /// /// ```{language} {{options like `no-format`, `--disable ruleName` can be put here}} /// // This content is returned as text, /// // and its range in the markdown string is returned as range. /// ``` func parseCodeBlocks(fromMarkdown markdown: String, language: String) throws -> [MarkdownCodeBlock] { struct PartialCodeBlock { let lineStartIndex: Int let topLevel: Bool let tickCount: Int let textAfterTicks: String } let lines = markdown.lineRanges var codeBlocks: [MarkdownCodeBlock] = [] var codeBlockStack = [PartialCodeBlock]() for (lineIndex, lineRange) in lines.enumerated() { let lineText = markdown[lineRange].trimmingCharacters(in: .whitespacesAndNewlines) let ticks = String(lineText.prefix(while: { $0 == "`" })) let tickCount = ticks.count guard tickCount >= 3 else { continue } let textAfterTicks = String(lineText.dropFirst(tickCount)) // If this fence has a different number of ticks from the previous fence, // or has text like ```language, then it's an open fence. let isOpenFence = !textAfterTicks.isEmpty || codeBlockStack.last?.tickCount != tickCount if isOpenFence { codeBlockStack.append(PartialCodeBlock( lineStartIndex: lineIndex + 1, topLevel: codeBlockStack.isEmpty, tickCount: tickCount, textAfterTicks: textAfterTicks )) } else if let partialBlock = codeBlockStack.popLast() { // Only store and return blocks that are top-level and match the given language guard partialBlock.topLevel, partialBlock.textAfterTicks.hasPrefix(language), lineIndex != partialBlock.lineStartIndex else { continue } let options = String(partialBlock.textAfterTicks.dropFirst(language.count)) .trimmingCharacters(in: .whitespacesAndNewlines) let codeEnd = lines[lineIndex - 1].upperBound let range = lines[partialBlock.lineStartIndex].lowerBound ..< codeEnd let codeText = String(markdown[range]) assert(markdown[range] == codeText) codeBlocks.append(MarkdownCodeBlock( language: language, range: range, text: codeText, options: options.isEmpty ? nil : options, lineStartIndex: partialBlock.lineStartIndex )) } } // Check for unbalanced code blocks if !codeBlockStack.isEmpty { throw FormatError.parsing("Unbalanced code block delimiters in markdown") } return codeBlocks }