watr
Version:
Light & fast WAT compiler – WebAssembly Text to binary, parse, print, transform
1,318 lines (1,199 loc) • 116 kB
JavaScript
/**
* AST optimizations for WebAssembly modules.
* Reduces code size and improves runtime performance.
*
* @module watr/optimize
*/
import parse from './parse.js'
import compile from './compile.js'
import { i32, i64 } from './encode.js'
import { walk, walkPost, clone } from './util.js'
import { resultType } from './const.js'
/**
* Recursively count AST nodes — fast size heuristic without compiling.
* @param {any} node
* @returns {number}
*/
const count = (node) => {
if (!Array.isArray(node)) return 1
let n = 1
for (let i = 0; i < node.length; i++) n += count(node[i])
return n
}
/**
* Compile AST and measure binary size in bytes.
* @param {Array} ast
* @returns {number}
*/
const binarySize = (ast) => {
try { return compile(ast).length } catch { return Infinity }
}
/**
* Fast structural equality of two AST nodes.
* Stops at first difference. Handles BigInt without stringification.
*/
const equal = (a, b) => {
if (a === b) return true
if (typeof a !== typeof b) return false
if (typeof a === 'bigint') return a === b
if (!Array.isArray(a) || !Array.isArray(b)) return false
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (!equal(a[i], b[i])) return false
return true
}
/**
* Locate the parts of an `(if ...)` node:
* condIdx → index of the condition expression
* cond → the condition expression itself
* thenBranch / elseBranch → the (then ...) / (else ...) sub-arrays, or null
* The condition sits after any leading `param`/`result` annotations and before
* the `then`/`else` arms.
*/
const parseIf = (node) => {
let condIdx = 1
while (condIdx < node.length) {
const c = node[condIdx]
if (Array.isArray(c) && (c[0] === 'then' || c[0] === 'else' || c[0] === 'result' || c[0] === 'param')) {
condIdx++
continue
}
break
}
let thenBranch = null, elseBranch = null
for (let i = condIdx + 1; i < node.length; i++) {
const c = node[i]
if (!Array.isArray(c)) continue
if (c[0] === 'then') thenBranch = c
else if (c[0] === 'else') elseBranch = c
}
return { condIdx, cond: node[condIdx], thenBranch, elseBranch }
}
// ==================== TREESHAKE ====================
/**
* Remove unused functions, globals, types, tables.
* Keeps exports and their transitive dependencies.
* @param {Array} ast
* @returns {Array}
*/
const treeshake = (ast) => {
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
// Index spaces. Each entry is shared between its $name key and its numeric idx
// key, so name/index lookups hit the same record. nodeMap covers reverse lookup
// (used during the filtering pass for unnamed definitions).
const funcs = new Map(), globals = new Map(), types = new Map()
const tables = new Map(), memories = new Map()
const nodeMap = new Map() // node → entry
const register = (map, node, idx, isImport = false) => {
const named = typeof node[1] === 'string' && node[1][0] === '$'
const name = named ? node[1] : idx
const inlineExported = !isImport && node.some(s => Array.isArray(s) && s[0] === 'export')
const entry = { node, idx, used: inlineExported, isImport }
map.set(name, entry)
if (named) map.set(idx, entry)
nodeMap.set(node, entry)
return entry
}
let funcIdx = 0, globalIdx = 0, typeIdx = 0, tableIdx = 0, memIdx = 0
const elems = [], data = [], exports = [], starts = []
for (const node of ast.slice(1)) {
if (!Array.isArray(node)) continue
const kind = node[0]
if (kind === 'type') register(types, node, typeIdx++)
else if (kind === 'func') register(funcs, node, funcIdx++)
else if (kind === 'global') register(globals, node, globalIdx++)
else if (kind === 'table') register(tables, node, tableIdx++)
else if (kind === 'memory') register(memories, node, memIdx++)
else if (kind === 'import') {
// Each import sub-item occupies its own slot in the relevant index space.
for (const sub of node) {
if (!Array.isArray(sub)) continue
if (sub[0] === 'func') register(funcs, sub, funcIdx++, true)
else if (sub[0] === 'global') register(globals, sub, globalIdx++, true)
else if (sub[0] === 'table') register(tables, sub, tableIdx++, true)
else if (sub[0] === 'memory') register(memories, sub, memIdx++, true)
}
}
else if (kind === 'export') exports.push(node)
else if (kind === 'start') starts.push(node)
else if (kind === 'elem') elems.push(node)
else if (kind === 'data') data.push(node)
}
// Worklist: function entries whose body still needs to be scanned for refs.
const work = []
const enqueue = (entry) => { if (entry && !entry.scanned) work.push(entry) }
const markFunc = (ref) => {
const e = funcs.get(ref); if (!e) return
if (!e.used) e.used = true
enqueue(e)
}
const markGlobal = (ref) => { const e = globals.get(ref); if (e) e.used = true }
const markTable = (ref) => { const e = tables.get(ref); if (e) e.used = true }
const markMemory = (ref) => { if (typeof ref === 'string' && ref[0] !== '$') ref = +ref; const e = memories.get(ref); if (e) e.used = true }
const markType = (ref) => { const e = types.get(ref); if (e) e.used = true }
// Roots: explicit exports, start funcs, elem-referenced funcs, inline-exported items.
for (const exp of exports) {
for (const sub of exp) {
if (!Array.isArray(sub)) continue
const [kind, ref] = sub
if (kind === 'func') markFunc(ref)
else if (kind === 'global') markGlobal(ref)
else if (kind === 'table') markTable(ref)
else if (kind === 'memory') markMemory(ref)
}
}
for (const start of starts) {
let ref = start[1]
if (typeof ref === 'string' && ref[0] !== '$') ref = +ref
markFunc(ref)
}
for (const elem of elems) {
walk(elem, n => {
if (Array.isArray(n) && n[0] === 'ref.func') markFunc(n[1])
else if (typeof n === 'string' && n[0] === '$') markFunc(n)
})
}
for (const d of data) {
const first = d[1]
if (Array.isArray(first) && first[0] === 'memory') markMemory(first[1])
else if (typeof first === 'string' && first[0] === '$') markMemory(first)
else if (Array.isArray(first)) markMemory(0)
}
for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) if (e.used) enqueue(e)
// If nothing anchors the module (no exports, start, elem, or inline exports),
// assume the module is consumed elsewhere and keep everything.
const hasAnchor = exports.length > 0 || starts.length > 0 || elems.length > 0 || work.length > 0
if (!hasAnchor) {
for (const m of [funcs, globals, tables, memories]) for (const e of m.values()) e.used = true
return ast
}
// Drain worklist: each function body gets walked exactly once.
while (work.length) {
const entry = work.pop()
if (entry.scanned) continue
entry.scanned = true
if (entry.isImport) continue
walk(entry.node, n => {
if (!Array.isArray(n)) {
if (typeof n === 'string' && n[0] === '$') markFunc(n)
return
}
const [op, ref] = n
if (op === 'call' || op === 'return_call' || op === 'ref.func') markFunc(ref)
else if (op === 'global.get' || op === 'global.set') markGlobal(ref)
else if (op === 'type') markType(ref)
else if (op === 'call_indirect' || op === 'return_call_indirect') {
for (const sub of n) if (typeof sub === 'string' && sub[0] === '$') markTable(sub)
}
if (typeof op === 'string' && (op.startsWith('memory.') || op.includes('.load') || op.includes('.store'))) {
markMemory(0)
}
})
}
// Filter: keep used definitions. nodeMap handles unnamed entries directly.
const result = ['module']
for (const node of ast.slice(1)) {
if (!Array.isArray(node)) { result.push(node); continue }
const kind = node[0]
if (kind === 'func' || kind === 'global' || kind === 'type') {
if (nodeMap.get(node)?.used) result.push(node)
} else if (kind === 'import') {
// Keep import only if any of its sub-items is used.
let used = false
for (const sub of node) {
if (!Array.isArray(sub)) continue
const e = nodeMap.get(sub)
if (e?.used) { used = true; break }
}
if (used) result.push(node)
} else {
result.push(node)
}
}
return result
}
// ==================== CONSTANT FOLDING ====================
/** IEEE 754 roundTiesToEven (bankers' rounding) */
const roundEven = (x) => x - Math.floor(x) !== 0.5 ? Math.round(x) : 2 * Math.round(x / 2)
// Bit-exact reinterpret helpers (preserve NaN payloads).
const _rb8 = new ArrayBuffer(8)
const _rf64 = new Float64Array(_rb8)
const _ri64 = new BigInt64Array(_rb8)
const _rb4 = new ArrayBuffer(4)
const _rf32 = new Float32Array(_rb4)
const _ri32 = new Int32Array(_rb4)
const i64FromF64 = (x) => { _rf64[0] = x; return _ri64[0] }
const f64FromI64 = (x) => { _ri64[0] = BigInt.asIntN(64, x); return _rf64[0] }
const i32FromF32 = (x) => { _rf32[0] = x; return _ri32[0] }
const f32FromI32 = (x) => { _ri32[0] = x | 0; return _rf32[0] }
/** Build i32 comparison folder: returns 1/0 */
const i32c = (fn) => (a, b) => fn(a, b) ? 1 : 0
/** Build unsigned i32 comparison folder */
const u32c = (fn) => (a, b) => fn(a >>> 0, b >>> 0) ? 1 : 0
/** Build i64 comparison folder */
const i64c = (fn) => (a, b) => fn(a, b) ? 1 : 0
/** Build unsigned i64 comparison folder */
const u64c = (fn) => (a, b) => fn(BigInt.asUintN(64, a), BigInt.asUintN(64, b)) ? 1 : 0
/**
* Constant folders, keyed by op. Each entry is the fold function; the result
* value-type is derived once via `resultType` (see `fold`).
*/
const FOLDABLE = {
// i32 arithmetic
'i32.add': (a, b) => (a + b) | 0,
'i32.sub': (a, b) => (a - b) | 0,
'i32.mul': (a, b) => Math.imul(a, b),
'i32.div_s': (a, b) => b !== 0 ? (a / b) | 0 : null,
'i32.div_u': (a, b) => b !== 0 ? ((a >>> 0) / (b >>> 0)) | 0 : null,
'i32.rem_s': (a, b) => b !== 0 ? (a % b) | 0 : null,
'i32.rem_u': (a, b) => b !== 0 ? ((a >>> 0) % (b >>> 0)) | 0 : null,
'i32.and': (a, b) => a & b,
'i32.or': (a, b) => a | b,
'i32.xor': (a, b) => a ^ b,
'i32.shl': (a, b) => a << (b & 31),
'i32.shr_s': (a, b) => a >> (b & 31),
'i32.shr_u': (a, b) => a >>> (b & 31),
'i32.rotl': (a, b) => { b &= 31; return ((a << b) | (a >>> (32 - b))) | 0 },
'i32.rotr': (a, b) => { b &= 31; return ((a >>> b) | (a << (32 - b))) | 0 },
'i32.eq': i32c((a, b) => a === b),
'i32.ne': i32c((a, b) => a !== b),
'i32.lt_s': i32c((a, b) => a < b),
'i32.lt_u': u32c((a, b) => a < b),
'i32.gt_s': i32c((a, b) => a > b),
'i32.gt_u': u32c((a, b) => a > b),
'i32.le_s': i32c((a, b) => a <= b),
'i32.le_u': u32c((a, b) => a <= b),
'i32.ge_s': i32c((a, b) => a >= b),
'i32.ge_u': u32c((a, b) => a >= b),
'i32.eqz': (a) => a === 0 ? 1 : 0,
'i32.clz': (a) => Math.clz32(a),
'i32.ctz': (a) => a === 0 ? 32 : 31 - Math.clz32(a & -a),
'i32.popcnt': (a) => { let c = 0; while (a) { c += a & 1; a >>>= 1 } return c },
'i32.wrap_i64': (a) => Number(BigInt.asIntN(32, a)),
'i32.extend8_s': (a) => (a << 24) >> 24,
'i32.extend16_s': (a) => (a << 16) >> 16,
// i64 (using BigInt)
'i64.add': (a, b) => BigInt.asIntN(64, a + b),
'i64.sub': (a, b) => BigInt.asIntN(64, a - b),
'i64.mul': (a, b) => BigInt.asIntN(64, a * b),
'i64.div_s': (a, b) => b !== 0n ? BigInt.asIntN(64, a / b) : null,
'i64.div_u': (a, b) => b !== 0n ? BigInt.asUintN(64, BigInt.asUintN(64, a) / BigInt.asUintN(64, b)) : null,
'i64.rem_s': (a, b) => b !== 0n ? BigInt.asIntN(64, a % b) : null,
'i64.rem_u': (a, b) => b !== 0n ? BigInt.asUintN(64, BigInt.asUintN(64, a) % BigInt.asUintN(64, b)) : null,
'i64.and': (a, b) => BigInt.asIntN(64, a & b),
'i64.or': (a, b) => BigInt.asIntN(64, a | b),
'i64.xor': (a, b) => BigInt.asIntN(64, a ^ b),
'i64.shl': (a, b) => BigInt.asIntN(64, a << (b & 63n)),
'i64.shr_s': (a, b) => BigInt.asIntN(64, a >> (b & 63n)),
'i64.shr_u': (a, b) => BigInt.asUintN(64, BigInt.asUintN(64, a) >> (b & 63n)),
'i64.eq': i64c((a, b) => a === b),
'i64.ne': i64c((a, b) => a !== b),
'i64.lt_s': i64c((a, b) => a < b),
'i64.lt_u': u64c((a, b) => a < b),
'i64.gt_s': i64c((a, b) => a > b),
'i64.gt_u': u64c((a, b) => a > b),
'i64.le_s': i64c((a, b) => a <= b),
'i64.le_u': u64c((a, b) => a <= b),
'i64.ge_s': i64c((a, b) => a >= b),
'i64.ge_u': u64c((a, b) => a >= b),
'i64.eqz': (a) => a === 0n ? 1 : 0,
'i64.extend_i32_s': (a) => BigInt(a),
'i64.extend_i32_u': (a) => BigInt(a >>> 0),
'i64.extend8_s': (a) => BigInt.asIntN(64, BigInt.asIntN(8, a)),
'i64.extend16_s': (a) => BigInt.asIntN(64, BigInt.asIntN(16, a)),
'i64.extend32_s': (a) => BigInt.asIntN(64, BigInt.asIntN(32, a)),
// f32/f64 (NaN/precision-aware via Math.fround)
'f32.add': (a, b) => Math.fround(a + b),
'f32.sub': (a, b) => Math.fround(a - b),
'f32.mul': (a, b) => Math.fround(a * b),
'f32.div': (a, b) => Math.fround(a / b),
'f32.neg': (a) => Math.fround(-a),
'f32.abs': (a) => Math.fround(Math.abs(a)),
'f32.sqrt': (a) => Math.fround(Math.sqrt(a)),
'f32.ceil': (a) => Math.fround(Math.ceil(a)),
'f32.floor': (a) => Math.fround(Math.floor(a)),
'f32.trunc': (a) => Math.fround(Math.trunc(a)),
'f32.nearest': (a) => Math.fround(roundEven(a)),
'f64.add': (a, b) => a + b,
'f64.sub': (a, b) => a - b,
'f64.mul': (a, b) => a * b,
'f64.div': (a, b) => a / b,
'f64.neg': (a) => -a,
'f64.abs': Math.abs,
'f64.sqrt': Math.sqrt,
'f64.ceil': Math.ceil,
'f64.floor': Math.floor,
'f64.trunc': Math.trunc,
'f64.nearest': roundEven,
// Bit-exact reinterprets (preserve NaN payloads)
'i32.reinterpret_f32': i32FromF32,
'f32.reinterpret_i32': f32FromI32,
'i64.reinterpret_f64': i64FromF64,
'f64.reinterpret_i64': f64FromI64,
// Numeric conversions (value-preserving where representable)
'f32.convert_i32_s': (a) => Math.fround(a | 0),
'f32.convert_i32_u': (a) => Math.fround(a >>> 0),
'f32.convert_i64_s': (a) => Math.fround(Number(BigInt.asIntN(64, a))),
'f32.convert_i64_u': (a) => Math.fround(Number(BigInt.asUintN(64, a))),
'f64.convert_i32_s': (a) => (a | 0),
'f64.convert_i32_u': (a) => (a >>> 0),
'f64.convert_i64_s': (a) => Number(BigInt.asIntN(64, a)),
'f64.convert_i64_u': (a) => Number(BigInt.asUintN(64, a)),
'f32.demote_f64': (a) => Math.fround(a),
'f64.promote_f32': (a) => Math.fround(a),
}
/**
* Parse a WAT `nan` / `nan:canonical` / `nan:arithmetic` / `nan:0xPAYLOAD`
* literal to a JS number with the exact bit pattern. `Number('nan:0x…')`
* collapses to canonical NaN, destroying the payload that NaN-boxing schemes
* (jz, etc.) encode their pointer/sentinel bits into. Returns null if `s` is
* not a NaN literal so callers can fall through to plain Number parsing.
*/
const _parseNanF64 = (s, i = s?.indexOf?.('nan')) => {
if (i < 0 || i == null) return null
let tail = s.slice(i + 4).replaceAll('_', ''),
bits = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? BigInt(tail) : 0x8000000000000n)
_ri64[0] = BigInt.asIntN(64, bits | 0x7ff0000000000000n | (s[0] === '-' ? 1n << 63n : 0n))
return _rf64[0]
}
const _parseNanF32 = (s, i = s?.indexOf?.('nan')) => {
if (i < 0 || i == null) return null
let tail = s.slice(i + 4).replaceAll('_', ''),
bits = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? parseInt(tail) : 0x400000)
_ri32[0] = (bits | 0x7f800000 | (s[0] === '-' ? 0x80000000 : 0)) | 0
return _rf32[0]
}
/**
* Extract constant value from node.
* @param {any} node
* @returns {{type: string, value: number|bigint}|null}
*/
const getConst = (node) => {
if (!Array.isArray(node) || node.length !== 2) return null
const [op, val] = node
if (op === 'i32.const') return { type: 'i32', value: (typeof val === 'string' ? i32.parse(val) : val) | 0 }
if (op === 'i64.const') return { type: 'i64', value: typeof val === 'string' ? i64.parse(val) : BigInt(val) }
if (op === 'f32.const') {
const n = _parseNanF32(val)
return { type: 'f32', value: n !== null ? n : Math.fround(Number(val)) }
}
if (op === 'f64.const') {
const n = _parseNanF64(val)
return { type: 'f64', value: n !== null ? n : Number(val) }
}
return null
}
/**
* Create const node from value.
* @param {string} type
* @param {number|bigint} value
* @returns {Array}
*/
const makeConst = (type, value) => {
if (type === 'i32') return ['i32.const', value | 0]
if (type === 'i64') return ['i64.const', value]
if (type === 'f32') return ['f32.const', Math.fround(value)]
if (type === 'f64') return ['f64.const', value]
return null
}
/**
* Fold constant expressions.
* @param {Array} ast
* @returns {Array}
*/
const fold = (ast) => {
return walkPost(ast, (node) => {
if (!Array.isArray(node)) return
const fn = FOLDABLE[node[0]]
if (!fn) return
// Unary
if (fn.length === 1 && node.length === 2) {
const a = getConst(node[1])
if (!a) return
const r = fn(a.value)
if (r === null) return
return makeConst(resultType(node[0]), r)
}
// Binary
if (fn.length === 2 && node.length === 3) {
const a = getConst(node[1]), b = getConst(node[2])
if (!a || !b) return
const r = fn(a.value, b.value)
if (r === null) return
return makeConst(resultType(node[0]), r)
}
})
}
// ==================== IDENTITY REMOVAL ====================
/**
* Create identity checker for commutative binary ops:
* neutral op x → x and x op neutral → x
*/
const commutativeIdentity = (neutral) => (a, b) => {
const ca = getConst(a), cb = getConst(b)
if (ca?.value === neutral) return b
if (cb?.value === neutral) return a
return null
}
/**
* Create identity checker for right-neutral binary ops:
* x op neutral → x
*/
const rightIdentity = (neutral) => (a, b) => getConst(b)?.value === neutral ? a : null
/** Identity operations that can be simplified */
const IDENTITIES = {
// x + 0 → x, 0 + x → x
'i32.add': commutativeIdentity(0),
'i64.add': commutativeIdentity(0n),
// x - 0 → x
'i32.sub': rightIdentity(0),
'i64.sub': rightIdentity(0n),
// x * 1 → x, 1 * x → x
'i32.mul': commutativeIdentity(1),
'i64.mul': commutativeIdentity(1n),
// x / 1 → x
'i32.div_s': rightIdentity(1),
'i32.div_u': rightIdentity(1),
'i64.div_s': rightIdentity(1n),
'i64.div_u': rightIdentity(1n),
// x & -1 → x, -1 & x → x (all bits set)
'i32.and': commutativeIdentity(-1),
'i64.and': commutativeIdentity(-1n),
// x | 0 → x, 0 | x → x
'i32.or': commutativeIdentity(0),
'i64.or': commutativeIdentity(0n),
// x ^ 0 → x, 0 ^ x → x
'i32.xor': commutativeIdentity(0),
'i64.xor': commutativeIdentity(0n),
// x << 0 → x, x >> 0 → x
'i32.shl': rightIdentity(0),
'i32.shr_s': rightIdentity(0),
'i32.shr_u': rightIdentity(0),
'i64.shl': rightIdentity(0n),
'i64.shr_s': rightIdentity(0n),
'i64.shr_u': rightIdentity(0n),
// f + 0 → x (careful with -0.0, skip for floats)
// f * 1 → x (careful with NaN, skip for floats)
}
/**
* Remove identity operations.
* @param {Array} ast
* @returns {Array}
*/
const identity = (ast) => {
return walkPost(ast, (node) => {
if (!Array.isArray(node) || node.length !== 3) return
const fn = IDENTITIES[node[0]]
if (!fn) return
const result = fn(node[1], node[2])
if (result === null) return // no optimization, keep original
return result
})
}
// ==================== STRENGTH REDUCTION ====================
/**
* Strength reduction: replace expensive ops with cheaper equivalents.
* @param {Array} ast
* @returns {Array}
*/
const strength = (ast) => {
return walkPost(ast, (node) => {
if (!Array.isArray(node) || node.length !== 3) return
const [op, a, b] = node
// x * 2^n → x << n
if (op === 'i32.mul') {
const cb = getConst(b)
if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) {
const shift = Math.log2(cb.value)
if (Number.isInteger(shift)) return ['i32.shl', a, ['i32.const', shift]]
}
const ca = getConst(a)
if (ca && ca.value > 0 && (ca.value & (ca.value - 1)) === 0) {
const shift = Math.log2(ca.value)
if (Number.isInteger(shift)) return ['i32.shl', b, ['i32.const', shift]]
}
}
if (op === 'i64.mul') {
const cb = getConst(b)
if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) {
const shift = BigInt(cb.value.toString(2).length - 1)
return ['i64.shl', a, ['i64.const', shift]]
}
const ca = getConst(a)
if (ca && ca.value > 0n && (ca.value & (ca.value - 1n)) === 0n) {
const shift = BigInt(ca.value.toString(2).length - 1)
return ['i64.shl', b, ['i64.const', shift]]
}
}
// x / 2^n → x >> n (unsigned only, signed division is more complex)
if (op === 'i32.div_u') {
const cb = getConst(b)
if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) {
const shift = Math.log2(cb.value)
if (Number.isInteger(shift)) return ['i32.shr_u', a, ['i32.const', shift]]
}
}
if (op === 'i64.div_u') {
const cb = getConst(b)
if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) {
const shift = BigInt(cb.value.toString(2).length - 1)
return ['i64.shr_u', a, ['i64.const', shift]]
}
}
// x % 2^n → x & (2^n - 1) (unsigned only)
if (op === 'i32.rem_u') {
const cb = getConst(b)
if (cb && cb.value > 0 && (cb.value & (cb.value - 1)) === 0) {
return ['i32.and', a, ['i32.const', cb.value - 1]]
}
}
if (op === 'i64.rem_u') {
const cb = getConst(b)
if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) {
return ['i64.and', a, ['i64.const', cb.value - 1n]]
}
}
})
}
// ==================== BRANCH SIMPLIFICATION ====================
/**
* Simplify branches with constant conditions.
* @param {Array} ast
* @returns {Array}
*/
const branch = (ast) => {
return walkPost(ast, (node) => {
if (!Array.isArray(node)) return
const op = node[0]
// (if (i32.const 0) then else) → else
// (if (i32.const N) then else) → then (N != 0)
if (op === 'if') {
const { cond, thenBranch, elseBranch } = parseIf(node)
const c = getConst(cond)
if (!c) return
const taken = c.value !== 0 && c.value !== 0n ? thenBranch : elseBranch
if (taken && taken.length > 1) {
const contents = taken.slice(1)
return contents.length === 1 ? contents[0] : ['block', ...contents]
}
return ['nop']
}
// (br_if $label (i32.const 0)) → nop
// (br_if $label (i32.const N)) → br $label (N != 0)
if (op === 'br_if' && node.length >= 3) {
const cond = node[node.length - 1]
const c = getConst(cond)
if (!c) return
if (c.value === 0 || c.value === 0n) return ['nop']
return ['br', node[1]]
}
// (select a b (i32.const 0)) → b
// (select a b (i32.const N)) → a (N != 0)
if (op === 'select' && node.length >= 4) {
const cond = node[node.length - 1]
const c = getConst(cond)
if (!c) return
if (c.value === 0 || c.value === 0n) return node[2] // b
return node[1] // a
}
})
}
// ==================== DEAD CODE ELIMINATION ====================
/** Control flow terminators */
const TERMINATORS = new Set(['unreachable', 'return', 'br', 'br_table'])
/**
* Remove dead code after control flow terminators.
* @param {Array} ast
* @returns {Array}
*/
const deadcode = (ast) => {
// Process each function body
walk(ast, (node) => {
if (!Array.isArray(node)) return
const kind = node[0]
// Process blocks: func, block, loop, if branches
if (kind === 'func' || kind === 'block' || kind === 'loop') {
eliminateDeadInBlock(node)
}
if (kind === 'if') {
// Process then/else branches
for (let i = 1; i < node.length; i++) {
if (Array.isArray(node[i]) && (node[i][0] === 'then' || node[i][0] === 'else')) {
eliminateDeadInBlock(node[i])
}
}
}
})
return ast
}
/**
* Remove instructions after terminators within a block.
* @param {Array} block
*/
const eliminateDeadInBlock = (block) => {
let terminated = false
let firstTerminator = -1
for (let i = 1; i < block.length; i++) {
const node = block[i]
// Skip type annotations
if (Array.isArray(node)) {
const op = node[0]
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
if (terminated) {
if (firstTerminator === -1) firstTerminator = i
}
if (TERMINATORS.has(op)) {
terminated = true
firstTerminator = i + 1
}
} else if (typeof node === 'string') {
// String instructions like 'unreachable', 'return', 'drop', 'nop'
if (terminated) {
if (firstTerminator === -1) firstTerminator = i
}
if (TERMINATORS.has(node)) {
terminated = true
firstTerminator = i + 1
}
}
}
// Remove dead code
if (firstTerminator > 0 && firstTerminator < block.length) {
block.splice(firstTerminator)
}
}
// ==================== LOCAL REUSE ====================
/**
* Reuse locals of the same type to reduce total local count.
* Basic version: deduplicate unused locals.
* @param {Array} ast
* @returns {Array}
*/
const localReuse = (ast) => {
walk(ast, (node) => {
if (!Array.isArray(node) || node[0] !== 'func') return
// Collect local declarations and their types
const localDecls = []
const localTypes = new Map() // $name → type
const usedLocals = new Set()
// Find all local declarations and usages
for (let i = 1; i < node.length; i++) {
const sub = node[i]
if (!Array.isArray(sub)) continue
if (sub[0] === 'local') {
localDecls.push({ node: sub, idx: i })
// (local $name type) or (local type)
if (typeof sub[1] === 'string' && sub[1][0] === '$') {
localTypes.set(sub[1], sub[2])
}
}
if (sub[0] === 'param') {
// Params are also locals
if (typeof sub[1] === 'string' && sub[1][0] === '$') {
localTypes.set(sub[1], sub[2])
usedLocals.add(sub[1]) // params always used
}
}
}
// Find which locals are actually used
walk(node, (n) => {
if (!Array.isArray(n)) return
const op = n[0]
if (op === 'local.get' || op === 'local.set' || op === 'local.tee') {
const ref = n[1]
if (typeof ref === 'string') usedLocals.add(ref)
}
})
// Remove unused local declarations
for (let i = localDecls.length - 1; i >= 0; i--) {
const { idx, node: decl } = localDecls[i]
const name = typeof decl[1] === 'string' && decl[1][0] === '$' ? decl[1] : null
if (name && !usedLocals.has(name)) {
node.splice(idx, 1)
}
}
})
return ast
}
// ==================== PROPAGATION & LOCAL ELIMINATION ====================
/** Operators with side effects: calls, mutators, control flow, exceptions, drops. */
const IMPURE_OPS = new Set([
'call', 'call_indirect', 'return_call', 'return_call_indirect',
'table.set', 'table.grow', 'table.fill', 'table.copy', 'table.init',
'struct.set', 'struct.new',
'array.set', 'array.new', 'array.new_fixed', 'array.new_data', 'array.new_elem',
'array.init_data', 'array.init_elem', 'ref.i31',
'global.set', 'local.set', 'local.tee',
'unreachable', 'return',
'br', 'br_if', 'br_table', 'br_on_null', 'br_on_non_null', 'br_on_cast', 'br_on_cast_fail',
'throw', 'rethrow', 'throw_ref', 'try_table',
'data.drop', 'elem.drop',
])
/** Substrings that flag an op as side-effecting (loads can trap, stores/atomics/memory ops mutate). */
const IMPURE_SUBSTRINGS = ['.store', 'memory.', '.atomic.']
/**
* Pure means: no side effects, no traps we care about, no control flow.
* Conservative — returns false for anything that might trap, mutate state, or branch.
*/
const isPure = (node) => {
if (!Array.isArray(node)) return true
const op = node[0]
if (typeof op !== 'string') return false
if (IMPURE_OPS.has(op)) return false
for (const sub of IMPURE_SUBSTRINGS) if (op.includes(sub)) return false
for (let i = 1; i < node.length; i++) if (Array.isArray(node[i]) && !isPure(node[i])) return false
return true
}
/** Count all local.get/set/tee occurrences in one walk */
const countLocalUses = (node) => {
const counts = new Map()
const ensure = name => { if (!counts.has(name)) counts.set(name, { gets: 0, sets: 0, tees: 0 }); return counts.get(name) }
walk(node, n => {
if (!Array.isArray(n) || n.length < 2 || typeof n[1] !== 'string') return
if (n[0] === 'local.get') ensure(n[1]).gets++
else if (n[0] === 'local.set') ensure(n[1]).sets++
else if (n[0] === 'local.tee') ensure(n[1]).tees++
})
return counts
}
/** A constant whose inlined form (opcode + immediate) is no wider than the ~2 B
* `local.get` it would replace — so propagating it to every use is byte-neutral
* at worst, and still drops the `local.set` + the `local` decl. f32/f64 consts
* (5/9 B) lose on reuse, so only narrow i32/i64 literals qualify. */
const isTinyConst = (node) => {
const c = getConst(node)
if (!c) return false
if (c.type === 'i32') { const v = c.value | 0; return v >= -64 && v <= 63 }
if (c.type === 'i64') { const v = typeof c.value === 'bigint' ? c.value : BigInt(c.value); return v >= -64n && v <= 63n }
return false
}
/** Can this tracked value be substituted for a local.get?
* - single use of a pure value: always shrinks (drops the set, the lone get, the decl);
* - any use of a tiny constant: byte-neutral at worst, still drops the set + decl.
* Anything else (a wide constant reused many times, an impure expr) could inflate
* or reorder side effects, so it's left alone. */
const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val)
/** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
const purgeRefs = (known, name) => {
for (const [key, tracked] of known) {
let refs = false
walk(tracked.val, n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === name) refs = true })
if (refs) known.delete(key)
}
}
/** True if `node` recursively contains an op that may read linear memory.
* Tracked values whose RHS reads memory go stale after any intervening
* memory-mutating op (`*.store`, `memory.copy/fill/init`, atomic stores/rmw). */
const readsMemory = (node) => {
if (!Array.isArray(node)) return false
const op = node[0]
if (typeof op === 'string') {
if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true
}
for (let i = 1; i < node.length; i++) if (readsMemory(node[i])) return true
return false
}
/** True if `node` references state a `call` could mutate.
* Calls cannot touch caller locals (those live in the function frame), so
* pure expressions over locals + constants survive any intervening call; only
* memory loads, global reads, and table reads (or further calls) can be stale
* after one. */
const readsCallableState = (node) => {
if (!Array.isArray(node)) return false
const op = node[0]
if (typeof op === 'string') {
if (op === 'global.get' || op === 'table.get' || op === 'table.size') return true
if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect') return true
if (op.includes('.load') || op === 'memory.copy' || op === 'memory.size') return true
}
for (let i = 1; i < node.length; i++) if (readsCallableState(node[i])) return true
return false
}
/** True if `node` recursively contains an op that may write linear memory. */
const writesMemory = (node) => {
if (!Array.isArray(node)) return false
const op = node[0]
if (typeof op === 'string') {
if (op.endsWith('.store') || op === 'memory.copy' || op === 'memory.fill' || op === 'memory.init') return true
// Atomic RMW / store / notify all mutate memory; `.atomic.load` doesn't.
if (op.includes('.atomic.') && !op.endsWith('.load')) return true
}
for (let i = 1; i < node.length; i++) if (writesMemory(node[i])) return true
return false
}
/** Try substitute local.get nodes with known values.
* When entering a nested scope (block/loop/if), drop tracking for any local
* that's re-assigned inside the subtree — the outer-tracked value is stale
* there. Without this, an outer `(local.set $x C)` would clobber an inner
* `(local.set $x V) (local.get $x)` (the inner get rewritten to `C` instead
* of `V`). Mostly latent until something — typically coalesceLocals — reuses
* one slot for the outer and inner roles, after which it surfaces as silent
* memory corruption. */
const substGets = (node, known) => {
if (!Array.isArray(node)) return node
const op = node[0]
if (op === 'local.get' && node.length === 2) {
const k = typeof node[1] === 'string' && known.get(node[1])
if (k && canSubst(k)) return clone(k.val)
return node
}
let inner = known
if (op === 'block' || op === 'loop' || op === 'if') {
let cloned = null
walk(node, n => {
if (!Array.isArray(n)) return
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string' && known.has(n[1])) {
if (!cloned) cloned = new Map(known)
cloned.delete(n[1])
}
})
if (cloned) inner = cloned
}
for (let i = 1; i < node.length; i++) {
const r = substGets(node[i], inner)
if (r !== node[i]) node[i] = r
// WASM evaluates operands left-to-right. A `local.set`/`local.tee` in this
// child updates the local before the next sibling reads it — drop tracked
// entries that are now stale, else a pre-tee constant leaks into the next
// sibling's `local.get` (visible after `coalesceLocals` aliases the tee'd
// local with a sibling-read local, e.g. `alloc($x<<3, $x)` collapsing to
// `alloc(BIG, SMALL)`).
if (i + 1 < node.length && Array.isArray(node[i])) {
walk(node[i], n => {
if (!Array.isArray(n)) return
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') {
if (inner === known) inner = new Map(known)
inner.delete(n[1])
purgeRefs(inner, n[1])
}
})
}
}
return node
}
/**
* Forward propagation pass: track local.set values and substitute local.gets.
* Returns true if any substitution was made.
* @param {Array} funcNode
* @param {Set<string>} params
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
*/
const forwardPropagate = (funcNode, params, useCounts) => {
let changed = false
const getUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
const known = new Map()
for (let i = 1; i < funcNode.length; i++) {
const instr = funcNode[i]
if (!Array.isArray(instr)) continue
const op = instr[0]
if (op === 'param' || op === 'result' || op === 'local' || op === 'type' || op === 'export') continue
// Track local.set / local.tee values (tee writes too — its result also leaves
// the value on the stack but the local is updated identically to set).
if ((op === 'local.set' || op === 'local.tee') && instr.length === 3 && typeof instr[1] === 'string') {
// substGets returns its argument unchanged unless the whole subtree
// resolves to a substitution (bare `(local.get $x)` root case) — assign
// back so the bare-RHS pattern actually propagates.
const sr = substGets(instr[2], known)
if (sr !== instr[2]) { instr[2] = sr; changed = true }
// Nested `local.set`/`local.tee` inside the RHS already ran when the next
// statement begins — drop tracked values that read those locals, else a
// later `local.get` substitutes a stale expression (e.g. `$ptr`'s
// `(local.get $ai0)` after a nested `(local.tee $ai0 …)` overwrites it).
walk(instr[2], n => {
if (!Array.isArray(n)) return
if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
{ known.delete(n[1]); purgeRefs(known, n[1]) }
})
const uses = getUseCount(instr[1])
purgeRefs(known, instr[1]) // entries that read this local just went stale
// Any tracked value whose RHS reads memory must be invalidated by the
// RHS itself if it writes memory (rare — only via nested store/copy/etc.,
// which would also pass through the post-statement purge below).
if (writesMemory(instr[2])) {
for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
}
known.set(instr[1], {
val: instr[2], pure: isPure(instr[2]),
readsMem: readsMemory(instr[2]),
singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0
})
continue
}
// Invalidate at control-flow boundaries
if (op === 'block' || op === 'loop' || op === 'if') known.clear()
// Calls invalidate tracked values that read state a callee can mutate
// (memory, globals, tables, nested calls). Pure expressions over locals
// and constants survive — callees can't reach caller locals.
if (op === 'call' || op === 'call_indirect' || op === 'return_call' || op === 'return_call_indirect')
for (const [key, tracked] of known) if (readsCallableState(tracked.val)) known.delete(key)
// Substitute: standalone local.get (walkPost can't replace root)
if (op === 'local.get' && instr.length === 2 && typeof instr[1] === 'string') {
const tracked = known.get(instr[1])
if (tracked && canSubst(tracked)) {
const replacement = clone(tracked.val)
instr.length = 0; instr.push(...(Array.isArray(replacement) ? replacement : [replacement]))
changed = true; continue
}
}
// Substitute nested local.gets (skip control-flow nodes — locals may be reassigned inside)
if (op !== 'block' && op !== 'loop' && op !== 'if') {
const prev = clone(instr)
substGets(instr, known)
if (!equal(prev, instr)) changed = true
// Invalidate tracking for any names written by a nested set/tee — those
// writes happened mid-expression and the substGets above used the
// pre-write tracked value (correct), but later reads must see the new
// (untracked) value, not the stale constant.
walk(instr, n => {
if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
{ known.delete(n[1]); purgeRefs(known, n[1]) }
})
// Memory write in this statement (any nested store / memory.copy / etc.)
// invalidates every tracked value whose RHS reads memory: inlining one
// later would substitute a now-stale load. Without this, a swap idiom
// (local.set $t (f64.load $p)) (f64.store $p (f64.load $q)) (f64.store $q (local.get $t))
// collapses to two stores that round-trip the same value:
// (f64.store $p (f64.load $q)) (f64.store $q (f64.load $p)) ;; bug
if (writesMemory(instr)) {
for (const [key, tracked] of known) if (tracked.readsMem) known.delete(key)
}
}
}
return changed
}
/**
* Remove adjacent (local.set $x expr) (local.get $x) pairs when $x has no other uses.
* Returns true if any pair was removed.
* @param {Array} funcNode
* @param {Set<string>} params
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
*/
const eliminateSetGetPairs = (funcNode, params, useCounts) => {
let changed = false
for (let i = 1; i < funcNode.length - 1; i++) {
const setNode = funcNode[i]
const getNode = funcNode[i + 1]
if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
const name = setNode[1]
if (getNode[1] !== name || params.has(name)) continue
const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
// Must be exactly 1 set and 1 get (the pair), no tees
if (uses.sets !== 1 || uses.gets !== 1 || uses.tees !== 0) continue
// Replace the pair with just the expression
const expr = clone(setNode[2])
funcNode.splice(i, 2, ...(Array.isArray(expr) ? [expr] : [expr]))
changed = true
i-- // adjust index because we removed 2 and inserted 1
}
return changed
}
/**
* Convert (local.set $x expr) (local.get $x) to (local.tee $x expr)
* when $x has additional uses beyond this pair.
* @param {Array} funcNode
* @param {Set<string>} params
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
*/
const createLocalTees = (funcNode, params, useCounts) => {
let changed = false
for (let i = 1; i < funcNode.length - 1; i++) {
const setNode = funcNode[i]
const getNode = funcNode[i + 1]
if (!Array.isArray(setNode) || setNode[0] !== 'local.set' || setNode.length !== 3) continue
if (!Array.isArray(getNode) || getNode[0] !== 'local.get' || getNode.length !== 2) continue
const name = setNode[1]
if (getNode[1] !== name || params.has(name)) continue
const uses = useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
// Only if there's more than just this set+get pair
if (uses.sets + uses.gets + uses.tees <= 2) continue
// Replace with local.tee (set+get combined)
funcNode.splice(i, 2, ['local.tee', name, clone(setNode[2])])
changed = true
}
return changed
}
/**
* Remove dead stores and unused local declarations in a reverse pass.
* Returns true if anything was removed.
* @param {Array} funcNode
* @param {Set<string>} params
* @param {Map<string,{gets:number,sets:number,tees:number}>} useCounts
*/
const eliminateDeadStores = (funcNode, params, useCounts) => {
let changed = false
const getPostUseCount = name => useCounts.get(name) || { gets: 0, sets: 0, tees: 0 }
for (let i = funcNode.length - 1; i >= 1; i--) {
const sub = funcNode[i]
if (!Array.isArray(sub)) continue
const name = typeof sub[1] === 'string' ? sub[1] : null
if (!name || params.has(name)) continue
const uses = getPostUseCount(name)
// Dead store: set but never read.
if (sub[0] === 'local.set' && uses.gets === 0 && uses.tees === 0) {
// `(local.set $x VALUE)` — drop the store with its value, but only when
// VALUE is pure (its side effects would otherwise still need to run).
if (sub.length === 3) {
if (isPure(sub[2])) { funcNode.splice(i, 1); changed = true }
}
// Bare `(local.set $x)` — the value is implicit on the stack (e.g. an
// exception payload landing from a `try_table` catch). Demote to `drop`
// so the dead store goes away without unbalancing the stack.
else if (sub.length === 2) {
funcNode[i] = ['drop']; changed = true
}
}
// Unused local declaration
else if (sub[0] === 'local' && name[0] === '$' && uses.gets === 0 && uses.sets === 0 && uses.tees === 0) {
funcNode.splice(i, 1); changed = true
}
}
return changed
}
/**
* Propagate values through locals and eliminate single-use/dead locals.
* Constants propagate to all uses; pure single-use exprs inline into get site.
* Multi-pass with batch counting for convergence.
*/
/** Block-like nodes whose body is a straight-line instruction list (after any header). */
const isScopeNode = (n) => Array.isArray(n) &&
(n[0] === 'func' || n[0] === 'block' || n[0] === 'loop' || n[0] === 'then' || n[0] === 'else')
const propagate = (ast) => {
walk(ast, (funcNode) => {
if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
const params = new Set()
for (const sub of funcNode)
if (Array.isArray(sub) && sub[0] === 'param' && typeof sub[1] === 'string') params.add(sub[1])
// Propagation runs per straight-line scope: the function body and every nested
// `block`/`loop`/`then`/`else` (including ones embedded in an expression, e.g. the
// `(block (result i32) …)` an inlined call leaves behind). Collect scopes deepest-
// first so inner simplifications shrink the use-counts the outer scopes see.
// Use-counts are always whole-function — a set/get pair or dead store is only
// touched when it's globally the sole occurrence, so per-scope work stays sound.
const scopes = []
walkPost(funcNode, n => { if (isScopeNode(n)) scopes.push(n) })
// One use-count per round, shared by every scope: substitutions only ever
// *drop* gets, so a stale count can only make a sub-pass act more cautiously
// (skip a not-yet-provably-dead store, decline a not-yet-provably-single use) —
// never wrongly. The next round re-counts and mops up. (Recounting per sub-pass
// per scope is O(scopes·funcSize) and crippling on big modules.)
for (let round = 0; round < 6; round++) {
const useCounts = countLocalUses(funcNode)
let progressed = false
for (const scope of scopes) {
if (forwardPropagate(scope, params, useCounts)) progressed = true
if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true
if (createLocalTees(scope, params, useCounts)) progressed = true
if (eliminateDeadStores(scope, params, useCounts)) progressed = true
}
if (!progressed) break
}
})
return ast
}
// ==================== FUNCTION INLINING ====================
/**
* Inline tiny functions (single expression, no locals, no params or simple params).
* @param {Array} ast
* @returns {Array}
*/
const inline = (ast) => {
if (!Array.isArray(ast) || ast[0] !== 'module') return ast
// Collect inlinable functions
const inlinable = new Map() // $name → { body, params }
for (const node of ast.slice(1)) {
if (!Array.isArray(node) || node[0] !== 'func') continue
const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
if (!name) continue
// Check if function is small enough to inline
let params = []
let body = []
let hasLocals = false
let hasExport = false
for (let i = 1; i < node.length; i++) {
const sub = node[i]
if (!Array.isArray(sub)) continue
if (sub[0] === 'param') {
// Collect param names and types
if (typeof sub[1] === 'string' && sub[1][0] === '$') {
params.push({ name: sub[1], type: sub[2] })
} else {
// Unnamed params - harder to inline
params = null
break
}
} else if (sub[0] === 'local') {
hasLocals = true
} else if (sub[0] === 'export') {
hasExport = true
} else if (sub[0] !== 'result' && sub[0] !== 'type') {
body.push(sub)
}
}
// Inline: no locals, <= 4 params, single expression body, not exported
if (params && !hasLocals && !hasExport && params.length <= 4 && body.length === 1) {
// Check if function mutates any of its params (local.set/tee on param),
// or contains a control-transfer op (`return`, `return_call`,
// `return_call_indirect`). Inlining such bodies into a different-typed
// caller would propagate the transfer to the caller, returning from the
// wrong function with the wrong type. Lifting the body into a
// `(block $exit ...)` and rewriting returns to `(br $exit X)` would
// unlock these — left for a future pass.
const paramNames = new Set(params.map(p => p.name))
let mutatesParam = false
let hasReturn = false
walk(body[0], (n) => {
if (!Array.isArray(n)) return
if ((n[0] === 'local.set' || n[0] === 'local.tee') && paramNames.has(n[1])) {
mutatesParam = true
}
if (n[0] === 'return' || n[0] === 'return_call' || n[0] === 'return_call_indirect') {
hasReturn = true
}
})
if (!mutatesParam && !hasReturn) {
inlinable.set(name, { body: body[0], params })
}
}
}
// Replace calls with inlined body
if (inlinable.size === 0) return ast
walkPost(ast, (node) => {
if (!Array.isArray(node) || node[0] !== 'call') return
const fname = node[1]
if (!inlinable.has(fname)) return
const { body, params } = inlinable.get(fname)
const args = node.slice(2)
// Simple case: no params
if (params.length === 0) {
return clone(body)
}
// Substitute params with args
const substituted = walkPost(clone(body), (n) => {
if (!Array.isArray(n) || n[0] !== 'local.get') return
const local = n[1]
const paramIdx = params.findIndex(p => p.name === local)
if (paramIdx !== -1 && args[paramIdx]) {
return clone(args[paramIdx])
}
})
return substituted
})
return ast
}
// ==================== INLINE-ONCE ====================
let inlineUid = 0
/**
* Inline functions that are called from exactly one place into their lone caller,
* then delete them. Unlike {@link inline} (which duplicates tiny stateless bodies),
* this never duplicates code and never inflates: each inlined function drops a
* function-section entry, a type-section entry (if now unused), and a `call`
* instruction, paying back only a `block`/`local.set` wrapper. This is what
* `wasm-opt -Oz` does — collapsing helper chains down to a couple of functions —
* and it's the bulk of the gap between hand-tuned WASM and naive codegen.
*
* A function `$f` qualifies when it is, all of:
* • named, with named params and locals (numeric indices can't be safely renamed);
* • referenced exactly once across the whole module, by a plain `call` (no
* `return_call`, `ref.func`, `elem`, `export`, or `start` reference, and not
* recursive);
* • single-result or void (a multi-value result can't be modeled as `(block (result …))`