diff --git a/Package.swift b/Package.swift index 38ebdbb0a..dd4e61995 100644 --- a/Package.swift +++ b/Package.swift @@ -58,6 +58,10 @@ var targets: [Target] = [ "SwiftOperators", "SwiftParser", "SwiftParserDiagnostics", "SwiftSyntax", "SwiftSyntaxBuilder", ]) ), + .target( + name: "_GenerateSwiftFormat", + dependencies: ["SwiftFormat"] + ), .plugin( name: "Format Source Code", capability: .command( @@ -86,9 +90,7 @@ var targets: [Target] = [ ), .executableTarget( name: "generate-swift-format", - dependencies: [ - "SwiftFormat" - ] + dependencies: ["_GenerateSwiftFormat"] ), .executableTarget( name: "swift-format", @@ -113,6 +115,7 @@ var targets: [Target] = [ dependencies: [ "SwiftFormat", "_SwiftFormatTestSupport", + "_GenerateSwiftFormat", .product(name: "Markdown", package: "swift-markdown"), ] + swiftSyntaxDependencies(["SwiftOperators", "SwiftParser", "SwiftSyntax", "SwiftSyntaxBuilder"]) ), diff --git a/Sources/SwiftFormat/Core/Pipelines+Generated.swift b/Sources/SwiftFormat/Core/Pipelines+Generated.swift index d64cdd888..54f843549 100644 --- a/Sources/SwiftFormat/Core/Pipelines+Generated.swift +++ b/Sources/SwiftFormat/Core/Pipelines+Generated.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2025 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 diff --git a/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift b/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift index ed06b5577..1ac41fd8d 100644 --- a/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift +++ b/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2025 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 diff --git a/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift b/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift index d5c9c9ba1..da43299ce 100644 --- a/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift +++ b/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2025 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 diff --git a/Sources/generate-swift-format/FileGenerator.swift b/Sources/_GenerateSwiftFormat/FileGenerator.swift similarity index 56% rename from Sources/generate-swift-format/FileGenerator.swift rename to Sources/_GenerateSwiftFormat/FileGenerator.swift index c4ba1f553..77c82c962 100644 --- a/Sources/generate-swift-format/FileGenerator.swift +++ b/Sources/_GenerateSwiftFormat/FileGenerator.swift @@ -13,10 +13,9 @@ import Foundation /// Common behavior used to generate source files. -protocol FileGenerator { - /// Types conforming to this protocol must implement this method to write their content into the - /// given file handle. - func write(into handle: FileHandle) throws +@_spi(Internal) public protocol FileGenerator { + /// Generates the file content as a String. + func generateContent() -> String } private struct FailedToCreateFileError: Error { @@ -25,26 +24,12 @@ private struct FailedToCreateFileError: Error { extension FileGenerator { /// Generates a file at the given URL, overwriting it if it already exists. - func generateFile(at url: URL) throws { + public func generateFile(at url: URL) throws { let fm = FileManager.default if fm.fileExists(atPath: url.path) { try fm.removeItem(at: url) } - - if !fm.createFile(atPath: url.path, contents: nil, attributes: nil) { - throw FailedToCreateFileError(url: url) - } - let handle = try FileHandle(forWritingTo: url) - defer { handle.closeFile() } - - try write(into: handle) - } -} - -extension FileHandle { - /// Writes the provided string as data to a file output stream. - public func write(_ string: String) { - guard let data = string.data(using: .utf8) else { return } - write(data) + let content = generateContent() + try content.write(to: url, atomically: true, encoding: .utf8) } } diff --git a/Sources/_GenerateSwiftFormat/GenerateSwiftFormatPaths.swift b/Sources/_GenerateSwiftFormat/GenerateSwiftFormatPaths.swift new file mode 100644 index 000000000..c36308568 --- /dev/null +++ b/Sources/_GenerateSwiftFormat/GenerateSwiftFormatPaths.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 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 namespace that provides paths for files generated by the `generate-swift-format` tool. +@_spi(Internal) public enum GenerateSwiftFormatPaths { + private static let sourcesDirectory = + URL(fileURLWithPath: #file) + .deletingLastPathComponent() + .deletingLastPathComponent() + public static let rulesDirectory = + sourcesDirectory + .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Rules") + public static let pipelineFile = + sourcesDirectory + .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Core") + .appendingPathComponent("Pipelines+Generated.swift") + public static let ruleRegistryFile = + sourcesDirectory + .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Core") + .appendingPathComponent("RuleRegistry+Generated.swift") + public static let ruleNameCacheFile = + sourcesDirectory + .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Core") + .appendingPathComponent("RuleNameCache+Generated.swift") + public static let ruleDocumentationFile = + sourcesDirectory + .appendingPathComponent("..") + .appendingPathComponent("Documentation") + .appendingPathComponent("RuleDocumentation.md") +} diff --git a/Sources/generate-swift-format/PipelineGenerator.swift b/Sources/_GenerateSwiftFormat/PipelineGenerator.swift similarity index 84% rename from Sources/generate-swift-format/PipelineGenerator.swift rename to Sources/_GenerateSwiftFormat/PipelineGenerator.swift index 151231c68..85a5be3f7 100644 --- a/Sources/generate-swift-format/PipelineGenerator.swift +++ b/Sources/_GenerateSwiftFormat/PipelineGenerator.swift @@ -13,24 +13,24 @@ import Foundation /// Generates the extensions to the lint and format pipelines. -final class PipelineGenerator: FileGenerator { +@_spi(Internal) public final class PipelineGenerator: FileGenerator { /// The rules collected by scanning the formatter source code. let ruleCollector: RuleCollector /// Creates a new pipeline generator. - init(ruleCollector: RuleCollector) { + public init(ruleCollector: RuleCollector) { self.ruleCollector = ruleCollector } - func write(into handle: FileHandle) throws { - handle.write( - """ + public func generateContent() -> String { + var result = "" + result += """ //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project // - // Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors + // Copyright (c) 2014 - 2025 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 @@ -65,58 +65,41 @@ final class PipelineGenerator: FileGenerator { } """ - ) for (nodeType, lintRules) in ruleCollector.syntaxNodeLinters.sorted(by: { $0.key < $1.key }) { - handle.write( - """ + result += """ override func visit(_ node: \(nodeType)) -> SyntaxVisitorContinueKind { """ - ) - for ruleName in lintRules.sorted() { - handle.write( - """ + result += """ visitIfEnabled(\(ruleName).visit, for: node) """ - ) } - - handle.write( - """ + result += """ return .visitChildren } """ - ) - - handle.write( - """ + result += """ override func visitPost(_ node: \(nodeType)) { """ - ) for ruleName in lintRules.sorted() { - handle.write( - """ + result += """ onVisitPost(rule: \(ruleName).self, for: node) """ - ) } - handle.write( - """ + result += """ } """ - ) } - handle.write( - """ + result += """ } extension FormatPipeline { @@ -125,24 +108,18 @@ final class PipelineGenerator: FileGenerator { var node = node """ - ) - for ruleName in ruleCollector.allFormatters.map({ $0.typeName }).sorted() { - handle.write( - """ + result += """ node = \(ruleName)(context: context).rewrite(node) """ - ) } - - handle.write( - """ + result += """ return node } } """ - ) + return result } } diff --git a/Sources/generate-swift-format/RuleCollector.swift b/Sources/_GenerateSwiftFormat/RuleCollector.swift similarity index 98% rename from Sources/generate-swift-format/RuleCollector.swift rename to Sources/_GenerateSwiftFormat/RuleCollector.swift index f39dc66e0..60646d37b 100644 --- a/Sources/generate-swift-format/RuleCollector.swift +++ b/Sources/_GenerateSwiftFormat/RuleCollector.swift @@ -16,7 +16,7 @@ import SwiftParser import SwiftSyntax /// Collects information about rules in the formatter code base. -final class RuleCollector { +@_spi(Internal) public final class RuleCollector { /// Information about a detected rule. struct DetectedRule: Hashable { /// The type name of the rule. @@ -45,10 +45,12 @@ final class RuleCollector { /// A dictionary mapping syntax node types to the lint/format rules that visit them. var syntaxNodeLinters = [String: [String]]() + public init() {} + /// Populates the internal collections with rules in the given directory. /// /// - Parameter url: The file system URL that should be scanned for rules. - func collect(from url: URL) throws { + public func collect(from url: URL) throws { // For each file in the Rules directory, find types that either conform to SyntaxLintRule or // inherit from SyntaxFormatRule. let fm = FileManager.default diff --git a/Sources/generate-swift-format/RuleDocumentationGenerator.swift b/Sources/_GenerateSwiftFormat/RuleDocumentationGenerator.swift similarity index 85% rename from Sources/generate-swift-format/RuleDocumentationGenerator.swift rename to Sources/_GenerateSwiftFormat/RuleDocumentationGenerator.swift index eb2375f26..8d8ae3016 100644 --- a/Sources/generate-swift-format/RuleDocumentationGenerator.swift +++ b/Sources/_GenerateSwiftFormat/RuleDocumentationGenerator.swift @@ -14,19 +14,19 @@ import Foundation import SwiftFormat /// Generates the markdown file with extended documenation on the available rules. -final class RuleDocumentationGenerator: FileGenerator { +@_spi(Internal) public final class RuleDocumentationGenerator: FileGenerator { /// The rules collected by scanning the formatter source code. let ruleCollector: RuleCollector /// Creates a new rule registry generator. - init(ruleCollector: RuleCollector) { + public init(ruleCollector: RuleCollector) { self.ruleCollector = ruleCollector } - func write(into handle: FileHandle) throws { - handle.write( - """ + public func generateContent() -> String { + var result = "" + result += """ # `swift-format` Lint and Format Rules @@ -41,20 +41,11 @@ final class RuleDocumentationGenerator: FileGenerator { """ - ) - for detectedRule in ruleCollector.allLinters.sorted(by: { $0.typeName < $1.typeName }) { - handle.write( - """ - - [\(detectedRule.typeName)](#\(detectedRule.typeName)) - - """ - ) + result += "- [\(detectedRule.typeName)](#\(detectedRule.typeName))\n" } - for detectedRule in ruleCollector.allLinters.sorted(by: { $0.typeName < $1.typeName }) { - handle.write( - """ + result += """ ### \(detectedRule.typeName) @@ -62,8 +53,8 @@ final class RuleDocumentationGenerator: FileGenerator { \(ruleFormatSupportDescription(for: detectedRule)) """ - ) } + return result } private func ruleFormatSupportDescription(for rule: RuleCollector.DetectedRule) -> String { diff --git a/Sources/generate-swift-format/RuleNameCacheGenerator.swift b/Sources/_GenerateSwiftFormat/RuleNameCacheGenerator.swift similarity index 80% rename from Sources/generate-swift-format/RuleNameCacheGenerator.swift rename to Sources/_GenerateSwiftFormat/RuleNameCacheGenerator.swift index b6be4ff96..8f9339900 100644 --- a/Sources/generate-swift-format/RuleNameCacheGenerator.swift +++ b/Sources/_GenerateSwiftFormat/RuleNameCacheGenerator.swift @@ -13,24 +13,24 @@ import Foundation /// Generates the rule registry file used to populate the default configuration. -final class RuleNameCacheGenerator: FileGenerator { +@_spi(Internal) public final class RuleNameCacheGenerator: FileGenerator { /// The rules collected by scanning the formatter source code. let ruleCollector: RuleCollector /// Creates a new rule registry generator. - init(ruleCollector: RuleCollector) { + public init(ruleCollector: RuleCollector) { self.ruleCollector = ruleCollector } - func write(into handle: FileHandle) throws { - handle.write( - """ + public func generateContent() -> String { + var result = "" + result += """ //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project // - // Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors + // Copyright (c) 2014 - 2025 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 @@ -45,11 +45,10 @@ final class RuleNameCacheGenerator: FileGenerator { public let ruleNameCache: [ObjectIdentifier: String] = [ """ - ) - for detectedRule in ruleCollector.allLinters.sorted(by: { $0.typeName < $1.typeName }) { - handle.write(" ObjectIdentifier(\(detectedRule.typeName).self): \"\(detectedRule.typeName)\",\n") + result += " ObjectIdentifier(\(detectedRule.typeName).self): \"\(detectedRule.typeName)\",\n" } - handle.write("]\n") + result += "]\n" + return result } } diff --git a/Sources/generate-swift-format/RuleRegistryGenerator.swift b/Sources/_GenerateSwiftFormat/RuleRegistryGenerator.swift similarity index 80% rename from Sources/generate-swift-format/RuleRegistryGenerator.swift rename to Sources/_GenerateSwiftFormat/RuleRegistryGenerator.swift index 3994f5b3f..1767927ca 100644 --- a/Sources/generate-swift-format/RuleRegistryGenerator.swift +++ b/Sources/_GenerateSwiftFormat/RuleRegistryGenerator.swift @@ -13,24 +13,24 @@ import Foundation /// Generates the rule registry file used to populate the default configuration. -final class RuleRegistryGenerator: FileGenerator { +@_spi(Internal) public final class RuleRegistryGenerator: FileGenerator { /// The rules collected by scanning the formatter source code. let ruleCollector: RuleCollector /// Creates a new rule registry generator. - init(ruleCollector: RuleCollector) { + public init(ruleCollector: RuleCollector) { self.ruleCollector = ruleCollector } - func write(into handle: FileHandle) throws { - handle.write( - """ + public func generateContent() -> String { + var result = "" + result += """ //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project // - // Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors + // Copyright (c) 2014 - 2025 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 @@ -44,11 +44,10 @@ final class RuleRegistryGenerator: FileGenerator { public static let rules: [String: Bool] = [ """ - ) - for detectedRule in ruleCollector.allLinters.sorted(by: { $0.typeName < $1.typeName }) { - handle.write(" \"\(detectedRule.typeName)\": \(!detectedRule.isOptIn),\n") + result += " \"\(detectedRule.typeName)\": \(!detectedRule.isOptIn),\n" } - handle.write(" ]\n}\n") + result += " ]\n}\n" + return result } } diff --git a/Sources/generate-swift-format/Syntax+Convenience.swift b/Sources/_GenerateSwiftFormat/Syntax+Convenience.swift similarity index 100% rename from Sources/generate-swift-format/Syntax+Convenience.swift rename to Sources/_GenerateSwiftFormat/Syntax+Convenience.swift diff --git a/Sources/generate-swift-format/main.swift b/Sources/generate-swift-format/main.swift index ea40bcd1b..d33c0a612 100644 --- a/Sources/generate-swift-format/main.swift +++ b/Sources/generate-swift-format/main.swift @@ -11,54 +11,24 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftSyntax +@_spi(Internal) import _GenerateSwiftFormat -let sourcesDirectory = URL(fileURLWithPath: #file) - .deletingLastPathComponent() - .deletingLastPathComponent() -let rulesDirectory = - sourcesDirectory - .appendingPathComponent("SwiftFormat") - .appendingPathComponent("Rules") -let pipelineFile = - sourcesDirectory - .appendingPathComponent("SwiftFormat") - .appendingPathComponent("Core") - .appendingPathComponent("Pipelines+Generated.swift") -let ruleRegistryFile = - sourcesDirectory - .appendingPathComponent("SwiftFormat") - .appendingPathComponent("Core") - .appendingPathComponent("RuleRegistry+Generated.swift") - -let ruleNameCacheFile = - sourcesDirectory - .appendingPathComponent("SwiftFormat") - .appendingPathComponent("Core") - .appendingPathComponent("RuleNameCache+Generated.swift") - -let ruleDocumentationFile = - sourcesDirectory - .appendingPathComponent("..") - .appendingPathComponent("Documentation") - .appendingPathComponent("RuleDocumentation.md") - -var ruleCollector = RuleCollector() -try ruleCollector.collect(from: rulesDirectory) +let ruleCollector = RuleCollector() +try ruleCollector.collect(from: GenerateSwiftFormatPaths.rulesDirectory) // Generate a file with extensions for the lint and format pipelines. let pipelineGenerator = PipelineGenerator(ruleCollector: ruleCollector) -try pipelineGenerator.generateFile(at: pipelineFile) +try pipelineGenerator.generateFile(at: GenerateSwiftFormatPaths.pipelineFile) // Generate the rule registry dictionary for configuration. let registryGenerator = RuleRegistryGenerator(ruleCollector: ruleCollector) -try registryGenerator.generateFile(at: ruleRegistryFile) +try registryGenerator.generateFile(at: GenerateSwiftFormatPaths.ruleRegistryFile) // Generate the rule name cache. let ruleNameCacheGenerator = RuleNameCacheGenerator(ruleCollector: ruleCollector) -try ruleNameCacheGenerator.generateFile(at: ruleNameCacheFile) +try ruleNameCacheGenerator.generateFile(at: GenerateSwiftFormatPaths.ruleNameCacheFile) // Generate the Documentation/RuleDocumentation.md file with rule descriptions. // This uses DocC comments from rule implementations. let ruleDocumentationGenerator = RuleDocumentationGenerator(ruleCollector: ruleCollector) -try ruleDocumentationGenerator.generateFile(at: ruleDocumentationFile) +try ruleDocumentationGenerator.generateFile(at: GenerateSwiftFormatPaths.ruleDocumentationFile) diff --git a/Tests/SwiftFormatTests/Utilities/GeneratedFilesValidityTests.swift b/Tests/SwiftFormatTests/Utilities/GeneratedFilesValidityTests.swift new file mode 100644 index 000000000..46877cff4 --- /dev/null +++ b/Tests/SwiftFormatTests/Utilities/GeneratedFilesValidityTests.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 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 XCTest +@_spi(Internal) import _GenerateSwiftFormat + +final class GeneratedFilesValidityTests: XCTestCase { + var ruleCollector: RuleCollector! + + override func setUpWithError() throws { + ruleCollector = RuleCollector() + try ruleCollector.collect(from: GenerateSwiftFormatPaths.rulesDirectory) + } + + func testGeneratedPipelineIsUpToDate() throws { + let pipelineGenerator = PipelineGenerator(ruleCollector: ruleCollector) + let generated = pipelineGenerator.generateContent() + let fileContents = try String(contentsOf: GenerateSwiftFormatPaths.pipelineFile) + XCTAssertEqual( + generated, + fileContents.normalizeNewlines(), + "Pipelines+Generated.swift is out of date. Please run 'swift run generate-swift-format'." + ) + } + + func testGeneratedRegistryIsUpToDate() throws { + let registryGenerator = RuleRegistryGenerator(ruleCollector: ruleCollector) + let generated = registryGenerator.generateContent() + let fileContents = try String(contentsOf: GenerateSwiftFormatPaths.ruleRegistryFile) + XCTAssertEqual( + generated, + fileContents.normalizeNewlines(), + "RuleRegistry+Generated.swift is out of date. Please run 'swift run generate-swift-format'." + ) + } + + func testGeneratedNameCacheIsUpToDate() throws { + let ruleNameCacheGenerator = RuleNameCacheGenerator(ruleCollector: ruleCollector) + let generated = ruleNameCacheGenerator.generateContent() + let fileContents = try String(contentsOf: GenerateSwiftFormatPaths.ruleNameCacheFile) + XCTAssertEqual( + generated, + fileContents.normalizeNewlines(), + "RuleNameCache+Generated.swift is out of date. Please run 'swift run generate-swift-format'." + ) + } + + func testGeneratedDocumentationIsUpToDate() throws { + let ruleDocumentationGenerator = RuleDocumentationGenerator(ruleCollector: ruleCollector) + let generated = ruleDocumentationGenerator.generateContent() + let fileContents = try String(contentsOf: GenerateSwiftFormatPaths.ruleDocumentationFile) + XCTAssertEqual( + generated, + fileContents.normalizeNewlines(), + "RuleDocumentation.md is out of date. Please run 'swift run generate-swift-format'." + ) + } +} + +private extension String { + /// Normalizes newlines for consistent comparison in tests. + /// + /// On Windows, `String(contentsOf:)` reads files with CRLF (`\r\n`) newlines, + /// which cause false negatives when comparing against generated strings using LF (`\n`). + func normalizeNewlines() -> String { + #if os(Windows) + return self.replacingOccurrences(of: "\r\n", with: "\n") + #else + return self + #endif + } +}