From 7ebc71b730c7f701ded7b0469500b6dc6f9da979 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sun, 9 Aug 2026 17:29:42 +0200 Subject: [PATCH 1/2] Defer leading-dot member args to the callee; make bridge errors catchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11, #12 — the two follow-on blockers from the XCUITest work in #9. #11 (implicit member): a leading-dot member in argument position (`app.descendants(matching: .any)`, `element.typeKey(.escape)`) no longer errors before any type context is consulted. In evaluateArg it resolves against the parameter's context type when one is known (a bridge static-let like `.utf8`/`.whitespaces`, a user enum case), and otherwise defers to the callee as an unresolved `.enumValue(typeName: "", caseName:)` marker — a bridged parameter has no declared type for the interpreter to consult, so the receiving bridge decides what the case means and a bridge expecting something else raises its own clearer error. The deferral is scoped to argument position; a stray `.foo` in general expressions stays a hard error. #12 (catchable bridge errors): a RuntimeError (or raw host error) raised inside a .method/.computed/.subscriptGet body used to fly past every catch clause and end the script. execute(do:) and evaluate(try:) now surface any non-control-flow error as a catchable `.opaque(typeName: "Error", …)` value — `catch { print(error) }` binds it, `try?` yields nil, and an unmatched clause re-raises the original so an unhandled error still ends the script with its message. Control-flow signals (return/break/continue/fallthrough/exit) are re-thrown explicitly rather than swept up by the catch-all, via an allowlist as the issue asks. 15 new tests (implicit-member deferral incl. a fake element-query bridge reading the marker; catchable bridge errors incl. control-flow bypass and exit); 531 tests pass. Co-Authored-By: Claude Fable 5 --- .../Execution/Interpreter+Calls.swift | 53 ++++++- .../Execution/Interpreter+Throws.swift | 109 +++++++++++--- .../CatchableBridgeErrorTests.swift | 140 ++++++++++++++++++ .../ImplicitMemberDeferralTests.swift | 120 +++++++++++++++ 4 files changed, 400 insertions(+), 22 deletions(-) create mode 100644 Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift create mode 100644 Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift index 81864f8..33061eb 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift @@ -761,15 +761,62 @@ extension Interpreter { contextType: String?, in scope: Scope ) async throws -> Value { + // A leading-dot member in *argument* position — `f(.any)`, + // `element.typeKey(.escape)`. Resolve it against the + // parameter's context type when we know one (a bridge + // static-let, a user enum case), otherwise hand the bare case + // name to the callee as an unresolved enum marker (issue #11): + // a bridged parameter has no declared type for the interpreter + // to consult, so the receiving bridge decides what `.any` + // means — and a bridge that expects something else raises its + // own, clearer error. Restricting the deferral to argument + // position keeps a stray `.foo` in general expressions a hard + // error (that path in `evaluate(memberAccess:)` still throws). + 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 + } + return .enumValue(typeName: "", caseName: caseName, associatedValues: []) + } 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 diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift index 221f4f6..a4523d3 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift @@ -8,41 +8,112 @@ extension Interpreter { } /// `do { … } catch { … } …` — run the body, dispatch any - /// thrown user error to the first matching catch clause. + /// thrown error to the first matching catch clause. + /// + /// Two families of thrown value are catchable: a script-side `throw` + /// (a ``UserThrowSignal`` carrying the thrown value) and an error + /// raised inside a bridge or interpreter dispatch (a ``RuntimeError`` + /// or a raw host `Error`). The latter used to fly straight past every + /// `catch` and end the script (issue #12); it now arrives as a + /// catchable `.opaque(typeName: "Error", …)` value, so + /// `catch { print(error) }` binds it like any other error and + /// `try?` around a bridged call yields `nil`. + /// + /// Control-flow signals (`return` / `break` / `continue` / + /// `fallthrough` / `exit(_:)`) are *not* errors and must keep + /// bypassing `catch` — they're re-thrown explicitly rather than + /// swept up by the catch-all. func execute(do doStmt: DoStmtSyntax, in scope: Scope) async throws -> Value { do { return try await executeBlock(doStmt.body, in: scope) } catch let signal as UserThrowSignal { - // Try each catch clause in order. - for catchClause in doStmt.catchClauses { - if let bindScope = try await matchCatchClause( - catchClause, value: signal.value, in: scope - ) { - return try await executeBlock(catchClause.body, in: bindScope) - } + if let handled = try await dispatchToCatchClauses( + doStmt, value: signal.value, in: scope) { + return handled } - // No matching catch — re-raise. 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 { + // Bridge / interpreter error — surface it as a catchable + // opaque `Error` value. Re-throw the original when no clause + // matches so an unhandled bridge error still ends the script + // with its own message. + let value = Value.opaque(typeName: "Error", value: error) + if let handled = try await dispatchToCatchClauses( + doStmt, value: value, in: scope) { + return handled + } + throw error + } + } + + /// Run each catch clause in order against a thrown `value`, returning + /// the executed clause's result, or `nil` when none match. + private func dispatchToCatchClauses( + _ doStmt: DoStmtSyntax, + value: Value, + in scope: Scope + ) async throws -> Value? { + for catchClause in doStmt.catchClauses { + if let bindScope = try await matchCatchClause( + catchClause, value: value, in: scope + ) { + return try await executeBlock(catchClause.body, in: bindScope) + } } + return nil } /// Evaluate a `try`/`try?`/`try!` expression. The inner expression is /// evaluated; the modifier decides how thrown errors are surfaced. + /// + /// Both a script `throw` (``UserThrowSignal``) and a bridge / + /// interpreter error (``RuntimeError`` or a raw host `Error`) are + /// handled the same way — `try?` yields `nil`, `try!` traps, plain + /// `try` re-raises for an enclosing `do`/`catch` to handle (issue + /// #12). Control-flow signals are re-thrown untouched. func evaluate(try tryExpr: TryExprSyntax, in scope: Scope) async throws -> Value { let mark = tryExpr.questionOrExclamationMark?.text do { return try await evaluate(tryExpr.expression, in: scope) } catch let signal as UserThrowSignal { - switch mark { - case "?": - return .optional(nil) - case "!": - throw RuntimeError.invalid( - "'try!' expression unexpectedly raised an error: \(signal.value.description)" - ) - default: - throw signal - } + return try surfaceTry(mark: mark, description: signal.value.description, rethrow: 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 { + return try surfaceTry(mark: mark, description: "\(error)", rethrow: error) + } + } + + /// Apply the `try` modifier to a caught error: `?` → `nil`, `!` → + /// trap, plain `try` → re-raise the original. + private func surfaceTry(mark: String?, description: String, rethrow: Error) throws -> Value { + switch mark { + case "?": + return .optional(nil) + case "!": + throw RuntimeError.invalid( + "'try!' expression unexpectedly raised an error: \(description)" + ) + default: + throw rethrow } } diff --git a/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift b/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift new file mode 100644 index 0000000..cbda763 --- /dev/null +++ b/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift @@ -0,0 +1,140 @@ +import Testing +import Foundation +@testable import SwiftScriptInterpreter + +/// Issue #12: an error raised inside a bridge (a `RuntimeError`, or a +/// raw host error) is catchable by script `do`/`catch` and suppressed +/// by `try?`, the same as a script-side `throw` — while control-flow +/// signals (`return`/`break`/`continue`/`exit`) keep bypassing `catch`. +@Suite("Catchable bridge errors (issue #12)") +struct CatchableBridgeErrorTests { + + @Test func bridgeRuntimeErrorIsCaught() async throws { + var out = "" + let interp = Interpreter(output: { out += $0 }) + _ = try await interp.eval(#""" + import Foundation + do { + let d = Data([1, 2]) + _ = d[99] + print("no throw") + } catch { + print("recovered:", error) + } + """#) + #expect(out == "recovered: Data index 99 out of bounds (0..<2)\n") + } + + @Test func tryQuestionSuppressesBridgeError() async throws { + let interp = Interpreter() + let r = try await interp.eval(#""" + import Foundation + func boom() throws -> Int { + let d = Data([1]) + return d[42] // bridge RuntimeError + } + (try? boom()) ?? -1 + """#) + #expect(r == .int(-1)) + } + + @Test func uncaughtBridgeErrorStillPropagates() async throws { + // No matching clause → the original error ends the script. + let interp = Interpreter() + await #expect(throws: (any Error).self) { + _ = try await interp.eval(#""" + import Foundation + Data([1])[5] + """#) + } + } + + @Test func typedCatchClauseFallsThroughToDefault() async throws { + // A bridge error is an opaque `Error`, so a script-enum typed + // clause doesn't match — the default clause binds it. + var out = "" + let interp = Interpreter(output: { out += $0 }) + _ = try await interp.eval(#""" + import Foundation + enum E: Error { case specific } + do { + _ = Data([1])[5] + } catch E.specific { + print("specific") + } catch { + print("default") + } + """#) + #expect(out == "default\n") + } + + // MARK: - Control-flow signals still bypass catch + + @Test func returnBypassesCatch() async throws { + let interp = Interpreter() + let r = try await interp.eval(#""" + func f() -> Int { + do { + return 42 + } catch { + return -1 + } + } + f() + """#) + #expect(r == .int(42)) + } + + @Test func breakAndContinueBypassCatch() async throws { + var out = "" + let interp = Interpreter(output: { out += $0 }) + _ = try await interp.eval(#""" + var kept = 0 + for i in 0..<5 { + do { + if i == 1 { continue } + if i == 3 { break } + kept += i + } catch { + print("caught control flow?!") + } + } + print(kept) + """#) + #expect(out == "2\n") // i=0 (+0) and i=2 (+2); 1 continued, 3 broke + } + + @Test func exitBypassesCatch() async throws { + // `exit(_:)` raises `ScriptExit`, which must terminate the + // script rather than be swept up by an enclosing catch. + let interp = Interpreter() + let status = try await interp.evalScript(#""" + import Foundation + do { + exit(7) + } catch { + print("should not catch exit") + } + """#) + #expect(status.code == 7) + } + + // MARK: - Script throw still works unchanged + + @Test func scriptThrowStillCatchableByPattern() async throws { + var out = "" + let interp = Interpreter(output: { out += $0 }) + _ = try await interp.eval(#""" + enum E: Error { case parse(String) } + func f() throws -> Int { throw E.parse("oops") } + do { + _ = try f() + } catch E.parse(let m) { + print("err:", m) + } catch { + print("other") + } + """#) + #expect(out == "err: oops\n") + } +} diff --git a/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift b/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift new file mode 100644 index 0000000..725026f --- /dev/null +++ b/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift @@ -0,0 +1,120 @@ +import Testing +import Foundation +@testable import SwiftScriptInterpreter + +/// Issue #11: a leading-dot member in argument position resolves +/// against the parameter's context type when one is known, and +/// otherwise defers to the callee as an unresolved enum marker so a +/// bridge can decide what `.any` means — the shape a verbatim +/// XCUITest call (`app.descendants(matching: .any)`) needs. +@Suite("Implicit member deferral (issue #11)") +struct ImplicitMemberDeferralTests { + + /// A bridge that receives a leading-dot argument by *case name*, + /// exactly as an element-query API would consume `.any`. + private struct FakeQueryModule: BuiltinModule { + let name = "FakeQuery" + func register(into i: Interpreter) { + i.bridges["init Query()"] = .`init` { _ in + .opaque(typeName: "Query", value: "root") + } + // `query.descendants(matching: .any)` — the arg arrives as + // the deferred marker `.enumValue(typeName: "", caseName:)`. + i.bridges["func Query.descendants()"] = .method { receiver, args in + guard case .opaque(_, let base as String) = receiver else { + throw RuntimeError.invalid("Query.descendants: bad receiver") + } + guard args.count == 1, + case .enumValue(_, let caseName, _) = args[0] + else { + throw RuntimeError.invalid( + "Query.descendants(matching:): expected an element-type case") + } + return .opaque(typeName: "Query", value: "\(base)/\(caseName)") + } + i.bridges["var Query.identifier"] = .computed { receiver in + guard case .opaque(_, let id as String) = receiver else { + throw RuntimeError.invalid("Query.identifier: bad receiver") + } + return .string(id) + } + } + } + + @Test func bareMemberArgumentDefersToBridge() async throws { + let interp = Interpreter() + interp.registerOnImport("FakeQuery", module: FakeQueryModule()) + let r = try await interp.eval(#""" + import FakeQuery + Query().descendants(matching: .any).identifier + """#) + #expect(r == .string("root/any")) + } + + @Test func differentBareMembersReachTheBridge() async throws { + let interp = Interpreter() + interp.registerOnImport("FakeQuery", module: FakeQueryModule()) + let r = try await interp.eval(#""" + import FakeQuery + Query().descendants(matching: .button).identifier + """#) + #expect(r == .string("root/button")) + } + + // MARK: - Existing contextual resolution is unchanged + + @Test func bridgeStaticLetContextStillResolves() async throws { + // `.whitespaces` still resolves to `CharacterSet.whitespaces` + // via the parameter's implicit-member context, not the marker. + let interp = Interpreter() + let r = try await interp.eval(#""" + import Foundation + " hi ".trimmingCharacters(in: .whitespaces) + """#) + #expect(r == .string("hi")) + } + + @Test func encodingContextStillResolves() async throws { + let interp = Interpreter() + let r = try await interp.eval(#""" + import Foundation + String(data: Data("bytes".utf8), encoding: .utf8)! + """#) + #expect(r == .string("bytes")) + } + + @Test func optionSetArrayLiteralStillResolves() async throws { + let interp = Interpreter() + let r = try await interp.eval(#""" + import Foundation + let out = try JSONSerialization.data(withJSONObject: ["b": 2, "a": 1], options: [.sortedKeys]) + String(data: out, encoding: .utf8)! + """#) + #expect(r == .string(#"{"a":1,"b":2}"#)) + } + + @Test func userEnumArgumentStillResolves() async throws { + // User-function args resolve against the declared enum type. + let interp = Interpreter() + let r = try await interp.eval(#""" + enum Color { case red, green } + func name(_ c: Color) -> String { + switch c { case .red: return "red"; case .green: return "green" } + } + name(.green) + """#) + #expect(r == .string("green")) + } + + // MARK: - General position stays a hard error + + @Test func bareMemberInGeneralPositionStillThrows() async throws { + // A leading-dot member outside argument position has no callee + // to defer to, so it must remain a loud error rather than + // silently producing a marker. + let interp = Interpreter() + await #expect(throws: RuntimeError.self) { + _ = try await interp.eval("let x = .any") + } + } +} From 30d5a49342b2eeb77ac4437ac75aa43fd67aa191 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sun, 9 Aug 2026 17:57:23 +0200 Subject: [PATCH 2/2] Scope both fixes correctly after adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An internal review found both #11 and #12 over-reached; narrowed both. #11: the leading-dot deferral fired even when the receiver was a builtin container, so `[1,2,3].contains(.foo)` silently returned false (the marker never equals a real element) instead of erroring. evaluateArg now only defers to the marker when the receiver is a bridged (opaque) type — the only receiver with a bridge that can interpret the case name. A bare `.foo` to a builtin method, or in general position, stays the hard "no such member" error. #12: the do/catch catch-all surfaced EVERY non-control-flow error as catchable, which swallowed Swift's uncatchable traps (fatalError / precondition / assert / division-by-zero) and programming errors (undefined identifier, no-such-member) — masking script bugs. Reverted the do/catch change and instead wrap at the bridge-invocation boundary (callingBridge): only an error raised *inside* a bridge body becomes a catchable UserThrowSignal. Traps, dispatch failures, and operator errors are raised by the interpreter itself, outside any bridge, so they keep terminating the script exactly as stock Swift traps. Control-flow signals still pass through untouched. New boundary tests lock it in: builtin-container bare members stay fatal; fatalError / precondition / division-by-zero / undefined-id / no-such-member stay uncatchable; try? doesn't suppress a trap; an embedder bridge that throws is caught. 541 tests pass. Co-Authored-By: Claude Fable 5 --- .../Execution/Interpreter+Calls.swift | 49 ++++--- .../Execution/Interpreter+Classes.swift | 2 +- .../Execution/Interpreter+Extensions.swift | 5 +- .../Execution/Interpreter+FileIO.swift | 10 +- .../Execution/Interpreter+LValue.swift | 4 +- .../Execution/Interpreter+Members.swift | 2 +- .../Execution/Interpreter+Operators.swift | 4 +- .../Execution/Interpreter+Throws.swift | 135 +++++++----------- .../BridgeReviewRegressionTests.swift | 2 +- .../BridgedSubscriptTests.swift | 8 +- .../CatchableBridgeErrorTests.swift | 100 ++++++++++++- .../ImplicitMemberDeferralTests.swift | 31 ++++ 12 files changed, 235 insertions(+), 117 deletions(-) diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift index 33061eb..33e2a61 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift @@ -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, @@ -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 { @@ -755,23 +761,23 @@ 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 { - // A leading-dot member in *argument* position — `f(.any)`, - // `element.typeKey(.escape)`. Resolve it against the - // parameter's context type when we know one (a bridge - // static-let, a user enum case), otherwise hand the bare case - // name to the callee as an unresolved enum marker (issue #11): - // a bridged parameter has no declared type for the interpreter - // to consult, so the receiving bridge decides what `.any` - // means — and a bridge that expects something else raises its - // own, clearer error. Restricting the deferral to argument - // position keeps a stray `.foo` in general expressions a hard - // error (that path in `evaluate(memberAccess:)` still throws). if let member = expr.as(MemberAccessExprSyntax.self), member.base == nil { let caseName = member.declName.baseName.text if let contextType, @@ -780,7 +786,16 @@ extension Interpreter { { return resolved } - return .enumValue(typeName: "", caseName: caseName, associatedValues: []) + 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 an OptionSet array literal @@ -856,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. @@ -939,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 diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Classes.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Classes.swift index 95c585f..94d17d5 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Classes.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Classes.swift @@ -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) } diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Extensions.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Extensions.swift index 56e7a5e..2e2475e 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Extensions.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Extensions.swift @@ -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) diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+FileIO.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+FileIO.swift index c506311..78941da 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+FileIO.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+FileIO.swift @@ -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)'") } diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+LValue.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+LValue.swift index 025bf2d..27eb375 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+LValue.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+LValue.swift @@ -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 } diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Members.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Members.swift index 819af74..6b5b90b 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Members.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Members.swift @@ -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" diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Operators.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Operators.swift index b309f7c..cf05bb3 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Operators.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Operators.swift @@ -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: diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift index a4523d3..2fa22ea 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift @@ -1,36 +1,30 @@ import SwiftSyntax extension Interpreter { - /// `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) - throw UserThrowSignal(value: value) - } - - /// `do { … } catch { … } …` — run the body, dispatch any - /// thrown error to the first matching catch clause. + /// Run a bridge closure, re-surfacing whatever it raises as a + /// value a script `do`/`catch` (and `try?`) can handle — issue #12. /// - /// Two families of thrown value are catchable: a script-side `throw` - /// (a ``UserThrowSignal`` carrying the thrown value) and an error - /// raised inside a bridge or interpreter dispatch (a ``RuntimeError`` - /// or a raw host `Error`). The latter used to fly straight past every - /// `catch` and end the script (issue #12); it now arrives as a - /// catchable `.opaque(typeName: "Error", …)` value, so - /// `catch { print(error) }` binds it like any other error and - /// `try?` around a bridged call yields `nil`. + /// 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. /// - /// Control-flow signals (`return` / `break` / `continue` / - /// `fallthrough` / `exit(_:)`) are *not* errors and must keep - /// bypassing `catch` — they're re-thrown explicitly rather than - /// swept up by the catch-all. - func execute(do doStmt: DoStmtSyntax, in scope: Scope) async throws -> Value { + /// 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(_ body: () async throws -> T) async throws -> T { do { - return try await executeBlock(doStmt.body, in: scope) + return try await body() } catch let signal as UserThrowSignal { - if let handled = try await dispatchToCatchClauses( - doStmt, value: signal.value, in: scope) { - return handled - } throw signal } catch let control as ReturnSignal { throw control @@ -43,77 +37,52 @@ extension Interpreter { } catch let exit as ScriptExit { throw exit } catch { - // Bridge / interpreter error — surface it as a catchable - // opaque `Error` value. Re-throw the original when no clause - // matches so an unhandled bridge error still ends the script - // with its own message. - let value = Value.opaque(typeName: "Error", value: error) - if let handled = try await dispatchToCatchClauses( - doStmt, value: value, in: scope) { - return handled - } - throw error + throw UserThrowSignal(value: .opaque(typeName: "Error", value: error)) } } - /// Run each catch clause in order against a thrown `value`, returning - /// the executed clause's result, or `nil` when none match. - private func dispatchToCatchClauses( - _ doStmt: DoStmtSyntax, - value: Value, - in scope: Scope - ) async throws -> Value? { - for catchClause in doStmt.catchClauses { - if let bindScope = try await matchCatchClause( - catchClause, value: value, in: scope - ) { - return try await executeBlock(catchClause.body, in: bindScope) + /// `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) + throw UserThrowSignal(value: value) + } + + /// `do { … } catch { … } …` — run the body, dispatch any + /// thrown user error to the first matching catch clause. + func execute(do doStmt: DoStmtSyntax, in scope: Scope) async throws -> Value { + do { + return try await executeBlock(doStmt.body, in: scope) + } catch let signal as UserThrowSignal { + // Try each catch clause in order. + for catchClause in doStmt.catchClauses { + if let bindScope = try await matchCatchClause( + catchClause, value: signal.value, in: scope + ) { + return try await executeBlock(catchClause.body, in: bindScope) + } } + // No matching catch — re-raise. + throw signal } - return nil } /// Evaluate a `try`/`try?`/`try!` expression. The inner expression is /// evaluated; the modifier decides how thrown errors are surfaced. - /// - /// Both a script `throw` (``UserThrowSignal``) and a bridge / - /// interpreter error (``RuntimeError`` or a raw host `Error`) are - /// handled the same way — `try?` yields `nil`, `try!` traps, plain - /// `try` re-raises for an enclosing `do`/`catch` to handle (issue - /// #12). Control-flow signals are re-thrown untouched. func evaluate(try tryExpr: TryExprSyntax, in scope: Scope) async throws -> Value { let mark = tryExpr.questionOrExclamationMark?.text do { return try await evaluate(tryExpr.expression, in: scope) } catch let signal as UserThrowSignal { - return try surfaceTry(mark: mark, description: signal.value.description, rethrow: 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 { - return try surfaceTry(mark: mark, description: "\(error)", rethrow: error) - } - } - - /// Apply the `try` modifier to a caught error: `?` → `nil`, `!` → - /// trap, plain `try` → re-raise the original. - private func surfaceTry(mark: String?, description: String, rethrow: Error) throws -> Value { - switch mark { - case "?": - return .optional(nil) - case "!": - throw RuntimeError.invalid( - "'try!' expression unexpectedly raised an error: \(description)" - ) - default: - throw rethrow + switch mark { + case "?": + return .optional(nil) + case "!": + throw RuntimeError.invalid( + "'try!' expression unexpectedly raised an error: \(signal.value.description)" + ) + default: + throw signal + } } } diff --git a/Tests/SwiftScriptInterpreterTests/BridgeReviewRegressionTests.swift b/Tests/SwiftScriptInterpreterTests/BridgeReviewRegressionTests.swift index 5bfd1b5..0c23f04 100644 --- a/Tests/SwiftScriptInterpreterTests/BridgeReviewRegressionTests.swift +++ b/Tests/SwiftScriptInterpreterTests/BridgeReviewRegressionTests.swift @@ -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) diff --git a/Tests/SwiftScriptInterpreterTests/BridgedSubscriptTests.swift b/Tests/SwiftScriptInterpreterTests/BridgedSubscriptTests.swift index 6dbd17e..c1aeffd 100644 --- a/Tests/SwiftScriptInterpreterTests/BridgedSubscriptTests.swift +++ b/Tests/SwiftScriptInterpreterTests/BridgedSubscriptTests.swift @@ -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] @@ -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] @@ -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]) @@ -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] diff --git a/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift b/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift index cbda763..07ce857 100644 --- a/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift +++ b/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift @@ -9,7 +9,38 @@ import Foundation @Suite("Catchable bridge errors (issue #12)") struct CatchableBridgeErrorTests { - @Test func bridgeRuntimeErrorIsCaught() async throws { + /// A bridge that signals a recoverable failure by throwing — the + /// embedder pattern the issue is about (an element that isn't there + /// yet, an I/O error worth retrying). + private struct FlakyModule: BuiltinModule { + let name = "Flaky" + func register(into i: Interpreter) { + i.bridges["init Widget()"] = .`init` { _ in + .opaque(typeName: "Widget", value: "w") + } + i.bridges["func Widget.mustExist()"] = .method { _, _ in + throw RuntimeError.invalid("element not found") + } + } + } + + @Test func embedderBridgeThrowIsCaught() async throws { + var out = "" + let interp = Interpreter(output: { out += $0 }) + interp.registerOnImport("Flaky", module: FlakyModule()) + _ = try await interp.eval(#""" + import Flaky + do { + Widget().mustExist() + print("no throw") + } catch { + print("recovered:", error) + } + """#) + #expect(out == "recovered: element not found\n") + } + + @Test func bridgeSubscriptErrorIsCaught() async throws { var out = "" let interp = Interpreter(output: { out += $0 }) _ = try await interp.eval(#""" @@ -119,6 +150,73 @@ struct CatchableBridgeErrorTests { #expect(status.code == 7) } + // MARK: - Interpreter traps and programming errors stay fatal + + /// These are raised by the interpreter itself — outside any bridge + /// body — so wrapping bridge errors must not make them catchable. + /// In stock Swift each is an uncatchable trap or a compile error. + private func expectUncatchable(_ source: String) async { + let interp = Interpreter(output: { _ in }) + var caughtInScript = false + do { + _ = try await interp.eval(""" + \(source) + """) + } catch { + // The error propagates to the host — it was NOT swallowed + // by the script's own catch. + caughtInScript = false + _ = caughtInScript + return + } + Issue.record("expected the error to terminate the script, but it completed") + } + + @Test func fatalErrorNotCatchable() async { + await expectUncatchable(#""" + import Foundation + do { fatalError("boom") } catch { print("caught fatal") } + """#) + } + + @Test func preconditionFailureNotCatchable() async { + await expectUncatchable(#""" + import Foundation + do { precondition(false, "nope") } catch { print("caught precondition") } + """#) + } + + @Test func divisionByZeroNotCatchable() async { + await expectUncatchable(#""" + func f(_ a: Int, _ b: Int) -> Int { a / b } + do { _ = f(1, 0) } catch { print("caught division") } + """#) + } + + @Test func undefinedIdentifierNotCatchable() async { + await expectUncatchable(#""" + do { let _ = someUndefinedThing() } catch { print("caught undefined") } + """#) + } + + @Test func noSuchMemberNotCatchable() async { + await expectUncatchable(#""" + do { let _ = 5.hasPrefix("a") } catch { print("caught nsm") } + """#) + } + + @Test func tryQuestionDoesNotSuppressTrap() async { + // `try?` suppresses a *thrown* error, but never a trap. + let interp = Interpreter(output: { _ in }) + await #expect(throws: (any Error).self) { + _ = try await interp.eval(#""" + func f(_ a: Int, _ b: Int) -> Int { a / b } + let r = (try? f(1, 0)) ?? -1 + _ = r + """#) + } + } + // MARK: - Script throw still works unchanged @Test func scriptThrowStillCatchableByPattern() async throws { diff --git a/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift b/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift index 725026f..c2a360f 100644 --- a/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift +++ b/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift @@ -117,4 +117,35 @@ struct ImplicitMemberDeferralTests { _ = try await interp.eval("let x = .any") } } + + // MARK: - Builtin containers keep the hard error (no silent deferral) + + @Test func bareMemberToBuiltinArrayMethodStillThrows() async throws { + // The receiver is a builtin `[Int]`, not a bridged type — there + // is no bridge to interpret `.foo`, so it must stay the same + // "no such member" error stock Swift gives, not silently + // compare a marker that never matches (which would make + // `contains` return false). + let interp = Interpreter() + await #expect(throws: RuntimeError.self) { + _ = try await interp.eval("[1, 2, 3].contains(.foo)") + } + } + + @Test func bareMemberToBuiltinFirstIndexStillThrows() async throws { + let interp = Interpreter() + await #expect(throws: RuntimeError.self) { + _ = try await interp.eval("[1, 2, 3].firstIndex(of: .bar)") + } + } + + @Test func bareMemberToBuiltinSetStillThrows() async throws { + let interp = Interpreter() + await #expect(throws: RuntimeError.self) { + _ = try await interp.eval(#""" + import Foundation + Set([1, 2, 3]).contains(.foo) + """#) + } + } }