Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ $ swift run swift-dependency-updater

### Locally

#### Update dependencies:

`swift-dependency-updater [update] [<folder>] [--keep-requirements]`

#### List all dependencies and possible updates:

`swift-dependency-updater list [<folder>] [--exclude-indirect] [--updates-only]`
Expand Down
4 changes: 4 additions & 0 deletions Sources/SwiftDependencyUpdaterLibrary/Dependency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ struct Dependency {
update: update)
}
}

func update(in folder: URL) throws {
try update?.execute(for: self, in: folder)
}
}

extension Dependency: CustomStringConvertible {
Expand Down
11 changes: 11 additions & 0 deletions Sources/SwiftDependencyUpdaterLibrary/Extensions/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,15 @@ extension String {
}
}

func matchingStringsWithRange(regex: NSRegularExpression) -> [[(string: String, range: NSRange)?]] {
let nsString = self as NSString
let results = regex.matches(in: self, options: [], range: NSRange(self.startIndex..., in: self))
return results.map { result in
(0..<result.numberOfRanges).map { result.range(at: $0).location != NSNotFound
? (string: nsString.substring(with: result.range(at: $0)), range: result.range(at: $0))
: nil
}
}
}

}
8 changes: 8 additions & 0 deletions Sources/SwiftDependencyUpdaterLibrary/ResolvedPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ struct ResolvedVersion: Decodable {
let revision: String
let version: Version?

public var versionNumberOrRevision: String {
if let version = version {
return "\(version)"
} else {
return "\(revision)"
}
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
branch = try container.decode(String?.self, forKey: .branch)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import ArgumentParser
import Foundation

struct UpdateCommand: ParsableCommand {

static var configuration = CommandConfiguration(commandName: "update", abstract: "Updates dependencies")

@Argument(help: "Path of the swift package") var folder: String = "."
@ArgumentParser.Flag(help: "Do not change version requirements in the Package.swift file.") private var keepRequirements: Bool = false

func run() throws {
let folder = URL(fileURLWithPath: folder)
guard folder.hasDirectoryPath else {
print("Folder argument must be a directory.")
throw ExitCode.failure
}
do {
var dependencies = try Dependency.loadDependencies(from: folder)
dependencies = dependencies.filter { $0.update != nil && $0.update != .skipped }
if keepRequirements {
dependencies = dependencies.filter {
if case .withChangingRequirements = $0.update {
return false
} else {
return true
}
}
}
if dependencies.isEmpty {
print("Everything is already up-to-date!".green)
} else {
try dependencies.forEach {
try $0.update(in: folder)
}
}
} catch {
print(error.localizedDescription)
throw ExitCode.failure
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ public struct SwiftDependencyUpdater: ParsableCommand {
commandName: "swift-dependency-updater",
abstract: "A CLI tool to update Swift Pacakge Manager dependencies",
version: "0.0.1",
subcommands: [List.self]
subcommands: [List.self, UpdateCommand.self],
defaultSubcommand: UpdateCommand.self

)

public init() {
Expand Down
97 changes: 97 additions & 0 deletions Sources/SwiftDependencyUpdaterLibrary/SwiftPackage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Foundation
import ShellOut

enum SwiftPackageError: Error, Equatable {
case invalidUpdate(String, Update)
case resultCountMismatch(String, Int)
case noResultMatch(String, [String?])
case readFailed(String)
case writeFailed(String)
}

struct SwiftPackage {

private let folder: URL
private let url: URL

init(in folder: URL) {
self.folder = folder
url = folder.appendingPathComponent("Package.swift", isDirectory: false)
}

func performUpdate(_ update: Update, of dependency: Dependency) throws -> Bool {
guard case var .withChangingRequirements(updatedVersion) = update else {
throw SwiftPackageError.invalidUpdate(dependency.name, update)
}

var string = try read()
let nsString = string as NSString

// swiftlint:disable:next line_length
let versionRegExString = "(\\.upToNextMajor\\s*\\(\\s*from\\s*:\\s*\"([0-9]*\\.[0-9]*\\.[0-9]*)\"\\s*\\))|(\\.upToNextMinor\\s*\\(\\s*from\\s*:\\s*\"([0-9]*\\.[0-9]*\\.[0-9]*)\"\\s*\\))|(\\.exact\\s*\\(\\s*\"([0-9]*\\.[0-9]*\\.[0-9]*)\"\\s*\\))|(from\\s*:\\s*\\s*\"([0-9]*\\.[0-9]*\\.[0-9]*)\")|(\\s*\"[0-9]*\\.[0-9]*\\.[0-9]*\"\\.\\.\\.\"([0-9]*\\.[0-9]*\\.[0-9]*)\")|(\\s*\"[0-9]*\\.[0-9]*\\.[0-9]*\"\\.\\.<\"([0-9]*\\.[0-9]*\\.[0-9]*)\")"
// swiftlint:disable:next line_length
let regex = try NSRegularExpression(pattern: "dependencies\\s*:\\s*\\[\\s*[^\\]]*\\.package\\s*\\(\\s*url\\s*:\\s*\"\(NSRegularExpression.escapedPattern(for: dependency.url.absoluteString))\"\\s*,\\s*(\(versionRegExString))", options: [.anchorsMatchLines])

let results = string.matchingStringsWithRange(regex: regex)
guard results.count == 1, let matches = results[safe: 0] else {
throw SwiftPackageError.resultCountMismatch(dependency.name, results.count)
}

var packageUpdate = false
if matches[2] != nil, let version = matches[3] {
string = nsString.replacingCharacters(in: version.range, with: "\(updatedVersion)")
} else if matches[4] != nil, let version = matches[5] {
string = nsString.replacingCharacters(in: version.range, with: "\(updatedVersion)")
} else if matches[6] != nil, let version = matches[7] {
string = nsString.replacingCharacters(in: version.range, with: "\(updatedVersion)")
} else if matches[8] != nil, let version = matches[9] {
string = nsString.replacingCharacters(in: version.range, with: "\(updatedVersion)")
} else if matches[10] != nil, let version = matches[11] {
string = nsString.replacingCharacters(in: version.range, with: "\(updatedVersion)")
packageUpdate = true
} else if matches[12] != nil, let version = matches[13] {
updatedVersion.patch += 1
string = nsString.replacingCharacters(in: version.range, with: "\(updatedVersion)")
packageUpdate = true
} else {
throw SwiftPackageError.noResultMatch(dependency.name, matches.map { $0?.string })
}

try write(string)
return packageUpdate
}

private func read() throws -> String {
do {
return try String(contentsOf: url, encoding: .utf8)
} catch {
throw SwiftPackageError.readFailed(error.localizedDescription)
}
}

private func write(_ string: String) throws {
do {
try string.write(to: url, atomically: true, encoding: .utf8)
} catch {
throw SwiftPackageError.writeFailed(error.localizedDescription)
}
}

}

extension SwiftPackageError: LocalizedError {
public var errorDescription: String? {
switch self {
case let .invalidUpdate(name, update):
return "Invalid update for \(name): \(update)"
case let .resultCountMismatch(name, count):
return "Finding version requirement in Package.swift failed for \(name): Got \(count) instead of 1 result"
case let .noResultMatch(name, results):
return "Finding version requirement in Package.swift failed for \(name). Findings: \(results)"
case let .readFailed(error):
return "Failed to read Package.swift file: \(error)"
case let .writeFailed(error):
return "Failed to write Package.swift file: \(error)"
}
}
}
25 changes: 25 additions & 0 deletions Sources/SwiftDependencyUpdaterLibrary/Update.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Releases
import ShellOut

enum UpdateError: Error, Equatable {
case resolvedVersionNotFound(String, Version, Version)
Expand Down Expand Up @@ -38,6 +39,30 @@ enum Update: Equatable {
return nil
}
}

func execute(for dependency: Dependency, in folder: URL) throws {
switch self {
case let .withChangingRequirements(version):
print("Updating \(dependency.name): \(dependency.resolvedVersion.versionNumberOrRevision) -> \(version)".bold)
let swiftPackage = SwiftPackage(in: folder)
let packageUpdate = try swiftPackage.performUpdate(self, of: dependency)
print("Updated Package.swift".green)
if packageUpdate {
try shellOut(to: "swift", arguments: ["package", "update", dependency.name, "--package-path", "\"\(folder.path)\"" ])
print("Resolved to new version".green)
} else {
try shellOut(to: "swift", arguments: ["package", "update", "resolve", "--package-path", "\"\(folder.path)\"" ])
print("Resolved Version".green)
}
case let .withoutChangingRequirements(version):
print("Updating \(dependency.name): \(dependency.resolvedVersion.versionNumberOrRevision) -> \(version)".bold)
try shellOut(to: "swift", arguments: ["package", "update", dependency.name, "--package-path", "\"\(folder.path)\"" ])
print("Resolved to new version".green)
default:
// Do nothing
break
}
}
}

extension Update: CustomStringConvertible {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,45 @@ import XCTest
class StringTests: XCTestCase {

func testMatchingStrings_multipleGroups() {
// swiftlint:disable:next force_try
let regex = try! NSRegularExpression(pattern: "^\\s+([^\\s]+:[^\\s]+)\\s+(-?[0-9]+(.[0-9]+)?)\\s+([^\\s]+)\\s*(;.*)?$", options: [])
let results = " Assets:Checking 1.00 EUR".matchingStrings(regex: regex)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0], [" Assets:Checking 1.00 EUR", "Assets:Checking", "1.00", ".00", "EUR", ""])
}

func testMatchingStrings_multipleResults() {
// swiftlint:disable:next force_try
let regex = try! NSRegularExpression(pattern: "\\d\\D\\d", options: [])
let results = "0a01b1".matchingStrings(regex: regex)
XCTAssertEqual(results.count, 2)
XCTAssertEqual(results[0], ["0a0"])
XCTAssertEqual(results[1], ["1b1"])
}

func testMatchingStringsWithRange_multipleGroups() {
let regex = try! NSRegularExpression(pattern: "^\\s+([^\\s]+:[^\\s]+)\\s+(-?[0-9]+(.[0-9]+)?)\\s+([^\\s]+)\\s*(;.*)?$", options: [])
let results = " Assets:Checking 1.00 EUR".matchingStringsWithRange(regex: regex)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0][0]?.string, " Assets:Checking 1.00 EUR")
XCTAssertEqual(results[0][1]?.string, "Assets:Checking")
XCTAssertEqual(results[0][2]?.string, "1.00")
XCTAssertEqual(results[0][3]?.string, ".00")
XCTAssertEqual(results[0][4]?.string, "EUR")
XCTAssertNil(results[0][5])
XCTAssertEqual(results[0][0]?.range, NSRange(location: 0, length: 26))
XCTAssertEqual(results[0][1]?.range, NSRange(location: 2, length: 15))
XCTAssertEqual(results[0][2]?.range, NSRange(location: 18, length: 4))
XCTAssertEqual(results[0][3]?.range, NSRange(location: 19, length: 3))
XCTAssertEqual(results[0][4]?.range, NSRange(location: 23, length: 3))
}

func testMatchingStringsWithRange_multipleResults() {
let regex = try! NSRegularExpression(pattern: "\\d\\D\\d", options: [])
let results = "0a01b1".matchingStringsWithRange(regex: regex)
XCTAssertEqual(results.count, 2)
XCTAssertEqual(results[0][0]?.string, "0a0")
XCTAssertEqual(results[1][0]?.string, "1b1")
XCTAssertEqual(results[0][0]?.range, NSRange(location: 0, length: 3))
XCTAssertEqual(results[1][0]?.range, NSRange(location: 3, length: 3))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ class ResolvedPackageTests: XCTestCase {
}

func testParsing() {

let folder = emptyFolderURL()
let file = temporaryFileURL(in: folder, name: "Package.resolved")
createFile(at: file, content: TestUtils.packageResolvedFileContent)
Expand Down Expand Up @@ -83,6 +82,18 @@ class ResolvedPackageTests: XCTestCase {
XCTAssertEqual("\(version)", "0.0.0 (abc, branch: main)")
}

func testVersionNumberOrRevision() {
let decoder = JSONDecoder()

var data = "{\"revision\": \"abc\", \"branch\": null, \"version\": null}".data(using: .utf8)!
var version = try! decoder.decode(ResolvedVersion.self, from: data)
XCTAssertEqual("\(version.versionNumberOrRevision)", "abc")

data = "{\"revision\": \"abc\", \"branch\": \"main\", \"version\": \"1.2.3\"}".data(using: .utf8)!
version = try! decoder.decode(ResolvedVersion.self, from: data)
XCTAssertEqual("\(version.versionNumberOrRevision)", "1.2.3")
}

func testResolvedPackageErrorString() {
XCTAssertEqual("\(ResolvedPackageError.readingFailed("abc").localizedDescription)", "Could not read Package.resolved file: abc")
XCTAssertEqual("\(ResolvedPackageError.parsingFailed("abc", "def").localizedDescription)", "Could not parse package data: abc\n\nPackage Data: def")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
@testable import SwiftDependencyUpdaterLibrary
import XCTest

class UpdateCommandTests: XCTestCase {

func testFileInsteadOfFolder() {
let url = emptyFileURL()
let result = outputFromExecutionWith(arguments: ["update", url.path])
XCTAssertEqual(result.exitCode, 1)
XCTAssertEqual(result.errorOutput, "")
XCTAssertEqual(result.output, "Folder argument must be a directory.")
}

func testEmptyFolder() {
let url = emptyFolderURL()
let result = outputFromExecutionWith(arguments: ["update", url.path])
XCTAssertEqual(result.exitCode, 1)
XCTAssertEqual(result.errorOutput, "")
XCTAssertEqual(result.output, "Could not get package data, swift package dump-package failed: error: root manifest not found")
}

func testInvalidPackage() {
let folder = emptyFolderURL()
let packageSwift = temporaryFileURL(in: folder, name: "Package.swift")
createFile(at: packageSwift, content: TestUtils.emptyPackageSwiftFileContent)
let packageResolved = temporaryFileURL(in: folder, name: "Package.resolved")
createFile(at: packageResolved, content: TestUtils.emptyPackageResolvedFileContent)
let result = outputFromExecutionWith(arguments: ["update", folder.path])
XCTAssertEqual(result.exitCode, 1)
XCTAssertEqual(result.errorOutput, "")
XCTAssert(result.output.contains("Could not get package data, swift package dump-package failed"))
}

func testNoDependencies() {
let folder = createEmptySwiftPackage()
let result = outputFromExecutionWith(arguments: ["update", folder.path])
XCTAssertEqual(result.exitCode, 0)
XCTAssertEqual(result.errorOutput, "")
XCTAssertEqual(result.output, "Everything is already up-to-date!")
}

func testDefaultCommand() {
let folder = createEmptySwiftPackage()
let result = outputFromExecutionWith(arguments: [folder.path])
XCTAssertEqual(result.exitCode, 0)
XCTAssertEqual(result.errorOutput, "")
XCTAssertEqual(result.output, "Everything is already up-to-date!")
}

func testNoDependenciesKeepRequirements() {
let folder = createEmptySwiftPackage()
let result = outputFromExecutionWith(arguments: ["update", folder.path, "--keep-requirements"])
XCTAssertEqual(result.exitCode, 0)
XCTAssertEqual(result.errorOutput, "")
XCTAssertEqual(result.output, "Everything is already up-to-date!")
}

func createEmptySwiftPackage() -> URL {
let folder = emptyFolderURL()
let packageSwift = temporaryFileURL(in: folder, name: "Package.swift")
createFile(at: packageSwift, content: TestUtils.emptyPackageSwiftFileContent)
let packageResolved = temporaryFileURL(in: folder, name: "Package.resolved")
createFile(at: packageResolved, content: TestUtils.emptyPackageResolvedFileContent)
let sourceFile = temporaryFileURL(in: folder.appendingPathComponent("Sources/Name"), name: "Name.swift")
createFile(at: sourceFile, content: "")

return folder
}

}
Loading