diff --git a/Fixtures/Scripts/EchoArguments/EchoArguments.swift b/Fixtures/Scripts/EchoArguments/EchoArguments.swift new file mode 100644 index 00000000000..d3dedd1c1e5 --- /dev/null +++ b/Fixtures/Scripts/EchoArguments/EchoArguments.swift @@ -0,0 +1,2 @@ +let arguments = CommandLine.arguments.dropFirst() +print(arguments.map{"\"\($0)\""}.joined(separator: " ")) diff --git a/Fixtures/Scripts/EchoArguments/PackageSyntax.txt b/Fixtures/Scripts/EchoArguments/PackageSyntax.txt new file mode 100644 index 00000000000..20a041ca6af --- /dev/null +++ b/Fixtures/Scripts/EchoArguments/PackageSyntax.txt @@ -0,0 +1,6 @@ +{ + "dependencies" : [ + + ], + "sourceFile" : "SCRIPT_DIR\/EchoArguments.swift" +} diff --git a/Fixtures/Scripts/EchoCWD/EchoCWD.swift b/Fixtures/Scripts/EchoCWD/EchoCWD.swift new file mode 100644 index 00000000000..dae616178e6 --- /dev/null +++ b/Fixtures/Scripts/EchoCWD/EchoCWD.swift @@ -0,0 +1,4 @@ +@package(name: "CwdDump", path: "cwd-dump") +import CwdDump + +CwdDump.main() diff --git a/Fixtures/Scripts/EchoCWD/PackageSyntax.txt b/Fixtures/Scripts/EchoCWD/PackageSyntax.txt new file mode 100644 index 00000000000..1b4bcef0cd8 --- /dev/null +++ b/Fixtures/Scripts/EchoCWD/PackageSyntax.txt @@ -0,0 +1,16 @@ +{ + "dependencies" : [ + { + "package" : { + "raw" : "name:\"CwdDump\",path:\"SCRIPT_DIR\/cwd-dump\"", + "path" : "SCRIPT_DIR\/cwd-dump", + "name" : "CwdDump" + }, + "modules" : [ + "CwdDump" + ] + } + ], + "sourceFile" : "SCRIPT_DIR\/EchoCWD.swift" +} + diff --git a/Fixtures/Scripts/EchoCWD/cwd-dump/Package.swift b/Fixtures/Scripts/EchoCWD/cwd-dump/Package.swift new file mode 100644 index 00000000000..2581d5a1d5d --- /dev/null +++ b/Fixtures/Scripts/EchoCWD/cwd-dump/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "CwdDump", + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "CwdDump", + targets: ["CwdDump"]) + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target(name: "CwdDump") + ] +) diff --git a/Fixtures/Scripts/EchoCWD/cwd-dump/Sources/CwdDump/CwdDump.swift b/Fixtures/Scripts/EchoCWD/cwd-dump/Sources/CwdDump/CwdDump.swift new file mode 100644 index 00000000000..2f94e0f0549 --- /dev/null +++ b/Fixtures/Scripts/EchoCWD/cwd-dump/Sources/CwdDump/CwdDump.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct CwdDump { + public static func main() { + print(FileManager().currentDirectoryPath) + } +} diff --git a/Package.swift b/Package.swift index 3746404c47b..a83c71a3622 100644 --- a/Package.swift +++ b/Package.swift @@ -152,6 +152,10 @@ let package = Package( /** Package model conventions and loading support */ name: "PackageLoading", dependencies: ["SwiftToolsSupport-auto", "Basics", "PackageModel", "SourceControl"]), + .target( + name: "ScriptingCore", + /** Package models for scripting support */ + dependencies: ["SwiftToolsSupport-auto", "Basics"]), // MARK: Package Dependency Resolution @@ -213,7 +217,7 @@ let package = Package( .target( /** High-level commands */ name: "Commands", - dependencies: ["SwiftToolsSupport-auto", "Basics", "Build", "PackageGraph", "SourceControl", "Xcodeproj", "Workspace", "XCBuildSupport", "ArgumentParser", "PackageCollections"]), + dependencies: ["SwiftToolsSupport-auto", "Basics", "Build", "PackageGraph", "SourceControl", "Xcodeproj", "Workspace", "XCBuildSupport", "ArgumentParser", "PackageCollections", "ScriptingCore"]), .target( /** The main executable provided by SwiftPM */ name: "swift-package", @@ -230,6 +234,10 @@ let package = Package( /** Runs an executable product */ name: "swift-run", dependencies: ["Commands"]), + .target( + /** Manages and runs a script */ + name: "swift-script", + dependencies: ["Commands"]), .target( /** Interacts with package collections */ name: "swift-package-collection", diff --git a/Sources/Basics/FileSystem+Extensions.swift b/Sources/Basics/FileSystem+Extensions.swift index 2d306539e07..0129187e43f 100644 --- a/Sources/Basics/FileSystem+Extensions.swift +++ b/Sources/Basics/FileSystem+Extensions.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2020 Apple Inc. and the Swift project authors + Copyright (c) 2020 - 2021 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See http://swift.org/LICENSE.txt for license information @@ -110,3 +110,27 @@ extension FileSystem { return idiomaticConfigDirectory } } + +// MARK: - script cache + +extension FileSystem { + /// SwiftPM cache directory under user's caches directory (if exists) + public var swiftScriptCacheDirectory: AbsolutePath { + return self.dotSwiftScriptCachesDirectory + } + + fileprivate var dotSwiftScriptCachesDirectory: AbsolutePath { + return self.dotSwiftPM.appending(component: "scripts") + } +} + +extension FileSystem { + public func getOrCreateSwiftScriptCacheDirectory() throws -> AbsolutePath { + let idiomaticCacheDirectory = self.swiftScriptCacheDirectory + // Create if necessary + if !self.exists(idiomaticCacheDirectory) { + try self.createDirectory(idiomaticCacheDirectory, recursive: true) + } + return idiomaticCacheDirectory + } +} diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index 14370edcab7..23c18190306 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -22,9 +22,11 @@ add_subdirectory(PackagePlugin) add_subdirectory(SPMBuildCore) add_subdirectory(SPMLLBuild) add_subdirectory(SourceControl) +add_subdirectory(ScriptingCore) add_subdirectory(swift-build) add_subdirectory(swift-package) add_subdirectory(swift-run) +add_subdirectory(swift-script) add_subdirectory(swift-test) add_subdirectory(Workspace) add_subdirectory(XCBuildSupport) diff --git a/Sources/Commands/CMakeLists.txt b/Sources/Commands/CMakeLists.txt index 8a067c7f340..f27260cd830 100644 --- a/Sources/Commands/CMakeLists.txt +++ b/Sources/Commands/CMakeLists.txt @@ -1,6 +1,6 @@ # 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 - 2021 Apple Inc. and the Swift project authors # Licensed under Apache License v2.0 with Runtime Library Exception # # See http://swift.org/LICENSE.txt for license information @@ -18,6 +18,7 @@ add_library(Commands SwiftPackageCollectionsTool.swift SwiftPackageTool.swift SwiftRunTool.swift + SwiftScriptTool.swift SwiftTestTool.swift SwiftTool.swift SymbolGraphExtract.swift @@ -28,6 +29,7 @@ target_link_libraries(Commands PUBLIC Build PackageCollections PackageGraph + ScriptingCore SourceControl TSCBasic TSCUtility diff --git a/Sources/Commands/Error.swift b/Sources/Commands/Error.swift index 14af126d0a8..110be3ff0c8 100644 --- a/Sources/Commands/Error.swift +++ b/Sources/Commands/Error.swift @@ -37,7 +37,6 @@ extension Error: CustomStringConvertible { // The name has underscore because of SR-4015. func handle(error: Swift.Error) { - switch error { case Diagnostics.fatalError: break @@ -55,7 +54,7 @@ func print(error: Any) { func print(diagnostic: Diagnostic, stdoutStream: OutputByteStream) { - let writer = InteractiveWriter.stderr + let writer = InteractiveWriter(stream: stdoutStream) if !(diagnostic.location is UnknownLocation) { writer.write(diagnostic.location.description) @@ -79,6 +78,12 @@ func print(diagnostic: Diagnostic, stdoutStream: OutputByteStream) { writer.write("\n") } +extension Diagnostic: ByteStreamable { + public func write(to stream: WritableByteStream) { + print(diagnostic: self, stdoutStream: stream) + } +} + /// This class is used to write on the underlying stream. /// /// If underlying stream is a not tty, the string will be written in without any diff --git a/Sources/Commands/SwiftRunTool.swift b/Sources/Commands/SwiftRunTool.swift index 201d86b6408..60ff76f8ac1 100644 --- a/Sources/Commands/SwiftRunTool.swift +++ b/Sources/Commands/SwiftRunTool.swift @@ -236,7 +236,7 @@ public struct SwiftRunTool: SwiftCommand { /// Executes the executable at the specified path. private func run( - _ excutablePath: AbsolutePath, + _ executablePath: AbsolutePath, originalWorkingDirectory: AbsolutePath, arguments: [String]) throws { @@ -246,8 +246,8 @@ public struct SwiftRunTool: SwiftCommand { try ProcessEnv.chdir(originalWorkingDirectory) } - let pathRelativeToWorkingDirectory = excutablePath.relative(to: originalWorkingDirectory) - try exec(path: excutablePath.pathString, args: [pathRelativeToWorkingDirectory.pathString] + arguments) + let pathRelativeToWorkingDirectory = executablePath.relative(to: originalWorkingDirectory) + try exec(path: executablePath.pathString, args: [pathRelativeToWorkingDirectory.pathString] + arguments) } /// Determines if a path points to a valid swift file. diff --git a/Sources/Commands/SwiftScriptTool.swift b/Sources/Commands/SwiftScriptTool.swift new file mode 100644 index 00000000000..bc956c5bd33 --- /dev/null +++ b/Sources/Commands/SwiftScriptTool.swift @@ -0,0 +1,381 @@ +/* + This source file is part of the Swift.org open source project + + Copyright 2015 - 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import ArgumentParser +import Basics +import Foundation +import PackageModel +import ScriptingCore +import TSCBasic +import Workspace + +protocol ScriptCommand: ParsableCommand { + var swiftOptions: SwiftToolOptions { get } + var file: String { get } + + func run(_ swiftTool: SwiftTool, as productName: String, at cacheDirPath: AbsolutePath) throws +} + +extension ScriptCommand { + public func run() throws { + let (productName, cacheDirPath) = try checkAndPerformCache(for: file) + + // Redirect to the cache directory. + var swiftOptions = swiftOptions + swiftOptions.packagePath = cacheDirPath + swiftOptions.buildPath = nil + let swiftTool = try SwiftTool(options: swiftOptions) + + try self.run(swiftTool, as: productName, at: cacheDirPath) + if swiftTool.diagnostics.hasErrors || swiftTool.executionStatus == .failure { + throw ExitCode.failure + } + } + + public static var _errorLabel: String { "error" } +} + +struct ScriptToolOptions: ParsableArguments { + /// If the executable product should be built before running. + @Flag(name: .customLong("skip-build"), help: "Skip building the executable product") + var shouldSkipBuild: Bool = false + + var shouldBuild: Bool { !shouldSkipBuild } + + /// The arguments to pass to the executable. + @Argument(parsing: .unconditionalRemaining, + help: "The arguments to pass to the executable") + var arguments: [String] = [] +} + +/// swift-script tool namespace +public struct SwiftScriptTool: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "script", + _superCommandName: "swift", + abstract: "Manage and run Swift scripts", + discussion: "SEE ALSO: swift build, swift run, swift package, swift test", + version: SwiftVersion.currentVersion.completeDisplayString, + subcommands: [ + Run.self, + Build.self, + Clean.self, + Reset.self, + Resolve.self, + List.self, + ], + helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) + + public init() {} + + public static var _errorLabel: String { "error" } +} + +extension SwiftScriptTool { + struct Run: ScriptCommand { + static let configuration = CommandConfiguration( + abstract: "Runs a script") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Argument(help: "The script file to run") + var file: String + + @OptionGroup() + var options: ScriptToolOptions + + /// Whether to show build output. + @Flag(help: "Print build output") + var quiet: Bool = false + + func run(_ swiftTool: SwiftTool, as productName: String, at cacheDirPath: AbsolutePath) throws { + let output = BufferedOutputByteStream() + // Mute build system if `-quiet` is set. + if quiet { + swiftTool.redirectStdoutTo(.init(output)) + } else { + swiftTool.redirectStdoutToStderr() + } + swiftTool.stdoutStream <<< Diagnostic(message: .note("Using cache: \(cacheDirPath.basename)")) + + do { + let buildSystem = try swiftTool.createBuildSystem(explicitProduct: nil) + if options.shouldBuild { + try buildSystem.build(subset: .product(productName)) + } + + let executablePath = try swiftTool.buildParameters().buildPath.appending(component: productName) + try Commands.run(executablePath, + originalWorkingDirectory: swiftTool.originalWorkingDirectory, + arguments: options.arguments) + } catch let error as ScriptError { + swiftTool.diagnostics.emit(error) + stderrStream <<< output.bytes + stderrStream.flush() + throw ExitCode.failure + } + } + } + + struct Build: ScriptCommand { + static let configuration = CommandConfiguration( + abstract: "Prebuild a script") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Argument(help: "The script file to build") + var file: String + + @OptionGroup() + var options: ScriptToolOptions + + @Option(name: .shortAndLong, help: "Save the prebuilt script binary", transform: AbsolutePath.init) + var output: AbsolutePath? + + func run(_ swiftTool: SwiftTool, as productName: String, at cacheDirPath: AbsolutePath) throws { + swiftTool.redirectStdoutToStderr() + swiftTool.diagnostics.emit(note: "Using cache: \(cacheDirPath.basename)") + + do { + let buildSystem = try swiftTool.createBuildSystem(explicitProduct: nil) + if options.shouldBuild { + try buildSystem.build(subset: .product(productName)) + } + + // Copy the prebuilt binary if output is set. + if let output = output { + let executablePath = try swiftTool.buildParameters().buildPath.appending(component: productName) + guard !localFileSystem.isDirectory(output) else { + throw ScriptError.isDirectory(output.pathString) + } + try? localFileSystem.removeFileTree(output) + try localFileSystem.copy(from: executablePath, to: output) + } + } catch let error as ScriptError { + swiftTool.diagnostics.emit(error) + throw ExitCode.failure + } + } + } +} + +extension SwiftScriptTool { + struct Clean: ScriptCommand { + static let configuration = CommandConfiguration( + abstract: "Delete build artifacts") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Argument(help: "The script file to clean build cache") + var file: String + + func run(_ swiftTool: SwiftTool, as productName: String, at cacheDirPath: AbsolutePath) throws { + try swiftTool.getActiveWorkspace().clean(with: swiftTool.diagnostics) + } + } + + struct Reset: ScriptCommand { + static let configuration = CommandConfiguration( + abstract: "Reset the complete cache directory") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Argument(help: "The script file to reset build cache") + var file: String + + func run(_ swiftTool: SwiftTool, as productName: String, at cacheDirPath: AbsolutePath) throws { + try localFileSystem.removeFileTree(cacheDirPath) + } + } + + struct Update: ScriptCommand { + static let configuration = CommandConfiguration( + abstract: "Update package dependencies") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Argument(help: "The script file to update dependencies") + var file: String + + @Flag(name: [.long, .customShort("n")], + help: "Display the list of dependencies that can be updated") + var dryRun: Bool = false + + @Argument(help: "The packages to update") + var packages: [String] = [] + + func run(_ swiftTool: SwiftTool, as productName: String, at cacheDirPath: AbsolutePath) throws { + let workspace = try swiftTool.getActiveWorkspace() + + let changes = try workspace.updateDependencies( + root: swiftTool.getWorkspaceRoot(), + packages: packages, + diagnostics: swiftTool.diagnostics, + dryRun: dryRun + ) + + // try to load the graph which will emit any errors + if !swiftTool.diagnostics.hasErrors { + _ = try workspace.loadPackageGraph( + rootInput: swiftTool.getWorkspaceRoot(), + diagnostics: swiftTool.diagnostics + ) + } + + if let pinsStore = swiftTool.diagnostics.wrap({ try workspace.pinsStore.load() }), + let changes = changes, dryRun { + logPackageChanges(changes: changes, pins: pinsStore) + } + + if !dryRun { + // Throw if there were errors when loading the graph. + // The actual errors will be printed before exiting. + guard !swiftTool.diagnostics.hasErrors else { + throw ExitCode.failure + } + } + } + } +} + +extension SwiftScriptTool { + struct ResolveOptions: ParsableArguments { + @Option(help: "The version to resolve at", transform: { Version(string: $0) }) + var version: Version? + + @Option(help: "The branch to resolve at") + var branch: String? + + @Option(help: "The revision to resolve at") + var revision: String? + + @Argument(help: "The name of the package to resolve") + var packageName: String? + } + + struct Resolve: ScriptCommand { + static let configuration = CommandConfiguration( + abstract: "Resolve package dependencies") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + @Argument(help: "The script file to clean") + var file: String + + @OptionGroup() + var resolveOptions: ResolveOptions + + func run(_ swiftTool: SwiftTool, as productName: String, at cacheDirPath: AbsolutePath) throws { + // If a package is provided, use that to resolve the dependencies. + if let packageName = resolveOptions.packageName { + let workspace = try swiftTool.getActiveWorkspace() + try workspace.resolve( + packageName: packageName, + root: swiftTool.getWorkspaceRoot(), + version: resolveOptions.version, + branch: resolveOptions.branch, + revision: resolveOptions.revision, + diagnostics: swiftTool.diagnostics) + if swiftTool.diagnostics.hasErrors { + throw ExitCode.failure + } + } else { + // Otherwise, run a normal resolve. + try swiftTool.resolve() + } + } + } +} + +extension SwiftScriptTool { + struct List: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List script caches") + + @OptionGroup(_hiddenFromHelp: true) + var swiftOptions: SwiftToolOptions + + func run() throws { + let cacheDir = try localFileSystem.getOrCreateSwiftScriptCacheDirectory() + let scripts = try localFileSystem.getDirectoryContents(cacheDir) + // Walk through the cache and find original script paths. + let resolved = try scripts.compactMap { script -> (String, String)? in + let sourceDir = cacheDir.appending(components: script, "Sources") + guard localFileSystem.isDirectory(sourceDir), + let name = try localFileSystem.getDirectoryContents(sourceDir).first, + case let path = sourceDir.appending(components: name, "main.swift"), + let original = try? FileManager.default.destinationOfSymbolicLink(atPath: path.pathString) else { + return nil + } + // Check if the original script still exists. + if localFileSystem.exists(path, followSymlink: true) { + return (script, original) + } else { + return (script, "\(original) (removed)") + } + } + // Print the resolved cache info. + print("\(scripts.count) script\(scripts.count > 1 ? "s" : "") cached at \(cacheDir)") + guard let maxLength = resolved.map(\.0.count).max() else { return } + resolved.forEach { (name, desc) in + print(name + String(repeating: " ", count: maxLength - name.count + 2) + desc) + } + } + } +} + +/// Executes the executable at the specified path. +fileprivate func run( + _ executablePath: AbsolutePath, + originalWorkingDirectory: AbsolutePath, + arguments: [String]) throws { + // Make sure we are running from the original working directory. + let cwd: AbsolutePath? = localFileSystem.currentWorkingDirectory + if cwd == nil || originalWorkingDirectory != cwd { + try ProcessEnv.chdir(originalWorkingDirectory) + } + + let pathRelativeToWorkingDirectory = executablePath.relative(to: originalWorkingDirectory) + try exec(path: executablePath.pathString, args: [pathRelativeToWorkingDirectory.pathString] + arguments) +} + +/// Logs all changed dependencies to a stream +/// - Parameter changes: Changes to log +/// - Parameter pins: PinsStore with currently pinned packages to compare changed packages to. +/// - Parameter stream: Stream used for logging +fileprivate func logPackageChanges(changes: [(PackageReference, Workspace.PackageStateChange)], pins: PinsStore, on stream: OutputByteStream = TSCBasic.stdoutStream) { + let changes = changes.filter { $0.1 != .unchanged } + + stream <<< "\n" + stream <<< "\(changes.count) dependenc\(changes.count == 1 ? "y has" : "ies have") changed\(changes.count > 0 ? ":" : ".")" + stream <<< "\n" + + for (package, change) in changes { + let currentVersion = pins.pinsMap[package.identity]?.state.description ?? "" + switch change { + case let .added(state): + stream <<< "+ \(package.name) \(state.requirement.prettyPrinted)" + case let .updated(state): + stream <<< "~ \(package.name) \(currentVersion) -> \(package.name) \(state.requirement.prettyPrinted)" + case .removed: + stream <<< "- \(package.name) \(currentVersion)" + case .unchanged: + continue + } + stream <<< "\n" + } + stream.flush() +} diff --git a/Sources/Commands/SwiftTool.swift b/Sources/Commands/SwiftTool.swift index 6eae2db5805..92230a30797 100644 --- a/Sources/Commands/SwiftTool.swift +++ b/Sources/Commands/SwiftTool.swift @@ -336,6 +336,9 @@ public class SwiftTool { /// The stream to print standard output on. fileprivate(set) var stdoutStream: OutputByteStream = TSCBasic.stdoutStream + + /// Whether not to print the stream if there's no error. + var quiet: Bool = false /// Holds the currently active workspace. /// @@ -574,8 +577,13 @@ public class SwiftTool { /// Start redirecting the standard output stream to the standard error stream. func redirectStdoutToStderr() { - self.stdoutStream = TSCBasic.stderrStream - DiagnosticsEngineHandler.default.stdoutStream = TSCBasic.stderrStream + redirectStdoutTo(TSCBasic.stderrStream) + } + + /// Start redirecting the standard output stream to another stream. + func redirectStdoutTo(_ stream: ThreadSafeOutputByteStream) { + self.stdoutStream = stream + DiagnosticsEngineHandler.default.stdoutStream = stream } /// Resolve the dependencies. diff --git a/Sources/SPMTestSupport/SwiftPMProduct.swift b/Sources/SPMTestSupport/SwiftPMProduct.swift index d6d41fd2905..89086aa4529 100644 --- a/Sources/SPMTestSupport/SwiftPMProduct.swift +++ b/Sources/SPMTestSupport/SwiftPMProduct.swift @@ -17,6 +17,7 @@ public enum SwiftPMProduct: Product { case SwiftPackage case SwiftTest case SwiftRun + case SwiftScript case XCTestHelper /// Executable name. @@ -30,6 +31,8 @@ public enum SwiftPMProduct: Product { return RelativePath("swift-test") case .SwiftRun: return RelativePath("swift-run") + case .SwiftScript: + return RelativePath("swift-script") case .XCTestHelper: return RelativePath("swiftpm-xctest-helper") } diff --git a/Sources/SPMTestSupport/misc.swift b/Sources/SPMTestSupport/misc.swift index 27bcdd7c9cf..2cbf0d7b3f2 100644 --- a/Sources/SPMTestSupport/misc.swift +++ b/Sources/SPMTestSupport/misc.swift @@ -62,7 +62,7 @@ public func fixture( return } - // The fixture contains either a checkout or just a Git directory. + // The fixture contains either a checkout, a script or just a Git directory. if localFileSystem.isFile(fixtureDir.appending(component: "Package.swift")) { // It's a single package, so copy the whole directory as-is. let dstDir = tmpDirPath.appending(component: copyName) @@ -70,6 +70,15 @@ public func fixture( // Invoke the block, passing it the path of the copied fixture. try body(dstDir) + } else if localFileSystem.isFile(fixtureDir.appending(component: "PackageSyntax.txt")) { + // It's a directory with scripts, so copy the whole directory as-is. + let dstDir = tmpDirPath.appending(component: copyName) + try systemQuietly("cp", "-R", "-H", fixtureDir.pathString, dstDir.pathString) + + // Mock package-syntax-parser output with PackageSyntax.txt + try withPackageSyntax(from: dstDir.appending(component: "PackageSyntax.txt")) { + try body(dstDir) + } } else { // Copy each of the package directories and construct a git repo in it. for fileName in try localFileSystem.getDirectoryContents(fixtureDir).sorted() { @@ -249,3 +258,22 @@ public func loadPackageGraph( createREPLProduct: createREPLProduct ) } + +private func withPackageSyntax(from file: AbsolutePath, _ body: () throws -> Void) throws -> Void { + var pathSuffix = "" + if let PATH = ProcessEnv.path { + pathSuffix += ":" + PATH + } + try withTemporaryDirectory { path in + let parser = path.appending(component: "package-syntax-parser") + try localFileSystem.writeFileContents(parser) { + // Manually escape the separator. + let dirname = file.dirname.replacingOccurrences(of: "/", with: "\\/") + // Replace placeholder with the real dirpath. + $0.write("cat \(file.pathString.spm_shellEscaped()) | sed 's$SCRIPT_DIR$\(dirname)$g' \n") + $0.flush() + } + try localFileSystem.chmod(.executable, path: parser) + try withCustomEnv(["PATH": path.pathString + pathSuffix], body: body) + } +} diff --git a/Sources/ScriptingCore/CMakeLists.txt b/Sources/ScriptingCore/CMakeLists.txt new file mode 100644 index 00000000000..429b393818b --- /dev/null +++ b/Sources/ScriptingCore/CMakeLists.txt @@ -0,0 +1,28 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_library(ScriptingCore + Models.swift + utils.swift) + +target_link_libraries(ScriptingCore PUBLIC + TSCBasic + TSCUtility + Basics) + +# NOTE(compnerd) workaround for CMake not setting up include flags yet +set_target_properties(ScriptingCore PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) + +if(USE_CMAKE_INSTALL) + install(TARGETS ScriptingCore + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) +endif() +set_property(GLOBAL APPEND PROPERTY SwiftPM_EXPORTS ScriptingCore) diff --git a/Sources/ScriptingCore/Models.swift b/Sources/ScriptingCore/Models.swift new file mode 100644 index 00000000000..8e9558245a2 --- /dev/null +++ b/Sources/ScriptingCore/Models.swift @@ -0,0 +1,70 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Foundation +import TSCBasic +import TSCUtility + +/// The model that represents a SwiftPM package dependency, or `PackageDescription.Dependency`. +public struct PackageModel: Codable { + /// The raw body of `@package(...)`. + let raw: String + /// The path to the local directory of the package. + let path: AbsolutePath? + /// The URL of a remote package. + let url: Foundation.URL? + /// The user-defined name for a package. + private let _name: String? + + /// The resolved name of the package. + public var name: String { + if let name = _name { + return name + } + let name: String + if let path = path { + name = path.basename + } else if let url = url { + name = url.pathComponents.last!.spm_dropGitSuffix() + } else { + preconditionFailure("Invalid package model") + } + return name + } +} + +/// The model that represents a SwiftPM package dependency and modules from it. +public struct PackageDependency: Codable { + /// The package dependency. + let package: PackageModel + /// Modules imported from the package. + var modules: [String] = [] + + init(of package: PackageModel) { + self.package = package + } +} + +/// The model that represents parsed SwiftPM dependency info from a script. +struct ScriptDependencies: Codable { + /// The path to the script file. + let sourceFile: AbsolutePath + /// The parsed dependencies. + let dependencies: [PackageDependency] +} + +public extension PackageModel { + enum CodingKeys: String, CodingKey { + case raw + case url + case path + case _name = "name" + } +} diff --git a/Sources/ScriptingCore/README.md b/Sources/ScriptingCore/README.md new file mode 100644 index 00000000000..bdc86d83ac8 --- /dev/null +++ b/Sources/ScriptingCore/README.md @@ -0,0 +1,5 @@ +# ScriptingCore Library + +This library defines the support for the `swift-script` tool, +including resolving the output from `package-syntax-parser` and +cache support. diff --git a/Sources/ScriptingCore/utils.swift b/Sources/ScriptingCore/utils.swift new file mode 100644 index 00000000000..1c6fe6c538e --- /dev/null +++ b/Sources/ScriptingCore/utils.swift @@ -0,0 +1,134 @@ +/* + This source file is part of the Swift.org open source project + + Copyright 2015 - 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Basics +import Foundation +import TSCBasic + +fileprivate extension AbsolutePath { + var sha256Hash: String { + String(ByteString(stringLiteral: pathString) + .sha256Checksum.prefix(6)) + } +} + +/// An enumeration of the errors that can be generated by the script tool. +public enum ScriptError: Swift.Error { + /// The specified file doesn't exist. + case fileNotFound(String) + /// The target path is directory. + case isDirectory(String) +} + +extension ScriptError: CustomStringConvertible { + public var description: String { + switch self { + case .fileNotFound(let path): + return "\(path) doesn't exist" + case .isDirectory(let path): + return "\(path) is a directory" + } + } +} + +/// Resolves a path string to `AbsolutePath`. +public func resolveFilePath(_ path: String) -> AbsolutePath? { + let absolutePath: AbsolutePath + if path.first == "/" { + absolutePath = AbsolutePath(path) + } else { + guard let cwd = localFileSystem.currentWorkingDirectory else { + return nil + } + absolutePath = AbsolutePath(path, relativeTo: cwd) + } + guard localFileSystem.isFile(absolutePath) else { + return nil + } + return absolutePath +} + +/// Perform cache for a script and returns cache info. +public func checkAndPerformCache(for file: String, at dirPath: AbsolutePath) throws -> (productName: String, cacheDirPath: AbsolutePath) { + if let scriptPath = resolveFilePath(file) { + let json: Data = try { + // See: https://github.com/stevapple/package-syntax-parser + let proc = Process(args: "package-syntax-parser", scriptPath.pathString) + try proc.launch() + return try Data(proc.waitUntilExit().output.get()) + }() + let decoder = JSONDecoder() + let manifest = try decoder.decode(ScriptDependencies.self, from: json) + + // Resolve product name and create cache directory. + let productName = scriptPath.basename.spm_dropSuffix(".swift").spm_mangledToBundleIdentifier() + let cacheDirPath = dirPath.appending(component: "\(productName)-\(scriptPath.sha256Hash)") + try localFileSystem.createDirectory(cacheDirPath, recursive: true) + + // Build package structure. + let sourceDirPath = cacheDirPath.appending(components: "Sources", productName) + try localFileSystem.createDirectory(sourceDirPath, recursive: true) + if !localFileSystem.exists(sourceDirPath.appending(component: "main.swift")) { + try localFileSystem.createSymbolicLink(sourceDirPath.appending(component: "main.swift"), pointingAt: scriptPath, relative: false) + } + + // Build target dependencies. + var targets: [(name: String, package: String)] = [] + for package in manifest.dependencies { + let packageName = package.package.name + for target in package.modules { + targets.append((target, packageName)) + } + } + + let packageSwift = """ + // swift-tools-version:5.4 + import PackageDescription + let package = Package( + name: "\(productName)", + products: [ + .executable( + name: "\(productName)", + targets: ["\(productName)"]), + ], + dependencies: [\(manifest.dependencies.map{".package(\($0.package.raw))"} + .joined(separator: ", "))], + targets: [ + .executableTarget( + name: "\(productName)", + dependencies: [\(targets.map {".product\($0)"}.joined(separator: ", "))], + swiftSettings: [.unsafeFlags(["-Xfrontend", "-ignore-package-declarations"])] + )] + ) + + """ + + try localFileSystem.writeIfChanged( + path: cacheDirPath.appending(component: "Package.swift"), + bytes: ByteString(stringLiteral: packageSwift) + ) + return (productName, cacheDirPath) + } else { + // Check if the specified cache exists. + guard !file.contains("/"), + case let cacheDirPath = dirPath.appending(component: file), + localFileSystem.isDirectory(cacheDirPath) else { + throw ScriptError.fileNotFound(file) + } + // Drop the hash suffix. + // eg. Dropping "-abc123" from "test-abc123" + return (String(file.dropLast(7)), cacheDirPath) + } +} + +/// Perform cache for a script and returns cache info. +public func checkAndPerformCache(for file: String) throws -> (productName: String, cacheDirPath: AbsolutePath) { + try checkAndPerformCache(for: file, at: localFileSystem.getOrCreateSwiftScriptCacheDirectory()) +} diff --git a/Sources/swift-package/main.swift b/Sources/swift-package/main.swift index e206219e1b7..1bfec937730 100644 --- a/Sources/swift-package/main.swift +++ b/Sources/swift-package/main.swift @@ -24,6 +24,8 @@ case "swift-test": SwiftTestTool.main() case "swift-run": SwiftRunTool.main() +case "swift-script": + SwiftScriptTool.main() case "swift-package-collection": SwiftPackageCollectionsTool.main() default: diff --git a/Sources/swift-script/CMakeLists.txt b/Sources/swift-script/CMakeLists.txt new file mode 100644 index 00000000000..7e57f0cb432 --- /dev/null +++ b/Sources/swift-script/CMakeLists.txt @@ -0,0 +1,17 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_executable(swift-script + main.swift) +target_link_libraries(swift-script PRIVATE + Commands) + +if(USE_CMAKE_INSTALL) +install(TARGETS swift-script + RUNTIME DESTINATION bin) +endif() diff --git a/Sources/swift-script/main.swift b/Sources/swift-script/main.swift new file mode 100644 index 00000000000..003476492a3 --- /dev/null +++ b/Sources/swift-script/main.swift @@ -0,0 +1,13 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Commands + +SwiftScriptTool.main() diff --git a/Tests/CommandsTests/ScriptToolTests.swift b/Tests/CommandsTests/ScriptToolTests.swift new file mode 100644 index 00000000000..4bc7d34cd5b --- /dev/null +++ b/Tests/CommandsTests/ScriptToolTests.swift @@ -0,0 +1,110 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import XCTest + +import SPMTestSupport +import Commands +import TSCBasic + +final class ScriptToolTests: XCTestCase { + private func execute(_ args: [String]) throws -> (stdout: String, stderr: String) { + return try SwiftPMProduct.SwiftScript.execute(args) + } + + private func build(_ args: String...) throws -> (stdout: String, stderr: String) { + return try execute(["build"] + args) + } + + private func run(_ args: String...) throws -> (stdout: String, stderr: String) { + return try execute(["run"] + args) + } + + func testUsage() throws { + let stdout = try execute(["-help"]).stdout + XCTAssert(stdout.contains("USAGE: swift script "), "got stdout:\n" + stdout) + } + + func testSeeAlso() throws { + let stdout = try execute(["--help"]).stdout + XCTAssert(stdout.contains("SEE ALSO: swift build, swift run, swift package, swift test"), "got stdout:\n" + stdout) + } + + func testVersion() throws { + let stdout = try execute(["--version"]).stdout + XCTAssert(stdout.contains("Swift Package Manager"), "got stdout:\n" + stdout) + } + + func testWrongScriptPath() throws { + try withTemporaryDirectory { path in + let pathString = path.appending(component: "EchoArguments").pathString + do { + _ = try run(pathString) + XCTFail("Unexpected success") + } catch SwiftPMProductError.executionFailure(_, _, let stderr) { + XCTAssertEqual(stderr, "error: \(pathString) doesn't exist\n") + } + } + } + + func testArgumentPassing() throws { + fixture(name: "Scripts/EchoArguments") { path in + let result = try run(path.appending(component: "EchoArguments.swift").pathString, + "1", "--hello", "world") + + // We only expect tool's output on the stdout stream. + XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), + #""1" "--hello" "world""#) + + // swift-build-tool output should go to stderr. + XCTAssertMatch(result.stderr, .contains("Using cache: EchoArguments-")) + XCTAssertMatch(result.stderr, .contains("Compiling")) + XCTAssertMatch(result.stderr, .contains("Linking")) + } + } + + func testCurrentWorkingDirectoryWithLocalDependency() throws { + fixture(name: "Scripts/EchoCWD") { path in + let result = try run(path.appending(component: "EchoCWD.swift").pathString) + + XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), + localFileSystem.currentWorkingDirectory?.pathString) + } + } + + func testPrebuild() throws { + fixture(name: "Scripts/EchoArguments") { path in + let result = try build(path.appending(component: "EchoArguments.swift").pathString) + + // swift-build-tool output should go to stderr. + XCTAssertMatch(result.stderr, .contains("Using cache: EchoArguments-")) + XCTAssertMatch(result.stderr, .contains("Compiling")) + XCTAssertMatch(result.stderr, .contains("Linking")) + } + } + + func testQuietMode() throws { + fixture(name: "Scripts/EchoArguments") { path in + let result = try run(path.appending(component: "EchoArguments.swift").pathString, "--quiet", + "1", "--hello", "world") + + // We only expect tool's output on the stdout stream. + XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), + #""1" "--hello" "world""#) + + // swift-build-tool output should be muted. + XCTAssertNoMatch(result.stderr, .contains("Using cache: EchoArguments-")) + XCTAssertNoMatch(result.stderr, .contains("Compiling")) + XCTAssertNoMatch(result.stderr, .contains("Linking")) + } + } + + // TODO: Tests for other swift-script tools +} diff --git a/Utilities/bootstrap b/Utilities/bootstrap index 59d9b99fda4..ab743ae7272 100755 --- a/Utilities/bootstrap +++ b/Utilities/bootstrap @@ -393,7 +393,7 @@ def install_swiftpm(prefix, args): # Install the swift-package tool and create symlinks to it. cli_tool_dest = os.path.join(prefix, "bin") install_binary(args, "swift-package", cli_tool_dest) - for tool in ["swift-build", "swift-test", "swift-run", "swift-package-collection"]: + for tool in ["swift-build", "swift-test", "swift-run", "swift-script", "swift-package-collection"]: src = "swift-package" dest = os.path.join(cli_tool_dest, tool) note("Creating tool symlink from %s to %s" % (src, dest))