diff --git a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Calls.swift index 81864f8..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,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 @@ -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. @@ -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 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 221f4f6..2fa22ea 100644 --- a/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift +++ b/Sources/SwiftScriptInterpreter/Execution/Interpreter+Throws.swift @@ -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(_ 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) 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 new file mode 100644 index 0000000..07ce857 --- /dev/null +++ b/Tests/SwiftScriptInterpreterTests/CatchableBridgeErrorTests.swift @@ -0,0 +1,238 @@ +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 { + + /// 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(#""" + 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: - 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 { + 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..c2a360f --- /dev/null +++ b/Tests/SwiftScriptInterpreterTests/ImplicitMemberDeferralTests.swift @@ -0,0 +1,151 @@ +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") + } + } + + // 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) + """#) + } + } +}