diff --git a/Fixtures/Miscellaneous/Playgrounds/.gitignore b/Fixtures/Miscellaneous/Playgrounds/.gitignore new file mode 100644 index 00000000000..0023a534063 --- /dev/null +++ b/Fixtures/Miscellaneous/Playgrounds/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Fixtures/Miscellaneous/Playgrounds/Simple/Package.swift b/Fixtures/Miscellaneous/Playgrounds/Simple/Package.swift new file mode 100644 index 00000000000..450f820c550 --- /dev/null +++ b/Fixtures/Miscellaneous/Playgrounds/Simple/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "Simple", + platforms: [.macOS(.v10_15)], + products: [ + .library( + name: "Simple", + targets: ["Simple"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-play-experimental", branch: "main"), + ], + targets: [ + .target( + name: "Simple", + dependencies: [ + .product(name: "Playgrounds", package: "swift-play-experimental"), + ] + ), + ] +) diff --git a/Fixtures/Miscellaneous/Playgrounds/Simple/Sources/Simple/Simple.swift b/Fixtures/Miscellaneous/Playgrounds/Simple/Sources/Simple/Simple.swift new file mode 100644 index 00000000000..6002d9f4b64 --- /dev/null +++ b/Fixtures/Miscellaneous/Playgrounds/Simple/Sources/Simple/Simple.swift @@ -0,0 +1,25 @@ +struct Simple { + var a = 1 + let b = 42 + func upper(_ input: String) -> String { + return input.uppercased() + } +} + +import Playgrounds + +#Playground { + let s = Simple() + print("a is \(s.a)") +} + +#Playground("Simple.b") { + let s = Simple() + print("b is \(s.b)") +} + +#Playground("Upper") { + let s = Simple() + let upperFoo = s.upper("foo") + print("Upper foo is \(upperFoo)") +} diff --git a/Package.swift b/Package.swift index d98d65acdb4..7ca2e13938d 100644 --- a/Package.swift +++ b/Package.swift @@ -713,6 +713,12 @@ let package = Package( dependencies: ["Commands"], exclude: ["CMakeLists.txt"] ), + .executableTarget( + /** For listing and running #Playground blocks */ + name: "swift-play", + dependencies: ["Commands"], + exclude: ["CMakeLists.txt"] + ), .executableTarget( /** Interacts with package collections */ name: "swift-package-collection", diff --git a/Sources/Basics/Concurrency/AsyncProcess.swift b/Sources/Basics/Concurrency/AsyncProcess.swift index 86222f8fa67..6810e18c155 100644 --- a/Sources/Basics/Concurrency/AsyncProcess.swift +++ b/Sources/Basics/Concurrency/AsyncProcess.swift @@ -177,6 +177,23 @@ package final class AsyncProcess { package typealias ReadableStream = AsyncStream<[UInt8]> + package enum InputRedirection: Sendable { + /// Do not redirect the input + case none + + /// Configure a writable stream which pipes to the process's stdin. + case writableStream + + package var redirectsInput: Bool { + switch self { + case .none: + false + case .writableStream: + true + } + } + } + package enum OutputRedirection: Sendable { /// Do not redirect the output case none @@ -320,6 +337,9 @@ package final class AsyncProcess { } } + /// How process redirects its input. + package let inputRedirection: InputRedirection + /// How process redirects its output. package let outputRedirection: OutputRedirection @@ -340,6 +360,7 @@ package final class AsyncProcess { /// - environment: The environment to pass to subprocess. By default the current process environment /// will be inherited. /// - workingDirectory: The path to the directory under which to run the process. + /// - inputRedirection: How process redirects its input. Default value is .writableStream. /// - outputRedirection: How process redirects its output. Default value is .collect. /// - startNewProcessGroup: If true, a new progress group is created for the child making it /// continue running even if the parent is killed or interrupted. Default value is true. @@ -349,6 +370,7 @@ package final class AsyncProcess { arguments: [String], environment: Environment = .current, workingDirectory: AbsolutePath, + inputRedirection: InputRedirection = .writableStream, outputRedirection: OutputRedirection = .collect, startNewProcessGroup: Bool = true, loggingHandler: LoggingHandler? = .none @@ -356,6 +378,7 @@ package final class AsyncProcess { self.arguments = arguments self.environment = environment self.workingDirectory = workingDirectory + self.inputRedirection = inputRedirection self.outputRedirection = outputRedirection self.startNewProcessGroup = startNewProcessGroup self.loggingHandler = loggingHandler ?? AsyncProcess.loggingHandler @@ -367,6 +390,7 @@ package final class AsyncProcess { /// - arguments: The arguments for the subprocess. /// - environment: The environment to pass to subprocess. By default the current process environment /// will be inherited. + /// - inputRedirection: How process redirects its input. Default value is .writableStream. /// - outputRedirection: How process redirects its output. Default value is .collect. /// - verbose: If true, launch() will print the arguments of the subprocess before launching it. /// - startNewProcessGroup: If true, a new progress group is created for the child making it @@ -375,6 +399,7 @@ package final class AsyncProcess { package init( arguments: [String], environment: Environment = .current, + inputRedirection: InputRedirection = .writableStream, outputRedirection: OutputRedirection = .collect, startNewProcessGroup: Bool = true, loggingHandler: LoggingHandler? = .none @@ -382,6 +407,7 @@ package final class AsyncProcess { self.arguments = arguments self.environment = environment self.workingDirectory = nil + self.inputRedirection = inputRedirection self.outputRedirection = outputRedirection self.startNewProcessGroup = startNewProcessGroup self.loggingHandler = loggingHandler ?? AsyncProcess.loggingHandler @@ -390,12 +416,14 @@ package final class AsyncProcess { package convenience init( args: [String], environment: Environment = .current, + inputRedirection: InputRedirection = .writableStream, outputRedirection: OutputRedirection = .collect, loggingHandler: LoggingHandler? = .none ) { self.init( arguments: args, environment: environment, + inputRedirection: inputRedirection, outputRedirection: outputRedirection, loggingHandler: loggingHandler ) @@ -404,12 +432,14 @@ package final class AsyncProcess { package convenience init( args: String..., environment: Environment = .current, + inputRedirection: InputRedirection = .writableStream, outputRedirection: OutputRedirection = .collect, loggingHandler: LoggingHandler? = .none ) { self.init( arguments: args, environment: environment, + inputRedirection: inputRedirection, outputRedirection: outputRedirection, loggingHandler: loggingHandler ) @@ -464,9 +494,11 @@ package final class AsyncProcess { } } - /// Launch the subprocess. Returns a WritableByteStream object that can be used to communicate to the process's - /// stdin. If needed, the stream can be closed using the close() API. Otherwise, the stream will be closed + /// Launch the subprocess. If inputRedirection is `.writableStream` (the default) it returns a + /// `WritableByteStream` object that can be used to communicate to the process's stdin. + /// If needed, the stream can be closed using the close() API. Otherwise, the stream will be closed /// automatically. + /// If inputRedirection is `.none` then the returned object shouldn't be used (it won't do anything). @discardableResult package func launch() throws -> any WritableByteStream { precondition( @@ -500,8 +532,15 @@ package final class AsyncProcess { process.executableURL = executablePath.asURL process.environment = .init(self.environment) - let stdinPipe = Pipe() - process.standardInput = stdinPipe + let stdinPipe: Pipe? + if self.inputRedirection.redirectsInput { + stdinPipe = Pipe() + process.standardInput = stdinPipe + } else { + // On Windows, explicitly inherit the current process's stdin + process.standardInput = FileHandle.standardInput + stdinPipe = nil + } let group = DispatchGroup() @@ -564,7 +603,12 @@ package final class AsyncProcess { } try process.run() - return stdinPipe.fileHandleForWriting + if let stdinPipe { + return stdinPipe.fileHandleForWriting + } else { + // For .none input redirection, return a null stream that discards all writes + return NullWritableByteStream() + } #elseif(!canImport(Darwin) || os(macOS)) // Initialize the spawn attributes. #if canImport(Darwin) || os(Android) || os(OpenBSD) || os(FreeBSD) @@ -632,20 +676,30 @@ package final class AsyncProcess { #endif } + let stdinStream: any WritableByteStream var stdinPipe: [Int32] = [-1, -1] - try open(pipe: &stdinPipe) - guard let fp = fdopen(stdinPipe[1], "wb") else { - throw AsyncProcess.Error.stdinUnavailable - } - let stdinStream = try LocalFileOutputByteStream(filePointer: fp, closeOnDeinit: true) + if self.inputRedirection.redirectsInput { + try open(pipe: &stdinPipe) - // Dupe the read portion of the remote to 0. - posix_spawn_file_actions_adddup2(&fileActions, stdinPipe[0], 0) + guard let fp = fdopen(stdinPipe[1], "wb") else { + throw AsyncProcess.Error.stdinUnavailable + } + stdinStream = try LocalFileOutputByteStream(filePointer: fp, closeOnDeinit: true) - // Close the other side's pipe since it was dupped to 0. - posix_spawn_file_actions_addclose(&fileActions, stdinPipe[0]) - posix_spawn_file_actions_addclose(&fileActions, stdinPipe[1]) + // Dupe the read portion of the remote to 0. + posix_spawn_file_actions_adddup2(&fileActions, stdinPipe[0], 0) + + // Close the other side's pipe since it was dupped to 0. + posix_spawn_file_actions_addclose(&fileActions, stdinPipe[0]) + posix_spawn_file_actions_addclose(&fileActions, stdinPipe[1]) + } + else { + // Dup this process's stdin to the sub-process's stdin + posix_spawn_file_actions_adddup2(&fileActions, 0, 0) + // stdin stream isn't used with this option + stdinStream = try LocalFileOutputByteStream(AbsolutePath(validating: "/dev/null")) + } var outputPipe: [Int32] = [-1, -1] var stderrPipe: [Int32] = [-1, -1] @@ -690,8 +744,10 @@ package final class AsyncProcess { } do { - // Close the local read end of the input pipe. - try close(fd: stdinPipe[0]) + if self.inputRedirection.redirectsInput { + // Close the local read end of the input pipe. + try close(fd: stdinPipe[0]) + } let group = DispatchGroup() if !self.outputRedirection.redirectsOutput { @@ -1353,4 +1409,21 @@ extension FileHandle: WritableByteStream { synchronizeFile() } } + +/// A WritableByteStream that discards all data written to it (like /dev/null on Unix) +private final class NullWritableByteStream: WritableByteStream { + var position: Int = 0 + + func write(_ byte: UInt8) { + position += 1 + } + + func write(_ bytes: some Collection) { + position += bytes.count + } + + func flush() { + // Nothing to flush for a null stream + } +} #endif diff --git a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift index b6b6c20fffa..8c03ccda33f 100644 --- a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift @@ -194,6 +194,8 @@ public final class SwiftModuleBuildDescription { public var isTestTarget: Bool { self.testTargetRole != nil } + + public let isPlaygroundRunnerTarget: Bool /// True if this module needs to be parsed as a library based on the target type and the configuration /// of the source code @@ -215,6 +217,11 @@ public final class SwiftModuleBuildDescription { if self.toolsVersion < .v5_5 || self.sources.count != 1 { return false } + if self.isPlaygroundRunnerTarget { + // Always true for the Playground runner executable target, as the derived source file hasn't + // been written yet. + return true + } // looking into the file content to see if it is using the @main annotation which requires parse-as-library return (try? containsAtMain(fileSystem: self.fileSystem, path: self.sources[0])) ?? false default: @@ -268,7 +275,8 @@ public final class SwiftModuleBuildDescription { shouldGenerateTestObservation: Bool = false, shouldDisableSandbox: Bool, fileSystem: FileSystem, - observabilityScope: ObservabilityScope + observabilityScope: ObservabilityScope, + isPlaygroundRunnerTarget: Bool = false ) throws { guard let swiftTarget = target.underlying as? SwiftModule else { throw InternalError("underlying target type mismatch \(target)") @@ -290,6 +298,8 @@ public final class SwiftModuleBuildDescription { self.testTargetRole = nil } + self.isPlaygroundRunnerTarget = isPlaygroundRunnerTarget + self.tempsPath = target.tempsPath(self.buildParameters) self.derivedSources = Sources(paths: [], root: self.tempsPath.appending("DerivedSources")) self.buildToolPluginInvocationResults = buildToolPluginInvocationResults diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder.swift index a8d7ef2126e..534bc5191b1 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder.swift @@ -108,10 +108,11 @@ public class LLBuildManifestBuilder { } } - // Skip test discovery if preparing for indexing + // Skip test & playground discovery if preparing for indexing if self.plan.destinationBuildParameters.prepareForIndexing == .off { try self.addTestDiscoveryGenerationCommand() try self.addTestEntryPointGenerationCommand() + try self.addPlaygroundRunnerMainGenerationCommand() } // Create command for all products in the plan. @@ -290,6 +291,28 @@ extension LLBuildManifestBuilder { ) } } + + private func addPlaygroundRunnerMainGenerationCommand() throws { + for module in self.plan.targets { + guard case .swift(let playgroundRunnerTarget) = module, + playgroundRunnerTarget.isPlaygroundRunnerTarget + else { continue } + + let inputs: [AbsolutePath: Bool] = [:] + let outputs = playgroundRunnerTarget.target.sources.paths + + let mainFileName = PlaygroundRunnerTool.mainFileName + guard let mainOutput = (outputs.first { $0.basename == mainFileName }) else { + throw InternalError("main output (\(mainFileName)) not found") + } + let cmdName = mainOutput.pathString + self.manifest.addPlaygroundRunnerCmd( + name: cmdName, + inputs: inputs.map(Node.file), + outputs: outputs.map(Node.file) + ) + } + } } extension ModuleBuildDescription { diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index ee3b48adfb6..8e82f1259ea 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -1058,6 +1058,7 @@ extension BuildDescription { let swiftFrontendCommands = llbuild.manifest.getCmdToolMap(kind: SwiftFrontendTool.self) let testDiscoveryCommands = llbuild.manifest.getCmdToolMap(kind: TestDiscoveryTool.self) let testEntryPointCommands = llbuild.manifest.getCmdToolMap(kind: TestEntryPointTool.self) + let playgroundRunnerCommands = llbuild.manifest.getCmdToolMap(kind: PlaygroundRunnerTool.self) let copyCommands = llbuild.manifest.getCmdToolMap(kind: CopyTool.self) let writeCommands = llbuild.manifest.getCmdToolMap(kind: WriteAuxiliaryFile.self) @@ -1068,6 +1069,7 @@ extension BuildDescription { swiftFrontendCommands: swiftFrontendCommands, testDiscoveryCommands: testDiscoveryCommands, testEntryPointCommands: testEntryPointCommands, + playgroundRunnerCommands: playgroundRunnerCommands, copyCommands: copyCommands, writeCommands: writeCommands, pluginDescriptions: plan.pluginDescriptions, diff --git a/Sources/Build/BuildPlan/BuildPlan+Playground.swift b/Sources/Build/BuildPlan/BuildPlan+Playground.swift new file mode 100644 index 00000000000..0c049f9990a --- /dev/null +++ b/Sources/Build/BuildPlan/BuildPlan+Playground.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 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 the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import class Basics.ObservabilityScope +import struct LLBuildManifest.PlaygroundRunnerTool + +@_spi(SwiftPMInternal) +import struct PackageGraph.ResolvedModule + +import struct PackageGraph.ResolvedProduct +import struct PackageModel.Sources +import class PackageModel.SwiftModule +import struct SPMBuildCore.BuildParameters +import protocol TSCBasic.FileSystem + +extension BuildPlan { + /// Creates and returns a module build description for a synthesized Playground runner executable target. + static func makeDerivedPlaygroundRunnerTargets( + playgroundRunnerProductBuildDescription: ProductBuildDescription, + destinationBuildParameters: BuildParameters, + toolsBuildParameters: BuildParameters, + shouldDisableSandbox: Bool, + _ fileSystem: FileSystem, + _ observabilityScope: ObservabilityScope + ) throws -> [(product: ResolvedProduct, playgroundRunnerTargetBuildDescription: SwiftModuleBuildDescription)] { + let playgroundProduct = playgroundRunnerProductBuildDescription.product + guard let playgroundRunnerTarget = playgroundProduct.modules.first(where: { $0.underlying.isPlaygroundRunner }) else { + return [] + } + + let targetName = playgroundRunnerTarget.name + + // Playground runner target builds from a derived source file (written out by a playground runner build cmd) + let derivedDir = playgroundRunnerProductBuildDescription.buildParameters.buildPath.appending(components: "\(targetName).derived") + let mainFile = derivedDir.appending(component: PlaygroundRunnerTool.mainFileName) + + let target = SwiftModule( + name: targetName, + type: .executable, + path: .root, + sources: Sources(paths: [mainFile], root: derivedDir), + dependencies: playgroundRunnerTarget.underlying.dependencies, // copy template target's dependencies + packageAccess: true, // playground target is allowed access to package decls + usesUnsafeFlags: false, + implicit: true, // implicitly created for swift play + isPlaygroundRunner: true + ) + + let resolvedTarget = ResolvedModule( + packageIdentity: playgroundProduct.packageIdentity, + underlying: target, + dependencies: playgroundRunnerTarget.dependencies, // copy template target's dependencies + defaultLocalization: playgroundProduct.defaultLocalization, + supportedPlatforms: playgroundProduct.supportedPlatforms, + platformVersionProvider: playgroundProduct.platformVersionProvider + ) + + let targetBuildDescription = try SwiftModuleBuildDescription( + package: playgroundRunnerProductBuildDescription.package, + target: resolvedTarget, + toolsVersion: playgroundRunnerProductBuildDescription.package.manifest.toolsVersion, + buildParameters: playgroundRunnerProductBuildDescription.buildParameters, + macroBuildParameters: toolsBuildParameters, + testTargetRole: nil, + shouldDisableSandbox: shouldDisableSandbox, + fileSystem: fileSystem, + observabilityScope: observabilityScope, + isPlaygroundRunnerTarget: true + ) + + return [(playgroundProduct, targetBuildDescription)] + } +} diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index b54f7b939c6..7e6e9a1c319 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -199,6 +199,9 @@ public class BuildPlan: SPMBuildCore.BuildPlan { @_spi(SwiftPMInternal) public private(set) var derivedTestTargetsMap: [ResolvedProduct.ID: [ResolvedModule]] = [:] + @_spi(SwiftPMInternal) + public private(set) var derivedPlaygroundTargetsMap: [ResolvedProduct.ID: [ResolvedModule]] = [:] + /// Cache for pkgConfig flags. private var pkgConfigCache = [SystemLibraryModule: (cFlags: [String], libs: [String])]() @@ -458,6 +461,28 @@ public class BuildPlan: SPMBuildCore.BuildPlan { self.derivedTestTargetsMap[item.product.id] = derivedTestTargets } + // Plan the derived playground runner targets, if necessary. + if let playgroundRunnerBuildDescription = productMap.first (where: { + $0.product.underlying.isPlaygroundRunner + }) { + let derivedPlaygroundRunnerTargets = try Self.makeDerivedPlaygroundRunnerTargets( + playgroundRunnerProductBuildDescription: playgroundRunnerBuildDescription, + destinationBuildParameters: destinationBuildParameters, + toolsBuildParameters: toolsBuildParameters, + shouldDisableSandbox: self.shouldDisableSandbox, + self.fileSystem, + self.observabilityScope + ) + + // Replace the placeholder target added by the PackageBuilder with the new derived target + let placeholderPlaygroundRunnerTargets = targetMap.filter { $0.module.underlying.isPlaygroundRunner } + targetMap = targetMap.subtracting(placeholderPlaygroundRunnerTargets) + for item in derivedPlaygroundRunnerTargets { + targetMap.insert(.swift(item.playgroundRunnerTargetBuildDescription)) + self.derivedPlaygroundTargetsMap[item.product.id] = [item.playgroundRunnerTargetBuildDescription.target] + } + } + self.buildToolPluginInvocationResults = buildToolPluginInvocationResults self.prebuildCommandResults = prebuildCommandResults diff --git a/Sources/Build/CMakeLists.txt b/Sources/Build/CMakeLists.txt index 495bd7a87e6..98b453cde45 100644 --- a/Sources/Build/CMakeLists.txt +++ b/Sources/Build/CMakeLists.txt @@ -21,6 +21,7 @@ add_library(Build BuildOperation.swift BuildPlan/BuildPlan.swift BuildPlan/BuildPlan+Clang.swift + BuildPlan/BuildPlan+Playground.swift BuildPlan/BuildPlan+Product.swift BuildPlan/BuildPlan+Swift.swift BuildPlan/BuildPlan+Test.swift diff --git a/Sources/Build/LLBuildCommands.swift b/Sources/Build/LLBuildCommands.swift index aeb9442eb19..161a11fc762 100644 --- a/Sources/Build/LLBuildCommands.swift +++ b/Sources/Build/LLBuildCommands.swift @@ -481,3 +481,62 @@ final class CopyCommand: CustomLLBuildCommand { return true } } + +extension PlaygroundRunnerTool { + public static var mainFileName: String { + "PlaygroundRunner.swift" + } +} + +final class PlaygroundRunnerCommand: CustomLLBuildCommand { + private func execute(fileSystem: Basics.FileSystem, tool: PlaygroundRunnerTool) throws { + let outputs = tool.outputs.compactMap { try? AbsolutePath(validating: $0.name) } + + // Find the main output file + let mainFileName = PlaygroundRunnerTool.mainFileName + guard let mainFile = outputs.first(where: { path in + path.basename == mainFileName + }) else { + throw InternalError("main file output (\(mainFileName)) not found") + } + + // Write the main file. + let stream = try LocalFileOutputByteStream(mainFile) + + stream.send( + #""" + import Foundation + import Playgrounds + + @main + struct Runner { + static func main() async { + await Playgrounds.__swiftPlayEntryPoint(CommandLine.arguments) + } + } + """# + ) + + stream.flush() + } + + override func execute( + _ command: SPMLLBuild.Command, + _: SPMLLBuild.BuildSystemCommandInterface + ) -> Bool { + do { + // This tool will never run without the build description. + guard let buildDescription = self.context.buildDescription else { + throw InternalError("unknown build description") + } + guard let tool = buildDescription.playgroundRunnerCommands[command.name] else { + throw InternalError("command \(command.name) not registered") + } + try self.execute(fileSystem: self.context.fileSystem, tool: tool) + return true + } catch { + self.context.observabilityScope.emit(error) + return false + } + } +} diff --git a/Sources/Build/LLBuildDescription.swift b/Sources/Build/LLBuildDescription.swift index 6dfa5fca2fa..9daab5a6e7b 100644 --- a/Sources/Build/LLBuildDescription.swift +++ b/Sources/Build/LLBuildDescription.swift @@ -39,6 +39,9 @@ public struct BuildDescription: Codable { /// The map of test entry point commands. let testEntryPointCommands: [LLBuildManifest.CmdName: TestEntryPointTool] + /// The map of playground runner commands. + let playgroundRunnerCommands: [LLBuildManifest.CmdName: PlaygroundRunnerTool] + /// The map of copy commands. let copyCommands: [LLBuildManifest.CmdName: CopyTool] @@ -73,6 +76,7 @@ public struct BuildDescription: Codable { swiftFrontendCommands: [LLBuildManifest.CmdName: SwiftFrontendTool], testDiscoveryCommands: [LLBuildManifest.CmdName: TestDiscoveryTool], testEntryPointCommands: [LLBuildManifest.CmdName: TestEntryPointTool], + playgroundRunnerCommands: [LLBuildManifest.CmdName: PlaygroundRunnerTool], copyCommands: [LLBuildManifest.CmdName: CopyTool], writeCommands: [LLBuildManifest.CmdName: WriteAuxiliaryFile], pluginDescriptions: [PluginBuildDescription] @@ -83,6 +87,7 @@ public struct BuildDescription: Codable { swiftFrontendCommands: swiftFrontendCommands, testDiscoveryCommands: testDiscoveryCommands, testEntryPointCommands: testEntryPointCommands, + playgroundRunnerCommands: playgroundRunnerCommands, copyCommands: copyCommands, writeCommands: writeCommands, pluginDescriptions: pluginDescriptions, @@ -96,6 +101,7 @@ public struct BuildDescription: Codable { swiftFrontendCommands: [LLBuildManifest.CmdName: SwiftFrontendTool], testDiscoveryCommands: [LLBuildManifest.CmdName: TestDiscoveryTool], testEntryPointCommands: [LLBuildManifest.CmdName: TestEntryPointTool], + playgroundRunnerCommands: [LLBuildManifest.CmdName: PlaygroundRunnerTool], copyCommands: [LLBuildManifest.CmdName: CopyTool], writeCommands: [LLBuildManifest.CmdName: WriteAuxiliaryFile], pluginDescriptions: [PluginBuildDescription], @@ -105,6 +111,7 @@ public struct BuildDescription: Codable { self.swiftFrontendCommands = swiftFrontendCommands self.testDiscoveryCommands = testDiscoveryCommands self.testEntryPointCommands = testEntryPointCommands + self.playgroundRunnerCommands = playgroundRunnerCommands self.copyCommands = copyCommands self.writeCommands = writeCommands self.explicitTargetDependencyImportCheckingMode = plan.destinationBuildParameters.driverParameters diff --git a/Sources/Build/LLBuildProgressTracker.swift b/Sources/Build/LLBuildProgressTracker.swift index 63a6a6caace..ac831059ba2 100644 --- a/Sources/Build/LLBuildProgressTracker.swift +++ b/Sources/Build/LLBuildProgressTracker.swift @@ -207,6 +207,8 @@ final class LLBuildProgressTracker: LLBuildBuildSystemDelegate, SwiftCompilerOut InProcessTool(self.buildExecutionContext, type: TestDiscoveryCommand.self) case TestEntryPointTool.name: InProcessTool(self.buildExecutionContext, type: TestEntryPointCommand.self) + case PlaygroundRunnerTool.name: + InProcessTool(self.buildExecutionContext, type: PlaygroundRunnerCommand.self) case PackageStructureTool.name: InProcessTool(self.buildExecutionContext, type: PackageStructureCommand.self) case CopyTool.name: diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index 20d0958b12b..ab3db586e9b 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -38,6 +38,7 @@ add_subdirectory(swift-build) add_subdirectory(swift-experimental-sdk) add_subdirectory(swift-sdk) add_subdirectory(swift-package) +add_subdirectory(swift-play) add_subdirectory(swift-run) add_subdirectory(swift-test) add_subdirectory(SwiftSDKCommand) diff --git a/Sources/Commands/CMakeLists.txt b/Sources/Commands/CMakeLists.txt index 303944aebcc..385118c9cea 100644 --- a/Sources/Commands/CMakeLists.txt +++ b/Sources/Commands/CMakeLists.txt @@ -44,6 +44,7 @@ add_library(Commands Snippets/Colorful.swift SwiftBuildCommand.swift SwiftRunCommand.swift + SwiftPlayCommand.swift SwiftTestCommand.swift CommandWorkspaceDelegate.swift Utilities/APIDigester.swift diff --git a/Sources/Commands/PackageCommands/CompletionCommand.swift b/Sources/Commands/PackageCommands/CompletionCommand.swift index 14a61a08c2a..391e6b3b730 100644 --- a/Sources/Commands/PackageCommands/CompletionCommand.swift +++ b/Sources/Commands/PackageCommands/CompletionCommand.swift @@ -45,6 +45,7 @@ extension SwiftPackageCommand { SwiftBuildCommand.self, SwiftTestCommand.self, SwiftPackageCommand.self, + SwiftPlayCommand.self, ] ) } diff --git a/Sources/Commands/SwiftPlayCommand.swift b/Sources/Commands/SwiftPlayCommand.swift new file mode 100644 index 00000000000..94eb62ad085 --- /dev/null +++ b/Sources/Commands/SwiftPlayCommand.swift @@ -0,0 +1,908 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 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 the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Basics +import CoreCommands +import Foundation +import PackageGraph +import PackageModel +import SPMBuildCore + +import enum TSCUtility.Diagnostics + +#if canImport(Android) +import Android +#endif + +struct PlayCommandOptions: ParsableArguments { + enum PlayMode: EnumerableFlag { + /// Build and execute playground and then automatically re-build and re-execute on any file changes + case liveUpdate + + /// Build and execute playground one time and immediately exit + case oneShot + + static func help(for value: PlayCommandOptions.PlayMode) -> ArgumentHelp? { + switch value { + case .oneShot: + return "Execute playground and exit immediately" + case .liveUpdate: + return "Execute playground and automatically re-execute on any source file changes" + } + } + } + + /// The mode in with the tool command should run. + @Flag var mode: PlayMode = .liveUpdate + + /// The playground to run. + @Argument(help: "The playground name to run", completion: .shellCommand("swift package completion-tool list-playgrounds")) + var playgroundName: String = "" + + /// List found playgrounds instead of running them + @Flag(name: .customLong("list"), help: "List all Playgrounds") + var list: Bool = false +} + +/// swift-play command namespace +public struct SwiftPlayCommand: AsyncSwiftCommand { + public static var configuration = CommandConfiguration( + commandName: "play", + _superCommandName: "swift", + abstract: "Build and run a playground", + discussion: "SEE ALSO: swift build, swift package, swift run, swift test", + version: SwiftVersion.current.completeDisplayString, + helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) + + @OptionGroup() + public var globalOptions: GlobalOptions + + @OptionGroup() + var options: PlayCommandOptions + + public var toolWorkspaceConfiguration: ToolWorkspaceConfiguration { + // Enabling the Playground product ensures a playground runner executable + // is synthesized for `swift play` to access and run playground macro code. + return .init(wantsPlaygroundProduct: true) + } + + public func run(_ swiftCommandState: SwiftCommandState) async throws { + if options.playgroundName == "" && !options.list { + throw ValidationError("Missing Playground name") + } + + var productsBuildParameters = try swiftCommandState.productsBuildParameters + + // Append additional build flags from the environment + if let envSwiftcOptions = ProcessInfo.processInfo.environment["SWIFTC_FLAGS"] { + var flags = productsBuildParameters.flags + let additionalFlags = envSwiftcOptions.components(separatedBy: CharacterSet.whitespaces) + flags.swiftCompilerFlags += additionalFlags + productsBuildParameters.flags = flags + } + + #if os(macOS) + // Add rpath so the synthesized Playgrounds executable can link + // $TOOLCHAIN/usr/lib/swift/macosx/libPlaygrounds.dylib at runtime. + // (Despite `swiftc -print-target-info` including this path in + // "runtimeLibraryPaths" it doesn't get included.) + let toolchainDir = try productsBuildParameters.toolchain.toolchainDir + let playgroundsLibPath = toolchainDir.appending(components: ["usr", "lib", "swift", "macosx"]) + productsBuildParameters.flags.linkerFlags.append(contentsOf: ["-rpath", playgroundsLibPath.pathString]) + #endif + + var buildAndPlayAgain: Bool = false + + repeat { + do { + let buildResult = try await buildPlaygroundRunner( + swiftCommandState: swiftCommandState, + productsBuildParameters: productsBuildParameters + ) + + if case .failure(_) = buildResult { + print("Build failed") + + if !buildAndPlayAgain { + // Exit immediately when initial build fails + break + } + } + + let result = try await startPlaygroundAndMonitorFilesIfNeeded( + buildResult: buildResult, + swiftCommandState: swiftCommandState + ) + + buildAndPlayAgain = (result == .shouldPlayAgain) + + } catch Diagnostics.fatalError { + throw ExitCode.failure + } + } while (buildAndPlayAgain) + } + + /// Builds the playground runner executable product. + /// + /// This method creates a build system using the provided Swift command state and build parameters, + /// then locates and builds the playground runner executable product that will be used to execute + /// playground code. + /// + /// - Parameters: + /// - swiftCommandState: The Swift command state containing workspace and configuration information + /// - productsBuildParameters: Build parameters specifying compilation settings and output paths + /// + /// - Returns: A `Result` containing either the name of the successfully built playground runner + /// product on success, or an `Error` on failure + /// + /// - Throws: `ExitCode.failure` if no playground runner executable product can be found in the + /// package graph + private func buildPlaygroundRunner( + swiftCommandState: SwiftCommandState, + productsBuildParameters: BuildParameters + ) async throws -> Result { + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitProduct: nil, + productsBuildParameters: productsBuildParameters + ) + + let allProducts = try await buildSystem.getPackageGraph().reachableProducts + if globalOptions.logging.veryVerbose { + for product in allProducts { + verboseLog("Found product: \(product)") + } + verboseLog("- Found \(allProducts.count) product\(allProducts.count==1 ? "" : "s")") + } + + guard let playgroundExecutableProduct = allProducts.first(where: { $0.underlying.isPlaygroundRunner }) else { + print("Could not create a playground executable.") + throw ExitCode.failure + } + verboseLog("Choosing product \"\(playgroundExecutableProduct.name)\", type: \(playgroundExecutableProduct.type)") + + // Build the playground runner executable product + do { + try await buildSystem.build(subset: .product(playgroundExecutableProduct.name), buildOutputs: []) + return Result.success(playgroundExecutableProduct.name) + } catch { + return Result.failure(error) + } + } + + private enum PlaygroundMonitorResult { + /// Indicates that the playground should be rebuilt and executed again + case shouldPlayAgain + /// Indicates that all playground monitoring and execution has finished + case shouldExit + } + + /// Starts the playground runner process and monitors for file changes if in live update mode. + /// + /// This method handles the execution of the playground runner executable and optionally monitors + /// source files for changes to enable automatic re-building and re-execution. The behavior + /// depends on the configured play mode: + /// - In `.oneShot` mode: Executes the playground once and exits immediately + /// - In `.liveUpdate` mode: Executes the playground and monitors for file changes to trigger rebuilds + /// + /// - Parameters: + /// - buildResult: The result of building the playground runner executable, containing either + /// the product name on success or an error on failure + /// - swiftCommandState: The Swift command state containing workspace configuration and file system access + /// + /// - Returns: A `PlaygroundMonitorResult` indicating the next action: + /// - `.shouldPlayAgain`: File changes were detected in live update mode, requiring a rebuild + /// - `.shouldExit`: The process completed normally or is running in one-shot mode + /// + /// - Throws: `ExitCode.failure` if the current working directory cannot be determined or if file + /// monitoring setup fails on macOS platforms + /// + /// - Note: File monitoring is only currently available on macOS. On other platforms, live update mode will + /// behave similarly to one-shot mode after the initial execution. + private func startPlaygroundAndMonitorFilesIfNeeded( + buildResult: Result, + swiftCommandState: SwiftCommandState + ) async throws -> PlaygroundMonitorResult { + + // Hand off playground execution to dynamically built playground runner executable + var runnerProcess: AsyncProcess? = nil + defer { + let signal: Int32 + #if os(Windows) + signal = 9 + #else + signal = SIGKILL + #endif + runnerProcess?.signal(signal) + } + + if case let .success(productName) = buildResult { + // Build was successful so launch the playground runner executable that was just built + let productRelativePath = try swiftCommandState.productsBuildParameters.executablePath(for: productName) + let runnerExecutablePath = try swiftCommandState.productsBuildParameters.buildPath.appending(productRelativePath) + + runnerProcess = try self.play( + executablePath: runnerExecutablePath, + originalWorkingDirectory: swiftCommandState.originalWorkingDirectory + ) + } + + if options.mode == .oneShot || options.list { + // Call playground runner (if available) then immediately exit + try await runnerProcess?.waitUntilExit() + return .shouldExit // don't build & play again + } + else { + // Live update mode: re-build/re-run on file changes + + guard let monitorURL = swiftCommandState.fileSystem.currentWorkingDirectory?.asURL else { + print("[No cwd]") + throw ExitCode.failure + } + + // Monitor for file changes (if supported) + var fileMonitor: FileMonitor? = nil + do { + verboseLog("Monitoring files at \(monitorURL)") + fileMonitor = try FileMonitor( + verboseLogging: globalOptions.logging.veryVerbose + ) + try fileMonitor?.startMonitoring(atURL: monitorURL) + } catch FileMonitor.FileMonitorError.notSupported { + verboseLog("Monitoring files not supported on this platform") + } catch { + print("FileMonitor failed for \(monitorURL): \(error)") + throw ExitCode.failure + } + + defer { + fileMonitor?.stopMonitoring() + } + + verboseLog("swift play waiting...") + + enum ProcessAndFileMonitorResult { + /// A source file was changed + case fileChanged + /// The monitored process exited + case processExited + } + + // Wait for either the process to finish or file changes to occur + let result = await withTaskGroup(of: ProcessAndFileMonitorResult.self) { group in + + // Task to wait for process completion, if a process is running. + // A process won't be running if the build fails, for example, but + // we still want to watch for file changes below to re-build/re-run. + if let runnerProcess { + group.addTask { + do { + try await runnerProcess.waitUntilExit() + } catch { + verboseLog("Runner process exited with error: \(error)") + } + return .processExited + } + } + + if let fileMonitor { + // Task to wait for file changes + group.addTask { + await fileMonitor.waitForChanges() + return .fileChanged + } + } + + // Return the first result from either task + let firstResult = await group.next() + + // Cancel remaining tasks + group.cancelAll() + + // Kill runner process, so that its task ends + let signal: Int32 + #if os(Windows) + signal = 9 + #else + signal = SIGKILL + #endif + runnerProcess?.signal(signal) + + guard let result = firstResult else { + // taskGroup returned no value so default to processExited + return ProcessAndFileMonitorResult.processExited + } + + return result + } + + switch result { + case .fileChanged: + verboseLog("Files changed, restarting...") + return .shouldPlayAgain + case .processExited: + verboseLog("Process exited") + return .shouldExit + } + } + } + + /// Executes the Playground via the specified executable at the specified path. + private func play( + executablePath: Basics.AbsolutePath, + originalWorkingDirectory: Basics.AbsolutePath + ) throws -> AsyncProcess { + var runnerArguments: [String] = [] + if options.mode == .oneShot { + runnerArguments.append("--one-shot") + } + runnerArguments.append(options.list ? "--list" : options.playgroundName) + + let runnerProcess = AsyncProcess( + arguments: [executablePath.pathString] + runnerArguments, + workingDirectory: originalWorkingDirectory, + inputRedirection: .none, // route parent process' stdin to playground runner process + outputRedirection: .none, // route playground runner process' stdout/stderr to default output + startNewProcessGroup: false // runner process tracks the parent process' lifetime + ) + + do { + verboseLog("Launching runner: \(executablePath.pathString) \(runnerArguments.joined(separator: " "))") + try runnerProcess.launch() + verboseLog("Runner launched with pid \(runnerProcess.processID)") + } catch { + print("[Playground runner launch failed with error: \(error)]") + throw ExitCode.failure + } + + return runnerProcess + } + + private func verboseLog(_ message: String) { + if globalOptions.logging.veryVerbose { + _verbosePlayLog(message) + } + } + + public init() {} +} + +// MARK: - Verbose Logging - + +fileprivate func _verbosePlayLog(_ message: String) { + print("[swift play: \(message)]") +} + +// MARK: - File Monitoring - + +final private class FileMonitor { + var isMonitoring: Bool = false + + enum FileMonitorError: Error { + case alreadyMonitoring + case notSupported + } + + private let verboseLogging: Bool + private let fileWatcher: FileWatcher + private let changeStream: AsyncStream + private let changeContinuation: AsyncStream.Continuation + + init(verboseLogging: Bool = false) throws { + self.verboseLogging = verboseLogging + + // Try to create a platform-specific file watcher. These aren't + // available for every platform, in which case throw notSupported. + guard let fileWatcher = try makeFileWatcher(verboseLogging: verboseLogging) else { + throw FileMonitorError.notSupported + } + self.fileWatcher = fileWatcher + + // Create an async stream for file change notifications + (self.changeStream, self.changeContinuation) = AsyncStream.makeStream() + } + + deinit { + stopMonitoring() + changeContinuation.finish() + } + + /// Starts monitoring for any files changes under the path at `url`. + /// Call `waitForChanges()` to wait for any file change events. + func startMonitoring(atURL url: URL) throws { + guard !isMonitoring else { + throw FileMonitorError.alreadyMonitoring + } + + // Register files to be monitored + try initializeMonitoring(forFilesAtURL: url) + + // Start monitoring for any file changes + fileWatcher.startWatching { + self.changeContinuation.yield() + } + + isMonitoring = true + } + + /// Stops all file monitoring. + func stopMonitoring() { + guard isMonitoring else { return } + fileWatcher.stopWatching() + isMonitoring = false + } + + /// Recursively initializes file monitoring for a directory and all its subdirectories. + /// + /// This method traverses the directory structure starting from the specified URL and registers + /// each directory (including the root directory) with the file watcher for monitoring. Hidden + /// directories (those starting with a dot) are excluded from monitoring. + /// + /// - Parameter url: The root directory URL to begin monitoring. All subdirectories within + /// this directory will also be monitored recursively. + /// + /// - Throws: An error if: + /// - The directory contents cannot be read + /// - File system access fails when checking if items are directories + /// - The underlying file watcher fails to monitor any directory + /// + /// - Note: This method excludes hidden directories (those with names starting with ".") from + /// monitoring to avoid watching temporary files, version control directories, and + /// system directories that typically don't contain user source code. + private func initializeMonitoring(forFilesAtURL url: URL) throws { + // Monitor the current directory + try fileWatcher.register(urlToWatch: url) + + // No need to register directories recursively on Windows + // as ReadDirectoryChangesW() handles recursive subdir monitoring. +#if !os(Windows) + let directoryContents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + let subdirs = directoryContents + .filter { $0.lastPathComponent.hasPrefix(".") == false } + .filter { url in + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) { + return isDir.boolValue + } + return false + } + + for subdirURL in subdirs { + try initializeMonitoring(forFilesAtURL: subdirURL) + } +#endif + } + + /// Asynchronously wait for file changes + func waitForChanges() async { + verboseLog("Waiting for changes") + for await _ in changeStream { + verboseLog("Detected change") + // Return immediately when a change is detected + return + } + // If the stream ends without yielding any values, we should still return + // This can happen if the monitor is cancelled before any changes occur + verboseLog("Stream ended without changes") + } + + func verboseLog(_ message: String) { + if verboseLogging { + _verbosePlayLog("\(type(of: self)): \(message)") + } + } +} + +fileprivate protocol FileWatcher { + init(verboseLogging: Bool) throws + + var verboseLogging: Bool { get } + + func register(urlToWatch url: URL) throws + + typealias ChangeHandler = () -> () + + func startWatching(withChangeHandler changeHandler: @escaping ChangeHandler) + + func stopWatching() +} + +extension FileWatcher { + func verboseLog(_ message: String) { + if verboseLogging { + _verbosePlayLog("\(type(of: self)): \(message)") + } + } +} + +fileprivate func makeFileWatcher(verboseLogging: Bool) throws -> (any FileWatcher)? { +#if os(macOS) + return try MacFileWatcher(verboseLogging: verboseLogging) +#elseif os(Linux) + return try LinuxFileWatcher(verboseLogging: verboseLogging) +#elseif os(Windows) + return try WindowsFileWatcher(verboseLogging: verboseLogging) +#else + return nil +#endif +} + +#if os(macOS) + +fileprivate final class MacFileWatcher: FileWatcher { + var fileHandles: [FileHandle] = [] + var sources: [DispatchSourceFileSystemObject] = [] + var changeHandler: ChangeHandler? = nil + let verboseLogging: Bool + + init(verboseLogging: Bool) throws { + self.verboseLogging = verboseLogging + } + + func register(urlToWatch url: URL) throws { + let monitoredFolderFileDescriptor = open(url.relativePath, O_EVTONLY) + let fileHandle = FileHandle(fileDescriptor: monitoredFolderFileDescriptor, closeOnDealloc: true) + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileHandle.fileDescriptor, + eventMask: [.all], + queue: DispatchQueue.global(qos: .background) + ) + + source.setEventHandler { + let event = source.data + self.process(event: event) + } + + source.setCancelHandler { + try? fileHandle.close() + } + + source.activate() + + sources.append(source) + fileHandles.append(fileHandle) + } + + func startWatching(withChangeHandler changeHandler: @escaping ChangeHandler) { + self.changeHandler = changeHandler + } + + func stopWatching() { + for source in sources { + if !source.isCancelled { + source.cancel() + } + } + self.fileHandles = [] + verboseLog("stopped") + } + + private func process(event: DispatchSource.FileSystemEvent) { + verboseLog("FileMonitor event \(event)") + self.changeHandler?() + } +} + +#elseif os(Linux) + +fileprivate final class LinuxFileWatcher: FileWatcher { + let inotifyFileDescriptor: Int32 + var watchDescriptors: [Int32] = [] + var monitoringTask: Task? + let verboseLogging: Bool + + init(verboseLogging: Bool = false) throws { + self.verboseLogging = verboseLogging + + // Initialize inotify + self.inotifyFileDescriptor = inotify_init1(Int32(IN_CLOEXEC)) + guard self.inotifyFileDescriptor != -1 else { + throw POSIXError(.init(rawValue: errno) ?? .ENODEV) + } + } + + func startWatching(withChangeHandler changeHandler: @escaping FileWatcher.ChangeHandler) { + monitoringTask = Task { + while !Task.isCancelled { + // Read events from inotify + var buffer = [UInt8](repeating: 0, count: 4096) + let bytesRead = read(inotifyFileDescriptor, &buffer, buffer.count) + + if bytesRead > 0 { + verboseLog("File watcher detected change via inotify") + changeHandler() + } else if bytesRead == -1 { + if errno == EINTR || errno == EAGAIN { + // Interrupted or would block, continue + continue + } else { + // Error occurred + verboseLog("File watcher read error: \(String(cString: strerror(errno)))") + break + } + } + } + } + } + + func register(urlToWatch url: URL) throws { + let watchDescriptor = inotify_add_watch( + inotifyFileDescriptor, + url.path, + UInt32(IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVE) + ) + + guard watchDescriptor != -1 else { + throw POSIXError(.init(rawValue: errno) ?? .ENODEV) + } + + watchDescriptors.append(watchDescriptor) + } + + func stopWatching() { + monitoringTask?.cancel() + + // Remove all watch descriptors + for watchDescriptor in watchDescriptors { + inotify_rm_watch(inotifyFileDescriptor, watchDescriptor) + } + watchDescriptors.removeAll() + + // Close inotify file descriptor + close(inotifyFileDescriptor) + verboseLog("stopped") + } +} + +#endif + +#if os(Windows) + +import WinSDK + +fileprivate final class WindowsFileWatcher: FileWatcher { + let verboseLogging: Bool + + private var changeHandler: ChangeHandler? + private var monitoringTask: Task? + + private typealias DirectoryWithHandle = (path: String, handle: HANDLE) + private var registeredDirectories: [DirectoryWithHandle] = [] + + init(verboseLogging: Bool = false) throws { + self.verboseLogging = verboseLogging + } + + func register(urlToWatch url: URL) throws { + let directoryPath = url.path + + // Convert Swift string to wide character string for Windows API + let widePath = directoryPath.withCString(encodedAs: UTF16.self) { $0 } + + // Open directory handle with FILE_LIST_DIRECTORY access + let directoryHandle = CreateFileW( + widePath, + DWORD(FILE_LIST_DIRECTORY), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED), + nil + ) + + guard let directoryHandle, directoryHandle != INVALID_HANDLE_VALUE else { + let error = GetLastError() + print("[WindowsFileWatcher] Failed to open directory '\(directoryPath)' with error: \(error)") + throw WindowsFileWatcherError.cannotOpenDirectory(path: directoryPath, errorCode: error) + } + + registeredDirectories.append((path: directoryPath, handle: directoryHandle)) + } + + func startWatching(withChangeHandler changeHandler: @escaping ChangeHandler) { + self.changeHandler = changeHandler + + monitoringTask = Task { + await withTaskGroup(of: Void.self) { group in + // Start monitoring each registered directory + for registeredDirectory in registeredDirectories { + group.addTask { + await self.monitorDirectory(at: registeredDirectory) + } + } + } + } + } + + func stopWatching() { + monitoringTask?.cancel() + monitoringTask = nil + + // Close all directory handles + for registeredDirectory in registeredDirectories { + CloseHandle(registeredDirectory.handle) + } + registeredDirectories.removeAll() + verboseLog("stopped") + } + + private func monitorDirectory(at directory: DirectoryWithHandle) async { + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + var bytesReturned: DWORD = 0 + var overlapped = OVERLAPPED() + + // Create event for overlapped I/O + overlapped.hEvent = CreateEventW(nil, true, false, nil) + defer { + if overlapped.hEvent != nil { + CloseHandle(overlapped.hEvent) + } + } + + while !Task.isCancelled { + // Start asynchronous directory change monitoring + let success = ReadDirectoryChangesW( + directory.handle, + &buffer, + DWORD(bufferSize), + true, // bWatchSubtree - monitor subdirectories + DWORD(FILE_NOTIFY_CHANGE_FILE_NAME | + FILE_NOTIFY_CHANGE_DIR_NAME | + FILE_NOTIFY_CHANGE_SIZE | + FILE_NOTIFY_CHANGE_LAST_WRITE), + &bytesReturned, + &overlapped, + nil + ) + + if !success { + let error = GetLastError() + if error != ERROR_IO_PENDING { + print("[WindowsFileWatcher] ReadDirectoryChangesW failed with error: \(error)") + break + } + } + + verboseLog("ReadDirectoryChangesW() successfully monitoring...") + + // Wait for the operation to complete, or timeout to check for cancellation + let waitResult = WaitForSingleObject(overlapped.hEvent, 1000) // 1 second timeout + + switch waitResult { + case DWORD(WAIT_OBJECT_0): + // Event was signaled - changes detected + var finalBytesReturned: DWORD = 0 + let getResult = GetOverlappedResult(directory.handle, &overlapped, &finalBytesReturned, false) + + if getResult && finalBytesReturned > 0 { + + // Parse the buffer to extract file change information + let changedFiles = parseFileChangeBuffer(buffer: buffer, bytesReturned: Int(finalBytesReturned)) + verboseLog("Detected change(s): \(changedFiles.map {(action, filename) in actionDescription(for: action) + " - " + filename}.joined(separator: ","))") + + // Filter out changes that weren't not interested in + let interestedChanges = changedFiles.filter { (action, filename) in + // Ignore any ".build" directories + if filename.starts(with: ".build") { + return false + } + return true + } + + if interestedChanges.count > 0 { + changeHandler?() + } + } + else { + verboseLog("GetOverlappedResult() returned \(getResult), finalBytesReturned=\(finalBytesReturned) - ignoring event") + } + + // Reset the event for next iteration + ResetEvent(overlapped.hEvent) + + case DWORD(WAIT_TIMEOUT): + // Timeout occurred, continue monitoring + verboseLog("Timeout, continue monitoring") + continue + + default: + // Error or other result + verboseLog("[WindowsFileWatcher] WaitForSingleObject returned unexpected value: \(waitResult)") + break + } + } + } + + /// Parses the buffer returned by ReadDirectoryChangesW to extract file change information. + /// + /// The buffer contains one or more FILE_NOTIFY_INFORMATION structures: + /// - NextEntryOffset: DWORD (offset to next record, 0 for last record) + /// - Action: DWORD (type of change that occurred) + /// - FileNameLength: DWORD (length of filename in bytes) + /// - FileName: WCHAR[] (wide character filename, not null-terminated) + /// + /// - Parameters: + /// - buffer: The buffer filled by ReadDirectoryChangesW + /// - bytesReturned: The number of valid bytes in the buffer + /// - Returns: Array of tuples containing (action, fileName) + private func parseFileChangeBuffer(buffer: [UInt8], bytesReturned: Int) -> [(DWORD, String)] { + var results: [(DWORD, String)] = [] + var offset = 0 + + while offset < bytesReturned { + // Ensure we have enough bytes for the fixed part of FILE_NOTIFY_INFORMATION + guard offset + 12 <= bytesReturned else { break } + + // Read the FILE_NOTIFY_INFORMATION structure + let nextEntryOffset = buffer.withUnsafeBufferPointer { bufferPtr in + bufferPtr.baseAddress!.advanced(by: offset).withMemoryRebound(to: DWORD.self, capacity: 1) { $0.pointee } + } + + let action = buffer.withUnsafeBufferPointer { bufferPtr in + bufferPtr.baseAddress!.advanced(by: offset + 4).withMemoryRebound(to: DWORD.self, capacity: 1) { $0.pointee } + } + + let fileNameLength = buffer.withUnsafeBufferPointer { bufferPtr in + bufferPtr.baseAddress!.advanced(by: offset + 8).withMemoryRebound(to: DWORD.self, capacity: 1) { $0.pointee } + } + + // Ensure we have enough bytes for the filename + let fileNameStart = offset + 12 + let fileNameEnd = fileNameStart + Int(fileNameLength) + guard fileNameEnd <= bytesReturned else { break } + + // Extract the wide character filename and convert to String + let fileName = buffer.withUnsafeBufferPointer { bufferPtr in + let wideCharPtr = bufferPtr.baseAddress!.advanced(by: fileNameStart).withMemoryRebound(to: UInt16.self, capacity: Int(fileNameLength / 2)) { $0 } + let wideCharCount = Int(fileNameLength / 2) + + // Create a String from the UTF-16 encoded wide characters + return String(utf16CodeUnits: wideCharPtr, count: wideCharCount) + } + + results.append((action, fileName)) + + // Move to the next record + if nextEntryOffset == 0 { + break // This was the last record + } + offset += Int(nextEntryOffset) + } + + return results + } + + /// Converts a Windows file notification action code to a human-readable description. + private func actionDescription(for action: DWORD) -> String { + switch action { + case DWORD(FILE_ACTION_ADDED): + return "File added" + case DWORD(FILE_ACTION_REMOVED): + return "File removed" + case DWORD(FILE_ACTION_MODIFIED): + return "File modified" + case DWORD(FILE_ACTION_RENAMED_OLD_NAME): + return "File renamed (old name)" + case DWORD(FILE_ACTION_RENAMED_NEW_NAME): + return "File renamed (new name)" + default: + return "Unknown action (\(action))" + } + } +} + +enum WindowsFileWatcherError: Error { + case cannotOpenDirectory(path: String, errorCode: DWORD) +} + +#endif diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index a723cf99912..1340069690c 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -64,15 +64,18 @@ public struct ToolWorkspaceConfiguration { let shouldInstallSignalHandlers: Bool let wantsMultipleTestProducts: Bool let wantsREPLProduct: Bool + let wantsPlaygroundProduct: Bool public init( shouldInstallSignalHandlers: Bool = true, wantsMultipleTestProducts: Bool = false, - wantsREPLProduct: Bool = false + wantsREPLProduct: Bool = false, + wantsPlaygroundProduct: Bool = false ) { self.shouldInstallSignalHandlers = shouldInstallSignalHandlers self.wantsMultipleTestProducts = wantsMultipleTestProducts self.wantsREPLProduct = wantsREPLProduct + self.wantsPlaygroundProduct = wantsPlaygroundProduct } } @@ -505,6 +508,7 @@ public final class SwiftCommandState { prefetchBasedOnResolvedFile: options.resolver.shouldEnableResolverPrefetching, shouldCreateMultipleTestProducts: toolWorkspaceConfiguration.wantsMultipleTestProducts || options.build.buildSystem.shouldCreateMultipleTestProducts, createREPLProduct: toolWorkspaceConfiguration.wantsREPLProduct, + createPlaygroundProduct: toolWorkspaceConfiguration.wantsPlaygroundProduct, additionalFileRules: options.build.buildSystem.additionalFileRules, sharedDependenciesCacheEnabled: self.options.caching.useDependenciesCache, fingerprintCheckingMode: self.options.security.fingerprintCheckingMode, diff --git a/Sources/LLBuildManifest/LLBuildManifest.swift b/Sources/LLBuildManifest/LLBuildManifest.swift index 520db084769..bb562790b02 100644 --- a/Sources/LLBuildManifest/LLBuildManifest.swift +++ b/Sources/LLBuildManifest/LLBuildManifest.swift @@ -260,6 +260,15 @@ public struct LLBuildManifest { addCommand(name: name, tool: tool) } + public mutating func addPlaygroundRunnerCmd( + name: String, + inputs: [Node], + outputs: [Node] + ) { + let tool = PlaygroundRunnerTool(inputs: inputs, outputs: outputs) + addCommand(name: name, tool: tool) + } + public mutating func addCopyCmd( name: String, inputs: [Node], diff --git a/Sources/LLBuildManifest/Tools.swift b/Sources/LLBuildManifest/Tools.swift index c8afbcd0b01..b12a541583d 100644 --- a/Sources/LLBuildManifest/Tools.swift +++ b/Sources/LLBuildManifest/Tools.swift @@ -73,6 +73,18 @@ public struct TestEntryPointTool: ToolProtocol { } } +public struct PlaygroundRunnerTool: ToolProtocol { + public static let name: String = "playground-runner-tool" + + public var inputs: [Node] + public var outputs: [Node] + + init(inputs: [Node], outputs: [Node]) { + self.inputs = inputs + self.outputs = outputs + } +} + public struct CopyTool: ToolProtocol { public static let name: String = "copy-tool" diff --git a/Sources/PackageGraph/ModulesGraph+Loading.swift b/Sources/PackageGraph/ModulesGraph+Loading.swift index 2ea14951129..6e350573357 100644 --- a/Sources/PackageGraph/ModulesGraph+Loading.swift +++ b/Sources/PackageGraph/ModulesGraph+Loading.swift @@ -33,6 +33,7 @@ extension ModulesGraph { prebuilts: [PackageIdentity: [String: PrebuiltLibrary]], // Product name to library mapping shouldCreateMultipleTestProducts: Bool = false, createREPLProduct: Bool = false, + createPlaygroundProduct: Bool = false, customPlatformsRegistry: PlatformRegistry? = .none, customXCTestMinimumDeploymentTargets: [PackageModel.Platform: PlatformVersion]? = .none, testEntryPointPath: AbsolutePath? = nil, @@ -171,6 +172,7 @@ extension ModulesGraph { shouldCreateMultipleTestProducts: shouldCreateMultipleTestProducts, testEntryPointPath: testEntryPointPath, createREPLProduct: manifest.packageKind.isRoot ? createREPLProduct : false, + createPlaygroundProduct: manifest.packageKind.isRoot ? createPlaygroundProduct : false, fileSystem: fileSystem, observabilityScope: nodeObservabilityScope, enabledTraits: enabledTraits diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index a5df0fb46e4..bc69254f2d8 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -463,6 +463,7 @@ public func loadModulesGraph( explicitProduct: String? = .none, shouldCreateMultipleTestProducts: Bool = false, createREPLProduct: Bool = false, + createPlaygroundProduct: Bool = false, useXCBuildFileRules: Bool = false, customXCTestMinimumDeploymentTargets: [PackageModel.Platform: PlatformVersion]? = .none, observabilityScope: ObservabilityScope, @@ -562,6 +563,7 @@ public func loadModulesGraph( prebuilts: prebuilts, shouldCreateMultipleTestProducts: shouldCreateMultipleTestProducts, createREPLProduct: createREPLProduct, + createPlaygroundProduct: createPlaygroundProduct, customXCTestMinimumDeploymentTargets: customXCTestMinimumDeploymentTargets, fileSystem: fileSystem, observabilityScope: observabilityScope, diff --git a/Sources/PackageLoading/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index eb3bbde4777..d562803f67d 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -107,6 +107,14 @@ extension Basics.Diagnostic { .error("unable to synthesize a REPL product as there are no library targets in the package") } + static var noLibraryTargetsForPlayground: Self { + .error("unable to synthesize a Playground product as there are no library targets in the package") + } + + static func invalidModuleTypeForPlaygroundRunner(moduleType: String) -> Self { + .error("invalid playground runner module type \(moduleType) as they must be executable") + } + static func brokenSymlink(_ path: AbsolutePath) -> Self { .warning("ignoring broken symlink \(path)") } diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index 08535c82807..6fb61c0cec1 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -365,6 +365,9 @@ public final class PackageBuilder { /// Create the special REPL product for this package. private let createREPLProduct: Bool + /// Create the special Playground product for this package. + private let createPlaygroundProduct: Bool + /// The additional file detection rules. private let additionalFileRules: [FileRuleDescription] @@ -412,6 +415,7 @@ public final class PackageBuilder { testEntryPointPath: AbsolutePath? = nil, warnAboutImplicitExecutableTargets: Bool = true, createREPLProduct: Bool = false, + createPlaygroundProduct: Bool = false, fileSystem: FileSystem, observabilityScope: ObservabilityScope, enabledTraits: Set @@ -426,6 +430,7 @@ public final class PackageBuilder { self.shouldCreateMultipleTestProducts = shouldCreateMultipleTestProducts self.testEntryPointPath = testEntryPointPath self.createREPLProduct = createREPLProduct + self.createPlaygroundProduct = createPlaygroundProduct self.warnAboutImplicitExecutableTargets = warnAboutImplicitExecutableTargets self.observabilityScope = observabilityScope.makeChildScope( description: "PackageBuilder", @@ -702,7 +707,21 @@ public final class PackageBuilder { snippetTargets = [] } - return targets + snippetTargets + var playgroundTargets: [Module] = [] + + if createPlaygroundProduct { + // Get list of original targets that the playground runner will depend on + let productTargetNames = Set(manifest.products.flatMap(\.targets)) + let playgroundDependencies = targets + .filter { $0.type == .library && productTargetNames.contains($0.name) } + .map { Module.Dependency.module($0, conditions: []) } + + // Create a new playground runner (executable) target + let playgroundRunnerModule = try createPlaygroundRunnerModule(dependencies: playgroundDependencies) + playgroundTargets.append(playgroundRunnerModule) + } + + return targets + snippetTargets + playgroundTargets } // Create targets from the provided potential targets. @@ -1736,11 +1755,26 @@ public final class PackageBuilder { } else { if self.manifest.packageKind.isRoot || implicitPlugInExecutables.contains(module.name) { // Generate an implicit product for the executable target + let productName: String + + // Custom product name for playground runner + if module.isPlaygroundRunner { + guard module.type == .executable else { + self.observabilityScope.emit(.invalidModuleTypeForPlaygroundRunner(moduleType: String(describing: module.type))) + continue + } + productName = self.manifest.displayName + Product.playgroundRunnerProductSuffix + } + else { + productName = module.name + } + let product = try Product( package: self.identity, - name: module.name, + name: productName, type: .executable, - modules: [module] + modules: [module], + isPlaygroundRunner: module.isPlaygroundRunner ) append(product) } @@ -2004,6 +2038,48 @@ extension PackageBuilder { } } +// MARK: - Playgrounds + +extension PackageBuilder { + private func createPlaygroundRunnerModule(dependencies: [Module.Dependency]) throws -> Module { + let moduleName = self.identity.description + Product.playgroundRunnerProductSuffix + + let targetDescriptionDependencies = dependencies + .map { + TargetDescription.Dependency.target(name: $0.name) + } + + let targetDescription = try TargetDescription( + name: moduleName, + dependencies: targetDescriptionDependencies, + path: nil, + sources: [], + type: .executable, + packageAccess: true + ) + + let buildSettings = try self.buildSettings( + for: targetDescription, + targetRoot: self.packagePath, + toolsSwiftVersion: self.toolsSwiftVersion() + ) + + return SwiftModule( + name: moduleName, + type: .executable, + path: .root, + sources: Sources(paths: [], root: self.packagePath), + dependencies: dependencies, + packageAccess: false, + buildSettings: buildSettings, + buildSettingsDescription: targetDescription.settings, + usesUnsafeFlags: false, + implicit: true, + isPlaygroundRunner: true + ) + } +} + extension Sequence { /// Construct a new array where each of the elements in the \c self /// sequence is preceded by the \c prefixElement. diff --git a/Sources/PackageModel/Module/Module.swift b/Sources/PackageModel/Module/Module.swift index 7001c244212..17ca60e5e2d 100644 --- a/Sources/PackageModel/Module/Module.swift +++ b/Sources/PackageModel/Module/Module.swift @@ -246,6 +246,9 @@ public class Module { /// or was synthesized (i.e. some test modules are synthesized). public let implicit: Bool + /// Whether this module represents a playground runner executable. + public let isPlaygroundRunner: Bool + init( name: String, potentialBundleName: String? = nil, @@ -261,7 +264,8 @@ public class Module { buildSettingsDescription: [TargetBuildSettingDescription.Setting], pluginUsages: [PluginUsage], usesUnsafeFlags: Bool, - implicit: Bool + implicit: Bool, + isPlaygroundRunner: Bool = false ) { self.name = name self.potentialBundleName = potentialBundleName @@ -279,6 +283,7 @@ public class Module { self.pluginUsages = pluginUsages self.usesUnsafeFlags = usesUnsafeFlags self.implicit = implicit + self.isPlaygroundRunner = isPlaygroundRunner } } diff --git a/Sources/PackageModel/Module/SwiftModule.swift b/Sources/PackageModel/Module/SwiftModule.swift index 1fbb2a257d4..a616cd43d15 100644 --- a/Sources/PackageModel/Module/SwiftModule.swift +++ b/Sources/PackageModel/Module/SwiftModule.swift @@ -71,7 +71,8 @@ public final class SwiftModule: Module { buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], pluginUsages: [PluginUsage] = [], usesUnsafeFlags: Bool, - implicit: Bool + implicit: Bool, + isPlaygroundRunner: Bool = false ) { self.declaredSwiftVersions = declaredSwiftVersions super.init( @@ -89,7 +90,8 @@ public final class SwiftModule: Module { buildSettingsDescription: buildSettingsDescription, pluginUsages: pluginUsages, usesUnsafeFlags: usesUnsafeFlags, - implicit: implicit + implicit: implicit, + isPlaygroundRunner: isPlaygroundRunner ) } diff --git a/Sources/PackageModel/Product.swift b/Sources/PackageModel/Product.swift index 4b5c79f696b..d9a27050f36 100644 --- a/Sources/PackageModel/Product.swift +++ b/Sources/PackageModel/Product.swift @@ -36,7 +36,13 @@ public class Product: Identifiable { /// The suffix for REPL product name. public static let replProductSuffix: String = "__REPL" - public init(package: PackageIdentity, name: String, type: ProductType, modules: [Module], testEntryPointPath: AbsolutePath? = nil) throws { + /// The suffix for the Playground runner product name. + public static let playgroundRunnerProductSuffix: String = "__Playgrounds" + + /// Whether the product represents a Playground runner. + public let isPlaygroundRunner: Bool + + public init(package: PackageIdentity, name: String, type: ProductType, modules: [Module], testEntryPointPath: AbsolutePath? = nil, isPlaygroundRunner: Bool = false) throws { guard !modules.isEmpty else { throw InternalError("Targets cannot be empty") } @@ -55,6 +61,7 @@ public class Product: Identifiable { self.identity = package.description.lowercased() + "_" + name self.modules = modules self.testEntryPointPath = testEntryPointPath + self.isPlaygroundRunner = isPlaygroundRunner } } diff --git a/Sources/Workspace/Workspace+Configuration.swift b/Sources/Workspace/Workspace+Configuration.swift index 9fecb0a0888..d9cfb920eff 100644 --- a/Sources/Workspace/Workspace+Configuration.swift +++ b/Sources/Workspace/Workspace+Configuration.swift @@ -786,6 +786,9 @@ public struct WorkspaceConfiguration { /// Whether to create a product for use in the Swift REPL public var createREPLProduct: Bool + /// Whether to create a product for use by swift-play + public var createPlaygroundProduct: Bool + /// Whether or not there should be import restrictions applied when loading manifests public var manifestImportRestrictions: (startingToolsVersion: ToolsVersion, allowedImports: [String])? @@ -809,6 +812,7 @@ public struct WorkspaceConfiguration { prefetchBasedOnResolvedFile: Bool, shouldCreateMultipleTestProducts: Bool, createREPLProduct: Bool, + createPlaygroundProduct: Bool, additionalFileRules: [FileRuleDescription], sharedDependenciesCacheEnabled: Bool, fingerprintCheckingMode: CheckingMode, @@ -827,6 +831,7 @@ public struct WorkspaceConfiguration { self.prefetchBasedOnResolvedFile = prefetchBasedOnResolvedFile self.shouldCreateMultipleTestProducts = shouldCreateMultipleTestProducts self.createREPLProduct = createREPLProduct + self.createPlaygroundProduct = createPlaygroundProduct self.additionalFileRules = additionalFileRules self.sharedDependenciesCacheEnabled = sharedDependenciesCacheEnabled self.fingerprintCheckingMode = fingerprintCheckingMode @@ -849,6 +854,7 @@ public struct WorkspaceConfiguration { prefetchBasedOnResolvedFile: true, shouldCreateMultipleTestProducts: false, createREPLProduct: false, + createPlaygroundProduct: false, additionalFileRules: [], sharedDependenciesCacheEnabled: true, fingerprintCheckingMode: .strict, diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 562194d7fd4..39e650d4114 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -1044,6 +1044,7 @@ extension Workspace { prebuilts: prebuilts, shouldCreateMultipleTestProducts: self.configuration.shouldCreateMultipleTestProducts, createREPLProduct: self.configuration.createREPLProduct, + createPlaygroundProduct: self.configuration.createPlaygroundProduct, customXCTestMinimumDeploymentTargets: customXCTestMinimumDeploymentTargets, testEntryPointPath: testEntryPointPath, fileSystem: self.fileSystem, @@ -1326,6 +1327,7 @@ extension Workspace { prebuilts: [:], shouldCreateMultipleTestProducts: self.configuration.shouldCreateMultipleTestProducts, createREPLProduct: self.configuration.createREPLProduct, + createPlaygroundProduct: self.configuration.createPlaygroundProduct, fileSystem: self.fileSystem, observabilityScope: observabilityScope, enabledTraits: try manifest.enabledTraits(using: .default) diff --git a/Sources/_InternalTestSupport/MockWorkspace.swift b/Sources/_InternalTestSupport/MockWorkspace.swift index 4e8b202654f..1ee3d111232 100644 --- a/Sources/_InternalTestSupport/MockWorkspace.swift +++ b/Sources/_InternalTestSupport/MockWorkspace.swift @@ -399,6 +399,7 @@ public final class MockWorkspace { prefetchBasedOnResolvedFile: WorkspaceConfiguration.default.prefetchBasedOnResolvedFile, shouldCreateMultipleTestProducts: WorkspaceConfiguration.default.shouldCreateMultipleTestProducts, createREPLProduct: WorkspaceConfiguration.default.createREPLProduct, + createPlaygroundProduct: WorkspaceConfiguration.default.createPlaygroundProduct, additionalFileRules: WorkspaceConfiguration.default.additionalFileRules, sharedDependenciesCacheEnabled: WorkspaceConfiguration.default.sharedDependenciesCacheEnabled, fingerprintCheckingMode: .strict, diff --git a/Sources/_InternalTestSupport/SwiftPMProduct.swift b/Sources/_InternalTestSupport/SwiftPMProduct.swift index 0608fe4ca47..220ee2b36bd 100644 --- a/Sources/_InternalTestSupport/SwiftPMProduct.swift +++ b/Sources/_InternalTestSupport/SwiftPMProduct.swift @@ -27,6 +27,7 @@ public enum SwiftPM { case Registry case Test case Run + case Play case experimentalSDK case sdk } @@ -45,6 +46,8 @@ extension SwiftPM { return "swift-test" case .Run: return "swift-run" + case .Play: + return "swift-play" case .experimentalSDK: return "swift-experimental-sdk" case .sdk: diff --git a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift index 92fe1554098..f6200c2fb92 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift @@ -42,6 +42,7 @@ extension Tag.Feature.Command { public enum Package {} public enum PackageRegistry {} @Tag public static var Build: Tag + @Tag public static var Play: Tag @Tag public static var Run: Tag @Tag public static var Sdk: Tag @Tag public static var Test: Tag diff --git a/Sources/_InternalTestSupport/misc.swift b/Sources/_InternalTestSupport/misc.swift index 944a9b1c079..52273f499e1 100644 --- a/Sources/_InternalTestSupport/misc.swift +++ b/Sources/_InternalTestSupport/misc.swift @@ -525,6 +525,29 @@ public func executeSwiftTest( return try await SwiftPM.Test.execute(args, packagePath: packagePath, env: env, throwIfCommandFails: throwIfCommandFails) } +@discardableResult +public func executeSwiftPlay( + _ packagePath: AbsolutePath?, + configuration: BuildConfiguration = .debug, + extraArgs: [String] = [], + Xcc: [String] = [], + Xld: [String] = [], + Xswiftc: [String] = [], + env: Environment? = nil, + throwIfCommandFails: Bool = false, + buildSystem: BuildSystemProvider.Kind = .native +) async throws -> (stdout: String, stderr: String) { + let args = swiftArgs( + configuration: configuration, + extraArgs: extraArgs, + Xcc: Xcc, + Xld: Xld, + Xswiftc: Xswiftc, + buildSystem: buildSystem + ) + return try await SwiftPM.Play.execute(args, packagePath: packagePath, env: env, throwIfCommandFails: throwIfCommandFails) +} + private func swiftArgs( configuration: BuildConfiguration, extraArgs: [String], @@ -562,6 +585,7 @@ public func loadPackageGraph( explicitProduct: String? = .none, shouldCreateMultipleTestProducts: Bool = false, createREPLProduct: Bool = false, + createPlaygroundProduct: Bool = false, useXCBuildFileRules: Bool = false, customXCTestMinimumDeploymentTargets: [PackageModel.Platform: PlatformVersion]? = .none, observabilityScope: ObservabilityScope, @@ -575,6 +599,7 @@ public func loadPackageGraph( explicitProduct: explicitProduct, shouldCreateMultipleTestProducts: shouldCreateMultipleTestProducts, createREPLProduct: createREPLProduct, + createPlaygroundProduct: createPlaygroundProduct, useXCBuildFileRules: useXCBuildFileRules, customXCTestMinimumDeploymentTargets: customXCTestMinimumDeploymentTargets, observabilityScope: observabilityScope, diff --git a/Sources/swift-package-manager/SwiftPM.swift b/Sources/swift-package-manager/SwiftPM.swift index 3268423b983..cb030f6c52b 100644 --- a/Sources/swift-package-manager/SwiftPM.swift +++ b/Sources/swift-package-manager/SwiftPM.swift @@ -54,6 +54,8 @@ struct SwiftPM { await PackageCollectionsCommand.main() case "swift-package-registry": await PackageRegistryCommand.main() + case "swift-play": + await SwiftPlayCommand.main() default: fatalError("swift-package-manager launched with unexpected name: \(execName ?? "(unknown)")") } diff --git a/Sources/swift-play/CMakeLists.txt b/Sources/swift-play/CMakeLists.txt new file mode 100644 index 00000000000..3fbd734ae39 --- /dev/null +++ b/Sources/swift-play/CMakeLists.txt @@ -0,0 +1,18 @@ +# This source file is part of the Swift 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 http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_executable(swift-play + Entrypoint.swift) +target_link_libraries(swift-play PRIVATE + Commands) + +target_compile_options(swift-play PRIVATE + -parse-as-library) + +install(TARGETS swift-play + RUNTIME DESTINATION bin) diff --git a/Sources/swift-play/Entrypoint.swift b/Sources/swift-play/Entrypoint.swift new file mode 100644 index 00000000000..6e6d7d49ca0 --- /dev/null +++ b/Sources/swift-play/Entrypoint.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 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 the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Commands + +@main +struct Entrypoint { + static func main() async { + await SwiftPlayCommand.main() + } +} diff --git a/Sources/swiftpm-playground-helper/Entrypoint.swift b/Sources/swiftpm-playground-helper/Entrypoint.swift new file mode 100644 index 00000000000..56af2674f16 --- /dev/null +++ b/Sources/swiftpm-playground-helper/Entrypoint.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 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 the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +@main +struct Entrypoint { + static func main() throws { + let args = CommandLine.arguments + if args.count >= 3, args[1] == "--lib-path" { + let bundlePath = args[2] + + #if os(macOS) + let flags = RTLD_LAZY | RTLD_FIRST + #else + let flags = RTLD_LAZY + #endif + guard let image = dlopen(bundlePath, flags) else { + let errorMessage: String = dlerror().flatMap { + String(validatingCString: $0) + } ?? "An unknown error occurred." + fatalError("Failed to open library at path \(bundlePath): \(errorMessage)") + } + defer { + dlclose(image) + } + + // Find and call the main function from the image. This function may + // link to the copy of Playgrounds included with Xcode, or may link to + // a copy that's included as a package dependency. + let main = dlsym(image, "_playground_main").map { + unsafeBitCast( + $0, + to: (@convention(c) (CInt, UnsafeMutablePointer?>) -> CInt).self + ) + } + if let main { + exit(main(CommandLine.argc, CommandLine.unsafeArgv)) + } + else { + fatalError("Failed to open library at path \(bundlePath): playground_main() not found") + } + } + } +} diff --git a/Tests/BuildTests/BuildPlanTests.swift b/Tests/BuildTests/BuildPlanTests.swift index a27d2f178f0..1dabfb98afb 100644 --- a/Tests/BuildTests/BuildPlanTests.swift +++ b/Tests/BuildTests/BuildPlanTests.swift @@ -2246,6 +2246,7 @@ class BuildPlanTestCase: BuildSystemProviderTestCase { ), ], createREPLProduct: true, + createPlaygroundProduct: false, observabilityScope: observability.topScope ) XCTAssertNoDiagnostics(observability.diagnostics) diff --git a/Tests/CommandsTests/PlayCommandTests.swift b/Tests/CommandsTests/PlayCommandTests.swift new file mode 100644 index 00000000000..e6368a1e3bd --- /dev/null +++ b/Tests/CommandsTests/PlayCommandTests.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 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 the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +import Basics +import Commands +import struct SPMBuildCore.BuildSystemProvider +import enum PackageModel.BuildConfiguration +import _InternalTestSupport +import Testing + +@Suite( + .serialized, // to limit the number of swift executables running. + .tags( + Tag.TestSize.large, + Tag.Feature.Command.Play, + ) +) +struct PlayCommandTests { + + private func execute( + _ args: [String], + packagePath: AbsolutePath? = nil, + configuration: BuildConfiguration = .debug, + buildSystem: BuildSystemProvider.Kind, + throwIfCommandFails: Bool = true + ) async throws -> (stdout: String, stderr: String) { + try await executeSwiftPlay( + packagePath, + configuration: configuration, + extraArgs: args, + throwIfCommandFails: throwIfCommandFails, + buildSystem: buildSystem, + ) + } + + @Test( + .tags( + Tag.Feature.Command.Play, + ), + arguments: SupportedBuildSystemOnAllPlatforms, BuildConfiguration.allCases, + ) + func swiftPlayUsage( + buildSystem: BuildSystemProvider.Kind, + configuration: BuildConfiguration, + ) async throws { + let stdout = try await execute( + ["-help"], + configuration: configuration, + buildSystem: buildSystem, + ).stdout + #expect(stdout.contains("USAGE: swift play"), "got stdout:\n\(stdout)") + } + + @Test( + .tags( + Tag.Feature.Command.Play, + ), + // TODO: SupportedBuildSystemOnAllPlatforms + arguments: [BuildSystemProvider.Kind.native], BuildConfiguration.allCases, + ) + func swiftPlayList( + buildSystem: BuildSystemProvider.Kind, + configuration: BuildConfiguration, + ) async throws { + try await fixture(name: "Miscellaneous/Playgrounds/Simple") { fixturePath in + let (stdout, stderr) = try await execute( + ["--list"], + packagePath: fixturePath, + configuration: configuration, + buildSystem: buildSystem, + ) + // build was run + #expect(stderr.contains("Build of product 'Simple__Playgrounds' complete!")) + + // getting the lists + #expect(stdout.contains("* Simple/Simple.swift:11 (unnamed)")) + #expect(stdout.contains("* Simple/Simple.swift:16 Simple.b")) + #expect(stdout.contains("* Simple/Simple.swift:21 Upper")) + } + } + +} + diff --git a/Tests/IntegrationTests/BasicTests.swift b/Tests/IntegrationTests/BasicTests.swift index 1f44d060556..ba9442838a0 100644 --- a/Tests/IntegrationTests/BasicTests.swift +++ b/Tests/IntegrationTests/BasicTests.swift @@ -448,6 +448,172 @@ private struct BasicTests { #expect(result.stdout.contains("Executed 2 tests, with 0 failures"), "stdout: '\(result.stdout)'\n stderr:'\(result.stderr)'") } } + + @Test( + .tags( + Tag.Feature.Command.Play, + Tag.Feature.Command.Package.Init, + Tag.Feature.PackageType.Library, + ), + ) + func testSwiftPlayList() throws { + let packageName = "SwiftPlayList" + try withTemporaryDirectory { tempDir in + let packagePath = tempDir.appending(component: packageName) + try localFileSystem.createDirectory(packagePath) + try await executeSwiftPackage( + packagePath, + extraArgs: ["init", "--type", "library", "--name", packageName], + buildSystem: .native + ) + try localFileSystem.writeFileContents( + packagePath.appending(component: "Package.swift"), + bytes: ByteString( + encodingAsUTF8: """ + // swift-tools-version: 6.2 + + import PackageDescription + + let package = Package( + name: "\(packageName)", + platforms: [.macOS(.v10_15)], // min for playgrounds lib + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "\(packageName)", + targets: ["\(packageName)"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-play-experimental", branch: "main"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "\(packageName)", + dependencies: [ + .product(name: "Playgrounds", package: "swift-play-experimental"), + ] + ), + .testTarget( + name: "\(packageName)Tests", + dependencies: ["\(packageName)"] + ), + ] + ) + """ + ), + atomically: true + ) + try localFileSystem.writeFileContents( + packagePath.appending(components: "Sources", "\(packageName)", "Playground.swift"), + bytes: ByteString( + encodingAsUTF8: """ + import Playgrounds + + #Playground("Answer") { + let answer = 42 + print(answer) + } + """ + ), + atomically: true + ) + let result = try await executeSwiftPlay( + packagePath, + extraArgs: [ + "--list", + ] + ) + + // Check the output. + #expect(result.stdout.contains("Found 1 Playground"), "stdout: '\(result.stdout)'\n stderr:'\(result.stderr)'") + #expect(result.stdout.contains("\(packageName)/Playground.swift:3 Answer"), "stdout: '\(result.stdout)'\n stderr:'\(result.stderr)'") + } + } + + @Test( + .tags( + Tag.Feature.Command.Play, + Tag.Feature.Command.Package.Init, + Tag.Feature.PackageType.Library, + ), + ) + func testSwiftPlayExecute() throws { + try withTemporaryDirectory { tempDir in + let packagePath = tempDir.appending(component: "swiftPlayExecute") + try localFileSystem.createDirectory(packagePath) + try await executeSwiftPackage( + packagePath, + extraArgs: ["init", "--type", "library"], + buildSystem: .native + ) + try localFileSystem.writeFileContents( + packagePath.appending(component: "Package.swift"), + bytes: ByteString( + encodingAsUTF8: """ + // swift-tools-version: 6.2 + + import PackageDescription + + let package = Package( + name: "swiftPlayExecute", + platforms: [.macOS(.v10_15)], // min for playgrounds lib + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "swiftPlayExecute", + targets: ["swiftPlayExecute"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-play-experimental", branch: "main"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "swiftPlayExecute", + dependencies: [ + .product(name: "Playgrounds", package: "swift-play-experimental"), + ] + ), + .testTarget( + name: "swiftPlayExecuteTests", + dependencies: ["swiftPlayExecute"] + ), + ] + ) + """ + ) + ) + try localFileSystem.writeFileContents( + packagePath.appending(components: "Sources", "swiftPlayExecute", "Playground.swift"), + bytes: ByteString( + encodingAsUTF8: """ + import Playgrounds + + #Playground("Answer") { + let answer = 42 + print("answer is \\(answer)") + } + """ + ) + ) + let result = try await executeSwiftPlay( + packagePath, + extraArgs: [ + "--one-shot", // immediately exit after execution + "Answer", // run this named playground + ] + ) + + // Check the output. + #expect(result.stdout.contains("answer is 42"), "stdout: '\(result.stdout)'\n stderr:'\(result.stderr)'") + } + } + } extension Character { diff --git a/Tests/PackageLoadingTests/PackageBuilderTests.swift b/Tests/PackageLoadingTests/PackageBuilderTests.swift index c7c685ecab9..0241a7a2dbb 100644 --- a/Tests/PackageLoadingTests/PackageBuilderTests.swift +++ b/Tests/PackageLoadingTests/PackageBuilderTests.swift @@ -3610,6 +3610,7 @@ final class PackageBuilderTester { shouldCreateMultipleTestProducts: shouldCreateMultipleTestProducts, warnAboutImplicitExecutableTargets: true, createREPLProduct: createREPLProduct, + createPlaygroundProduct: false, fileSystem: fs, observabilityScope: observability.topScope, enabledTraits: [] diff --git a/Utilities/bootstrap b/Utilities/bootstrap index 1b6009b7afd..f63d38cd322 100755 --- a/Utilities/bootstrap +++ b/Utilities/bootstrap @@ -548,7 +548,11 @@ def install_swiftpm(prefix, args): if os.path.exists(os.path.join(args.bin_dir, "swiftpm-testing-helper")): install_binary(args, "swiftpm-testing-helper", aux_tool_dest) - for tool in ["swift-build", "swift-test", "swift-run", "swift-package-collection", "swift-package-registry", "swift-sdk", "swift-experimental-sdk"]: + # `swiftpm-playground-helper` only exists on Darwin platforms + if os.path.exists(os.path.join(args.bin_dir, "swiftpm-playground-helper")): + install_binary(args, "swiftpm-playground-helper", aux_tool_dest) + + for tool in ["swift-build", "swift-test", "swift-run", "swift-play", "swift-package-collection", "swift-package-registry", "swift-sdk", "swift-experimental-sdk"]: src = "swift-package" dest = os.path.join(cli_tool_dest, tool) logging.info("Creating tool symlink from %s to %s", src, dest)