fix(swift): type an optional field and read through its force-unwrap

Swift cannot declare a stored property with neither a type nor an initializer,
so its "declare now, assign in init" idiom is an OPTIONAL field read back
through a force-unwrap. That shape resolved nothing, and it was broken in two
independent places — each alone leaves it broken:

  1. `var a: Outer?` parses as `type_annotation(optional_type(user_type(…)))`,
     but the property-annotation pattern required the `user_type` to be a DIRECT
     child, so an optional field was never typed at all. The pattern added here
     captures the INNER `type_identifier`, so the binding is `Outer` without
     relying on `stripOptional` reducing an `Outer?` spelling.
  2. `self.a!` is a `postfix_expression`, which the receiver walk did not peel,
     so even a typed field could not be read through the unwrap.

For (2), `postfix_expression` is NOT added to `TRANSPARENT_RECEIVER_WRAPPERS`
outright: unlike TypeScript's `non_null_expression` — which is only ever `!` —
Swift's node also carries user-defined postfix operators, which can return
anything. Peeling those would type the receiver as the operand and mint a
confidently WRONG owner, the failure mode compound-receiver.ts calls strictly
worse than no edge. So the peel is operator-gated: transparent only when the
node's text ends in `!`, which is provably type-preserving.

Verified: force-unwrap `self.a!.inner()`, optional chain `self.b?.inner()`, and
the plain annotated field all resolve; previously only the plain one did.

The gate keeps this off every other language — `postfix_expression` is not a
node type the other grammars produce here — and the full resolver + CFG suite is
green at 3166 passed / 0 failed, against a 3165 baseline.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 16:02:10 +00:00
parent 90c72e49f8
commit d5d878033b
2 changed files with 44 additions and 5 deletions

View file

@ -107,6 +107,23 @@ const SWIFT_SCOPE_QUERY = `
(type_annotation
(user_type (type_identifier) @type-binding.type))) @type-binding.annotation
;; Optional property annotations: \`var owner: Owner?\` (#2807). The pattern
;; above requires the \`user_type\` to be a DIRECT child of the annotation;
;; an optional inserts an \`optional_type\` level between them, so an optional
;; field was never typed at all and \`self.owner!.method()\` could not resolve
;; its receiver. Declaring a field optional and assigning it later is the
;; idiomatic Swift way to express a field that has no value at init time, so
;; this is the common shape, not an edge case.
;;
;; The INNER \`type_identifier\` is captured, so the binding is \`Owner\` with no
;; reliance on \`stripOptional\` reducing a \`Owner?\` spelling.
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @type-binding.name)
(type_annotation
(optional_type
(user_type (type_identifier) @type-binding.type)))) @type-binding.annotation
;; ── Type bindings — stored / local-var constructor inference:
;; \`let p = Product(...)\` (constructor) and \`let u = getUser()\`
;; (free-call result; chain-follow resolves getUser → its return type).

View file

@ -561,6 +561,32 @@ const TRANSPARENT_RECEIVER_WRAPPERS = new Set([
'parenthesized_expression', // `(svc)`
]);
/**
* Wrappers that are transparent only for SOME operators, keyed by the operator
* text that makes them so.
*
* Swift force-unwrap (`self.a!`) is the exact semantic of TypeScript's
* `non_null_expression` above — it yields the wrapped type — but Swift parses it
* as the general `postfix_expression`, which ALSO carries user-defined postfix
* operators. Those can return anything, so peeling the node type unconditionally
* would type the receiver as the operand and could produce a confidently wrong
* owner. Reading the operator keeps the peel to the case that is provably
* type-preserving.
*/
const OPERATOR_GATED_RECEIVER_WRAPPERS = new Map<string, string>([
['postfix_expression', '!'], // Swift `self.a!`
]);
/** Is `node` a wrapper that denotes exactly what its operand denotes? */
function isTransparentReceiverWrapper(node: SyntaxNode): boolean {
if (TRANSPARENT_RECEIVER_WRAPPERS.has(node.type)) return true;
const operator = OPERATOR_GATED_RECEIVER_WRAPPERS.get(node.type);
if (operator === undefined) return false;
// The operator is an anonymous token, so it is not in `namedChildren`; the
// node's own text is the reliable place to read it.
return node.text.trimEnd().endsWith(operator);
}
/**
* Iteration bound for the wrapper peel. Its OWN constant, not `MAX_CHAIN_DEPTH`.
*
@ -575,11 +601,7 @@ const MAX_TRANSPARENT_WRAPPER_DEPTH = 3;
/** Peel transparent wrappers off a base receiver node. */
function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode {
let current = node;
for (
let i = 0;
i < MAX_TRANSPARENT_WRAPPER_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type);
i++
) {
for (let i = 0; i < MAX_TRANSPARENT_WRAPPER_DEPTH && isTransparentReceiverWrapper(current); i++) {
const inner = current.namedChildren?.find((c) => c !== null);
if (inner === undefined || inner === null) break;
current = inner;