diff --git a/README.md b/README.md index db2e090a9..1d24b4321 100644 --- a/README.md +++ b/README.md @@ -200,19 +200,24 @@ configuration, by redirecting it to a file and editing it. ### Configuring the Command Line Tool -For any source file being checked or formatted, `swift-format` looks for a -JSON-formatted file named `.swift-format` in the same directory. If one is -found, then that file is loaded to determine the tool's configuration. If the -file is not found, then it looks in the parent directory, and so on. +For any source file being checked or formatted, `swift-format` looks for +configuration files in the same directory, and parent directories. -If no configuration file is found, a default configuration is used. The -settings in the default configuration can be viewed by running -`swift-format dump-configuration`, which will dump it to standard -output. +If it finds a file named `.swift-format-ignore`, its contents will determine +which files in that directory will be ignored by `swift-format`. Currently +the only supported option is `*`, which ignores all files. + +If it finds a JSON-formatted file called `.swift-format`, then that +file is loaded to determine the tool's configuration. + +If no configuration file is found at any level, a default configuration +is used. The settings in the default configuration can be viewed by +running `swift-format dump-configuration`, which will dump it to +standard output. If the `--configuration ` option is passed to `swift-format`, then that configuration will be used unconditionally and the file system will not be -searched. +searched for `.swift-format` files. See [Documentation/Configuration.md](Documentation/Configuration.md) for a description of the configuration file format and the settings that are diff --git a/Sources/SwiftFormat/Core/IgnoreFile.swift b/Sources/SwiftFormat/Core/IgnoreFile.swift new file mode 100644 index 000000000..9bd009c02 --- /dev/null +++ b/Sources/SwiftFormat/Core/IgnoreFile.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// A file that describes which files and directories should be ignored by the formatter. +/// In the future, this file may contain complex rules for ignoring files, based +/// on pattern matching file paths. +/// +/// Currently, the only valid content for an ignore file is a single asterisk "*", +/// optionally surrounded by whitespace. +public class IgnoreFile { + /// Name of the ignore file to look for. + /// The presence of this file in a directory will cause the formatter + /// to skip formatting files in that directory and its subdirectories. + public static let standardFileName = ".swift-format-ignore" + + /// Errors that can be thrown by the IgnoreFile initializer. + public enum Error: Swift.Error { + /// Error thrown when initialising with invalid content. + case invalidContent + + /// Error thrown when we fail to initialise with the given URL. + case invalidFile(URL, Swift.Error) + } + + /// Create an instance from a string. + /// Returns nil if the content is not valid. + public init(_ content: String) throws { + guard content.trimmingCharacters(in: .whitespacesAndNewlines) == "*" else { + throw Error.invalidContent + } + } + + /// Create an instance from the contents of the file at the given URL. + /// Throws an error if the file content can't be read, or is not valid. + public convenience init(contentsOf url: URL) throws { + do { + try self.init(try String(contentsOf: url, encoding: .utf8)) + } catch { + throw Error.invalidFile(url, error) + } + } + + /// Create an instance for the given directory, if a valid + /// ignore file with the standard name is found in that directory. + /// Returns nil if no ignore file is found. + /// Throws an error if an invalid ignore file is found. + /// + /// Note that this initializer does not search parent directories for ignore files. + public convenience init?(forDirectory directory: URL) throws { + let url = directory.appendingPathComponent(IgnoreFile.standardFileName) + + do { + try self.init(contentsOf: url) + } catch { + if case let Error.invalidFile(_, underlying) = error, (underlying as NSError).domain == NSCocoaErrorDomain, + (underlying as NSError).code == NSFileReadNoSuchFileError + { + return nil + } + throw error + } + } + + /// Create an instance to use for the given URL. + /// We search for an ignore file starting from the given URL's container, + /// and moving up the directory tree, until we reach the root directory. + /// Returns nil if no ignore file is found. + /// Throws an error if an invalid ignore file is found somewhere + /// in the directory tree. + /// + /// Note that we start the search from the given URL's **container**, + /// not the URL itself; the URL passed in is expected to be for a file. + /// If you pass a directory URL, the search will not include the contents + /// of that directory. + public convenience init?(for url: URL) throws { + guard !url.isRoot else { + return nil + } + + var containingDirectory = url.absoluteURL.standardized + repeat { + containingDirectory.deleteLastPathComponent() + let url = containingDirectory.appendingPathComponent(IgnoreFile.standardFileName) + if FileManager.default.isReadableFile(atPath: url.path) { + try self.init(contentsOf: url) + return + } + } while !containingDirectory.isRoot + return nil + } + + /// Should the given URL be processed? + /// Currently the only valid ignore file content is "*", + /// which means that all files should be ignored. + func shouldProcess(_ url: URL) -> Bool { + return false + } +} diff --git a/Sources/SwiftFormat/Utilities/FileIterator.swift b/Sources/SwiftFormat/Utilities/FileIterator.swift index b0a8d2f06..4a573dd3c 100644 --- a/Sources/SwiftFormat/Utilities/FileIterator.swift +++ b/Sources/SwiftFormat/Utilities/FileIterator.swift @@ -57,7 +57,7 @@ public struct FileIterator: Sequence, IteratorProtocol { /// - workingDirectory: `URL` that indicates the current working directory. Used for testing. public init(urls: [URL], followSymlinks: Bool, workingDirectory: URL = URL(fileURLWithPath: ".")) { self.workingDirectory = workingDirectory - self.urls = urls + self.urls = urls.filter(inputShouldBeProcessed(at:)) self.urlIterator = self.urls.makeIterator() self.followSymlinks = followSymlinks } @@ -92,6 +92,20 @@ public struct FileIterator: Sequence, IteratorProtocol { fallthrough case .typeDirectory: + do { + if let ignoreFile = try IgnoreFile(forDirectory: next), !ignoreFile.shouldProcess(next) { + // skip this directory and its subdirectories if it should be ignored + continue + } + } catch IgnoreFile.Error.invalidFile(let url, _) { + // we hit an invalid ignore file + // we return the path of the ignore file so that we can report an error + // and process the directory as normal + output = url + } catch { + // we hit another unexpected error; process the directory as normal + } + dirIterator = FileManager.default.enumerator( at: next, includingPropertiesForKeys: nil, @@ -179,3 +193,34 @@ private func fileType(at url: URL) -> FileAttributeType? { // Linux. return try? FileManager.default.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType } + +/// Returns true if the file should be processed. +/// Directories are always processed. +/// For other files, we look for an ignore file in the containing +/// directory or any of its parents. +/// If there is no ignore file, we process the file. +/// If an ignore file is found, we consult it to see if the file should be processed. +/// An invalid ignore file is treated here as if it does not exist, but +/// will be reported as an error when we try to process the directory. +private func inputShouldBeProcessed(at url: URL) -> Bool { + guard fileType(at: url) != .typeDirectory else { + return true + } + + let ignoreFile = try? IgnoreFile(for: url) + return ignoreFile?.shouldProcess(url) ?? true +} + +fileprivate extension URL { + var isRoot: Bool { + #if os(Windows) + // FIXME: We should call into Windows' native check to check if this path is a root once https://github.com/swiftlang/swift-foundation/issues/976 is fixed. + // https://github.com/swiftlang/swift-format/issues/844 + return self.pathComponents.count <= 1 + #else + // On Linux, we may end up with an string for the path due to https://github.com/swiftlang/swift-foundation/issues/980 + // TODO: Remove the check for "" once https://github.com/swiftlang/swift-foundation/issues/980 is fixed. + return self.path == "/" || self.path == "" + #endif + } +} \ No newline at end of file diff --git a/Sources/swift-format/Frontend/Frontend.swift b/Sources/swift-format/Frontend/Frontend.swift index a3ea18a4f..4d3c4002a 100644 --- a/Sources/swift-format/Frontend/Frontend.swift +++ b/Sources/swift-format/Frontend/Frontend.swift @@ -165,6 +165,13 @@ class Frontend { /// Read and prepare the file at the given path for processing, optionally synchronizing /// diagnostic output. private func openAndPrepareFile(at url: URL) -> FileToProcess? { + guard url.lastPathComponent != IgnoreFile.standardFileName else { + diagnosticsEngine.emitError( + "Invalid ignore file \(url.relativePath): currently the only supported content for ignore files is a single asterisk `*`, which matches all files." + ) + return nil + } + guard let sourceFile = try? FileHandle(forReadingFrom: url) else { diagnosticsEngine.emitError( "Unable to open \(url.relativePath): file is not readable or does not exist" diff --git a/Tests/SwiftFormatTests/Core/IgnoreFileTests.swift b/Tests/SwiftFormatTests/Core/IgnoreFileTests.swift new file mode 100644 index 000000000..2bff90f36 --- /dev/null +++ b/Tests/SwiftFormatTests/Core/IgnoreFileTests.swift @@ -0,0 +1,126 @@ +@_spi(Internal) import SwiftFormat +import XCTest + +final class IgnoreFileTests: XCTestCase { + var testTreeURL: URL? + + /// Description of a file or directory tree to create for testing. + enum TestTree { + case file(String, String) + case directory(String, [TestTree]) + } + + override func tearDown() { + // Clean up any test tree after each test. + if let testTreeURL { + // try? FileManager.default.removeItem(at: testTreeURL) + } + } + + /// Make a temporary directory tree for testing. + /// Returns the URL of the root directory. + /// The tree will be cleaned up after the test. + /// If a tree is already set up, it will be cleaned up first. + func makeTempTree(_ tree: TestTree) throws -> URL { + if let testTreeURL { + try? FileManager.default.removeItem(at: testTreeURL) + } + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true) + try writeTree(tree, to: tempURL) + testTreeURL = tempURL + return tempURL + } + + /// Write a file or directory tree to the given root URL. + func writeTree(_ tree: TestTree, to root: URL) throws { + switch tree { + case let .file(name, contents): + print("Writing file \(name) to \(root)") + try contents.write(to: root.appendingPathComponent(name), atomically: true, encoding: .utf8) + case let .directory(name, children): + let directory = root.appendingPathComponent(name) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for child in children { + try writeTree(child, to: directory) + } + } + } + + func testMissingIgnoreFile() throws { + let url = URL(filePath: "/") + XCTAssertNil(try IgnoreFile(forDirectory: url)) + XCTAssertNil(try IgnoreFile(for: url.appending(path: "file.swift"))) + } + + func testValidIgnoreFile() throws { + let url = try makeTempTree(.file(IgnoreFile.standardFileName, "*")) + XCTAssertNotNil(try IgnoreFile(forDirectory: url)) + XCTAssertNotNil(try IgnoreFile(for: url.appending(path: "file.swift"))) + } + + func testInvalidIgnoreFile() throws { + let url = try makeTempTree(.file(IgnoreFile.standardFileName, "this is an invalid pattern")) + XCTAssertThrowsError(try IgnoreFile(forDirectory: url)) + XCTAssertThrowsError(try IgnoreFile(for: url.appending(path: "file.swift"))) + } + + func testEmptyIgnoreFile() throws { + XCTAssertThrowsError(try IgnoreFile("")) + } + + func testNestedIgnoreFile() throws { + let url = try makeTempTree(.file(IgnoreFile.standardFileName, "*")) + let fileInSubdirectory = url.appendingPathComponent("subdirectory").appending(path: "file.swift") + XCTAssertNotNil(try IgnoreFile(for: fileInSubdirectory)) + } + + func testIterateWithIgnoreFile() throws { + let url = try makeTempTree(.file(IgnoreFile.standardFileName, "*")) + let iterator = FileIterator(urls: [url], followSymlinks: false) + let files = Array(iterator) + XCTAssertEqual(files.count, 0) + } + + func testIterateWithInvalidIgnoreFile() throws { + let url = try makeTempTree(.file(IgnoreFile.standardFileName, "this file is invalid")) + let iterator = FileIterator(urls: [url], followSymlinks: false) + let files = Array(iterator) + XCTAssertEqual(files.count, 1) + XCTAssertTrue(files.first?.lastPathComponent == IgnoreFile.standardFileName) + } + + func testIterateWithNestedIgnoreFile() throws { + let url = try makeTempTree( + .directory( + "Source", + [ + .directory( + "Ignored", + [ + .file(IgnoreFile.standardFileName, "*"), + .file("file.swift", "contents"), + ] + ), + .directory( + "Not Ignored", + [ + .file("file.swift", "contents") + ] + ), + ] + ) + ) + + XCTAssertNil(try IgnoreFile(forDirectory: url)) + XCTAssertNil(try IgnoreFile(for: url.appending(path: "Source/file.swift"))) + XCTAssertNotNil(try IgnoreFile(for: url.appending(path: "Source/Ignored/file.swift"))) + let iterator = FileIterator(urls: [url], followSymlinks: false) + let files = Array(iterator) + + XCTAssertEqual(files.count, 1) + XCTAssertEqual(files.first?.lastPathComponent, "file.swift") + } + +} diff --git a/Tests/SwiftFormatTests/Resources/Ignore Files/nested/.swift-format-ignore b/Tests/SwiftFormatTests/Resources/Ignore Files/nested/.swift-format-ignore new file mode 100644 index 000000000..f59ec20aa --- /dev/null +++ b/Tests/SwiftFormatTests/Resources/Ignore Files/nested/.swift-format-ignore @@ -0,0 +1 @@ +* \ No newline at end of file