Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability To Turn Off Formatting For Subdirectory #873

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eb29cb8
Initial stab at option to disable all formatting.
samdeane Nov 7, 2024
356b55f
Updated config documentation in README.
samdeane Nov 7, 2024
021d6b3
Fixed typo, initialiser.
samdeane Nov 7, 2024
3e4c5d2
Documented allDisabled key
samdeane Nov 7, 2024
8bade83
Emit unmodified source if allDisabled is true.
samdeane Nov 7, 2024
aac6fe8
Don't lint if allDisabled is set.
samdeane Nov 7, 2024
a7d4c24
Change flag to skipAll.
samdeane Nov 7, 2024
3046d92
Tweaked docs
samdeane Nov 7, 2024
bb4382e
Check for suppression file first.
samdeane Nov 7, 2024
17dc1bd
Use .swift-format-ignore as the file name.
samdeane Nov 7, 2024
cf27a3c
Move suppression logic up to FileIterator
samdeane Nov 7, 2024
7b22d51
Removed spurious newlines
samdeane Nov 7, 2024
fb8b06f
Filter urls to remove ignored ones.
samdeane Nov 7, 2024
5d85e22
Tweaked readme.
samdeane Nov 7, 2024
9ab91a5
Removed obsolete addition.
samdeane Nov 7, 2024
7b35513
Oops - skipping initial directory...
samdeane Nov 7, 2024
b2975de
Documentation tweaks.
samdeane Nov 7, 2024
ed1d3c4
Add minimal IgnoreFile abstraction.
samdeane Nov 27, 2024
3bc036e
Comment for IgnoreFile class
samdeane Nov 27, 2024
cff2b22
Added IgnoreFile unit tests
samdeane Nov 27, 2024
e9861ca
Added test for nested directory.
samdeane Nov 27, 2024
ba343ca
Added string constructor. Don't skip dir.
samdeane Dec 11, 2024
c9a14e3
Removed test resources
samdeane Dec 11, 2024
707061f
fixed nested test with explanatory comment
samdeane Dec 11, 2024
85840d6
Fixed merge conflict
samdeane Dec 11, 2024
5b5db57
Actually, this test should fail (currently)
samdeane Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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
Expand Down
109 changes: 109 additions & 0 deletions Sources/SwiftFormat/Core/IgnoreFile.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
47 changes: 46 additions & 1 deletion Sources/SwiftFormat/Utilities/FileIterator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
7 changes: 7 additions & 0 deletions Sources/swift-format/Frontend/Frontend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test case that ensures this diagnostic gets produced?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have existing code to test the front end output over a directory?
If you do, then sure.
If not, I think adding that code is outside the scope of this change and would be better off as a follow-up issue.

"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"
Expand Down
126 changes: 126 additions & 0 deletions Tests/SwiftFormatTests/Core/IgnoreFileTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*