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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 8 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
branches: [ "**" ]

jobs:
test:
Expand All @@ -13,33 +13,17 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-15, ubuntu-24.04, windows-2025]
os: [macos-15]

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up swift
uses: SwiftyLab/setup-swift@latest
with:
swift-version: 6.2

- name: Print Swift version and location (Unix)
if: runner.os != 'Windows'
run: |
swift --version
which swift
which swiftc
echo "PATH=$PATH"

- name: Print Swift version and location (Windows)
if: runner.os == 'Windows'
run: |
swift --version
where swift
where swiftc
echo "PATH=$env:PATH"

- name: Restore Cache for SPM
uses: actions/cache@v4
with:
Expand All @@ -48,6 +32,8 @@ jobs:
restore-keys: |
${{ runner.os }}-spm-

- name: Run tests
run: swift test
- name: Build
run: swift build --build-tests --disable-xctest

- name: Run tests
run: swift test --skip-update --skip-build --verbose --disable-xctest
136 changes: 91 additions & 45 deletions Sources/gyb-swift/ExecutionContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,70 +110,116 @@ struct CodeGenerator {
return fixSourceLocationPlacement(code)
}

/// Executes `ast` with `bindings` by compiling and running generated Swift code.
func execute(_ ast: AST, bindings: [String: String] = [:]) throws -> String {
/// Executes `ast` with `bindings` using Swift interpreter or compilation.
///
/// By default, uses the Swift interpreter on non-Windows platforms for faster execution.
/// On Windows or when `forceCompilation` is true, compiles and runs the generated code.
func execute(
_ ast: AST, bindings: [String: String] = [:], forceCompilation: Bool = false
) throws -> String {
let swiftCode = generateCompleteProgram(ast, bindings: bindings)

// Write to temporary file
return
(isWindows || forceCompilation)
? try executeViaCompilation(swiftCode)
: try executeViaInterpreter(swiftCode)
}

/// Executes `swiftCode` using the Swift interpreter (fast).
private func executeViaInterpreter(_ swiftCode: String) throws -> String {
let tempDir = FileManager.default.temporaryDirectory
let uuid = UUID().uuidString
let sourceFile = tempDir.appendingPathComponent("gyb_\(uuid).swift")
let executableFile = tempDir.appendingPathComponent("gyb_\(uuid)")
let moduleCacheDir = tempDir.appendingPathComponent("gyb_\(uuid)_modules")
let temp = tempDir.appendingPathComponent("gyb_\(UUID().uuidString).swift")

defer {
try? FileManager.default.removeItem(at: sourceFile)
try? FileManager.default.removeItem(at: executableFile)
try? FileManager.default.removeItem(at: moduleCacheDir)
// On Windows, also try to remove .exe
try? FileManager.default.removeItem(
at: tempDir.appendingPathComponent("gyb_\(uuid).exe"))
try? FileManager.default.removeItem(at: temp)
}

try swiftCode.write(to: temp, atomically: true, encoding: .utf8)

let result = try runProcess("swift", arguments: [temp.platformString])

guard result.exitStatus == 0 else {
let errorOutput = String(data: result.stderr, encoding: .utf8) ?? "Unknown error"
throw GYBError.executionFailed(filename: filename, errorOutput: errorOutput)
}

try swiftCode.write(to: sourceFile, atomically: true, encoding: .utf8)
return String(data: result.stdout, encoding: .utf8) ?? ""
}

/// Executes `swiftCode` by compiling and running the executable.
private func executeViaCompilation(_ swiftCode: String) throws -> String {
let tempFiles = createTempFiles()
defer { cleanupTempFiles(tempFiles) }

// Compile the Swift code
let compileProcess = try processForCommand(
try swiftCode.write(to: tempFiles.source, atomically: true, encoding: .utf8)
try compileSwiftCode(
source: tempFiles.source, output: tempFiles.executable, moduleCache: tempFiles.moduleCache)
return try runCompiledExecutable(tempFiles.actualExecutable)
}

/// Temporary files needed for compilation.
private struct TempFiles {
let source: URL
let executable: URL
let actualExecutable: URL
let moduleCache: URL
}

/// Creates temporary files for compilation with platform-specific executable naming.
private func createTempFiles() -> TempFiles {
let tempDir = FileManager.default.temporaryDirectory
let uuid = UUID().uuidString
let source = tempDir.appendingPathComponent("gyb_\(uuid).swift")
let executable = tempDir.appendingPathComponent("gyb_\(uuid)")
let moduleCache = tempDir.appendingPathComponent("gyb_\(uuid)_modules")

// On Windows, the compiled executable will have .exe extension
let actualExecutable =
isWindows
? tempDir.appendingPathComponent("gyb_\(uuid).exe")
: executable

return TempFiles(
source: source,
executable: executable,
actualExecutable: actualExecutable,
moduleCache: moduleCache
)
}

/// Removes all temporary files, ignoring errors.
private func cleanupTempFiles(_ files: TempFiles) {
try? FileManager.default.removeItem(at: files.source)
try? FileManager.default.removeItem(at: files.actualExecutable)
try? FileManager.default.removeItem(at: files.moduleCache)
}

/// Compiles Swift source file to executable.
private func compileSwiftCode(source: URL, output: URL, moduleCache: URL) throws {
let result = try runProcess(
"swiftc",
arguments: [
sourceFile.platformString,
"-o", executableFile.platformString,
"-module-cache-path", moduleCacheDir.platformString,
source.platformString,
"-o", output.platformString,
"-module-cache-path", moduleCache.platformString,
])

let compileError = Pipe()
compileProcess.standardOutput = Pipe()
compileProcess.standardError = compileError

try compileProcess.run()
compileProcess.waitUntilExit()

if compileProcess.terminationStatus != 0 {
let errorData = compileError.fileHandleForReading.readDataToEndOfFile()
let errorOutput = String(data: errorData, encoding: .utf8) ?? "Unknown error"
guard result.exitStatus == 0 else {
let errorOutput = String(data: result.stderr, encoding: .utf8) ?? "Unknown error"
throw GYBError.executionFailed(filename: filename, errorOutput: errorOutput)
}
}

// Run the compiled executable
let runProcess = try processForCommand(
executableFile.platformString, arguments: [])

let outputPipe = Pipe()
let errorPipe = Pipe()
runProcess.standardOutput = outputPipe
runProcess.standardError = errorPipe

try runProcess.run()
runProcess.waitUntilExit()
/// Runs compiled executable and returns its output.
private func runCompiledExecutable(_ executable: URL) throws -> String {
let result = try runProcess(executable.platformString, arguments: [])

if runProcess.terminationStatus != 0 {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let errorOutput = String(data: errorData, encoding: .utf8) ?? "Unknown error"
guard result.exitStatus == 0 else {
let errorOutput = String(data: result.stderr, encoding: .utf8) ?? "Unknown error"
throw GYBError.executionFailed(filename: filename, errorOutput: errorOutput)
}

let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
return String(data: outputData, encoding: .utf8) ?? ""
return String(data: result.stdout, encoding: .utf8) ?? ""
}

/// Returns the start position of `nodes`'s first element.
Expand Down
95 changes: 54 additions & 41 deletions Sources/gyb-swift/ProcessUtilities.swift
Original file line number Diff line number Diff line change
@@ -1,59 +1,41 @@
import Foundation

#if os(Windows)
private let isWindows = true
internal let isWindows = true
#else
private let isWindows = false
internal let isWindows = false
#endif

#if os(macOS)
private let isMacOS = true
internal let isMacOS = true
#else
private let isMacOS = false
internal let isMacOS = false
#endif

/// The environment variables of the running process.
///
/// On platforms where environment variable names are case-insensitive (Windows), the keys have
/// all been normalized to upper case, so looking up a variable value from this dictionary by a
/// name that isn't all-uppercase is a non-portable operation.
private let environmentVariables = isWindows
private let environmentVariables =
isWindows
? Dictionary(
uniqueKeysWithValues: ProcessInfo.processInfo.environment.lazy.map {
(key: $0.key.uppercased(), value: $0.value)
})
: ProcessInfo.processInfo.environment

/// Runs `executable` with `arguments`, returning stdout trimmed of whitespace.
///
/// Returns `nil` if the process fails or produces no output.
private func runProcessForOutput(
_ executable: String, arguments: [String]
) throws -> String {
let p = Process()
p.executableURL = URL(fileURLWithPath: executable)
p.arguments = arguments
let result = try runProcess(executable, arguments: arguments)

let output = Pipe()
p.standardOutput = output
p.standardError = Pipe()

do {
try p.run()
}
catch let e {
throw Failure("running \(executable) \(arguments) threw.", e)
guard result.exitStatus == 0 else {
throw Failure("\(executable) \(arguments) exited with \(result.exitStatus)")
}

p.waitUntilExit()

guard p.terminationStatus == 0 else {
throw Failure("\(executable) \(arguments) exited with \(p.terminationStatus)")
}

guard let output = String(
data: output.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8) else {
guard let output = String(data: result.stdout, encoding: .utf8) else {
throw Failure("output of \(executable) \(arguments) not UTF-8 encoded")
}

Expand Down Expand Up @@ -103,26 +85,28 @@ private func sdkRootPath() throws -> String {
return path
}

/// Creates a `Process` configured to execute the given command via PATH resolution.
/// Returns a `Process` that runs `command` with the given `arguments`.
///
/// On Unix-like systems, uses `/usr/bin/env` to resolve the command from PATH.
/// On Windows, searches PATH explicitly to find the full executable path.
/// On macOS, sets SDKROOT environment variable if not already set.
/// If `command` contains no path separators, it will be found in
/// `PATH`. On macOS, ensures SDKROOT is set in the environment in
/// case the command needs it.
func processForCommand(_ command: String, arguments: [String]) throws -> Process {
let p = Process()

if isWindows {
// On Windows, search PATH explicitly to avoid looking in current directory
let executablePath = try findWindowsExecutableInPath(command)
p.executableURL = URL(fileURLWithPath: executablePath)
p.arguments = arguments
p.arguments = arguments
// If command contains path separators, use it directly without PATH search
if command.contains(isWindows ? "\\" : "/") {
p.executableURL = URL(fileURLWithPath: command)
} else {
// On Unix-like systems, use /usr/bin/env which searches PATH safely
p.executableURL = URL(fileURLWithPath: "/usr/bin/env")
p.arguments = [command] + arguments
if isWindows {
p.executableURL = URL(fileURLWithPath: try findWindowsExecutableInPath(command))
} else {
// Let env find and run the executable.
p.executableURL = URL(fileURLWithPath: "/usr/bin/env")
p.arguments = [command] + arguments
}
}

// On macOS, ensure SDKROOT is set for Swift compilation
if isMacOS {
var environment = ProcessInfo.processInfo.environment
if environment["SDKROOT"] == nil {
Expand All @@ -133,3 +117,32 @@ func processForCommand(_ command: String, arguments: [String]) throws -> Process

return p
}

/// Output from running a process.
struct ProcessOutput {
/// Standard output data.
let stdout: Data
/// Standard error data.
let stderr: Data
/// Process exit status.
let exitStatus: Int32
}

/// Runs `command` with `arguments`, returning captured output and exit status.
func runProcess(_ command: String, arguments: [String]) throws -> ProcessOutput {
let process = try processForCommand(command, arguments: arguments)

let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe

try process.run()
process.waitUntilExit()

return ProcessOutput(
stdout: stdoutPipe.fileHandleForReading.readDataToEndOfFile(),
stderr: stderrPipe.fileHandleForReading.readDataToEndOfFile(),
exitStatus: process.terminationStatus
)
}
11 changes: 10 additions & 1 deletion Sources/gyb-swift/gyb_swift.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ struct GYBSwift: ParsableCommand {
""")
var templateGeneratesSwift: Bool = false

@Flag(
help: """
Force compilation of template instead of using Swift interpreter.
By default, templates are executed via Swift interpreter on non-Windows platforms for speed.
This flag forces compilation, useful for testing the compilation path.
""")
var forceTemplateCompilation: Bool = false

@Flag(help: "Dump the parsed template AST to stdout")
var dump: Bool = false

Expand Down Expand Up @@ -169,7 +177,8 @@ struct GYBSwift: ParsableCommand {
}

// Execute template
var result = try generator.execute(ast, bindings: bindings)
var result = try generator.execute(
ast, bindings: bindings, forceCompilation: forceTemplateCompilation)

// Fix #sourceLocation directives in template output if needed
if templateGeneratesSwift {
Expand Down
Loading