diff --git a/Sources/SwiftFormat/API/Configuration.swift b/Sources/SwiftFormat/API/Configuration.swift index 49283687e..ef72ab552 100644 --- a/Sources/SwiftFormat/API/Configuration.swift +++ b/Sources/SwiftFormat/API/Configuration.swift @@ -515,11 +515,24 @@ public struct Configuration: Codable, Equatable { candidateDirectory.appendPathComponent("placeholder") } repeat { + let previousDirectory = candidateDirectory candidateDirectory.deleteLastPathComponent() let candidateFile = candidateDirectory.appendingPathComponent(Self.swiftFormatFilename) if FileManager.default.isReadableFile(atPath: candidateFile.path) { return candidateFile } + + // Stop if removing the last path component made no progress. `deleteLastPathComponent()` is a + // pure function of the path, so once it is a no-op it stays a no-op: there is no reachable + // parent above this point, and continuing would only spin until `isRoot` (which it never + // reaches). On the Foundation that originally exhibited #1035, a trailing redundant separator + // (e.g. `/path/to//main.swift`) stalled here; current Foundation collapses such separators as + // it ascends, so this guard is a defensive backstop rather than the load-bearing fix on that + // toolchain. Breaking here can never skip a directory the loop could otherwise have searched. + // See https://github.com/swiftlang/swift-format/issues/1035. + if candidateDirectory == previousDirectory { + break + } } while !candidateDirectory.isRoot return nil diff --git a/Tests/SwiftFormatTests/API/ConfigurationTests.swift b/Tests/SwiftFormatTests/API/ConfigurationTests.swift index d983e9cb9..b3b3becf6 100644 --- a/Tests/SwiftFormatTests/API/ConfigurationTests.swift +++ b/Tests/SwiftFormatTests/API/ConfigurationTests.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// +import Foundation import SwiftFormat import XCTest @@ -135,4 +136,42 @@ final class ConfigurationTests: XCTestCase { XCTAssertEqual(config, expected) #endif } + + func testMissingConfigurationFilePathWithRedundantSlashesTerminates() throws { + // A path that contains a run of redundant separators (here `///`) is not collapsed by `URL`'s + // standardization, so walking up its parent directories must not loop forever when no + // configuration file exists. See https://github.com/swiftlang/swift-format/issues/1035. + #if os(Windows) + let path = #"C:\test\path\no\configuration\\\exists\anywhere\main.swift"# + #else + let path = "/test/path/no/configuration///exists/anywhere/main.swift" + #endif + XCTAssertNil(Configuration.url(forConfigurationFileApplyingTo: URL(fileURLWithPath: path))) + } + + func testFindsConfigurationFileWhenPathContainsRedundantSlashes() throws { + // The parent-directory walk must terminate *and* still locate the configuration file when the + // source file path contains redundant separators (e.g. `///`), which can happen when paths are + // composed by other tools. See https://github.com/swiftlang/swift-format/issues/1035. + #if os(Windows) + try XCTSkipIf(true, "Redundant POSIX-style `///` separators are not meaningful on Windows.") + #else + let projectDir = FileManager.default.temporaryDirectory + .appendingPathComponent("swift-format-1035-\(UUID().uuidString)", isDirectory: true) + let sourceDir = projectDir.appendingPathComponent("Sources").appendingPathComponent("MyModule") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: projectDir) } + + let configURL = projectDir.appendingPathComponent(".swift-format") + try Data("{}".utf8).write(to: configURL) + + // Build a source file path that contains redundant `///` separators below `projectDir`. + let redundantSlashPath = projectDir.path + "/Sources/MyModule///main.swift" + let sourceFile = URL(fileURLWithPath: redundantSlashPath) + XCTAssertTrue(sourceFile.path.contains("///"), "Test setup must preserve the redundant slashes.") + + let foundConfigURL = Configuration.url(forConfigurationFileApplyingTo: sourceFile) + XCTAssertEqual(foundConfigURL?.standardizedFileURL, configURL.standardizedFileURL) + #endif + } }