Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9e59847
Initial "swift play" prototype for macOS.
chrismiles Apr 18, 2025
d1e56fe
Fix missing import.
chrismiles Apr 29, 2025
eeee639
Fix swift play for Linux.
Apr 29, 2025
ea18469
Fix for new implicit parameter.
chrismiles Jun 13, 2025
c3e3457
Swift-play synthesizes a playground runner executable dynamically.
chrismiles Jun 2, 2025
3cf74e8
Fixes list option taking a second to complete.
chrismiles Jun 27, 2025
aee4a32
Adopts AsyncProcess.
chrismiles Jul 1, 2025
623a7e4
Fixed stdin support and made it async compatible.
chrismiles Jul 2, 2025
c74da12
Fixes for Linux.
chrismiles Jul 2, 2025
b94e759
Fix stdin support by duping the parent process's stdin to the playgro…
chrismiles Jul 4, 2025
7a35c7e
Fixes waiting for file change after a failed build.
chrismiles Jul 15, 2025
6137e07
Adds file monitoring support for Linux.
chrismiles Jul 16, 2025
f1fffb6
Adds some basic "swift play" integration tests.
chrismiles Jul 19, 2025
7ba8526
Adds first set of play command tests.
chrismiles Jul 22, 2025
e42cca7
Compatibility fixes after updating from main.
chrismiles Jul 25, 2025
6114238
Fixes build issue on Windows.
chrismiles Jul 29, 2025
9b8bdb2
Implemented file watching support for Windows.
chrismiles Aug 6, 2025
9761a6e
Fixes stdin support on Windows.
chrismiles Aug 7, 2025
4416100
Updates cmakelists for Build dir with new playground source.
chrismiles Aug 8, 2025
b6ec187
Add all runtimeLibraryPaths as rpaths for the Playground executable.
chrismiles Aug 14, 2025
560e73f
Add mac-specific rpath for libPlaygrounds.
chrismiles Aug 15, 2025
8afcccb
Fixed API change in swift play basic integration tests.
chrismiles Aug 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Fixtures/Miscellaneous/Playgrounds/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
25 changes: 25 additions & 0 deletions Fixtures/Miscellaneous/Playgrounds/Simple/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
),
]
)
Original file line number Diff line number Diff line change
@@ -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)")
}
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 90 additions & 17 deletions Sources/Basics/Concurrency/AsyncProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -349,13 +370,15 @@ package final class AsyncProcess {
arguments: [String],
environment: Environment = .current,
workingDirectory: AbsolutePath,
inputRedirection: InputRedirection = .writableStream,
outputRedirection: OutputRedirection = .collect,
startNewProcessGroup: Bool = true,
loggingHandler: LoggingHandler? = .none
) {
self.arguments = arguments
self.environment = environment
self.workingDirectory = workingDirectory
self.inputRedirection = inputRedirection
self.outputRedirection = outputRedirection
self.startNewProcessGroup = startNewProcessGroup
self.loggingHandler = loggingHandler ?? AsyncProcess.loggingHandler
Expand All @@ -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
Expand All @@ -375,13 +399,15 @@ package final class AsyncProcess {
package init(
arguments: [String],
environment: Environment = .current,
inputRedirection: InputRedirection = .writableStream,
outputRedirection: OutputRedirection = .collect,
startNewProcessGroup: Bool = true,
loggingHandler: LoggingHandler? = .none
) {
self.arguments = arguments
self.environment = environment
self.workingDirectory = nil
self.inputRedirection = inputRedirection
self.outputRedirection = outputRedirection
self.startNewProcessGroup = startNewProcessGroup
self.loggingHandler = loggingHandler ?? AsyncProcess.loggingHandler
Expand All @@ -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
)
Expand All @@ -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
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<UInt8>) {
position += bytes.count
}

func flush() {
// Nothing to flush for a null stream
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)")
Expand All @@ -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
Expand Down
25 changes: 24 additions & 1 deletion Sources/Build/BuildManifest/LLBuildManifestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading