diff --git a/Sources/SwiftRefactor/PackageManifest/ManifestEditError.swift b/Sources/SwiftRefactor/PackageManifest/ManifestEditError.swift index d87de291394..12e81798edf 100644 --- a/Sources/SwiftRefactor/PackageManifest/ManifestEditError.swift +++ b/Sources/SwiftRefactor/PackageManifest/ManifestEditError.swift @@ -18,6 +18,7 @@ import SwiftSyntax public enum ManifestEditError: Error, Equatable { case cannotFindPackage case cannotFindTargets + case cannotFindDependencies case cannotFindTarget(targetName: String) case cannotFindArrayLiteralArgument(argumentName: String) case cannotAddSettingsToPluginTarget @@ -32,6 +33,8 @@ extension ManifestEditError: CustomStringConvertible { return "invalid manifest: unable to find 'Package' declaration" case .cannotFindTargets: return "unable to find package targets in manifest" + case .cannotFindDependencies: + return "unable to find package dependencies in manifest" case .cannotFindTarget(targetName: let name): return "unable to find target named '\(name)' in package" case .cannotFindArrayLiteralArgument(argumentName: let name): diff --git a/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift b/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift new file mode 100644 index 00000000000..331c7e8f275 --- /dev/null +++ b/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift @@ -0,0 +1,146 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder + +/// Upgrade all package dependencies that have a `from` or `exact` version restriction to their latest released version, +/// including new major versions. +/// +/// The intention of this refactoring is to update leaf package (like executables) to the latest version of their +/// dependencies, allowing them to also pick up potentially API-breaking new major or prerelease versions that can +/// easily go unnoticed using `swift package update`. +@_spi(PackageRefactor) +public struct UpgradePackageDependencies: EditRefactoringProvider { + @_spi(PackageRefactor) + public enum Dependency: Sendable, Hashable { + /// A source-control dependency declared with `.package(url:)`. + case sourceControl(url: String) + + /// A registry dependency declared with `.package(id:)`. + case registry(identity: String) + + fileprivate init?(_ syntax: VersionedDependencySyntaxNodes) { + switch syntax.kind { + case .sourceControl(let url): + guard let url = url.representedLiteralValue else { + return nil + } + self = .sourceControl(url: url) + case .registry(let identity): + guard let identity = identity.representedLiteralValue else { + return nil + } + self = .registry(identity: identity) + } + } + } + + public typealias Input = SourceFileSyntax + + public struct Context { + let latestVersions: [Dependency: String] + + public init( + resolvingLatestVersionIn manifest: SourceFileSyntax, + using resolveLatestPackageVersion: + @Sendable @escaping (_ dependency: Dependency, _ currentVersion: String) async -> String? + ) async throws { + let dependencies = try findDependencies(in: manifest) + self.latestVersions = await withTaskGroup( + of: (dependency: Dependency, latestVersion: String)?.self + ) { taskGroup in + for dependencySyntax in dependencies { + taskGroup.addTask { + guard let dependency = Dependency(dependencySyntax), + let currentVersion = dependencySyntax.currentVersion.representedLiteralValue + else { + return nil + } + guard let latestVersion = await resolveLatestPackageVersion(dependency, currentVersion) else { + return nil + } + return (dependency, latestVersion) + } + } + var latestVersions: [Dependency: String] = [:] + for await case .some(let result) in taskGroup { + latestVersions[result.dependency] = result.latestVersion + } + return latestVersions + } + } + } + + fileprivate struct VersionedDependencySyntaxNodes { + enum Kind { + case sourceControl(url: StringLiteralExprSyntax) + case registry(identity: StringLiteralExprSyntax) + } + let kind: Kind + let currentVersion: StringLiteralExprSyntax + } + + private static func findDependencies(in manifest: SourceFileSyntax) throws -> [VersionedDependencySyntaxNodes] { + guard let packageCall = manifest.findCall(calleeName: "Package") else { + throw ManifestEditError.cannotFindPackage + } + guard let dependencies = packageCall.findArgument(labeled: "dependencies")?.expression.as(ArrayExprSyntax.self) + else { + throw ManifestEditError.cannotFindDependencies + } + return dependencies.elements.compactMap { (dependency) -> VersionedDependencySyntaxNodes? in + guard let functionCall = dependency.expression.as(FunctionCallExprSyntax.self), + functionCall.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.tokenKind + == .identifier("package") + else { + return nil + } + + let kind: VersionedDependencySyntaxNodes.Kind + if let url = functionCall.findArgument(labeled: "url")?.expression.as(StringLiteralExprSyntax.self) { + kind = .sourceControl(url: url) + } else if let identity = functionCall.findArgument(labeled: "id")?.expression.as(StringLiteralExprSyntax.self) { + kind = .registry(identity: identity) + } else { + return nil + } + + // TODO: Support version initialized using `Version` initializer + if let version = functionCall.findArgument(labeled: "from")?.expression.as(StringLiteralExprSyntax.self) { + return VersionedDependencySyntaxNodes(kind: kind, currentVersion: version) + } else if let version = functionCall.findArgument(labeled: "exact")?.expression.as(StringLiteralExprSyntax.self) { + return VersionedDependencySyntaxNodes(kind: kind, currentVersion: version) + } else { + return nil + } + } + } + + public static func textRefactor(syntax manifest: SourceFileSyntax, in context: Context) throws -> [SourceEdit] { + return try findDependencies(in: manifest).compactMap { dependencySyntax in + guard let dependency = Dependency(dependencySyntax), + let latestVersion = context.latestVersions[dependency] + else { + return nil + } + guard dependencySyntax.currentVersion.representedLiteralValue != latestVersion else { + return nil + } + return SourceEdit( + range: dependencySyntax.currentVersion.trimmedRange, + replacement: StringLiteralExprSyntax(content: latestVersion).description + ) + } + } +} diff --git a/Tests/SwiftRefactorTest/ManifestEditTests.swift b/Tests/SwiftRefactorTest/ManifestEditTests.swift index eb53f384f90..2bdb8852145 100644 --- a/Tests/SwiftRefactorTest/ManifestEditTests.swift +++ b/Tests/SwiftRefactorTest/ManifestEditTests.swift @@ -965,6 +965,71 @@ final class ManifestEditTests: XCTestCase { ) } } + + func testUpgradePackageDependencies() async throws { + try await assertManifestRefactor( + """ + // swift-tools-version: 6.0 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax", from: "510.0.0"), + .package(url: "https://github.com/swiftlang/swift-format", exact: "510.0.0"), + .package(url: "https://github.com/unable-to-resolve", exact: "1.0.0"), + .package(id: "scope.upgradable", from: "1.0.0"), + .package(id: "scope.pinned", exact: "2.5.0"), + .package(id: "scope.unable-to-resolve", from: "1.0.0"), + .package(path: "my-local-dependency"), + ], + targets: [] + ) + """, + expectedManifest: """ + // swift-tools-version: 6.0 + let package = Package( + name: "packages", + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax", from: "603.0.1"), + .package(url: "https://github.com/swiftlang/swift-format", exact: "603.0.0"), + .package(url: "https://github.com/unable-to-resolve", exact: "1.0.0"), + .package(id: "scope.upgradable", from: "2.0.0"), + .package(id: "scope.pinned", exact: "3.1.0"), + .package(id: "scope.unable-to-resolve", from: "1.0.0"), + .package(path: "my-local-dependency"), + ], + targets: [] + ) + """ + ) { manifest in + let context = try await UpgradePackageDependencies.Context(resolvingLatestVersionIn: manifest) { + (dependency, currentVersion) in + switch dependency { + case .sourceControl(url: "https://github.com/swiftlang/swift-syntax"): + XCTAssertEqual(currentVersion, "510.0.0") + return "603.0.1" + case .sourceControl(url: "https://github.com/swiftlang/swift-format"): + XCTAssertEqual(currentVersion, "510.0.0") + return "603.0.0" + case .sourceControl(url: "https://github.com/unable-to-resolve"): + XCTAssertEqual(currentVersion, "1.0.0") + return nil + case .registry(identity: "scope.upgradable"): + XCTAssertEqual(currentVersion, "1.0.0") + return "2.0.0" + case .registry(identity: "scope.pinned"): + XCTAssertEqual(currentVersion, "2.5.0") + return "3.1.0" + case .registry(identity: "scope.unable-to-resolve"): + XCTAssertEqual(currentVersion, "1.0.0") + return nil + default: + XCTFail("Resolving package version called with an unexpected dependency: \(dependency)") + return nil + } + } + return try UpgradePackageDependencies.textRefactor(syntax: manifest, in: context) + } + } } /// Assert that applying the given edit/refactor operation to the manifest @@ -1010,3 +1075,25 @@ func assertManifestRefactor( line: line ) } + +func assertManifestRefactor( + _ originalManifest: SourceFileSyntax, + expectedManifest: SourceFileSyntax, + file: StaticString = #filePath, + line: UInt = #line, + operation: (SourceFileSyntax) async throws -> [SourceEdit] +) async rethrows { + let edits = try await operation(originalManifest) + let editedManifestSource = FixItApplier.apply( + edits: edits, + to: originalManifest + ) + + let editedManifest = Parser.parse(source: editedManifestSource) + assertStringsEqualWithDiff( + editedManifest.description, + expectedManifest.description, + file: file, + line: line + ) +}