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}