From 66397fe793aacfc42733f2da5a25d7f65a27388f Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Fri, 29 May 2026 13:01:37 -0700 Subject: [PATCH 1/2] Add refactoring action to upgrade package dependencies to latest major version 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`. --- .../PackageManifest/ManifestEditError.swift | 3 + .../UpgradePackageDependencies.swift | 106 ++++++++++++++++++ .../SwiftRefactorTest/ManifestEditTests.swift | 72 ++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift 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..2ed3e04dfed --- /dev/null +++ b/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// +// 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 { + public typealias Input = SourceFileSyntax + + public struct Context { + let latestVersions: [String: String] + + public init( + resolvingLatestVersionIn manifest: SourceFileSyntax, + using resolveLatestPackageVersion: @Sendable @escaping (_ url: String, _ currentVersion: String) async -> String? + ) async throws { + let dependencies = try findDependencies(in: manifest) + self.latestVersions = await withTaskGroup(of: (url: String, latestVersion: String)?.self) { taskGroup in + for dependency in dependencies { + taskGroup.addTask { + guard let url = dependency.url.representedLiteralValue, + let currentVersion = dependency.currentVersion.representedLiteralValue + else { + return nil + } + guard let latestVersion = await resolveLatestPackageVersion(url, currentVersion) else { + return nil + } + return (url, latestVersion) + } + } + var latestVersions: [String: String] = [:] + for await case .some(let result) in taskGroup { + latestVersions[result.url] = result.latestVersion + } + return latestVersions + } + } + } + + private struct VersionedDependency { + let url: StringLiteralExprSyntax + let currentVersion: StringLiteralExprSyntax + } + + private static func findDependencies(in manifest: SourceFileSyntax) throws -> [VersionedDependency] { + 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) -> VersionedDependency? in + guard let functionCall = dependency.expression.as(FunctionCallExprSyntax.self), + functionCall.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.tokenKind + == .identifier("package"), + let url = functionCall.findArgument(labeled: "url")?.expression.as(StringLiteralExprSyntax.self) + else { + return nil + } + + // TODO: Support version initialized using `Version` initializer + if let version = functionCall.findArgument(labeled: "from")?.expression.as(StringLiteralExprSyntax.self) { + return VersionedDependency(url: url, currentVersion: version) + } else if let version = functionCall.findArgument(labeled: "exact")?.expression.as(StringLiteralExprSyntax.self) { + return VersionedDependency(url: url, currentVersion: version) + } else { + return nil + } + } + } + + public static func textRefactor(syntax manifest: SourceFileSyntax, in context: Context) throws -> [SourceEdit] { + return try findDependencies(in: manifest).compactMap { dependency in + guard let urlValue = dependency.url.representedLiteralValue, let latestVersion = context.latestVersions[urlValue] + else { + return nil + } + guard dependency.currentVersion.representedLiteralValue != latestVersion else { + return nil + } + return SourceEdit( + range: dependency.currentVersion.trimmedRange, + replacement: StringLiteralExprSyntax(content: latestVersion).description + ) + } + } +} diff --git a/Tests/SwiftRefactorTest/ManifestEditTests.swift b/Tests/SwiftRefactorTest/ManifestEditTests.swift index eb53f384f90..679dd4ef60b 100644 --- a/Tests/SwiftRefactorTest/ManifestEditTests.swift +++ b/Tests/SwiftRefactorTest/ManifestEditTests.swift @@ -965,6 +965,56 @@ 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(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(path: "my-local-dependency"), + ], + targets: [] + ) + """ + ) { manifest in + let context = try await UpgradePackageDependencies.Context(resolvingLatestVersionIn: manifest) { + (url, currentVersion) in + switch url { + case "https://github.com/swiftlang/swift-syntax": + XCTAssertEqual(currentVersion, "510.0.0") + return "603.0.1" + case "https://github.com/swiftlang/swift-format": + XCTAssertEqual(currentVersion, "510.0.0") + return "603.0.0" + case "https://github.com/unable-to-resolve": + XCTAssertEqual(currentVersion, "1.0.0") + return nil + default: + XCTFail("Resolving package version called with an unexpected url: \(url)") + return nil + } + } + return try UpgradePackageDependencies.textRefactor(syntax: manifest, in: context) + } + } } /// Assert that applying the given edit/refactor operation to the manifest @@ -1010,3 +1060,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 + ) +} From 9c1bfeea1524d3853dd7dec10c3740ebbc77a582 Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Thu, 2 Jul 2026 14:20:24 +0200 Subject: [PATCH 2/2] Support upgrading dependencies in package registries --- .../UpgradePackageDependencies.swift | 84 ++++++++++++++----- .../SwiftRefactorTest/ManifestEditTests.swift | 27 ++++-- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift b/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift index 2ed3e04dfed..331c7e8f275 100644 --- a/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift +++ b/Sources/SwiftRefactor/PackageManifest/UpgradePackageDependencies.swift @@ -22,45 +22,76 @@ import SwiftSyntaxBuilder /// 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: [String: String] + let latestVersions: [Dependency: String] public init( resolvingLatestVersionIn manifest: SourceFileSyntax, - using resolveLatestPackageVersion: @Sendable @escaping (_ url: String, _ currentVersion: String) async -> String? + using resolveLatestPackageVersion: + @Sendable @escaping (_ dependency: Dependency, _ currentVersion: String) async -> String? ) async throws { let dependencies = try findDependencies(in: manifest) - self.latestVersions = await withTaskGroup(of: (url: String, latestVersion: String)?.self) { taskGroup in - for dependency in dependencies { + self.latestVersions = await withTaskGroup( + of: (dependency: Dependency, latestVersion: String)?.self + ) { taskGroup in + for dependencySyntax in dependencies { taskGroup.addTask { - guard let url = dependency.url.representedLiteralValue, - let currentVersion = dependency.currentVersion.representedLiteralValue + guard let dependency = Dependency(dependencySyntax), + let currentVersion = dependencySyntax.currentVersion.representedLiteralValue else { return nil } - guard let latestVersion = await resolveLatestPackageVersion(url, currentVersion) else { + guard let latestVersion = await resolveLatestPackageVersion(dependency, currentVersion) else { return nil } - return (url, latestVersion) + return (dependency, latestVersion) } } - var latestVersions: [String: String] = [:] + var latestVersions: [Dependency: String] = [:] for await case .some(let result) in taskGroup { - latestVersions[result.url] = result.latestVersion + latestVersions[result.dependency] = result.latestVersion } return latestVersions } } } - private struct VersionedDependency { - let url: StringLiteralExprSyntax + 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 -> [VersionedDependency] { + private static func findDependencies(in manifest: SourceFileSyntax) throws -> [VersionedDependencySyntaxNodes] { guard let packageCall = manifest.findCall(calleeName: "Package") else { throw ManifestEditError.cannotFindPackage } @@ -68,20 +99,28 @@ public struct UpgradePackageDependencies: EditRefactoringProvider { else { throw ManifestEditError.cannotFindDependencies } - return dependencies.elements.compactMap { (dependency) -> VersionedDependency? in + 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"), - let url = functionCall.findArgument(labeled: "url")?.expression.as(StringLiteralExprSyntax.self) + == .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 VersionedDependency(url: url, currentVersion: version) + return VersionedDependencySyntaxNodes(kind: kind, currentVersion: version) } else if let version = functionCall.findArgument(labeled: "exact")?.expression.as(StringLiteralExprSyntax.self) { - return VersionedDependency(url: url, currentVersion: version) + return VersionedDependencySyntaxNodes(kind: kind, currentVersion: version) } else { return nil } @@ -89,16 +128,17 @@ public struct UpgradePackageDependencies: EditRefactoringProvider { } public static func textRefactor(syntax manifest: SourceFileSyntax, in context: Context) throws -> [SourceEdit] { - return try findDependencies(in: manifest).compactMap { dependency in - guard let urlValue = dependency.url.representedLiteralValue, let latestVersion = context.latestVersions[urlValue] + return try findDependencies(in: manifest).compactMap { dependencySyntax in + guard let dependency = Dependency(dependencySyntax), + let latestVersion = context.latestVersions[dependency] else { return nil } - guard dependency.currentVersion.representedLiteralValue != latestVersion else { + guard dependencySyntax.currentVersion.representedLiteralValue != latestVersion else { return nil } return SourceEdit( - range: dependency.currentVersion.trimmedRange, + range: dependencySyntax.currentVersion.trimmedRange, replacement: StringLiteralExprSyntax(content: latestVersion).description ) } diff --git a/Tests/SwiftRefactorTest/ManifestEditTests.swift b/Tests/SwiftRefactorTest/ManifestEditTests.swift index 679dd4ef60b..2bdb8852145 100644 --- a/Tests/SwiftRefactorTest/ManifestEditTests.swift +++ b/Tests/SwiftRefactorTest/ManifestEditTests.swift @@ -976,6 +976,9 @@ final class ManifestEditTests: XCTestCase { .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: [] @@ -989,6 +992,9 @@ final class ManifestEditTests: XCTestCase { .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: [] @@ -996,19 +1002,28 @@ final class ManifestEditTests: XCTestCase { """ ) { manifest in let context = try await UpgradePackageDependencies.Context(resolvingLatestVersionIn: manifest) { - (url, currentVersion) in - switch url { - case "https://github.com/swiftlang/swift-syntax": + (dependency, currentVersion) in + switch dependency { + case .sourceControl(url: "https://github.com/swiftlang/swift-syntax"): XCTAssertEqual(currentVersion, "510.0.0") return "603.0.1" - case "https://github.com/swiftlang/swift-format": + case .sourceControl(url: "https://github.com/swiftlang/swift-format"): XCTAssertEqual(currentVersion, "510.0.0") return "603.0.0" - case "https://github.com/unable-to-resolve": + 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 url: \(url)") + XCTFail("Resolving package version called with an unexpected dependency: \(dependency)") return nil } }