onedrop_hlsl/rewrite/
mod.rs

1//! AST-driven HLSL→HLSL rewrites.
2//!
3//! Operates on the HLSL source *before* the regex pipeline in
4//! [`crate::translate_shader`]. Each pass parses the source with
5//! [`crate::parse::parse_hlsl`], walks the AST to spot a specific HLSL
6//! idiom that the downstream regex passes can't recover from, and emits a
7//! list of [`TextEdit`]s applied back to the source.
8//!
9//! Falls back silently when the parser can't consume the input — the
10//! original source is returned unchanged so the regex pipeline still has a
11//! chance. The corpus parse rate is 100 % on `test-presets-200/`, so the
12//! fallback is exercised only by malformed test inputs in practice.
13//!
14//! Each pass lives in its own submodule and is composed in order by
15//! [`apply_all`]. The shared [`WalkCtx`] and type-inference helpers live
16//! here in `mod.rs`; private items in this file are accessible to the
17//! pass submodules under Rust's descendant-visibility rules.
18
19use crate::ast::*;
20use crate::lex::Span;
21use crate::parse::parse_hlsl;
22use crate::types::{SymbolTable, WgslType, vec_of_size, vec_size};
23
24mod array_lower;
25mod bare_expr;
26mod binop_vec;
27mod bool_arith;
28mod brace_init;
29mod chained_init;
30mod comma_paren;
31mod embedded_assign;
32mod for_init;
33mod modf_arity;
34mod qa_stub;
35mod scalar_swizzle;
36mod swizzle_assign;
37mod ternary;
38mod texture_uv;
39mod user_fn;
40mod vec_cmp;
41
42#[cfg(test)]
43mod tests;
44
45// Re-export the entry point of each pass so callers (and the tests
46// module) see them under the `rewrite::` path the original monolithic
47// file exposed.
48#[cfg(test)]
49pub(super) use array_lower::{lower_array_globals, lower_local_arrays};
50pub(super) use binop_vec::rewrite_binary_vec_mismatches;
51pub(super) use bool_arith::rewrite_bool_to_float;
52pub(super) use brace_init::rewrite_brace_init;
53pub(super) use chained_init::rewrite_chained_assign_inits;
54pub(super) use comma_paren::rewrite_comma_paren;
55pub(super) use embedded_assign::rewrite_embedded_assigns;
56#[cfg(test)]
57pub(super) use for_init::rewrite_for_int_init;
58pub(super) use modf_arity::rewrite_modf_arity;
59pub(super) use qa_stub::inject_qa_stub;
60pub(super) use scalar_swizzle::rewrite_scalar_swizzle;
61pub(super) use swizzle_assign::rewrite_swizzle_assigns;
62pub(super) use ternary::rewrite_ternary_to_select;
63pub(super) use texture_uv::coerce_texture_uv_args;
64pub(super) use user_fn::coerce_user_fn_args;
65pub(super) use vec_cmp::rewrite_vec_scalar_compare;
66
67use array_lower::{collect_global_array_edits, collect_local_array_edits};
68use bare_expr::collect_bare_expr_stmt_edits;
69use for_init::collect_for_int_edits;
70
71/// Apply every AST-driven rewrite pass in series. Returns the rewritten
72/// source. If parsing fails at any step, returns the source unchanged from
73/// that step onward — the pipeline is best-effort.
74///
75/// Pass order matters: the HLSL-preserving passes (stub injection, binop
76/// truncation, UV coercion) run first so each one can re-parse the
77/// in-flight source. The "lowering" passes (array globals, local arrays,
78/// `for (int …)` init) emit WGSL-shaped syntax that the parser can't
79/// re-consume, so they all share **one** parse via [`apply_lowerings`] and
80/// commit their edits together at the end.
81pub fn apply_all(src: &str) -> String {
82    // `(a, b, c)` (C-style comma operator in paren-expression position) —
83    // HLSL evaluates each and yields the rightmost; WGSL has no comma op
84    // and refuses the syntax. Token-driven strip of `a, b,` so the parens
85    // hold only the rightmost. Runs first because every later pass sees
86    // a cleaner source — no comma-op surprises in expression positions.
87    let s = rewrite_comma_paren(src);
88    // `float2x2 rot = { a, b, c, d };` — HLSL brace-init of vec/mat
89    // locals. WGSL has no brace-init form for these types and the
90    // downstream local-decl regex skips any decl containing `{` (to avoid
91    // greedy-swallowing function bodies), so the rewrite must run as an
92    // AST pass. Lowers to `T x = T(a, b, c, d);`.
93    let s = rewrite_brace_init(&s);
94    // HLSL `modf(x, out)` is the two-arg form (writes int part to out,
95    // returns fract). WGSL `modf(x)` returns a struct; the two-arg form
96    // is a parse error. Lift to a `let` temp + two assigns. Runs after
97    // brace_init / chained_init / comma_paren so the rewritten source
98    // still parses for the later AST passes.
99    let s = rewrite_modf_arity(&s);
100    // `T x = y = expr;` (chained-assign in init position) — HLSL accepts,
101    // WGSL has no assignment expression. Lower to `y = expr; T x = y;`
102    // first so every later pass and the downstream regex pipeline see
103    // valid-shape declarations.
104    let s = rewrite_chained_assign_inits(&s);
105    // `lerp(a, tmp = expr, b)` and similar — HLSL assignment-as-arg shapes.
106    // Lift the side effect to a preceding statement so the downstream
107    // regex pipeline doesn't see `tmp = expr` in a position WGSL refuses.
108    let s = rewrite_embedded_assigns(&s);
109    let s = inject_qa_stub(&s);
110    // Ternary `cond ? a : b` doesn't exist in WGSL. Lower to
111    // `select(b, a, cond)` early so subsequent passes don't trip on the `?`
112    // token (the regex pipeline doesn't model it; type-aware passes give
113    // up). Emit replacements as HLSL-shaped `select(...)` calls so later
114    // passes can still re-parse the source.
115    let s = rewrite_ternary_to_select(&s);
116    // Multi-component swizzle assigns (`uv.xy += rhs`) are illegal in WGSL.
117    // Lower them to a per-component sequence before any pass that re-parses
118    // (no later pass tries to re-recognise the original `lhs.SWIZ = rhs`
119    // shape — it's purely about getting valid LHSs to naga).
120    let s = rewrite_swizzle_assigns(&s);
121    let s = rewrite_binary_vec_mismatches(&s);
122    let s = coerce_texture_uv_args(&s);
123    let s = coerce_user_fn_args(&s);
124    // `vec3 >= 0.1` and friends. HLSL broadcasts the scalar for
125    // comparisons; WGSL refuses. Must run before `rewrite_bool_to_float`
126    // so the wrap below sees a well-typed `vec3<bool>` comparison
127    // (otherwise its select() ends up wrapping a still-invalid mixed-width
128    // operand).
129    let s = rewrite_vec_scalar_compare(&s);
130    // `float mask = a > b;` and `float3 x = (vec_a > vec_b);` need the
131    // comparison wrapped in `select(0.0, 1.0, …)` since WGSL doesn't coerce
132    // bool↔f32. Runs late so it sees the post-typed source.
133    let s = rewrite_bool_to_float(&s);
134    // `<scalar>.xxx` is HLSL broadcast syntax; WGSL doesn't accept it.
135    // Rewrite to a constructor `float3(<scalar>)` (regex pipeline later
136    // maps `float3` → `vec3<f32>`).
137    let s = rewrite_scalar_swizzle(&s);
138    apply_lowerings(&s)
139}
140
141/// Collect every "WGSL-output" lowering edit against a **single** parse of
142/// `src`, then apply them in one pass. Splitting these across separate
143/// pipeline steps would corrupt the second step: once the first emits
144/// `var name: array<…>` (or `var i: i32`), the source is no longer valid
145/// HLSL and a fresh `parse_hlsl` call returns `Err`, silently skipping the
146/// later passes. The for-int init rewrite once turned that latent hazard
147/// into a real regression on the Stahlregen + suksma `dotes` pair (the
148/// for-int rewrite ran first, then `lower_array_globals` couldn't
149/// re-parse). The combined pass below avoids the order trap.
150fn apply_lowerings(src: &str) -> String {
151    let Ok(tu) = parse_hlsl(src) else {
152        return src.to_string();
153    };
154    let mut edits = Vec::new();
155    collect_global_array_edits(&tu, src, &mut edits);
156    if let Some(body) = &tu.shader_body {
157        collect_local_array_edits(body, src, &mut edits);
158        collect_for_int_edits(body, &mut edits);
159        collect_bare_expr_stmt_edits(body, src, &mut edits);
160    }
161    for item in &tu.items {
162        if let Item::Function(f) = item {
163            collect_local_array_edits(&f.body, src, &mut edits);
164            collect_for_int_edits(&f.body, &mut edits);
165            collect_bare_expr_stmt_edits(&f.body, src, &mut edits);
166        }
167    }
168    apply_edits(src, &mut edits)
169}
170
171// ---------------------------------------------------------------------------
172// Text edit machinery
173// ---------------------------------------------------------------------------
174
175/// One textual replacement on the original source. `start..end` are byte
176/// offsets; `replacement` is the new text.
177#[derive(Debug, Clone)]
178struct TextEdit {
179    start: u32,
180    end: u32,
181    replacement: String,
182}
183
184/// Apply a list of non-overlapping edits in order. Sorts by start position
185/// descending so each application doesn't invalidate the remaining offsets.
186/// Edits that overlap are dropped silently — the AST walker is responsible
187/// for ensuring non-overlap by emitting one edit per syntactic position.
188fn apply_edits(src: &str, edits: &mut [TextEdit]) -> String {
189    if edits.is_empty() {
190        return src.to_string();
191    }
192    edits.sort_by(|a, b| b.start.cmp(&a.start));
193    let mut out = src.to_string();
194    let mut last_start = u32::MAX;
195    for e in edits.iter() {
196        if e.end > last_start {
197            continue; // overlap — skip
198        }
199        let s = e.start as usize;
200        let n = e.end as usize;
201        out.replace_range(s..n, &e.replacement);
202        last_start = e.start;
203    }
204    out
205}
206
207/// AST walker state: a small stacked symbol table plus a sink for text
208/// edits. Each `walk_*` consults and updates the table; `walk_expr` returns
209/// the inferred type of the expression so callers can decide whether to
210/// emit a fix-up edit.
211struct WalkCtx<'a> {
212    src: &'a str,
213    scopes: Vec<std::collections::HashMap<String, WgslType>>,
214    edits: Vec<TextEdit>,
215}
216
217impl<'a> WalkCtx<'a> {
218    fn new(src: &'a str) -> Self {
219        // Bootstrap from the existing SymbolTable so the wrapper preludes
220        // (`uv`, `ret`, `roam_sin`, `q1`, …) are recognised. We then drop
221        // the SymbolTable and copy its locals into the bottom scope.
222        let seed = SymbolTable::from_source("");
223        let mut ctx = Self {
224            src,
225            scopes: vec![Default::default()],
226            edits: Vec::new(),
227        };
228        for (k, v) in seed.locals {
229            ctx.scopes[0].insert(k, v);
230        }
231        ctx
232    }
233
234    fn seed_globals(&mut self, tu: &TranslationUnit) {
235        for item in &tu.items {
236            if let Item::GlobalVar(g) = item {
237                let ty = if g.array_len.is_some() {
238                    // Arrays don't broadcast as vecs; mark Unknown so
239                    // the binop pass doesn't try to coerce them.
240                    WgslType::Unknown
241                } else {
242                    type_from_typeref(&g.ty)
243                };
244                self.declare(&g.name, ty);
245            }
246        }
247    }
248
249    fn scope_push(&mut self) {
250        self.scopes.push(Default::default());
251    }
252    fn scope_pop(&mut self) {
253        self.scopes.pop();
254    }
255    fn declare(&mut self, name: &str, ty: WgslType) {
256        let last = self.scopes.last_mut().expect("at least one scope");
257        last.insert(name.to_string(), ty);
258    }
259    fn lookup(&self, name: &str) -> WgslType {
260        for s in self.scopes.iter().rev() {
261            if let Some(t) = s.get(name) {
262                return *t;
263            }
264        }
265        WgslType::Unknown
266    }
267
268    /// Slice of original source between span bounds.
269    fn slice(&self, span: Span) -> &'a str {
270        &self.src[span.start as usize..span.end as usize]
271    }
272
273    fn emit_truncation(&mut self, span: Span, target_size: usize) {
274        // `1` collapses a vec down to scalar via `.x` — used by the
275        // user-fn arg coercion when a preset feeds a vec3 to a helper
276        // declared `float lavcol(float t)`.
277        let swizzle = match target_size {
278            1 => ".x",
279            2 => ".xy",
280            3 => ".xyz",
281            _ => return,
282        };
283        let text = self.slice(span);
284        // Avoid double-wrapping if the text already ends with the target
285        // swizzle (idempotency on repeat runs).
286        if text.ends_with(swizzle) {
287            return;
288        }
289        // Split wrap into two zero-length insertions so any inner edits
290        // inside `span` can coexist. An earlier implementation used a
291        // single `replace(start..end, "(text).xyz")` edit, which got
292        // dropped by [`apply_edits`]'s overlap guard whenever a nested
293        // binop or UV truncation already emitted within the same range
294        // (real example: `.15*tex2D(noise, uv2*ret)` — the inner
295        // `uv2*ret → uv2*ret.xy` edit shadowed the outer wrap to `.xyz`
296        // of the whole `.15*tex2D(...)` subexpression, leaving the
297        // goody+martin preset with a vec4×vec3 multiply naga rejected).
298        // Two zero-length inserts at the operand's start and end don't
299        // overlap any range-based inner edit, so the wrap survives.
300        if needs_parens_for_swizzle(text) {
301            self.edits.push(TextEdit {
302                start: span.start,
303                end: span.start,
304                replacement: "(".to_string(),
305            });
306            self.edits.push(TextEdit {
307                start: span.end,
308                end: span.end,
309                replacement: format!("){swizzle}"),
310            });
311        } else {
312            // Bare ident / parenthesised whole — no extra parens needed.
313            // Single zero-length append at end is equivalent to the old
314            // replace-range edit (the original text is preserved by the
315            // range we don't touch).
316            self.edits.push(TextEdit {
317                start: span.end,
318                end: span.end,
319                replacement: swizzle.to_string(),
320            });
321        }
322    }
323
324    /// Wrap a vec2 expression in `vec3<f32>(<expr>, 0.0)` so it satisfies
325    /// a helper that demands a vec3 (real example: MD2 presets calling
326    /// `lum(roam_sin.yx)` where the runtime padded implicitly). Emitted as
327    /// two zero-length insertions instead of one replace-range edit so any
328    /// inner edits inside the arg (e.g. a swizzle rewrite) don't get
329    /// dropped by [`apply_edits`]'s overlap guard.
330    fn emit_pad_vec2_to_vec3(&mut self, span: Span) {
331        self.edits.push(TextEdit {
332            start: span.start,
333            end: span.start,
334            replacement: "vec3<f32>(".to_string(),
335        });
336        self.edits.push(TextEdit {
337            start: span.end,
338            end: span.end,
339            replacement: ", 0.0)".to_string(),
340        });
341    }
342
343    /// Wrap a scalar expression in `vec2<f32>(<expr>)` so it satisfies a
344    /// 2D-texture coord parameter. MD2 presets occasionally pass a single
345    /// channel (`tex2D(sampler_noise_hq, uv.x)`) where naga demands a
346    /// vec2; HLSL silently broadcast in that situation. Emitted as two
347    /// zero-length insertions, same overlap-safety rationale as
348    /// [`Self::emit_pad_vec2_to_vec3`].
349    fn emit_scalar_to_vec2(&mut self, span: Span) {
350        self.edits.push(TextEdit {
351            start: span.start,
352            end: span.start,
353            replacement: "vec2<f32>(".to_string(),
354        });
355        self.edits.push(TextEdit {
356            start: span.end,
357            end: span.end,
358            replacement: ")".to_string(),
359        });
360    }
361}
362
363fn needs_parens_for_swizzle(s: &str) -> bool {
364    // A bare identifier or a parenthesised whole doesn't need extra
365    // parens. Any operator at the top level does. We conservatively
366    // assume any non-ident / non-call shape needs parens.
367    let t = s.trim();
368    if t.is_empty() {
369        return false;
370    }
371    // Already parenthesised whole?
372    if t.starts_with('(') && t.ends_with(')') && balanced(t) {
373        return false;
374    }
375    // Bare identifier?
376    if t.chars().all(|c| c.is_alphanumeric() || c == '_') {
377        return false;
378    }
379    // A member/swizzle chain `foo.bar.baz` is OK to append `.xyz` to.
380    if is_ident_chain(t) {
381        return false;
382    }
383    true
384}
385
386fn is_ident_chain(t: &str) -> bool {
387    let mut started = false;
388    for c in t.chars() {
389        if c == '.' {
390            if !started {
391                return false;
392            }
393            started = false;
394        } else if c.is_alphanumeric() || c == '_' {
395            started = true;
396        } else {
397            return false;
398        }
399    }
400    started
401}
402
403fn balanced(t: &str) -> bool {
404    // Quick check: parens balance such that the whole text is wrapped by
405    // the outermost pair (not just two separate paren groups).
406    let mut depth = 0i32;
407    for (i, c) in t.char_indices() {
408        match c {
409            '(' => depth += 1,
410            ')' => {
411                depth -= 1;
412                if depth == 0 && i != t.len() - 1 {
413                    return false;
414                }
415            }
416            _ => {}
417        }
418    }
419    depth == 0
420}
421
422fn type_from_typeref(t: &TypeRef) -> WgslType {
423    match t.name.as_str() {
424        "float" => WgslType::F32,
425        "int" => WgslType::I32,
426        "bool" => WgslType::Bool,
427        "float2" | "vec2" => WgslType::Vec2F,
428        "float3" | "vec3" => WgslType::Vec3F,
429        "float4" | "vec4" => WgslType::Vec4F,
430        "float2x2" | "mat2x2" => WgslType::Mat2F,
431        "float3x3" | "mat3x3" => WgslType::Mat3F,
432        "float4x4" | "mat4x4" => WgslType::Mat4F,
433        "float1" => WgslType::F32, // HLSL 1-vec, rare
434        _ => WgslType::Unknown,
435    }
436}
437
438fn widen_type(a: WgslType, b: WgslType) -> WgslType {
439    match (a, b) {
440        (WgslType::Unknown, x) | (x, WgslType::Unknown) => x,
441        (x, y) if x == y => x,
442        (WgslType::Vec4F, _) | (_, WgslType::Vec4F) => WgslType::Vec4F,
443        (WgslType::Vec3F, _) | (_, WgslType::Vec3F) => WgslType::Vec3F,
444        (WgslType::Vec2F, _) | (_, WgslType::Vec2F) => WgslType::Vec2F,
445        _ => a,
446    }
447}
448
449fn constructor_return(callee: &str) -> Option<WgslType> {
450    Some(match callee {
451        "float" => WgslType::F32,
452        "int" => WgslType::I32,
453        "float2" | "vec2" => WgslType::Vec2F,
454        "float3" | "vec3" => WgslType::Vec3F,
455        "float4" | "vec4" => WgslType::Vec4F,
456        _ => return None,
457    })
458}
459
460fn builtin_return(callee: &str, args: &[Expr], ctx: &mut WalkCtx) -> WgslType {
461    match callee {
462        // HLSL is case-insensitive on builtins; the corpus mixes `tex2D`
463        // and `tex2d` freely (the regex pass normalises to `tex2D` later
464        // but that runs AFTER these AST passes). Without the lowercase
465        // forms here `builtin_return` returned Unknown for `tex2d(...)`,
466        // which disabled the binop walker's vec4↔vec3 truncation on
467        // `ret = tex2d(...) - ret;` — by far the dominant residual
468        // InvalidBinaryOperandTypes cluster on the 2000-sample.
469        "tex2D" | "tex2d" | "tex3D" | "tex3d" | "textureSample" => WgslType::Vec4F,
470        "GetPixel" => WgslType::Vec3F,
471        "GetBlur1" | "GetBlur2" | "GetBlur3" => WgslType::Vec3F,
472        "lum" => WgslType::F32,
473        "length" | "dot" | "distance" => WgslType::F32,
474        "sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "atan2" | "sqrt" | "abs" | "sign"
475        | "floor" | "ceil" | "fract" | "exp" | "log" | "saturate" | "frac" => {
476            // Same type as arg.
477            if let Some(a) = args.first() {
478                return infer_type(a, ctx);
479            }
480            WgslType::F32
481        }
482        "clamp" | "min" | "max" | "mix" | "smoothstep" | "step" | "pow" | "lerp" => {
483            // Widen across all args.
484            let mut t = WgslType::Unknown;
485            for a in args {
486                t = widen_type(t, infer_type(a, ctx));
487            }
488            t
489        }
490        "normalize" => {
491            if let Some(a) = args.first() {
492                return infer_type(a, ctx);
493            }
494            WgslType::Unknown
495        }
496        // HLSL's `mul(vec, mat)` and `mul(mat, vec)` returns a vector of
497        // the input vec's shape. Without this, corpus expressions like
498        // `mul(uv, mat2x2) + ret1*.1` infer the LHS as `Unknown`, which
499        // disables `binop_vec`'s size-mismatch truncation and surfaces as
500        // `InvalidBinaryOperandTypes Add(vec2, vec3)` at validate time.
501        // Matrix constructors are `mat2x2`/`mat3x3`/`mat4x4` (the
502        // translator's downstream type form).
503        "mul" => {
504            // Return the vector-shaped argument's type. If both are vecs,
505            // pick the first; if neither is, widen them.
506            let mut found_vec: WgslType = WgslType::Unknown;
507            for a in args {
508                let t = infer_type(a, ctx);
509                if t.is_vec() {
510                    found_vec = t;
511                    break;
512                }
513            }
514            if matches!(found_vec, WgslType::Unknown) {
515                let mut t = WgslType::Unknown;
516                for a in args {
517                    t = widen_type(t, infer_type(a, ctx));
518                }
519                t
520            } else {
521                found_vec
522            }
523        }
524        // Matrix constructors emit a matrix; downstream `binop_vec`
525        // only triggers when both operands are vecs, so returning a
526        // matrix type here keeps `vec * mat` quiet (it will not match
527        // the both-is-vec truncation guard). Currently we don't carry
528        // distinct matrix dimensions through `WgslType`; treat the call
529        // as opaque (Unknown) — the binop walker already returns
530        // `widen_type(Vec2F, Unknown) = Vec2F` which is the actual
531        // result of `vec2 * mat2x2`, so the downstream truncation
532        // proceeds correctly.
533        "mat2x2" | "mat3x3" | "mat4x4" | "float2x2" | "float3x3" | "float4x4" => WgslType::Unknown,
534        _ => WgslType::Unknown,
535    }
536}
537
538/// Type-only version of `walk_expr` — doesn't emit edits. Used by
539/// builtin-return inference where we want the type of an arg without
540/// double-walking it.
541fn infer_type(e: &Expr, ctx: &mut WalkCtx) -> WgslType {
542    match e {
543        Expr::Lit(l) => match l.value {
544            LitValue::Int(_) | LitValue::Float(_) => WgslType::F32,
545            LitValue::Bool(_) => WgslType::Bool,
546        },
547        Expr::Ident(name, _) => ctx.lookup(name),
548        Expr::Swizzle(s) => {
549            let base = infer_type(&s.base, ctx);
550            if base.is_vec() {
551                vec_of_size(s.components.len())
552            } else {
553                WgslType::Unknown
554            }
555        }
556        Expr::Call(c) => {
557            if let Some(t) = constructor_return(&c.callee) {
558                return t;
559            }
560            builtin_return(&c.callee, &c.args, ctx)
561        }
562        Expr::Unary(u) => infer_type(&u.operand, ctx),
563        Expr::Binary(b) => widen_type(infer_type(&b.lhs, ctx), infer_type(&b.rhs, ctx)),
564        _ => WgslType::Unknown,
565    }
566}
567
568/// Stand-alone type inference that doesn't emit edits — used by the bool
569/// pass to peek at an Assign's LHS type before deciding whether to wrap.
570fn infer_type_static(e: &Expr, ctx: &mut WalkCtx) -> WgslType {
571    match e {
572        Expr::Ident(name, _) => ctx.lookup(name),
573        Expr::Lit(l) => match l.value {
574            LitValue::Int(_) | LitValue::Float(_) => WgslType::F32,
575            LitValue::Bool(_) => WgslType::Bool,
576        },
577        Expr::Swizzle(s) => {
578            let base = infer_type_static(&s.base, ctx);
579            if base.is_vec() || matches!(base, WgslType::F32 | WgslType::I32) {
580                vec_of_size(s.components.len())
581            } else {
582                WgslType::Unknown
583            }
584        }
585        Expr::Call(c) => {
586            if let Some(t) = constructor_return(&c.callee) {
587                return t;
588            }
589            builtin_return(&c.callee, &c.args, ctx)
590        }
591        Expr::Binary(b) => widen_type(
592            infer_type_static(&b.lhs, ctx),
593            infer_type_static(&b.rhs, ctx),
594        ),
595        _ => WgslType::Unknown,
596    }
597}