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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 70 additions & 8 deletions Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ extension Interpreter {
in: scope
))
}
return try await body(args)
return try await callingBridge { try await body(args) }
}
}
// Built-in type initializer registered via `registerInit` (URL,
Expand Down Expand Up @@ -345,13 +345,19 @@ extension Interpreter {
// for a small allowlist of methods — full bidirectional inference
// is bigger than what we need here.
let implicitContext = implicitMemberContext(method: methodName, receiver: receiver)
// Only a bridged (opaque) receiver has a bridge that can
// interpret a deferred leading-dot member — the issue #11
// case (`app.descendants(matching: .any)`). For builtin
// containers a bare `.foo` stays a hard error.
let receiverIsBridged = { if case .opaque = receiver { return true }; return false }()
var args: [Value] = []
for arg in call.arguments {
args.append(try await evaluateArg(
arg.expression,
label: arg.label?.text,
contextType: implicitContext,
in: scope
in: scope,
deferToCallee: receiverIsBridged
))
}
if let trailing = call.trailingClosure {
Expand Down Expand Up @@ -755,21 +761,77 @@ extension Interpreter {

/// Evaluate a call argument, resolving a bare implicit-member access
/// (`.whitespaces`) against `contextType` when supplied.
///
/// `deferToCallee` — set only for a call whose receiver is a bridged
/// (opaque) type — controls the issue #11 fallback: a leading-dot
/// member that resolves against no known context is handed to the
/// callee as an unresolved enum marker instead of erroring, so a
/// bridge (`app.descendants(matching: .any)`) can interpret the
/// case name. It stays *off* for builtin containers, so
/// `[1, 2, 3].contains(.foo)` remains the hard "no such member"
/// error stock Swift gives rather than silently comparing against a
/// marker that never matches.
func evaluateArg(
_ expr: ExprSyntax,
label: String?,
contextType: String?,
in scope: Scope
in scope: Scope,
deferToCallee: Bool = false
) async throws -> Value {
if let member = expr.as(MemberAccessExprSyntax.self), member.base == nil {
let caseName = member.declName.baseName.text
if let contextType,
let resolved = try await resolveContextualMember(
caseName, typeName: contextType, in: scope)
{
return resolved
}
if deferToCallee {
// A bridged parameter has no declared type for us to
// consult; hand the bare case name to the receiving
// bridge, which decides what it means (or raises its
// own clearer error).
return .enumValue(typeName: "", caseName: caseName, associatedValues: [])
}
// No context and no bridge to defer to — fall through so
// `evaluate(memberAccess:)` raises the "unsupported implicit
// member access" error.
}
if let contextType {
// Resolves both a bare `.member` static-let and an
// OptionSet array literal (`[.sortedKeys, .prettyPrinted]`)
// against the context type — see `evaluate(_:expectingTypeName:in:)`.
// Resolves an OptionSet array literal
// (`[.sortedKeys, .prettyPrinted]`) and other contextual
// forms against the context type — see
// `evaluate(_:expectingTypeName:in:)`.
return try await evaluate(expr, expectingTypeName: contextType, in: scope)
}
return try await evaluate(expr, in: scope)
}

/// Resolve a leading-dot case name against a known context type:
/// a bridge `static let` (`.utf8` → `String.Encoding.utf8`,
/// `.whitespaces` → `CharacterSet.whitespaces`) or a user enum
/// case. Returns `nil` when the name doesn't resolve, so the
/// caller can defer to the callee. Mirrors the static-let arm of
/// `evaluate(_:expectingTypeName:in:)` for the single-member case.
func resolveContextualMember(
_ caseName: String,
typeName: String,
in scope: Scope
) async throws -> Value? {
switch bridges["static let \(typeName).\(caseName)"] {
case .staticValue(let v)?:
return v
case .staticComputed(let body)?:
return try await body()
default:
break
}
if enumDefs[typeName] != nil {
return enumCaseAccess(typeName: typeName, caseName: caseName)
}
return nil
}

/// Attempt a mutating method call on a stored variable (`Bool.toggle`,
/// `Array.append`, etc.). Returns nil if `methodName` isn't a known
/// mutating method on the variable's value type, so the caller can
Expand Down Expand Up @@ -809,7 +871,7 @@ extension Interpreter {
let args = try await argSyntaxes.asyncMap {
try await evaluate($0.expression, in: scope)
}
let (result, updated) = try await body(receiver, args)
let (result, updated) = try await callingBridge { try await body(receiver, args) }
// `writeLValuePath` writes in place through a class boundary
// (so `let h` on a class still mutates its Data property) and
// enforces `let` immutability for pure-value chains.
Expand Down Expand Up @@ -892,7 +954,7 @@ extension Interpreter {
let args = try await argSyntaxes.asyncMap {
try await evaluate($0.expression, in: scope)
}
let (result, updated) = try await body(value, args)
let (result, updated) = try await callingBridge { try await body(value, args) }
try storage.write(updated)
return result

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ extension Interpreter {
}
let baseValue: Value
if let body = bridgeInit {
baseValue = try await body(args)
baseValue = try await callingBridge { try await body(args) }
} else {
baseValue = try await invoke(extensionInit!, args: args)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,10 @@ extension Interpreter {
) async throws -> Value {
switch fn.kind {
case .builtinMethod(let body):
return try await body(receiver, args)
// Bridge method / computed body — its errors are catchable.
return try await callingBridge { try await body(receiver, args) }
case .builtin(let body):
return try await body(args)
return try await callingBridge { try await body(args) }
case .user(let body, let capturedScope):
let callScope = Scope(parent: capturedScope)
callScope.bind("self", value: receiver, mutable: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,16 @@ extension Interpreter {
case .method(let body)? =
bridges[bridgeKey(forMethod: name, on: "FileManager", labels: labels)]
{
return try await body(
boxOpaque(FileManager.default, typeName: "FileManager"), args)
return try await callingBridge {
try await body(boxOpaque(FileManager.default, typeName: "FileManager"), args)
}
}
if case .method(let body)? =
bridges[bridgeKey(forMethod: name, on: "FileManager", labels: [])]
{
return try await body(
boxOpaque(FileManager.default, typeName: "FileManager"), args)
return try await callingBridge {
try await body(boxOpaque(FileManager.default, typeName: "FileManager"), args)
}
}
throw RuntimeError.invalid("'FileManager' has no method '\(name)'")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,10 @@ extension Interpreter {
if rest.isEmpty, let entry = propertyIndex["\(typeName).\(head)"] {
switch entry.setter {
case .setter(let body)?:
try await body(container, value)
try await callingBridge { try await body(container, value) }
return nil
case .structSetter(let body)?:
return try await body(container, value)
return try await callingBridge { try await body(container, value) }
default:
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ extension Interpreter {
if case .subscriptGet(let body)? =
bridges[bridgeKey(forSubscriptGetOn: opaqueType)]
{
return try await body(receiver, args)
return try await callingBridge { try await body(receiver, args) }
}
throw RuntimeError.invalid(
"value of type '\(opaqueType)' has no subscript"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,9 @@ extension Interpreter {
"value of type '\(opaqueType)' has no settable subscript"
)
}
let updated = try await body(binding.value, args, value)
let updated = try await callingBridge {
try await body(binding.value, args, value)
}
_ = scope.assign(varName, value: updated)
return .void
default:
Expand Down
40 changes: 40 additions & 0 deletions Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
import SwiftSyntax

extension Interpreter {
/// Run a bridge closure, re-surfacing whatever it raises as a
/// value a script `do`/`catch` (and `try?`) can handle — issue #12.
///
/// A `RuntimeError` or raw host `Error` thrown from a
/// `.method` / `.computed` / `.subscriptGet` / … body becomes a
/// ``UserThrowSignal`` carrying an opaque `Error`, so a bridge that
/// signals a recoverable failure (an element that isn't there, an
/// I/O error worth retrying) is catchable like any other thrown
/// value. A script throw (already a ``UserThrowSignal``) passes
/// through unchanged.
///
/// The control-flow signals pass through untouched so a
/// `return` / `break` / `continue` / `exit` that unwinds through a
/// bridge which invoked a script closure keeps its meaning. And,
/// crucially, this only wraps errors that originate *inside a
/// bridge body*: the interpreter's own diagnostics — undefined
/// identifier, no-such-member, and the uncatchable
/// `fatalError` / `precondition` / division-by-zero traps — are
/// raised outside any bridge closure and so keep terminating the
/// script, exactly as stock Swift traps.
func callingBridge<T>(_ body: () async throws -> T) async throws -> T {
do {
return try await body()
} catch let signal as UserThrowSignal {
throw signal
} catch let control as ReturnSignal {
throw control
} catch let control as BreakSignal {
throw control
} catch let control as ContinueSignal {
throw control
} catch let control as FallthroughSignal {
throw control
} catch let exit as ScriptExit {
throw exit
} catch {
throw UserThrowSignal(value: .opaque(typeName: "Error", value: error))
}
}

/// `throw expr` — evaluate the expression and raise it as a user error.
func execute(throw throwStmt: ThrowStmtSyntax, in scope: Scope) async throws -> Value {
let value = try await evaluate(throwStmt.expression, in: scope)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ struct BridgeReviewRegressionTests {

@Test func numericInitOverflowThrows() async throws {
let interp = Interpreter()
await #expect(throws: RuntimeError.self) {
await #expect(throws: (any Error).self) {
_ = try await interp.eval(#"""
import Foundation
UInt8(300)
Expand Down
8 changes: 4 additions & 4 deletions Tests/SwiftScriptInterpreterTests/BridgedSubscriptTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct BridgedSubscriptTests {

@Test func dataSliceOutOfSliceBoundsThrows() async throws {
let interp = Interpreter()
await #expect(throws: RuntimeError.self) {
await #expect(throws: (any Error).self) {
_ = try await interp.eval(#"""
import Foundation
Data([1, 2, 3, 4, 5])[1..<4][0]
Expand Down Expand Up @@ -66,7 +66,7 @@ struct BridgedSubscriptTests {

@Test func dataIndexOutOfBoundsThrows() async throws {
let interp = Interpreter()
await #expect(throws: RuntimeError.self) {
await #expect(throws: (any Error).self) {
_ = try await interp.eval(#"""
import Foundation
Data([1])[5]
Expand All @@ -76,7 +76,7 @@ struct BridgedSubscriptTests {

@Test func dataWriteToLetConstantRejected() async throws {
let interp = Interpreter()
await #expect(throws: RuntimeError.self) {
await #expect(throws: (any Error).self) {
_ = try await interp.eval(#"""
import Foundation
let d = Data([1, 2])
Expand Down Expand Up @@ -165,7 +165,7 @@ struct BridgedSubscriptTests {

@Test func unbridgedOpaqueSubscriptFailsLoudly() async throws {
let interp = Interpreter()
await #expect(throws: RuntimeError.self) {
await #expect(throws: (any Error).self) {
_ = try await interp.eval(#"""
import Foundation
UUID()[0]
Expand Down
Loading