Skip to content

Commit 8707af0

Browse files
authored
Merge pull request #644 from allevato/no-dot-files
Ignore symlinks and hidden (dot) files during `--recursive`.
2 parents 36b8870 + c9a84d6 commit 8707af0

File tree

5 files changed

+193
-33
lines changed

5 files changed

+193
-33
lines changed

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ let package = Package(
130130
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
131131
]
132132
),
133+
.testTarget(
134+
name: "swift-formatTests",
135+
dependencies: ["swift-format"]
136+
),
133137
]
134138
)
135139

Sources/swift-format/Frontend/Frontend.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,17 @@ class Frontend {
130130
"processURLs(_:) should only be called when 'urls' is non-empty.")
131131

132132
if parallel {
133-
let filesToProcess = FileIterator(urls: urls).compactMap(openAndPrepareFile)
133+
let filesToProcess =
134+
FileIterator(urls: urls, followSymlinks: lintFormatOptions.followSymlinks)
135+
.compactMap(openAndPrepareFile)
134136
DispatchQueue.concurrentPerform(iterations: filesToProcess.count) { index in
135137
processFile(filesToProcess[index])
136138
}
137139
} else {
138-
FileIterator(urls: urls).lazy.compactMap(openAndPrepareFile).forEach(processFile)
140+
FileIterator(urls: urls, followSymlinks: lintFormatOptions.followSymlinks)
141+
.lazy
142+
.compactMap(openAndPrepareFile)
143+
.forEach(processFile)
139144
}
140145
}
141146

Sources/swift-format/Subcommands/LintFormatOptions.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ struct LintFormatOptions: ParsableArguments {
7171
""")
7272
var colorDiagnostics: Bool?
7373

74+
/// Whether symlinks should be followed.
75+
@Flag(help: """
76+
Follow symbolic links passed on the command line, or found during directory traversal when \
77+
using `-r/--recursive`.
78+
""")
79+
var followSymlinks: Bool = false
80+
7481
/// The list of paths to Swift source files that should be formatted or linted.
7582
@Argument(help: "Zero or more input filenames.")
7683
var paths: [String] = []

Sources/swift-format/Utilities/FileIterator.swift

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,54 +14,82 @@ import Foundation
1414

1515
/// Iterator for looping over lists of files and directories. Directories are automatically
1616
/// traversed recursively, and we check for files with a ".swift" extension.
17-
struct FileIterator: Sequence, IteratorProtocol {
17+
@_spi(Testing)
18+
public struct FileIterator: Sequence, IteratorProtocol {
1819

1920
/// List of file and directory URLs to iterate over.
20-
let urls: [URL]
21+
private let urls: [URL]
22+
23+
/// If true, symlinks will be followed when iterating over directories and files. If not, they
24+
/// will be ignored.
25+
private let followSymlinks: Bool
2126

2227
/// Iterator for the list of URLs.
23-
var urlIterator: Array<URL>.Iterator
28+
private var urlIterator: Array<URL>.Iterator
2429

2530
/// Iterator for recursing through directories.
26-
var dirIterator: FileManager.DirectoryEnumerator? = nil
31+
private var dirIterator: FileManager.DirectoryEnumerator? = nil
2732

2833
/// The current working directory of the process, which is used to relativize URLs of files found
2934
/// during iteration.
30-
let workingDirectory = URL(fileURLWithPath: ".")
35+
private let workingDirectory = URL(fileURLWithPath: ".")
3136

3237
/// Keep track of the current directory we're recursing through.
33-
var currentDirectory = URL(fileURLWithPath: "")
38+
private var currentDirectory = URL(fileURLWithPath: "")
3439

3540
/// Keep track of files we have visited to prevent duplicates.
36-
var visited: Set<String> = []
41+
private var visited: Set<String> = []
3742

3843
/// The file extension to check for when recursing through directories.
39-
let fileSuffix = ".swift"
44+
private let fileSuffix = ".swift"
4045

4146
/// Create a new file iterator over the given list of file URLs.
4247
///
4348
/// The given URLs may be files or directories. If they are directories, the iterator will recurse
4449
/// into them.
45-
init(urls: [URL]) {
50+
public init(urls: [URL], followSymlinks: Bool) {
4651
self.urls = urls
4752
self.urlIterator = self.urls.makeIterator()
53+
self.followSymlinks = followSymlinks
4854
}
4955

5056
/// Iterate through the "paths" list, and emit the file paths in it. If we encounter a directory,
5157
/// recurse through it and emit .swift file paths.
52-
mutating func next() -> URL? {
58+
public mutating func next() -> URL? {
5359
var output: URL? = nil
5460
while output == nil {
5561
// Check if we're recursing through a directory.
5662
if dirIterator != nil {
5763
output = nextInDirectory()
5864
} else {
59-
guard let next = urlIterator.next() else { return nil }
60-
var isDir: ObjCBool = false
61-
if FileManager.default.fileExists(atPath: next.path, isDirectory: &isDir), isDir.boolValue {
62-
dirIterator = FileManager.default.enumerator(at: next, includingPropertiesForKeys: nil)
65+
guard var next = urlIterator.next() else {
66+
// If we've reached the end of all the URLs we wanted to iterate over, exit now.
67+
return nil
68+
}
69+
70+
guard let fileType = fileType(at: next) else {
71+
continue
72+
}
73+
74+
switch fileType {
75+
case .typeSymbolicLink:
76+
guard
77+
followSymlinks,
78+
let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: next.path)
79+
else {
80+
break
81+
}
82+
next = URL(fileURLWithPath: destination)
83+
fallthrough
84+
85+
case .typeDirectory:
86+
dirIterator = FileManager.default.enumerator(
87+
at: next,
88+
includingPropertiesForKeys: nil,
89+
options: [.skipsHiddenFiles])
6390
currentDirectory = next
64-
} else {
91+
92+
default:
6593
// We'll get here if the path is a file, or if it doesn't exist. In the latter case,
6694
// return the path anyway; we'll turn the error we get when we try to open the file into
6795
// an appropriate diagnostic instead of trying to handle it here.
@@ -82,23 +110,41 @@ struct FileIterator: Sequence, IteratorProtocol {
82110
private mutating func nextInDirectory() -> URL? {
83111
var output: URL? = nil
84112
while output == nil {
85-
if let item = dirIterator?.nextObject() as? URL {
86-
if item.lastPathComponent.hasSuffix(fileSuffix) {
87-
var isDir: ObjCBool = false
88-
if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir)
89-
&& !isDir.boolValue
90-
{
91-
// We can't use the `.producesRelativePathURLs` enumeration option because it isn't
92-
// supported yet on Linux, so we need to relativize the URL ourselves.
93-
let relativePath =
94-
item.path.hasPrefix(workingDirectory.path)
95-
? String(item.path.dropFirst(workingDirectory.path.count + 1))
96-
: item.path
97-
output =
98-
URL(fileURLWithPath: relativePath, isDirectory: false, relativeTo: workingDirectory)
99-
}
113+
guard let item = dirIterator?.nextObject() as? URL else {
114+
break
115+
}
116+
117+
guard item.lastPathComponent.hasSuffix(fileSuffix), let fileType = fileType(at: item) else {
118+
continue
119+
}
120+
121+
var path = item.path
122+
switch fileType {
123+
case .typeSymbolicLink where followSymlinks:
124+
guard
125+
let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: path)
126+
else {
127+
break
100128
}
101-
} else { break }
129+
path = destination
130+
fallthrough
131+
132+
case .typeRegular:
133+
// We attempt to relativize the URLs based on the current working directory, not the
134+
// directory being iterated over, so that they can be displayed better in diagnostics. Thus,
135+
// if the user passes paths that are relative to the current working diectory, they will
136+
// be displayed as relative paths. Otherwise, they will still be displayed as absolute
137+
// paths.
138+
let relativePath =
139+
path.hasPrefix(workingDirectory.path)
140+
? String(path.dropFirst(workingDirectory.path.count + 1))
141+
: path
142+
output =
143+
URL(fileURLWithPath: relativePath, isDirectory: false, relativeTo: workingDirectory)
144+
145+
default:
146+
break
147+
}
102148
}
103149
// If we've exhausted the files in the directory recursion, unset the directory iterator.
104150
if output == nil {
@@ -107,3 +153,10 @@ struct FileIterator: Sequence, IteratorProtocol {
107153
return output
108154
}
109155
}
156+
157+
/// Returns the type of the file at the given URL.
158+
private func fileType(at url: URL) -> FileAttributeType? {
159+
// We cannot use `URL.resourceValues(forKeys:)` here because it appears to behave incorrectly on
160+
// Linux.
161+
return try? FileManager.default.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType
162+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import XCTest
2+
3+
@_spi(Testing) import swift_format
4+
5+
final class FileIteratorTests: XCTestCase {
6+
private var tmpdir: URL!
7+
8+
override func setUpWithError() throws {
9+
tmpdir = try FileManager.default.url(
10+
for: .itemReplacementDirectory,
11+
in: .userDomainMask,
12+
appropriateFor: FileManager.default.temporaryDirectory,
13+
create: true)
14+
15+
// Create a simple file tree used by the tests below.
16+
try touch("project/real1.swift")
17+
try touch("project/real2.swift")
18+
try touch("project/.hidden.swift")
19+
try touch("project/.build/generated.swift")
20+
try symlink("project/link.swift", to: "project/.hidden.swift")
21+
}
22+
23+
override func tearDownWithError() throws {
24+
try FileManager.default.removeItem(at: tmpdir)
25+
}
26+
27+
func testNoFollowSymlinks() {
28+
let seen = allFilesSeen(iteratingOver: [tmpdir], followSymlinks: false)
29+
XCTAssertEqual(seen.count, 2)
30+
XCTAssertTrue(seen.contains { $0.hasSuffix("project/real1.swift") })
31+
XCTAssertTrue(seen.contains { $0.hasSuffix("project/real2.swift") })
32+
}
33+
34+
func testFollowSymlinks() {
35+
let seen = allFilesSeen(iteratingOver: [tmpdir], followSymlinks: true)
36+
XCTAssertEqual(seen.count, 3)
37+
XCTAssertTrue(seen.contains { $0.hasSuffix("project/real1.swift") })
38+
XCTAssertTrue(seen.contains { $0.hasSuffix("project/real2.swift") })
39+
// Hidden but found through the visible symlink project/link.swift
40+
XCTAssertTrue(seen.contains { $0.hasSuffix("project/.hidden.swift") })
41+
}
42+
43+
func testTraversesHiddenFilesIfExplicitlySpecified() {
44+
let seen = allFilesSeen(
45+
iteratingOver: [tmpURL("project/.build"), tmpURL("project/.hidden.swift")],
46+
followSymlinks: false)
47+
XCTAssertEqual(seen.count, 2)
48+
XCTAssertTrue(seen.contains { $0.hasSuffix("project/.build/generated.swift") })
49+
XCTAssertTrue(seen.contains { $0.hasSuffix("project/.hidden.swift") })
50+
}
51+
52+
func testDoesNotFollowSymlinksIfFollowSymlinksIsFalseEvenIfExplicitlySpecified() {
53+
// Symlinks are not traversed even if `followSymlinks` is false even if they are explicitly
54+
// passed to the iterator. This is meant to avoid situations where a symlink could be hidden by
55+
// shell expansion; for example, if the user writes `swift-format --no-follow-symlinks *`, if
56+
// the current directory contains a symlink, they would probably *not* expect it to be followed.
57+
let seen = allFilesSeen(iteratingOver: [tmpURL("project/link.swift")], followSymlinks: false)
58+
XCTAssertTrue(seen.isEmpty)
59+
}
60+
}
61+
62+
extension FileIteratorTests {
63+
/// Returns a URL to a file or directory in the test's temporary space.
64+
private func tmpURL(_ path: String) -> URL {
65+
return tmpdir.appendingPathComponent(path, isDirectory: false)
66+
}
67+
68+
/// Create an empty file at the given path in the test's temporary space.
69+
private func touch(_ path: String) throws {
70+
let fileURL = tmpURL(path)
71+
try FileManager.default.createDirectory(
72+
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
73+
FileManager.default.createFile(atPath: fileURL.path, contents: Data())
74+
}
75+
76+
/// Create a symlink between files or directories in the test's temporary space.
77+
private func symlink(_ source: String, to target: String) throws {
78+
try FileManager.default.createSymbolicLink(
79+
at: tmpURL(source), withDestinationURL: tmpURL(target))
80+
}
81+
82+
/// Computes the list of all files seen by using `FileIterator` to iterate over the given URLs.
83+
private func allFilesSeen(iteratingOver urls: [URL], followSymlinks: Bool) -> [String] {
84+
let iterator = FileIterator(urls: urls, followSymlinks: followSymlinks)
85+
var seen: [String] = []
86+
for next in iterator {
87+
seen.append(next.path)
88+
}
89+
return seen
90+
}
91+
}

0 commit comments

Comments
 (0)