Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/zapcode-core/src/compiler/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ pub enum Instruction {
Call(usize),
Return,
CallExternal(String, usize),
/// Call a global builtin function by name (e.g. parseInt, isNaN, Number).
CallBuiltin(String, usize),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instruction derives serde serialization and the complete CompiledProgram is stored in postcard snapshots. Inserting CallBuiltin here shifts the enum tags for every following opcode (Jump onward), so a snapshot created by Zapcode 1.5.3 may decode old instructions as different instructions after an upgrade.

Example scenario:

const response = await externalTool();
response ? 1 : 2;

The persisted snapshot contains the post-suspension jump bytecode. After this insertion, those old enum tags no longer identify the same opcodes.

Please append CallBuiltin at the end of the enum to preserve existing tags. Longer term, snapshots should use an explicit format version and legacy fixture tests, but preserving enum order is the minimal fix required in this PR.


// Control flow
Jump(usize),
Expand Down
20 changes: 20 additions & 0 deletions crates/zapcode-core/src/compiler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ struct Compiler {
external_functions: HashSet<String>,
}

/// Global functions dispatched via `Instruction::CallBuiltin`. Kept in sync with
/// `builtins::call_global_function`.
fn is_global_builtin_fn(name: &str) -> bool {
matches!(
name,
"parseInt" | "parseFloat" | "isNaN" | "isFinite" | "String" | "Number" | "Boolean"
)
}

struct LoopInfo {
break_patches: Vec<usize>,
continue_patches: Vec<usize>,
Expand Down Expand Up @@ -919,6 +928,17 @@ impl Compiler {
return Ok(());
}
}
// Direct call to a global builtin function (parseInt, Number, …),
// unless shadowed by a local of the same name.
if let Expr::Ident(name) = callee.as_ref() {
if self.resolve_local(name).is_none() && is_global_builtin_fn(name) {
for arg in args {
self.compile_expr(arg)?;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This compile-time rewrite bypasses normal lexical/global resolution. resolve_local() sees only this function's local slots, not a binding captured from an enclosing scope. The builtin also is not registered as a first-class global value.

Both cases were verified at this PR head:

const Number = value => value + 1;
const call = () => Number(41);
call();

Expected: 42; actual: 41.

const parse = parseInt;
parse("42");

Expected: 42; actual: TypeError("undefined is not a function").

Please register these as normal builtin function Values and invoke them through ordinary lexical/global call resolution. That lets captured/local bindings shadow them and allows assignment, callbacks, and object properties. Please add both cases as regression tests.

self.emit(Instruction::CallBuiltin(name.clone(), args.len()));
return Ok(());
}
}
self.compile_expr(callee)?;
for arg in args {
self.compile_expr(arg)?;
Expand Down
130 changes: 130 additions & 0 deletions crates/zapcode-core/src/vm/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,136 @@ fn call_array_method(arr: &[Value], method: &str, args: &[Value]) -> Result<Opti
Ok(Some(result))
}

/// Dispatch a global builtin function call (parseInt, isNaN, Number, …).
/// Returns `None` only for an unrecognized name (the compiler shouldn't emit one).
pub fn call_global_function(name: &str, args: &[Value]) -> Option<Value> {
let value = match name {
"parseInt" => parse_int(args),
"parseFloat" => parse_float(args),
"isNaN" => Value::Bool(arg_num(args, 0).is_nan()),
"isFinite" => Value::Bool(arg_num(args, 0).is_finite()),
"String" => match args.first() {
Some(v) => Value::String(Arc::from(v.to_js_string().as_str())),
None => Value::String(Arc::from("")),
},
"Number" => match args.first() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These conversions use the current generic to_number()/Rust parsing behavior rather than ECMAScript coercion. I verified:

[Number(""), Number(" 42 "), Number("0x10")]

Expected: [0, 42, 16]; actual: [NaN, NaN, NaN].

I also verified:

parseInt("10", 4294967298)

Expected: 2 because JavaScript applies ToInt32 to the radix; actual: NaN.

Please implement shared ECMAScript-style ToNumber/ToString helpers and use ToInt32 for the radix. At minimum, cover empty/trimmed strings, numeric prefixes, signed zero, Infinity, and radix conversion in regression tests. If exact ECMAScript behavior is intentionally out of scope, these functions need explicit documented subset semantics rather than presenting them as standard globals.

Some(v) => narrow_number(v.to_number()),
None => Value::Int(0),
},
"Boolean" => Value::Bool(args.first().map(|v| v.is_truthy()).unwrap_or(false)),
_ => return None,
};
Some(value)
}

/// Represent a finite integral number as `Int` (nicer `===`), else `Float`.
fn narrow_number(n: f64) -> Value {
if n.is_finite() && n.fract() == 0.0 && n.abs() < i64::MAX as f64 {
Value::Int(n as i64)
} else {
Value::Float(n)
}
}

fn parse_int(args: &[Value]) -> Value {
let s = args.first().map(|v| v.to_js_string()).unwrap_or_default();
let chars: Vec<char> = s.trim_start().chars().collect();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parser clones the full input string and then materializes Vec<char>, but neither temporary allocation is charged to ResourceTracker. The subsequent scan runs inside one opcode, while the VM checks elapsed time only between opcodes. A sufficiently large guest- or host-provided string can therefore amplify memory and occupy the worker beyond configured limits before control returns to the VM.

I deliberately did not run an unbounded stress payload during review. This finding is based on the allocation and control-flow path here.

Please avoid materializing Vec<char> by parsing with an iterator/byte indices, impose an input-size/resource preflight, and periodically check the execution deadline during long scans. parseFloat needs equivalent bounds. Add a bounded test with low memory/time limits that returns a controlled limit error.

let mut i = 0;
let mut sign = 1.0;
if i < chars.len() && (chars[i] == '+' || chars[i] == '-') {
if chars[i] == '-' {
sign = -1.0;
}
i += 1;
}

let radix_arg = args.get(1).map(|v| v.to_number()).unwrap_or(f64::NAN);
let mut radix: i64 = if radix_arg.is_nan() || radix_arg == 0.0 {
0
} else {
radix_arg as i64
};
if radix != 0 && !(2..=36).contains(&radix) {
return Value::Float(f64::NAN);
}
if (radix == 0 || radix == 16)
&& i + 1 < chars.len()
&& chars[i] == '0'
&& (chars[i + 1] == 'x' || chars[i + 1] == 'X')
{
i += 2;
radix = 16;
}
if radix == 0 {
radix = 10;
}

let mut value = 0.0;
let mut any = false;
while i < chars.len() {
match chars[i].to_digit(36) {
Some(d) if (d as i64) < radix => {
value = value * radix as f64 + d as f64;
any = true;
i += 1;
}
_ => break,
}
}
if !any {
return Value::Float(f64::NAN);
}
narrow_number(sign * value)
}

fn parse_float(args: &[Value]) -> Value {
let s = args.first().map(|v| v.to_js_string()).unwrap_or_default();
let s = s.trim_start();
if s.starts_with("Infinity") || s.starts_with("+Infinity") {
return Value::Float(f64::INFINITY);
}
if s.starts_with("-Infinity") {
return Value::Float(f64::NEG_INFINITY);
}
let b = s.as_bytes();
let n = b.len();
let mut i = 0;
if i < n && (b[i] == b'+' || b[i] == b'-') {
i += 1;
}
let mut saw_digit = false;
while i < n && b[i].is_ascii_digit() {
i += 1;
saw_digit = true;
}
if i < n && b[i] == b'.' {
i += 1;
while i < n && b[i].is_ascii_digit() {
i += 1;
saw_digit = true;
}
}
if saw_digit && i < n && (b[i] == b'e' || b[i] == b'E') {
let mut j = i + 1;
if j < n && (b[j] == b'+' || b[j] == b'-') {
j += 1;
}
if j < n && b[j].is_ascii_digit() {
while j < n && b[j].is_ascii_digit() {
j += 1;
}
i = j;
}
}
if !saw_digit {
return Value::Float(f64::NAN);
}
s[..i]
.parse::<f64>()
.map(Value::Float)
.unwrap_or(Value::Float(f64::NAN))
}

// ── Object static methods ────────────────────────────────────────────

fn call_object_method(method: &str, args: &[Value]) -> Result<Option<Value>> {
Expand Down
16 changes: 16 additions & 0 deletions crates/zapcode-core/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1808,6 +1808,22 @@ impl Vm {
snapshot,
}));
}
Instruction::CallBuiltin(name, arg_count) => {
let mut args = Vec::with_capacity(arg_count);
for _ in 0..arg_count {
args.push(self.pop()?);
}
args.reverse();
match builtins::call_global_function(&name, &args) {
Some(val) => self.push(val)?,
None => {
return Err(ZapcodeError::TypeError(format!(
"{} is not a function",
name
)))
}
}
}

// Control flow
Instruction::Jump(target) => {
Expand Down
40 changes: 40 additions & 0 deletions crates/zapcode-core/tests/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,3 +574,43 @@ fn test_array_some_empty() {
let result = eval_ts("[].some((x) => x > 0)").unwrap();
assert_eq!(result, Value::Bool(false));
}

// ── global builtin functions (regression: parseInt/parseFloat were missing) ──

#[test]
fn test_parse_int() {
assert_eq!(eval_ts("parseInt('42px')").unwrap(), Value::Int(42));
assert_eq!(eval_ts("parseInt(' -7')").unwrap(), Value::Int(-7));
assert_eq!(eval_ts("parseInt('0xFF')").unwrap(), Value::Int(255));
assert_eq!(eval_ts("parseInt('101', 2)").unwrap(), Value::Int(5));
assert_eq!(eval_ts("parseInt('ff', 16)").unwrap(), Value::Int(255));
assert!(matches!(eval_ts("parseInt('abc')").unwrap(), Value::Float(f) if f.is_nan()));
}

#[test]
fn test_parse_float() {
assert_eq!(eval_ts("parseFloat('2.5xyz')").unwrap(), Value::Float(2.5));
assert_eq!(
eval_ts("parseFloat(' 1e3')").unwrap(),
Value::Float(1000.0)
);
assert!(matches!(eval_ts("parseFloat('nope')").unwrap(), Value::Float(f) if f.is_nan()));
}

#[test]
fn test_isnan_isfinite_number_string_boolean() {
assert_eq!(eval_ts("isNaN(NaN)").unwrap(), Value::Bool(true));
assert_eq!(eval_ts("isNaN(3)").unwrap(), Value::Bool(false));
assert_eq!(eval_ts("isFinite(1/0)").unwrap(), Value::Bool(false));
assert_eq!(eval_ts("isFinite(42)").unwrap(), Value::Bool(true));
assert_eq!(eval_ts("Number('42')").unwrap(), Value::Int(42));
assert_eq!(eval_ts("String(42)").unwrap(), Value::String("42".into()));
assert_eq!(eval_ts("Boolean(0)").unwrap(), Value::Bool(false));
}

#[test]
fn test_global_builtin_shadowed_by_local() {
// A local named `Number` must win over the builtin.
let r = eval_ts("const Number = (x) => x + 1; Number(41)").unwrap();
assert_eq!(r, Value::Int(42));
}