From 179bd81566462d62c9e4a47732db85e18aae69d2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:59:37 -0800 Subject: [PATCH 1/4] Scripts: the $ object's other members, localize and toSource A script's $ object now has the other members the Dollar object page lists: about, appEncoding, bp, build, buildDate, colorPicker, decimalPoint, engineName, error, flags, gc, getenv, setenv, global, hiresTimer, level, line, locale, localize, memCache, os, screens, sleep, stack, strict and version. Scripts also get the global localize(object, ...values), toSource(value) and Object.prototype.toSource(). Each run starts with the interface's locale and its own $ state; $.setenv changes only the run's copy of LANG, LC_MESSAGES, LC_ALL and OS, and $.getenv returns null for every other name. $.sleep waits from 0 to 1,000 ms between frames, and script.stop ends the wait. toSource writes plain data only and throws on cycles, getters, host objects and values past the data limits. The script checker reports $.localize as a $ member instead of the global localize function. --- crates/engine/src/cmd/scripts.rs | 3 +- crates/engine/src/script/dollar.rs | 307 ++++++++++++++++ crates/engine/src/script/mod.rs | 83 ++++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/script_dollar.rs | 215 +++++++++++ crates/js/src/builtins.rs | 8 + crates/js/src/builtins/extensions.rs | 393 +++++++++++++++++++++ crates/js/src/extension_tests.rs | 179 ++++++++++ crates/js/src/host.rs | 35 ++ crates/js/src/interp.rs | 18 + crates/js/src/lib.rs | 5 +- crates/js/src/realm.rs | 17 + crates/scriptcheck/src/analyze/features.rs | 2 - crates/scriptcheck/src/catalog.rs | 2 + crates/scriptcheck/src/tests.rs | 11 +- crates/ui-egui/src/app.rs | 6 +- docs/mcp.md | 46 +++ docs/target-app-parity.md | 1 + 18 files changed, 1317 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/script/dollar.rs create mode 100644 crates/engine/src/tests/script_dollar.rs create mode 100644 crates/js/src/builtins/extensions.rs create mode 100644 crates/js/src/extension_tests.rs diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 66f3f8bf8..f85ddd138 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -162,7 +162,8 @@ fn run(s: &mut Session, p: &Value) -> Result { _ => return Err(param("give exactly one of `path` and `source`")), }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); - let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel }; + let locale = script::dollar::ui_locale(s.ui_language.as_deref().unwrap_or(&s.prefs.interface_language)); + let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, locale }; let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs new file mode 100644 index 000000000..bbddfb330 --- /dev/null +++ b/crates/engine/src/script/dollar.rs @@ -0,0 +1,307 @@ +//! The members of a script's `$` object besides `$.write`, `$.writeln`, `$.evalFile`, `$.fileName` +//! and `$.includePath`, from the Dollar object +//! page (), and +//! the locale the global `localize` reads +//! (). +//! +//! Every value here belongs to one run: a run starts with the locale of the interface, the +//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero, +//! and what a script changes ends with its run. `$.setenv` never changes the process's environment. +//! +//! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and +//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is +//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0 +//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the +//! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no +//! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own +//! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most +//! [`MAX_ENV_BYTES`] bytes of letters, digits and `_ . - @`; and `$.sleep` waits at most +//! [`MAX_SLEEP_MS`] milliseconds, between two frames of the app, and does not wait on the web, +//! which has no clock. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem}; + +use super::{Host, function}; + +/// The longest `$.sleep`, in milliseconds. +pub const MAX_SLEEP_MS: f64 = 1_000.0; +/// The environment variables `$.getenv` reads and `$.setenv` sets. +pub const ENV_NAMES: &[&str] = &["LANG", "LC_MESSAGES", "LC_ALL", "OS"]; +/// The most bytes of an environment value. +pub const MAX_ENV_BYTES: usize = 64; +/// The value of `$.flags` that turns localization on, the only one a script may set. +pub const LOCALIZE_FLAG: f64 = 512.0; + +/// What `$` knows of a run. +pub(crate) struct RunInfo { + /// The locale the run starts with, such as `de_DE`. + pub locale: String, + /// The realm reads scripts as strict mode code. + pub strict: bool, +} + +/// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language, +/// with its region after `_` where the code gives one (`pt_BR`; `zh_CN` for `zh-hans` and `zh_TW` +/// for `zh-hant`); `en` for a code that is not a language. +pub(crate) fn ui_locale(code: &str) -> String { + let mut parts = code.split(['-', '_']); + let language = parts.next().unwrap_or_default().to_ascii_lowercase(); + if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_lowercase()) { + return "en".into(); + } + let region = match parts.next().map(str::to_ascii_lowercase).as_deref() { + Some("hans") => Some("CN".to_string()), + Some("hant") => Some("TW".to_string()), + Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_lowercase()) => Some(r.to_ascii_uppercase()), + _ => None, + }; + region.map_or(language.clone(), |r| format!("{language}_{r}")) +} + +/// A locale a script gives `$.locale`, as `ll` or `ll_RR`: a language of two or three letters and +/// an optional region of two letters or three digits, joined by `_` or `-`, in any case. +fn script_locale(text: &str) -> Option { + let mut parts = text.split(['-', '_']); + let language = parts.next()?; + let region = parts.next(); + if parts.next().is_some() || !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) { + return None; + } + let language = language.to_ascii_lowercase(); + match region { + None => Some(language), + Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_alphabetic()) => Some(format!("{language}_{}", r.to_ascii_uppercase())), + Some(r) if r.len() == 3 && r.bytes().all(|b| b.is_ascii_digit()) => Some(format!("{language}_{r}")), + Some(_) => None, + } +} + +/// The languages whose numbers are written with a decimal comma (VectorCraft's own list). +const DECIMAL_COMMA: &[&str] = &[ + "bg", "ca", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hr", "hu", "id", "it", "lt", "lv", "nb", "nl", "no", "pl", "pt", "ro", "ru", "sk", + "sl", "sr", "sv", "tr", "uk", "vi", +]; + +/// The character `locale` writes before a number's fraction. +pub(crate) fn decimal_point(locale: &str) -> &'static str { + let language = locale.split('_').next().unwrap_or_default(); + if DECIMAL_COMMA.contains(&language) { "," } else { "." } +} + +/// The platform suffix of a localization object's names on this system. +pub(crate) fn platform() -> Option<&'static str> { + if cfg!(target_arch = "wasm32") { + None + } else if cfg!(windows) { + Some("Win") + } else if cfg!(target_os = "macos") { + Some("Mac") + } else { + Some("Unix") + } +} + +/// `$.os`: the system, without its version. +fn os() -> &'static str { + if cfg!(target_arch = "wasm32") { + "Web" + } else if cfg!(windows) { + "Windows" + } else if cfg!(target_os = "macos") { + "macOS" + } else if cfg!(target_os = "linux") { + "Linux" + } else if cfg!(target_os = "freebsd") { + "FreeBSD" + } else { + "Unix" + } +} + +/// Is `value` one an environment value may be? +fn env_value_ok(value: &str) -> bool { + value.len() <= MAX_ENV_BYTES && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b'@')) +} + +/// The values of [`ENV_NAMES`] in the process's environment, each left out when it is not one an +/// environment value may be. +fn process_env() -> Vec<(&'static str, Option)> { + ENV_NAMES.iter().map(|&n| (n, std::env::var(n).ok().filter(|v| env_value_ok(v)))).collect() +} + +fn one_string(args: &[Data], name: &str) -> Result { + match args.first() { + Some(Data::String(s)) => Ok(s.clone()), + _ => Err(HostError::Type(format!("$.{name}: give a name, as a string"))), + } +} + +fn value(v: Data) -> HostItem { + HostItem::Value(v) +} + +fn text(s: impl Into) -> Data { + Data::String(s.into()) +} + +/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. +pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { + let version = env!("CARGO_PKG_VERSION"); + let host_locale = info.locale.clone(); + localization.set_locale(&info.locale); + localization.set_platform(platform()); + let env = Rc::new(RefCell::new(process_env())); + let timer = Rc::new(Cell::new(Duration::ZERO)); + let getter = |f: Rc Data>| HostItem::Getter(function(move |_| Ok(f()))); + let property = |get: Rc Data>, set: Rc Result<(), HostError>>| HostItem::Property { + get: function(move |_| Ok(get())), + set: function(move |mut a| { + set(if a.is_empty() { Data::Undefined } else { a.swap_remove(0) })?; + Ok(Data::Undefined) + }), + }; + let fail = |message: &'static str| HostItem::Function(function(move |_| Err(HostError::Error(message.to_string())))); + + let locale = { + let (l, s) = (localization.clone(), localization.clone()); + property( + Rc::new(move || text(l.locale())), + Rc::new(move |v| { + let locale = match v { + Data::Null | Data::Undefined => host_locale.clone(), + Data::String(t) if t.is_empty() => host_locale.clone(), + Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?, + _ => return Err(HostError::Type(BAD_LOCALE.to_string())), + }; + s.set_locale(&locale); + Ok(()) + }), + ) + }; + let localize = { + let (l, s) = (localization.clone(), localization.clone()); + property( + Rc::new(move || Data::Bool(l.enabled())), + Rc::new(move |v| match v { + Data::Bool(on) => { + s.set_enabled(on); + Ok(()) + } + _ => Err(HostError::Type("$.localize: give true or false".into())), + }), + ) + }; + let flags = { + let (l, s) = (localization.clone(), localization.clone()); + property( + Rc::new(move || Data::Number(if l.enabled() { LOCALIZE_FLAG } else { 0.0 })), + Rc::new(move |v| match v { + Data::Number(n) if n == 0.0 || n == LOCALIZE_FLAG => { + s.set_enabled(n == LOCALIZE_FLAG); + Ok(()) + } + Data::Number(_) => Err(HostError::Range("$.flags: only 512, which turns localization on, or 0 can be set".into())), + _ => Err(HostError::Type("$.flags: give a number".into())), + }), + ) + }; + let decimal = { + let l = localization.clone(); + getter(Rc::new(move || text(decimal_point(&l.locale())))) + }; + let app_encoding = property( + Rc::new(|| text("UTF-8")), + Rc::new(|v| match v { + Data::String(t) if t.eq_ignore_ascii_case("utf-8") || t.eq_ignore_ascii_case("utf8") => Ok(()), + Data::String(_) => Err(HostError::Range("$.appEncoding: scripts here read and write UTF-8 only".into())), + _ => Err(HostError::Type("$.appEncoding: give an encoding's name, as a string".into())), + }), + ); + let mem_cache = + property(Rc::new(|| Data::Number(0.0)), Rc::new(|_| Err(HostError::Error("$.memCache: the memory cache size cannot be set".into())))); + let hires_timer = { + let (h, last) = (host.clone(), timer.clone()); + getter(Rc::new(move || { + let Some(now) = h.elapsed() else { return Data::Number(0.0) }; + let since = now.saturating_sub(last.replace(now)); + #[allow(clippy::cast_precision_loss)] + Data::Number(since.as_micros() as f64) + })) + }; + let getenv = { + let (h, env) = (host.clone(), env.clone()); + function(move |a| { + let name = one_string(&a, "getenv")?; + h.charge_call(name.len() as u64 + 2)?; + let env = env.borrow(); + let found = env.iter().find(|(n, _)| *n == name).and_then(|(_, v)| v.clone()); + Ok(found.map_or(Data::Null, Data::String)) + }) + }; + let setenv = { + let (h, env) = (host.clone(), env.clone()); + function(move |a| { + let name = one_string(&a, "setenv")?; + let Some(Data::String(v)) = a.get(1) else { return Err(HostError::Type("$.setenv: give a value, as a string".into())) }; + h.charge_call((name.len() + v.len()) as u64 + 4)?; + let mut env = env.borrow_mut(); + let Some(slot) = env.iter_mut().find(|(n, _)| *n == name) else { + return Err(HostError::Error(format!("$.setenv: only {} can be set", ENV_NAMES.join(", ")))); + }; + if !env_value_ok(v) { + return Err(HostError::Range(format!("$.setenv: a value has at most {MAX_ENV_BYTES} letters, digits and _ . - @"))); + } + slot.1 = Some(v.clone()); + Ok(Data::Undefined) + }) + }; + let sleep = { + let h = host.clone(); + function(move |a| { + let ms = match a.first() { + Some(Data::Number(n)) if (0.0..=MAX_SLEEP_MS).contains(n) => *n, + Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))), + _ => return Err(HostError::Type("$.sleep: give a number of milliseconds".into())), + }; + h.charge_call(8)?; + h.sleep(Duration::from_secs_f64(ms / 1000.0)); + Ok(Data::Undefined) + }) + }; + let about = text(format!("VectorCraft script engine {version}")); + + vec![ + ("about".into(), HostItem::Function(function(move |_| Ok(about.clone())))), + ("appEncoding".into(), app_encoding), + ("bp".into(), fail("$.bp: scripts here have no debugger")), + ("build".into(), value(text(version))), + ("buildDate".into(), value(Data::Null)), + ("colorPicker".into(), fail("$.colorPicker: scripts cannot show a dialog yet")), + ("decimalPoint".into(), decimal), + ("engineName".into(), value(text("VectorCraft"))), + ("error".into(), HostItem::Script(ScriptItem::LastError)), + ("flags".into(), flags), + ("gc".into(), HostItem::Function(function(|_| Ok(Data::Undefined)))), + ("getenv".into(), HostItem::Function(getenv)), + ("global".into(), HostItem::Script(ScriptItem::Global)), + ("hiresTimer".into(), hires_timer), + ("level".into(), value(Data::Number(0.0))), + ("line".into(), HostItem::Script(ScriptItem::Line)), + ("locale".into(), locale), + ("localize".into(), localize), + ("memCache".into(), mem_cache), + ("os".into(), value(text(os()))), + ("screens".into(), value(Data::Array(Vec::new()))), + ("setenv".into(), HostItem::Function(setenv)), + ("sleep".into(), HostItem::Function(sleep)), + ("stack".into(), HostItem::Script(ScriptItem::Stack)), + ("strict".into(), value(Data::Bool(info.strict))), + ("version".into(), value(text(version))), + ] +} + +const BAD_LOCALE: &str = "$.locale: give a locale such as \"en\" or \"de_DE\", or null for the interface's"; diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index f2e4cc5ba..02c081a5d 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -31,9 +31,11 @@ //! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line //! there too. The session's console gets a start line, the run's output, its uncaught error, an end //! line and a report line; the run's report holds its own output and error, its time, its host -//! calls by kind and its slowest commands. +//! calls by kind and its slowest commands. The rest of `$`, and the global `localize` and +//! `toSource`, are in [`dollar`]. pub mod console; +pub mod dollar; pub mod include; use std::cell::{Cell, RefCell}; @@ -88,7 +90,7 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30; /// The version `vc.api.version` gives. pub const API_VERSION: &str = "1.0"; /// The features `vc.api.has` knows. -pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console"]; +pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dollar"]; /// The most commands a run's report lists in `slowestCommands`. pub const SLOWEST_COMMANDS: usize = 5; @@ -147,6 +149,8 @@ pub(crate) struct Request { pub source: String, pub args: Map, pub fuel: u64, + /// The locale the run starts with (`$.locale`). + pub locale: String, } thread_local! { @@ -489,6 +493,10 @@ struct Host { /// The last error `$.evalFile` raised for a file it could not run, with the file, line and /// column that error is at. eval_failure: RefCell>, + /// When the run started, where the platform has a clock. + started: Option, + /// The time since [`Host::started`] at which a `$.sleep` ends. + wake_at: Cell>, } /// An error `$.evalFile` raised, as thrown, and the file, line and column it is at. @@ -566,6 +574,38 @@ impl Host { pending.end_line(|line, cut| self.line(console::Entry::Output(line), cut)); } } + + /// The time since the run started; none without a clock. + fn elapsed(&self) -> Option { + #[cfg(test)] + if let Some(d) = TEST_ELAPSED.with(Cell::get) { + return Some(d); + } + self.started.map(|s| s.elapsed()) + } + + /// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock + /// it does not wait. + fn sleep(&self, d: std::time::Duration) { + if let Some(now) = self.elapsed() + && !d.is_zero() + { + self.wake_at.set(Some(now.saturating_add(d))); + self.pause.set(true); + } + } + + /// The time left of a `$.sleep`, or none when the run is not asleep. + fn asleep(&self) -> Option { + let wake = self.wake_at.get()?; + let left = self.elapsed().map_or(std::time::Duration::ZERO, |now| wake.saturating_sub(now)); + if left.is_zero() { + self.wake_at.set(None); + return None; + } + Some(left) + } + fn over(&self, limit: &str) -> HostError { HostError::Abort(limit.to_string()) } @@ -924,8 +964,9 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) { /// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)` /// gives them, one after another, and `$.writeln(...values)` does the same and ends the line. /// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file -/// being run and `$.includePath` the `#includepath` list. -fn dollar_object(host: &Rc) -> Vec<(String, HostItem)> { +/// being run and `$.includePath` the `#includepath` list. Its other members are +/// [`dollar::members`]. +fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let writer = |end: bool| { let h = host.clone(); HostItem::TextFunction(function(move |a| { @@ -949,13 +990,15 @@ fn dollar_object(host: &Rc) -> Vec<(String, HostItem)> { let end = host.clone(); HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) } }; - vec![ + let mut items = vec![ ("write".into(), writer(false)), ("writeln".into(), writer(true)), ("evalFile".into(), eval_file), ("fileName".into(), getter(include::Files::file_name)), ("includePath".into(), getter(include::Files::include_path)), - ] + ]; + items.extend(dollar::members(host, info, localization)); + items } /// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being @@ -1163,6 +1206,15 @@ impl Session { !self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false) } + /// The time left of the running script's `$.sleep`, or none when it is not asleep. A run asked + /// to stop is not asleep: the next [`Session::script_pump`] ends it. + pub fn script_sleep_left(&self) -> Option { + if self.script_stop.is_set() { + return None; + } + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.asleep())).flatten() + } + /// The name of the running script. pub fn script_name(&self) -> Option { self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone())) @@ -1243,6 +1295,7 @@ impl Session { }; let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() }; let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() }; + let strict = options.strict; let mut realm = Realm::with_options(options, limits); realm.share_interrupt(&self.script_stop); realm.set_data_limits(DataLimits::default()); @@ -1274,18 +1327,23 @@ impl Session { script_map: RefCell::default(), eval_maps: RefCell::default(), eval_failure: RefCell::default(), + started: clock(), + wake_at: Cell::new(None), }); let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) .map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?; let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start }; self.script_console.push(line, false); + let info = dollar::RunInfo { locale: request.locale, strict }; + let localization = realm.localization(); let started = match expanded { Err(f) => Err(preprocessor_error(f)), Ok(expanded) => { *host.script_map.borrow_mut() = expanded.map; realm - .define_host_object("vc", vc_object(&host, args)) - .and_then(|()| realm.define_host_object("$", dollar_object(&host))) + .add_script_extensions() + .and_then(|()| realm.define_host_object("vc", vc_object(&host, args))) + .and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization))) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), &e)) } @@ -1312,7 +1370,7 @@ impl Session { let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { return Some(Err(EngineError::Other(JOB_GONE.to_string()))); }; - if job.host.waiting.get() && !self.script_stop.is_set() { + if (job.host.waiting.get() || job.host.asleep().is_some()) && !self.script_stop.is_set() { JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); self.script_key = Some(key); return None; @@ -1441,6 +1499,11 @@ impl Session { if self.script_waiting() { self.script_stop.set(); } + // A `$.sleep` waits here, where nothing is drawn, instead of turning the loop over. + #[cfg(not(target_arch = "wasm32"))] + if let Some(left) = self.script_sleep_left() { + std::thread::sleep(left.min(std::time::Duration::from_millis(100))); + } } } } @@ -1620,4 +1683,6 @@ thread_local! { pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; + /// Tests: the time since a run started, in place of its clock's. + pub(crate) static TEST_ELAPSED: Cell> = const { Cell::new(None) }; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9ad671843..2507f59d0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -151,6 +151,7 @@ mod save; mod saveoptions; mod scalestrokes; mod script_console; +mod script_dollar; mod script_folder; mod script_include; mod script_run; diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs new file mode 100644 index 000000000..9ec90b5f7 --- /dev/null +++ b/crates/engine/src/tests/script_dollar.rs @@ -0,0 +1,215 @@ +//! The `$` object's members besides `$.write` and `$.writeln`, the global `localize` and +//! `toSource`, and what of them one run keeps to itself. + +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::script::TEST_ELAPSED; +use crate::*; + +/// The values `source` gives `vc.log`, in a run of its text. +fn logged(s: &mut Session, source: &str) -> Value { + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap_or_else(|e| panic!("{source}: {e}"))["log"].clone() +} + +/// The one value `expr` gives, logged. +fn value(s: &mut Session, expr: &str) -> Value { + logged(s, &format!("vc.log({expr});"))[0].clone() +} + +/// The name of the error `statement` throws and its message, as `Name: message`. +fn thrown(s: &mut Session, statement: &str) -> String { + value(s, &format!("(function () {{ try {{ {statement}; return 'no error'; }} catch (e) {{ return e.name + ': ' + e.message; }} }})()")) + .as_str() + .unwrap_or_default() + .to_string() +} + +#[test] +fn each_member_has_its_value() { + let mut s = Session::new(); + let version = env!("CARGO_PKG_VERSION"); + let os = if cfg!(windows) { + "Windows" + } else if cfg!(target_os = "macos") { + "macOS" + } else if cfg!(target_os = "linux") { + "Linux" + } else if cfg!(target_os = "freebsd") { + "FreeBSD" + } else { + "Unix" + }; + for (expr, want) in [ + ("$.about()", json!(format!("VectorCraft script engine {version}"))), + ("$.appEncoding", json!("UTF-8")), + ("$.build", json!(version)), + ("$.buildDate", json!(null)), + ("$.decimalPoint", json!(".")), + ("$.engineName", json!("VectorCraft")), + ("$.error", json!(null)), + ("$.flags", json!(0)), + ("$.gc() === undefined", json!(true)), + ("$.global === this", json!(true)), + ("typeof $.hiresTimer === 'number' && $.hiresTimer >= 0", json!(true)), + ("$.level", json!(0)), + ("\n$.line", json!(2)), + ("$.locale", json!("en")), + ("$.localize", json!(false)), + ("$.memCache", json!(0)), + ("$.os", json!(os)), + ("$.screens.length", json!(0)), + ("$.stack", json!("[global]:1\n")), + ("$.strict", json!(false)), + ("$.version", json!(version)), + ("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")), + ("vc.api.has('dollar')", json!(true)), + ] { + assert_eq!(value(&mut s, expr), want, "{expr}"); + } + assert_eq!(thrown(&mut s, "$.bp()"), "Error: $.bp: scripts here have no debugger"); + assert_eq!(thrown(&mut s, "$.colorPicker(0)"), "Error: $.colorPicker: scripts cannot show a dialog yet"); +} + +#[test] +fn read_only_members_keep_their_values_and_settable_ones_check_what_they_are_given() { + let mut s = Session::new(); + assert_eq!(value(&mut s, "($.build = 'x', $.level = 3, $.os = 'y', [$.build === 'x', $.level, $.os === 'y'])"), json!([false, 0, false])); + assert!(thrown(&mut s, "(function () { 'use strict'; $.version = '9'; })()").starts_with("TypeError")); + assert!(thrown(&mut s, "(function () { 'use strict'; $.error = null; })()").starts_with("TypeError")); + assert_eq!(value(&mut s, "($.appEncoding = 'utf-8', $.appEncoding)"), json!("UTF-8")); + assert!(thrown(&mut s, "$.appEncoding = 'ISO-8859-1'").starts_with("RangeError")); + assert!(thrown(&mut s, "$.appEncoding = 8").starts_with("TypeError")); + assert!(thrown(&mut s, "$.memCache = 1024").starts_with("Error")); + assert_eq!(value(&mut s, "($.flags = 512, [$.flags, $.localize])"), json!([512, true])); + assert_eq!(value(&mut s, "($.flags = 512, $.flags = 0, [$.flags, $.localize])"), json!([0, false])); + for bad in ["$.flags = 2", "$.flags = 513", "$.flags = -512"] { + assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}"); + } + assert!(thrown(&mut s, "$.flags = '512'").starts_with("TypeError")); + assert!(thrown(&mut s, "$.localize = 1").starts_with("TypeError")); +} + +#[test] +fn the_locale_starts_as_the_interfaces_and_a_change_lasts_one_run() { + let mut s = Session::new(); + for (ui, want) in [("de", "de"), ("pt-br", "pt_BR"), ("zh-hant", "zh_TW"), ("zh-hans", "zh_CN"), ("auto", "en"), ("x", "en")] { + s.ui_language = Some(ui.into()); + assert_eq!(value(&mut s, "$.locale"), json!(want), "{ui}"); + } + s.ui_language = None; + s.prefs.interface_language = "fr".into(); + assert_eq!(value(&mut s, "$.locale"), json!("fr"), "without a language from the app, the preference"); + s.ui_language = Some("de".into()); + let lookup = "localize({en: 'e', de: 'd', fr: 'f', fr_CA: 'c'})"; + assert_eq!(value(&mut s, &format!("[{lookup}, $.decimalPoint]")), json!(["d", ","])); + let changed = format!( + "$.locale = 'FR-ca'; var a = [$.locale, {lookup}]; $.locale = 'fr'; a.push({lookup}); $.locale = null; a.push($.locale, {lookup}); a" + ); + assert_eq!(value(&mut s, &format!("(function () {{ {changed}; return a; }})()")), json!(["fr_CA", "c", "f", "de", "d"])); + assert_eq!(value(&mut s, "($.locale = '', $.locale)"), json!("de"), "an empty locale is the interface's"); + assert_eq!(value(&mut s, "($.locale = 'en_US', $.locale)"), json!("en_US")); + assert_eq!(value(&mut s, "$.locale"), json!("de"), "the next run starts with the interface's again"); + for bad in ["$.locale = 'german'", "$.locale = 'de_DE_x'", "$.locale = 'd'"] { + assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}"); + } + assert!(thrown(&mut s, "$.locale = 5").starts_with("TypeError")); +} + +#[test] +fn localized_to_string_is_on_only_while_the_run_turns_it_on() { + let mut s = Session::new(); + s.ui_language = Some("de".into()); + let text = "String({en: 'Open', de: 'Öffnen'})"; + assert_eq!(value(&mut s, text), json!("[object Object]")); + assert_eq!(value(&mut s, &format!("($.localize = true, {text})")), json!("Öffnen")); + assert_eq!(value(&mut s, text), json!("[object Object]"), "the next run starts with it off"); + assert_eq!(value(&mut s, "localize('%1 of %2', 3, 7)"), json!("3 of 7"), "localize works with it off"); +} + +#[test] +fn to_source_writes_plain_data() { + let mut s = Session::new(); + assert_eq!(value(&mut s, "toSource({a: [1, 'x'], b: null})"), json!(r#"({"a":[1, "x"], "b":null})"#)); + assert_eq!(value(&mut s, "[true, 2].toSource()"), json!("[true, 2]")); + assert!(thrown(&mut s, "var o = {}; o.o = o; toSource(o)").starts_with("TypeError")); + assert!(thrown(&mut s, "toSource(vc)").starts_with("TypeError"), "a host object is not plain data"); +} + +#[test] +fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() { + let mut s = Session::new(); + for name in ["HOME", "PATH", "USERPROFILE", "TEMP", "lang", ""] { + assert_eq!(value(&mut s, &format!("$.getenv('{name}')")), json!(null), "{name}"); + } + let process = std::env::var("OS").ok(); + let before = value(&mut s, "$.getenv('OS')"); + if let Some(os) = process.as_deref().filter(|v| v.len() <= 64 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-@".contains(&b))) { + assert_eq!(before, json!(os)); + } + assert_eq!(value(&mut s, "($.setenv('OS', 'Test-1'), $.getenv('OS'))"), json!("Test-1")); + assert_eq!(std::env::var("OS").ok(), process, "the process's environment stays as it was"); + assert_eq!(value(&mut s, "$.getenv('OS')"), before, "the next run reads the process's value again"); + assert!(thrown(&mut s, "$.setenv('HOME', 'x')").starts_with("Error")); + assert!(thrown(&mut s, "$.setenv('LANG', 'a b')").starts_with("RangeError")); + assert!(thrown(&mut s, &format!("$.setenv('LANG', '{}')", "a".repeat(65))).starts_with("RangeError")); + assert!(thrown(&mut s, "$.setenv('LANG')").starts_with("TypeError")); + assert!(thrown(&mut s, "$.getenv(1)").starts_with("TypeError")); +} + +#[test] +fn error_line_and_stack_follow_the_running_code() { + let mut s = Session::new(); + let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());"; + assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true])); + assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one"); +} + +#[test] +fn sleep_waits_between_slices_until_its_time_has_passed() { + let mut s = Session::new(); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + s.execute("script.run", &json!({"source": "vc.log(1); $.sleep(250); vc.log(2);", "name": "t.js"})).unwrap(); + let logs = + |s: &mut Session| s.execute("script.console", &json!({})).unwrap()["lines"].as_array().unwrap().iter().filter(|l| l["kind"] == "log").count(); + assert!(s.script_pump().is_none()); + assert_eq!(logs(&mut s), 1); + TEST_ELAPSED.set(Some(Duration::from_millis(249))); + assert!(s.script_pump().is_none()); + assert!(s.script_pump().is_none()); + assert_eq!(logs(&mut s), 1, "the run waits"); + assert_eq!(s.script_sleep_left(), Some(Duration::from_millis(1)), "the app can wait this long before the next frame"); + TEST_ELAPSED.set(Some(Duration::from_millis(250))); + let r = s.script_pump().unwrap().unwrap(); + assert_eq!(r["log"], json!([1, 2])); + TEST_ELAPSED.set(None); +} + +#[test] +fn a_sleeping_run_stops_when_asked() { + let mut s = Session::new(); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + s.execute("script.run", &json!({"source": "$.sleep(1000); vc.log(2);", "name": "t.js"})).unwrap(); + assert!(s.script_pump().is_none()); + assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1))); + s.execute("script.stop", &json!({})).unwrap(); + assert_eq!(s.script_sleep_left(), None, "a run asked to stop does not wait"); + assert!(s.script_pump().unwrap().is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "stopped"); + TEST_ELAPSED.set(None); +} + +#[test] +fn sleep_takes_milliseconds_from_0_to_1000() { + let mut s = Session::new(); + assert_eq!(value(&mut s, "($.sleep(0), $.sleep(1), 'woke')"), json!("woke")); + for bad in ["$.sleep(-1)", "$.sleep(1001)"] { + assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}"); + } + for bad in ["$.sleep()", "$.sleep('5')", "$.sleep(NaN)", "$.sleep(Infinity)"] { + assert!(thrown(&mut s, bad).starts_with("TypeError"), "{bad}"); + } +} diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 62f6ade7a..0bda89b5e 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -10,6 +10,7 @@ //! run past [`crate::Limits`]. mod array; +pub(crate) mod extensions; mod global; mod json; mod math; @@ -358,6 +359,13 @@ fn object_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResu /// `Object.prototype.toString` (section 15.2.4.2). fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + // With `$.localize` on, a localization object gives its text for the current locale. + if interp.localization.enabled() + && let Value::Object(id) = this + && let Some(text) = extensions::localized_text(interp, id)? + { + return Ok(text); + } let class = match &this { Value::Undefined => "Undefined", Value::Null => "Null", diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs new file mode 100644 index 000000000..102139c9e --- /dev/null +++ b/crates/js/src/builtins/extensions.rs @@ -0,0 +1,393 @@ +//! The script extensions a host adds with [`crate::Realm::add_script_extensions`]: the global +//! `localize`, the global `toSource` and `Object.prototype.toSource`, and the getters behind +//! [`crate::ScriptItem`]. `localize` and `Object.prototype.toString` read the locale of +//! [`Localization`]. +//! +//! `toSource` writes primitives, arrays and plain objects as the text of a JavaScript expression +//! that makes the same value: strings in double quotes, property names quoted, properties in the +//! order the object lists them, a hole in an array as an empty element. It never runs a getter or +//! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed +//! primitive, an array with properties besides its elements, an object whose prototype is not +//! `Object.prototype`, a property that is not enumerable or any other object that is not plain +//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the +//! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use super::{Builder, arg, method}; +use crate::heap::{Class, NativeFn, ObjId, Prop, key}; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::value::{Value, array_index, index_key, number_to_str}; +use crate::{ScriptItem, bytecode::Code}; + +/// The locale `localize` looks a localization object up in, and whether `toString` gives an +/// object's localized text (`$.localize`). A host changes it through [`crate::Realm::localization`]. +#[derive(Debug)] +pub struct Localization { + locale: RefCell, + platform: Cell>, + enabled: Cell, +} + +impl Default for Localization { + fn default() -> Self { + Self { locale: RefCell::new("en".into()), platform: Cell::new(None), enabled: Cell::new(false) } + } +} + +impl Localization { + /// The locale, a language and an optional region joined by `_`, such as `de_DE`. + pub fn locale(&self) -> String { + self.locale.borrow().clone() + } + + pub fn set_locale(&self, locale: &str) { + *self.locale.borrow_mut() = locale.to_string(); + } + + /// The platform suffix the lookup tries first (`Win`, `Mac` or `Unix`), or none. + pub fn platform(&self) -> Option<&'static str> { + self.platform.get() + } + + pub fn set_platform(&self, platform: Option<&'static str>) { + self.platform.set(platform); + } + + /// Does `Object.prototype.toString` give an object's localized text? + pub fn enabled(&self) -> bool { + self.enabled.get() + } + + pub fn set_enabled(&self, on: bool) { + self.enabled.set(on); + } + + /// The property names a localization object is looked up by, in order: the locale with the + /// platform suffix, the locale, its language with the suffix, its language, and `en`. + pub fn keys(&self) -> Vec { + let locale = self.locale(); + let language = locale.split('_').next().unwrap_or_default().to_string(); + let mut keys = Vec::with_capacity(5); + for name in [&locale, &language] { + if let Some(p) = self.platform() { + keys.push(format!("{name}_{p}")); + } + keys.push(name.clone()); + } + keys.push("en".into()); + let mut unique = Vec::with_capacity(keys.len()); + for k in keys { + if !k.is_empty() && !unique.contains(&k) { + unique.push(k); + } + } + unique + } +} + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let global = interp.global; + method(interp, global, "localize", localize, 1)?; + method(interp, global, "toSource", to_source, 1)?; + let object_proto = interp.intrinsics.object_proto; + method(interp, object_proto, "toSource", to_source_method, 0) +} + +/// The text of the localization object `id` for the current locale: the string value of the first +/// of [`Localization::keys`] that the object has as its own data property. No getter runs. +pub(crate) fn localized_text(interp: &mut Interp, id: ObjId) -> JsResult> { + if !matches!(interp.object(id)?.class, Class::Ordinary) { + return Ok(None); + } + for k in interp.localization.keys() { + interp.use_fuel()?; + if let Some(Prop::Data { value: v @ Value::String(_), .. }) = interp.get_own_property(id, &key(&k))? { + return Ok(Some(v)); + } + } + Ok(None) +} + +/// `localize(object, ...values)`: the text of a localization object for the current locale, or of +/// a string, with `%1` replaced by the first value, `%2` by the second and so on. A number with no +/// value stays as it is. An object with none of the locale's names is returned as it is. +fn localize(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let mark = interp.temp_roots.len(); + interp.temp_roots.extend(args.iter().cloned()); + let r = localize_rooted(interp, args); + interp.temp_roots.truncate(mark); + r +} + +fn localize_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { + let text = match args.first() { + Some(Value::String(s)) => s.flat(), + Some(first @ Value::Object(id)) => match localized_text(interp, *id)? { + Some(Value::String(s)) => s.flat(), + _ => return Ok(first.clone()), + }, + _ => return Err(interp.type_error("localize: the first argument must be a localization object or a string")), + }; + let values = args.get(1..).unwrap_or_default(); + let mut converted: Vec>> = vec![None; values.len()]; + let mut out = Builder::new(); + let (mut start, mut i) = (0, 0); + while let Some(&u) = text.get(i) { + if u != u16::from(b'%') { + i += 1; + continue; + } + let digits = text.get(i + 1..).unwrap_or_default().iter().take(4).take_while(|d| (0x30..=0x39).contains(*d)).count(); + let n = text.get(i + 1..i + 1 + digits).unwrap_or_default().iter().fold(0usize, |n, d| n * 10 + usize::from(*d - 0x30)); + let Some(slot) = n.checked_sub(1).filter(|&k| digits > 0 && k < values.len()) else { + i += 1 + digits; + continue; + }; + out.push_units(interp, text.get(start..i).unwrap_or_default())?; + let units = match converted.get(slot).cloned().flatten() { + Some(u) => u, + None => { + let s = interp.to_str(&arg(values, slot))?.flat(); + if let Some(c) = converted.get_mut(slot) { + *c = Some(s.clone()); + } + s + } + }; + out.push_units(interp, &units)?; + i += 1 + digits; + start = i; + } + out.push_units(interp, text.get(start..).unwrap_or_default())?; + Ok(out.finish(interp)) +} + +/// The global `toSource(value)`. +fn to_source(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + write_source(interp, arg(args, 0)) +} + +/// `Object.prototype.toSource()`: the source of the value it is called on. +fn to_source_method(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + write_source(interp, this) +} + +fn write_source(interp: &mut Interp, v: Value) -> JsResult { + let mark = interp.temp_roots.len(); + interp.temp_roots.push(v.clone()); + let mut w = Source { out: Builder::new(), ancestors: Vec::new(), entries: 0 }; + let r = w.value(interp, &v); + interp.temp_roots.truncate(mark); + r?; + Ok(w.out.finish(interp)) +} + +/// A value being written as source: the text so far, the objects it is inside and the entries it +/// has written. +struct Source { + out: Builder, + ancestors: Vec, + entries: usize, +} + +impl Source { + fn push(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + self.out.push_str(interp, s)?; + self.check_length(interp) + } + + fn check_length(&mut self, interp: &mut Interp) -> JsResult<()> { + if self.out.units.len() > interp.data_limits.bytes { + return Err(interp.throw(ErrorType::Range, "toSource: the source is too long")); + } + Ok(()) + } + + fn count(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> { + self.entries = self.entries.saturating_add(n); + if self.entries > interp.data_limits.entries { + return Err(interp.throw(ErrorType::Range, "toSource: the value has too many entries")); + } + Ok(()) + } + + fn value(&mut self, interp: &mut Interp, v: &Value) -> JsResult<()> { + interp.use_fuel()?; + match v { + Value::Undefined => self.push(interp, "(void 0)"), + Value::Null => self.push(interp, "null"), + Value::Bool(b) => self.push(interp, if *b { "true" } else { "false" }), + Value::Number(n) if *n == 0.0 && n.is_sign_negative() => self.push(interp, "-0"), + Value::Number(n) => { + self.out.push_units(interp, &number_to_str(*n).flat())?; + self.check_length(interp) + } + Value::String(s) => self.quoted(interp, &s.flat()), + Value::Object(id) => self.object(interp, *id), + } + } + + /// `s` in double quotes, with the characters a string literal cannot hold escaped. + fn quoted(&mut self, interp: &mut Interp, s: &[u16]) -> JsResult<()> { + self.out.push_str(interp, "\"")?; + let mut start = 0; + for (i, &c) in s.iter().enumerate() { + let escape = match c { + 0x22 => "\\\"".to_string(), + 0x5C => "\\\\".to_string(), + 0x0A => "\\n".to_string(), + 0x0D => "\\r".to_string(), + 0x09 => "\\t".to_string(), + 0x08 => "\\b".to_string(), + 0x0C => "\\f".to_string(), + 0..=0x1F | 0x7F | 0x2028 | 0x2029 | 0xD800..=0xDFFF => format!("\\u{c:04X}"), + _ => continue, + }; + self.out.push_units(interp, s.get(start..i).unwrap_or_default())?; + self.out.push_str(interp, &escape)?; + start = i + 1; + } + self.out.push_units(interp, s.get(start..).unwrap_or_default())?; + self.push(interp, "\"") + } + + fn object(&mut self, interp: &mut Interp, id: ObjId) -> JsResult<()> { + if self.ancestors.contains(&id) { + return Err(interp.type_error("toSource: the value contains itself")); + } + if self.ancestors.len() >= interp.data_limits.depth { + return Err(interp.throw(ErrorType::Range, "toSource: the value is nested too deep")); + } + let (object_proto, array_proto) = (interp.intrinsics.object_proto, interp.intrinsics.array_proto); + let o = interp.object(id)?; + // The text makes an array or an object from `Object.prototype` with enumerable properties + // only, so an array with other properties, an object with another prototype and a property + // that is not enumerable are refused. + let enumerable = o.props.iter().all(|(_, p)| p.enumerable()); + let shape = match &o.class { + Class::Array { length, .. } + if o.proto == Some(array_proto) && enumerable && o.props.iter().all(|(k, _)| array_index(k).is_some_and(|i| i < *length)) => + { + Some(Err(*length)) + } + Class::Array { .. } => return Err(interp.type_error(NOT_PLAIN_ARRAY)), + Class::Ordinary if o.proto == Some(object_proto) && enumerable => { + Some(Ok(o.props.iter().map(|(k, p)| (k.clone(), p.clone())).collect::>())) + } + Class::Ordinary => return Err(interp.type_error(NOT_PLAIN_OBJECT)), + _ => None, + }; + let Some(shape) = shape else { + let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name()); + return Err(interp.type_error(&what)); + }; + self.ancestors.push(id); + let r = match shape { + Err(length) => self.array(interp, id, length), + Ok(props) => self.plain(interp, props), + }; + self.ancestors.pop(); + r + } + + fn array(&mut self, interp: &mut Interp, id: ObjId, length: u32) -> JsResult<()> { + self.count(interp, length as usize)?; + self.push(interp, "[")?; + let mut hole = false; + for i in 0..length { + interp.use_fuel()?; + if i > 0 { + self.push(interp, ", ")?; + } + let prop = interp.object(id)?.props.get(&index_key(i)).cloned(); + hole = prop.is_none(); + match prop { + None => {} + Some(Prop::Data { value, .. }) => self.value(interp, &value)?, + Some(Prop::Accessor { .. }) => return Err(interp.type_error("toSource: a getter cannot be written as source")), + } + } + // A hole at the end needs a comma of its own, or the array would be one shorter. + if hole { + self.push(interp, ",")?; + } + self.push(interp, "]") + } + + fn plain(&mut self, interp: &mut Interp, props: Vec<(crate::heap::Key, Prop)>) -> JsResult<()> { + self.count(interp, props.len())?; + self.push(interp, "({")?; + for (n, (k, prop)) in props.into_iter().enumerate() { + interp.use_fuel()?; + if n > 0 { + self.push(interp, ", ")?; + } + let Prop::Data { value, .. } = prop else { return Err(interp.type_error("toSource: a getter cannot be written as source")) }; + self.quoted(interp, &k)?; + self.push(interp, ":")?; + self.value(interp, &value)?; + } + self.push(interp, "})") + } +} + +const NOT_PLAIN_ARRAY: &str = + "toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source"; +const NOT_PLAIN_OBJECT: &str = + "toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source"; + +/// The getter behind `item`; [`ScriptItem::Global`] is a value and has none. +pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> { + match item { + ScriptItem::Global => None, + ScriptItem::LastError => Some(("error", last_error)), + ScriptItem::Line => Some(("line", line)), + ScriptItem::Stack => Some(("stack", stack)), + } +} + +fn last_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(interp.last_error.clone()) +} + +fn line(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let line = interp.script_frames().last().map_or(0, |(code, pc, _)| code.line_column(pc.saturating_sub(1)).0); + Ok(Value::Number(f64::from(line))) +} + +/// The most frames `$.stack` lists, and the most characters of a function's name it shows. +const STACK_FRAMES: usize = 64; +const STACK_NAME_UNITS: usize = 64; + +/// The script's frames, outermost first, one a line: the function's name and the line it is at. +fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let frames: Vec<(Rc, usize, bool)> = interp.script_frames().map(|(c, pc, f)| (c.clone(), pc, f)).collect(); + let skip = frames.len().saturating_sub(STACK_FRAMES); + let mut text = String::new(); + if skip > 0 { + text.push_str("…\n"); + } + for (code, pc, function) in frames.iter().skip(skip) { + interp.use_fuel()?; + let name = if *function { function_name(&frames, code) } else { "[global]".to_string() }; + let line = code.line_column(pc.saturating_sub(1)).0; + text.push_str(&format!("{name}:{line}\n")); + } + let mut out = Builder::new(); + out.push_str(interp, &text)?; + Ok(out.finish(interp)) +} + +/// The name `code` has: its own as a function expression's, or the one a function declaration of +/// a frame's code gives it, cut to [`STACK_NAME_UNITS`]; `[anonymous]` without one. +fn function_name(frames: &[(Rc, usize, bool)], code: &Rc) -> String { + let declared = frames.iter().find_map(|(outer, ..)| { + outer.fun_decls.iter().find(|(_, i)| outer.functions.get(*i as usize).is_some_and(|f| Rc::ptr_eq(f, code))).map(|(name, _)| name.clone()) + }); + match code.self_name.clone().or(declared) { + Some(name) => String::from_utf16_lossy(name.get(..STACK_NAME_UNITS).unwrap_or(&*name)), + None => "[anonymous]".to_string(), + } +} diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs new file mode 100644 index 000000000..77f40e55a --- /dev/null +++ b/crates/js/src/extension_tests.rs @@ -0,0 +1,179 @@ +//! The script extensions: `localize`, `toSource`, host properties with setters and the items that +//! read the interpreter's own state. + +use std::cell::RefCell; +use std::rc::Rc; + +use super::*; + +fn realm() -> Realm { + let mut r = Realm::new(); + r.add_script_extensions().unwrap(); + r +} + +fn text(r: &mut Realm, source: &str) -> String { + match r.run(source) { + Ok(JsValue::String(s)) => s, + other => panic!("{source}: {other:?}"), + } +} + +fn thrown(r: &mut Realm, source: &str) -> String { + match r.run(source) { + Err(e) if e.kind == RunErrorKind::Thrown => e.message, + other => panic!("{source}: {other:?}"), + } +} + +#[test] +fn localize_looks_up_the_locale_with_its_platform_then_its_language_then_english() { + let mut r = realm(); + let l = r.localization(); + l.set_locale("de_DE"); + l.set_platform(Some("Win")); + assert_eq!(l.keys(), ["de_DE_Win", "de_DE", "de_Win", "de", "en"]); + let all = "({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd', de_DE_Win: 'ddw'})"; + assert_eq!(text(&mut r, &format!("localize({all})")), "ddw"); + assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd'})"), "dd"); + assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw'})"), "dw"); + assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', DE: 'x'})"), "d", "names are case sensitive"); + assert_eq!(text(&mut r, "localize({en: 'e', fr: 'f'})"), "e"); + l.set_platform(None); + assert_eq!(text(&mut r, &format!("localize({all})")), "dd"); + l.set_locale("en"); + assert_eq!(l.keys(), ["en"]); +} + +#[test] +fn localize_replaces_numbered_values_once_and_leaves_the_rest() { + let mut r = realm(); + assert_eq!(text(&mut r, "localize({en: '%2 before %1, %1 again'}, 'a', 'b')"), "b before a, a again"); + assert_eq!(text(&mut r, "localize('%1%% %3 %0 %', 'x', '%1')"), "x%% %3 %0 %"); + assert_eq!(text(&mut r, "localize('%2', 'x', '%1')"), "%1", "a value is not replaced again"); + assert_eq!(text(&mut r, "localize('%1', {toString: function () { return 'o'; }})"), "o"); + assert_eq!(text(&mut r, "var n = 0; var v = {toString: function () { n++; return 'v'; }}; localize('%1%1', v) + n"), "vv1"); +} + +#[test] +fn localize_gives_back_an_object_without_the_locale_and_runs_no_getter() { + let mut r = realm(); + r.localization().set_locale("fr_FR"); + assert_eq!(r.run("var o = {de: 'd'}; localize(o) === o").unwrap(), JsValue::Bool(true)); + let source = "var hit = false; var o = {get fr() { hit = true; return 'g'; }, en: 'e'}; localize(o) + ',' + hit"; + assert_eq!(text(&mut r, source), "e,false"); + assert_eq!(text(&mut r, "localize({fr: 1, en: 'e'})"), "e", "a value that is not a string is passed over"); + for bad in ["localize()", "localize(1)", "localize(null)"] { + assert!(thrown(&mut r, bad).starts_with("TypeError"), "{bad}"); + } +} + +#[test] +fn to_string_localizes_an_object_only_while_localization_is_on() { + let mut r = realm(); + r.localization().set_locale("ja"); + let source = "String({en: 'Hello', ja: 'こんにちは'}) + '|' + String({x: 1})"; + assert_eq!(text(&mut r, source), "[object Object]|[object Object]"); + r.localization().set_enabled(true); + assert_eq!(text(&mut r, source), "こんにちは|[object Object]"); +} + +#[test] +fn to_source_writes_plain_data_as_an_expression_that_makes_it_again() { + let mut r = realm(); + let source = r#"toSource({a: [1, "x\n\"", null, true, -0, 1.5e300], "b c": {d: undefined}, e: {}, f: []})"#; + let want = r#"({"a":[1, "x\n\"", null, true, -0, 1.5e+300], "b c":({"d":(void 0)}), "e":({}), "f":[]})"#; + assert_eq!(text(&mut r, source), want); + assert_eq!(text(&mut r, "[1, , 3].toSource() + '|' + [1, ,].toSource() + '|' + [,].toSource()"), "[1, , 3]|[1, ,]|[,]"); + assert_eq!( + text(&mut r, "toSource(NaN) + toSource(-Infinity) + toSource() + toSource('\\u2028\\ud800\\x7f')"), + "NaN-Infinity(void 0)\"\\u2028\\uD800\\u007F\"" + ); + assert_eq!(text(&mut r, "(5).toSource() + 'a'.toSource() + true.toSource()"), "5\"a\"true"); + // Parsed back, the text makes the same value: holes, -0 and undefined included. + let round = r#"var v = {a: [1, , "q\u0001'\\"], b: {"0": -0, "": undefined, "x y": [[]]}}; + var w = eval(toSource(v)); + [JSON.stringify(w) === JSON.stringify(v), w.a.length, 1 in w.a, 1 / w.b[0], "" in w.b].join()"#; + assert_eq!(text(&mut r, round), "true,3,false,-Infinity,true"); + let shared = "var s = {k: 1}; toSource([s, s])"; + assert_eq!(text(&mut r, shared), r#"[({"k":1}), ({"k":1})]"#, "a value seen twice that is not its own ancestor is written twice"); +} + +#[test] +fn to_source_refuses_what_is_not_plain_data() { + let mut r = realm(); + for (source, kind) in [ + ("var o = {}; o.o = o; toSource(o)", "TypeError"), + ("var a = [1]; a.push([a]); toSource(a)", "TypeError"), + ("toSource({f: function () {}})", "TypeError"), + ("toSource({get g() { return 1; }})", "TypeError"), + ("toSource(new Date(0))", "TypeError"), + ("toSource([/x/])", "TypeError"), + ("toSource(new Error('e'))", "TypeError"), + ("toSource(new String('s'))", "TypeError"), + ("var a = [1]; a.extra = 2; toSource(a)", "TypeError"), + ("toSource(Object.create(Array.prototype))", "TypeError"), + ("var o = {}; Object.defineProperty(o, 'hidden', {value: 1}); toSource(o)", "TypeError"), + ("var a = [0]; Object.defineProperty(a, '0', {value: 1, enumerable: false}); toSource(a)", "TypeError"), + ("toSource(Object.create({a: 1}))", "TypeError"), + ("toSource(Object.create(null))", "TypeError"), + ("function F() { this.a = 1; } toSource(new F())", "TypeError"), + ("var d = []; for (var i = 0; i < 70; i++) d = [d]; toSource(d)", "RangeError"), + ("var a = []; a.length = 2000000; toSource(a)", "RangeError"), + ] { + assert!(thrown(&mut r, source).starts_with(kind), "{source}"); + } + assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run"); +} + +#[test] +fn to_source_stops_at_the_fuel_limit() { + let mut r = realm(); + r.set_limits(Limits { fuel: 200_000, ..Limits::default() }); + let e = r.run("var a = []; for (var i = 0; i < 1000; i++) a.push(i); while (true) toSource(a);").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel)); +} + +#[test] +fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() { + let mut r = realm(); + let items = ["global", "error", "line", "stack"] + .into_iter() + .zip([ScriptItem::Global, ScriptItem::LastError, ScriptItem::Line, ScriptItem::Stack]) + .map(|(n, i)| (n.to_string(), HostItem::Script(i))) + .collect(); + r.define_host_object("h", items).unwrap(); + assert_eq!(r.run("var later = 2; h.global === this && h.global.later === 2").unwrap(), JsValue::Bool(true)); + let source = "var before = h.error; try { null.x; } catch (e) {} var caught = h.error; try { throw 'text'; } catch (e) {} + [before === null, caught instanceof TypeError, h.error === caught].join()"; + assert_eq!(text(&mut r, source), "true,true,true"); + assert_eq!(r.run("h.error").unwrap(), JsValue::Null, "each run starts without one"); + assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(3.0)); + let source = "function outer() {\n return inner();\n}\nfunction inner() {\n return h.stack;\n}\nvar f = function () { return outer(); };\nf()"; + assert_eq!(text(&mut r, source), "[global]:8\n[anonymous]:7\nouter:2\ninner:5\n"); + let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0]"; + let lines = text(&mut r, deep); + assert_eq!(lines, "66,…", "64 frames, the line that some are left out, and the last line's end"); + assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned"); +} + +#[test] +fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() { + let mut r = Realm::new(); + let seen: Rc>> = Rc::default(); + let log = seen.clone(); + let get: HostFunction = Rc::new(move |_| Ok(Data::String("got".into()))); + let set: HostFunction = Rc::new(move |args| match args.first() { + Some(Data::Number(n)) if *n < 0.0 => Err(HostError::Range("no negatives".into())), + Some(d) => { + log.borrow_mut().push(d.clone()); + Ok(Data::Undefined) + } + None => Ok(Data::Undefined), + }); + r.define_host_object("h", vec![("p".into(), HostItem::Property { get, set })]).unwrap(); + assert_eq!(r.run("h.p = 5; h.p = {a: [1]}; h.p").unwrap(), JsValue::String("got".into())); + assert_eq!(*seen.borrow(), [Data::Number(5.0), Data::Object(vec![("a".into(), Data::Array(vec![Data::Number(1.0)]))])]); + let e = r.run("try { h.p = -1; } catch (e) { e instanceof RangeError && e.message }").unwrap(); + assert_eq!(e, JsValue::String("no negatives".into())); +} diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index b725245ce..fd431f750 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -74,6 +74,12 @@ pub enum HostItem { TextFunction(HostFunction), /// A property whose value the function gives each time it is read. Getter(HostFunction), + /// A property whose value `get` gives each time it is read, and that `set` is given each value + /// assigned to it, as its one argument; an error from `set` is thrown to the script. + Property { + get: HostFunction, + set: HostFunction, + }, /// A frozen object with these members. Object(Vec<(String, HostItem)>), /// A function whose host function gives JavaScript source as a string, which then runs as @@ -86,6 +92,23 @@ pub enum HostItem { source: HostFunction, end: Rc, }, + /// A read-only property that reads the interpreter's own state. + Script(ScriptItem), +} + +/// The interpreter's own state a host object can show. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScriptItem { + /// The global object itself, with every global the script has made. + Global, + /// The last `Error` object thrown in the current run, caught or not, or `null` before one. + LastError, + /// The line of the script's code that reads it, counted from 1. + Line, + /// The running frames of the script's code, outermost first, one a line as `name:line`: the + /// global code is `[global]` and a function without a name `[anonymous]`. At most 64 frames + /// are listed, the innermost ones, after a line `…` when some are left out. + Stack, } enum Step { @@ -410,12 +433,24 @@ impl Interp { HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, false)), flags: ENUMERABLE }, HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE }, + HostItem::Property { get, set } => Prop::Accessor { + get: Some(self.host_function(get, &full, false)), + set: Some(self.host_function(set, &full, false)), + flags: ENUMERABLE, + }, HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, HostItem::Evaluator { source, end } => { let proto = self.intrinsics.function_proto; let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto))); Prop::Data { value: Value::Object(f), flags: ENUMERABLE } } + HostItem::Script(item) => match crate::builtins::extensions::getter(item) { + None => Prop::Data { value: Value::Object(self.global), flags: ENUMERABLE }, + Some((name, f)) => { + let get = crate::builtins::native(self, name, f, 0, crate::heap::NativeKind::Plain, false)?; + Prop::Accessor { get: Some(get), set: None, flags: ENUMERABLE } + } + }, }; self.define(o, key(&name), prop) } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 22bbb6c0d..7bfd72528 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -164,6 +164,10 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The last `Error` object thrown in this run, caught or not, or `null` (`$.error`). + pub(crate) last_error: Value, + /// The locale `localize` and `Object.prototype.toString` read. + pub(crate) localization: Rc, /// The state of `Math.random`'s generator. pub(crate) random: u64, /// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a @@ -373,6 +377,8 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + last_error: Value::Null, + localization: Rc::default(), random: random_seed(), join_stack: Vec::new(), held_bytes: 0, @@ -543,6 +549,7 @@ impl Interp { roots.values.extend(self.stack.iter()); roots.values.extend(self.temp_roots.iter()); roots.values.push(&self.last_thrown); + roots.values.push(&self.last_error); for f in &self.frames { roots.values.push(&f.this); roots.values.push(&f.completion); @@ -628,6 +635,11 @@ impl Interp { /// boundary frame. Returns the exception when no handler takes it before the boundary frame. fn unwind(&mut self, v: Value) -> JsResult<()> { self.note_site(); + if let Value::Object(o) = v + && self.heap.object(o).is_some_and(|o| matches!(o.class, Class::Error)) + { + self.last_error = v.clone(); + } loop { let Some(frame) = self.frames.last_mut() else { return Err(Abrupt::Throw(v)) }; if let Some(h) = frame.code.handler(frame.pc.saturating_sub(1)) { @@ -655,6 +667,12 @@ impl Interp { } } + /// Each running frame of script code, outermost first: its code, the index of its next + /// instruction, and whether it runs a function (not global or eval code). + pub(crate) fn script_frames(&self) -> impl Iterator, usize, bool)> { + self.frames.iter().map(|f| (&f.code, f.pc, f.callee.is_some())) + } + fn abort_to_boundary(&mut self) { while let Some(frame) = self.frames.pop() { self.stack.truncate(frame.base); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 67ee2712c..74117bd9a 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -65,8 +65,9 @@ pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, }; +pub use builtins::extensions::Localization; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; -pub use host::{Data, DataLimits, HostError, HostFunction, HostItem}; +pub use host::{Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE}; @@ -187,6 +188,8 @@ impl LineTable { #[cfg(test)] mod builtins_tests; #[cfg(test)] +mod extension_tests; +#[cfg(test)] mod host_tests; #[cfg(test)] mod interp_tests; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index beaad2557..2638c236f 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -221,6 +221,7 @@ impl Realm { interp.source_units = 0; interp.throw_site = None; interp.last_thrown = Value::Undefined; + interp.last_error = Value::Null; interp.host_abort = None; interp.error_tag = UNKNOWN_SOURCE; let strict = interp.options.strict; @@ -339,6 +340,22 @@ impl Realm { } } + /// Adds the script extensions: the global `localize(object, ...values)`, which gives a + /// localization object's text for the locale of [`Realm::localization`], the global + /// `toSource(value)` and `Object.prototype.toSource()`, which write plain data as source text, + /// and the localized `Object.prototype.toString` while [`crate::Localization::enabled`]. + pub fn add_script_extensions(&mut self) -> Result<(), RunError> { + match crate::builtins::extensions::install(&mut self.interp) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + + /// The locale `localize` reads, which the host's functions may change during a run. + pub fn localization(&self) -> std::rc::Rc { + self.interp.localization.clone() + } + /// Defines the global variable `name` holding the exception the last run did not catch /// (`undefined` if there was none). pub fn bind_last_thrown(&mut self, name: &str) { diff --git a/crates/scriptcheck/src/analyze/features.rs b/crates/scriptcheck/src/analyze/features.rs index a0fdc084a..b5dd2299e 100644 --- a/crates/scriptcheck/src/analyze/features.rs +++ b/crates/scriptcheck/src/analyze/features.rs @@ -156,8 +156,6 @@ impl<'s, 'a, 'l> Analyzer<'s, 'a, 'l> { } else if n == catalog::DOLLAR_EVAL_FILE { self.acc.feature(FeatureId::DollarEvalFile, line); self.acc.outside(OutsideId::UnseenCode, line, None); - } else if n == catalog::LOCALIZE { - self.acc.feature(FeatureId::Localize, line); } else { self.acc.feature(FeatureId::Dollar, line); } diff --git a/crates/scriptcheck/src/catalog.rs b/crates/scriptcheck/src/catalog.rs index 50ddee3a1..7ee71c9ee 100644 --- a/crates/scriptcheck/src/catalog.rs +++ b/crates/scriptcheck/src/catalog.rs @@ -16,6 +16,8 @@ pub(crate) const DOLLAR_EVAL_FILE: &str = "evalFile"; // https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/ pub(crate) const NOTIFY: &[&str] = &["alert", "confirm", "prompt"]; +// The global function. `$.localize` is a property of `$` (the Dollar object page above), so it is +// a `$` member: // https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/ pub(crate) const LOCALIZE: &str = "localize"; diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 21025a6e9..e7ead63a4 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -35,6 +35,7 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[ ("directive.target", " //@target acme", "x(); //@target acme"), ("directive.targetengine", "#targetengine session", "var targetengine = 'session';"), ("dollar", "$.writeln('hi');", "log.writeln('hi');"), + ("dollar", "$.localize = true;", "obj.localize = true;"), ("dollar.env", "var home = $.getenv('HOME');", "var home = env.getenv('HOME');"), ("dollar.evalFile", "$.evalFile(f);", "loader.evalFile(f);"), ("dynamicCode", "eval(code);", "safe.eval(code);"), @@ -68,7 +69,6 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[ ("file.write", "Folder.temp.remove();", "cache.temp.remove();"), ("hostCheck", "if (parseInt(app.version) < 20) {}", "if (parseInt(appVersion) < 20) {}"), ("localize", "var s = localize(text);", "var s = text.localize;"), - ("localize", "var s = $.localize(text);", "var s = obj.localize(text);"), ("menuCommand", "app.executeMenuCommand('acmecopyall');", "executeMenuCommand('acmecopyall');"), ("notify", "alert('done');", "panel.alert('done');"), ("notify", "if (confirm('Go?')) {}", "function confirm(m) {}"), @@ -103,6 +103,15 @@ fn each_feature_is_found_where_it_is_and_nowhere_else() { } } +#[test] +fn the_dollar_objects_localize_property_is_not_the_global_localize_function() { + for src in ["$.localize = true;", "if ($.localize) {}", "var s = $.localize(text);"] { + let r = report(src); + assert!(feature(&r, "dollar").is_some(), "{src}: {:?}", r.features); + assert!(feature(&r, "localize").is_none(), "{src}: {:?}", r.features); + } +} + #[test] fn features_are_listed_in_alphabetical_order_of_their_ids() { let ids: Vec<&str> = FeatureId::ALL.iter().map(|f| f.id()).collect(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8237bc877..fade698a2 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -343,7 +343,11 @@ impl VectorcraftApp { crate::script_runs::poll(self); crate::scripts_menu::poll(self, ctx); if self.session.script_running() && !self.session.script_waiting() { - ctx.request_repaint(); + // A script in `$.sleep` goes on when its time is up; until then the app can idle. + match self.session.script_sleep_left() { + Some(left) => ctx.request_repaint_after(left), + None => ctx.request_repaint(), + } } #[cfg(not(target_arch = "wasm32"))] self.tick_link_updates(ctx, now); diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..e60877805 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1597,6 +1597,52 @@ VectorCraft reads them by these rules of its own: - A missing or refused included file ends the run before any of it runs. An error in an included file or a file `$.evalFile` ran is reported at that file's line and column, and the report's `file` names the file. +`$` also has the other members the Dollar object page lists, besides `$.evalFile`, `$.fileName` and `$.includePath` +above. Each run +starts with its own `$`: what a script changes there lasts until the run ends. The values and rules below are +VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: + +| Member | In VectorCraft | +|--------|----------------| +| `about()` | `"VectorCraft script engine "`; no window opens | +| `appEncoding` | `"UTF-8"`; setting `"UTF-8"` is accepted, any other encoding throws a `RangeError` | +| `bp()`, `colorPicker()` | throw an `Error`: scripts have no debugger and cannot show a dialog | +| `build`, `version` | VectorCraft's version | +| `buildDate` | `null` | +| `decimalPoint` | `","` for a locale whose language writes a decimal comma (such as `de`, `fr`, `ru`), otherwise `"."` | +| `engineName` | `"VectorCraft"` | +| `error` | the last `Error` object the run threw, caught or not, or `null` before one | +| `flags` | `0`, or `512` while localization is on; only `0` and `512` can be set, and setting `512` turns `$.localize` on | +| `gc()` | does nothing; the interpreter collects its own garbage | +| `getenv(name)`, `setenv(name, value)` | only `LANG`, `LC_MESSAGES`, `LC_ALL` and `OS`; `getenv` of any other name returns `null`, and `setenv` of one throws. `setenv` changes the value for the rest of the run only, never the app's environment. A value has at most 64 letters, digits and `_ . - @` | +| `global` | the global object | +| `hiresTimer` | the microseconds since the run last read it, or since the run started; `0` on the web | +| `level`, `memCache` | `0`; setting `memCache` throws | +| `line`, `stack` | the line of the code that reads it; the running code, outermost first, one a line as `name:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name), at most 64 lines | +| `locale` | the interface's language as a locale such as `de` or `pt_BR` (`en` when the interface follows the system); a script can set `ll` or `ll_RR`, and `null` or `""` sets the interface's again | +| `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | +| `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | +| `screens` | an empty array | +| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web | +| `strict` | `false` | + +Members that are values (`build`, `level`, `os` and the like) cannot be changed. A wrong value for a member that can +be set throws a `TypeError` or `RangeError`. + +`localize(object, ...values)` gives the text of a localization object, an object whose property names are locales +(https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/). It looks up the +locale with the platform's suffix (`_Win`, `_Mac` or `_Unix`; none on the web), the locale, its language with the +suffix, its language, and then `en`, and returns the object itself when none of them is a string property of it. No +getter runs. `%1` is replaced by the first value, `%2` by the second and so on, each once; a number with no value stays +as it is. A string as the first argument is used as the text. + +`toSource(value)` and `value.toSource()` write `undefined`, `null`, booleans, numbers, strings, arrays and plain +objects as the text of an expression that makes the same value, such as `({"a":[1, "x"]})`. A function, a getter, a +date, a regular expression, an error, a host object such as `vc`, an array with properties besides its elements, an +object whose prototype is not `Object.prototype`, a property that is not enumerable, or an object that contains +itself throws a `TypeError`. A value deeper than 64 levels, with more than 1,048,576 entries or with text longer than 16,777,216 +characters throws a `RangeError`. + The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A run that changes no document adds no undo step. The journal records each command the run finished, as it records commands run one at a time. A preference the run changed takes the run's value, and a preference changed in the app diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49713d661..a5732109f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -730,6 +730,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From 0bb923fc409a788e0a0c33a6b1219be5cd53be57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:18:55 -0800 Subject: [PATCH 2/4] Scripts: $.sleep throws where the run cannot wait, and toSource checks its length as it escapes A getter, a conversion, a callback of a built-in function or a file $.evalFile runs is executed on native frames, so the run cannot stop at a $.sleep there. Such a call used to only mark the run to pause, so the code after it ran first and the wait came later. $.sleep with more than 0 milliseconds now throws an Error there, as alert, confirm and prompt do. toSource checked the 16,777,216-unit length limit only after a whole string was written. A long string of characters that are escaped could reach the heap limit first, which ends the run instead of throwing a RangeError. The limit is now checked before each part is added. --- crates/engine/src/script/dollar.rs | 2 +- crates/engine/src/script/mod.rs | 15 +++++++++++++-- crates/engine/src/tests/script_dollar.rs | 17 +++++++++++++++++ crates/js/src/builtins/extensions.rs | 14 ++++++++++++-- crates/js/src/extension_tests.rs | 12 ++++++++++++ docs/mcp.md | 2 +- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs index bbddfb330..3e6b00584 100644 --- a/crates/engine/src/script/dollar.rs +++ b/crates/engine/src/script/dollar.rs @@ -268,7 +268,7 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc return Err(HostError::Type("$.sleep: give a number of milliseconds".into())), }; h.charge_call(8)?; - h.sleep(Duration::from_secs_f64(ms / 1000.0)); + h.sleep(Duration::from_secs_f64(ms / 1000.0))?; Ok(Data::Undefined) }) }; diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 18af39c36..3e74f96d8 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -507,6 +507,8 @@ struct Host { started: Option, /// The time since [`Host::started`] at which a `$.sleep` ends. wake_at: Cell>, + /// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]). + can_wait: Rc>, } /// An error `$.evalFile` raised, as thrown, and the file, line and column it is at. @@ -595,14 +597,22 @@ impl Host { } /// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock - /// it does not wait. - fn sleep(&self, d: std::time::Duration) { + /// it does not wait. A getter, a conversion, a built-in's callback or a file `$.evalFile` runs + /// cannot stop at the call, so a wait there is an error. + fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> { if let Some(now) = self.elapsed() && !d.is_zero() { + if !self.can_wait.get() { + return Err(HostError::Error( + "$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code" + .into(), + )); + } self.wake_at.set(Some(now.saturating_add(d))); self.pause.set(true); } + Ok(()) } /// The time left of a `$.sleep`, or none when the run is not asleep. @@ -1357,6 +1367,7 @@ impl Session { eval_failure: RefCell::default(), started: clock(), wake_at: Cell::new(None), + can_wait: realm.wait_flag(), }); let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) .map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?; diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index 9ec90b5f7..c080b6a14 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -213,3 +213,20 @@ fn sleep_takes_milliseconds_from_0_to_1000() { assert!(thrown(&mut s, bad).starts_with("TypeError"), "{bad}"); } } + +#[test] +fn sleep_throws_where_the_run_cannot_wait_instead_of_running_on() { + let mut s = Session::new(); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); vc.log('after'); return 1; } }); + try { o.x; } catch (e) { vc.log(e.name + ': ' + e.message); } + try { [1].map(function () { $.sleep(250); vc.log('after'); }); } catch (e) { vc.log(e.name + ': ' + e.message); } + [1].map(function () { $.sleep(0); }); + vc.log('done');"; + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + let r = s.script_pump().expect("the run does not wait").unwrap(); + TEST_ELAPSED.set(None); + let message = "Error: $.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"; + assert_eq!(r["log"], json!([message, message, "done"])); +} diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs index 102139c9e..0ac7e9ac6 100644 --- a/crates/js/src/builtins/extensions.rs +++ b/crates/js/src/builtins/extensions.rs @@ -199,7 +199,12 @@ impl Source { } fn check_length(&mut self, interp: &mut Interp) -> JsResult<()> { - if self.out.units.len() > interp.data_limits.bytes { + self.fit(interp, 0) + } + + /// Checks that `more` code units can be added without passing the length limit. + fn fit(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> { + if self.out.units.len().saturating_add(more) > interp.data_limits.bytes { return Err(interp.throw(ErrorType::Range, "toSource: the source is too long")); } Ok(()) @@ -229,8 +234,11 @@ impl Source { } } - /// `s` in double quotes, with the characters a string literal cannot hold escaped. + /// `s` in double quotes, with the characters a string literal cannot hold escaped. The length + /// limit is checked before each addition, so a string that grows as it is escaped throws a + /// `RangeError` before the text takes more heap than the limit allows. fn quoted(&mut self, interp: &mut Interp, s: &[u16]) -> JsResult<()> { + self.fit(interp, 1)?; self.out.push_str(interp, "\"")?; let mut start = 0; for (i, &c) in s.iter().enumerate() { @@ -245,10 +253,12 @@ impl Source { 0..=0x1F | 0x7F | 0x2028 | 0x2029 | 0xD800..=0xDFFF => format!("\\u{c:04X}"), _ => continue, }; + self.fit(interp, (i - start).saturating_add(escape.len()))?; self.out.push_units(interp, s.get(start..i).unwrap_or_default())?; self.out.push_str(interp, &escape)?; start = i + 1; } + self.fit(interp, s.len().saturating_sub(start).saturating_add(1))?; self.out.push_units(interp, s.get(start..).unwrap_or_default())?; self.push(interp, "\"") } diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 77f40e55a..54d1a1029 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -126,6 +126,18 @@ fn to_source_refuses_what_is_not_plain_data() { assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run"); } +#[test] +fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() { + let mut r = realm(); + // A run's heap in the web build. 2^23 control characters, each written as six, pass a quarter + // of it, which is all one string may take. + r.set_limits(Limits { heap_bytes: 128 << 20, ..Limits::default() }); + let source = "var s = '\\x01'; for (var i = 0; i < 23; i++) s = s + s; + try { toSource(s); 'no error'; } catch (e) { e.name + ': ' + e.message; }"; + assert_eq!(text(&mut r, source), "RangeError: toSource: the source is too long"); + assert_eq!(text(&mut r, "toSource('a\\x01a\\x01a\\x01a\\x01').length + ''"), "30", "short strings are written whole"); +} + #[test] fn to_source_stops_at_the_fuel_limit() { let mut r = realm(); diff --git a/docs/mcp.md b/docs/mcp.md index 520c59a99..7bcd5ca3e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1625,7 +1625,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: | `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | | `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | | `screens` | an empty array | -| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web | +| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | | `strict` | `false` | Members that are values (`build`, `level`, `os` and the like) cannot be changed. A wrong value for a member that can From af22ac8f50ad18ecdc8488ca0c927084c91915be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 04:23:32 -0800 Subject: [PATCH 3/4] Scripts: $.stack names a declared function after its declaring function returns The compiler now keeps a function declaration's name with its code, so $.stack no longer shows [anonymous] for a declared function that is called after the function that declared it has returned. The member test expects "Web" for $.os in the wasm build, as the engine gives there. --- crates/engine/src/tests/script_dollar.rs | 4 +++- crates/js/src/builtins/extensions.rs | 15 ++++++--------- crates/js/src/bytecode.rs | 2 ++ crates/js/src/compiler.rs | 3 +++ crates/js/src/extension_tests.rs | 2 ++ crates/js/src/heap.rs | 2 +- crates/js/src/interp_tests.rs | 1 + 7 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index c080b6a14..07dbb5381 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -30,7 +30,9 @@ fn thrown(s: &mut Session, statement: &str) -> String { fn each_member_has_its_value() { let mut s = Session::new(); let version = env!("CARGO_PKG_VERSION"); - let os = if cfg!(windows) { + let os = if cfg!(target_arch = "wasm32") { + "Web" + } else if cfg!(windows) { "Windows" } else if cfg!(target_os = "macos") { "macOS" diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs index 0ac7e9ac6..15ebbe734 100644 --- a/crates/js/src/builtins/extensions.rs +++ b/crates/js/src/builtins/extensions.rs @@ -381,7 +381,7 @@ fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult } for (code, pc, function) in frames.iter().skip(skip) { interp.use_fuel()?; - let name = if *function { function_name(&frames, code) } else { "[global]".to_string() }; + let name = if *function { function_name(code) } else { "[global]".to_string() }; let line = code.line_column(pc.saturating_sub(1)).0; text.push_str(&format!("{name}:{line}\n")); } @@ -390,14 +390,11 @@ fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult Ok(out.finish(interp)) } -/// The name `code` has: its own as a function expression's, or the one a function declaration of -/// a frame's code gives it, cut to [`STACK_NAME_UNITS`]; `[anonymous]` without one. -fn function_name(frames: &[(Rc, usize, bool)], code: &Rc) -> String { - let declared = frames.iter().find_map(|(outer, ..)| { - outer.fun_decls.iter().find(|(_, i)| outer.functions.get(*i as usize).is_some_and(|f| Rc::ptr_eq(f, code))).map(|(name, _)| name.clone()) - }); - match code.self_name.clone().or(declared) { - Some(name) => String::from_utf16_lossy(name.get(..STACK_NAME_UNITS).unwrap_or(&*name)), +/// The name `code` has: its own as a function expression's, or its declaration's, cut to +/// [`STACK_NAME_UNITS`]; `[anonymous]` without one. +fn function_name(code: &Code) -> String { + match code.self_name.as_ref().or(code.decl_name.as_ref()) { + Some(name) => String::from_utf16_lossy(name.get(..STACK_NAME_UNITS).unwrap_or(&**name)), None => "[anonymous]".to_string(), } } diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index b8bd2783b..9681d0168 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -195,6 +195,8 @@ pub(crate) struct Code { pub(crate) strict: bool, /// A function expression's own name, bound inside it. pub(crate) self_name: Option, + /// A function declaration's name, which `$.stack` shows. + pub(crate) decl_name: Option, pub(crate) params: Vec, pub(crate) vars: Vec, /// Function declarations to instantiate on entry: their names and indices in `functions`. diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index ae470be44..34e64814b 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -1193,6 +1193,7 @@ impl<'a> FnCompiler<'a> { max_stack: 0, strict: self.strict, self_name: meta.self_name, + decl_name: meta.decl_name, params: meta.params, vars: hoisted.vars, fun_decls: hoisted.fun_decls, @@ -1209,6 +1210,7 @@ impl<'a> FnCompiler<'a> { #[derive(Default)] struct Meta { self_name: Option, + decl_name: Option, params: Vec, span: (u32, u32), } @@ -1231,6 +1233,7 @@ pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc, c.emit(Op::Return); let name = f.name.as_ref().map(|n| key_of(&n.name)); let meta = Meta { + decl_name: if f.kind == FunctionKind::Declaration { name.clone() } else { None }, self_name: if bind_name && f.kind == FunctionKind::Expression { name } else { None }, params: f.params.iter().map(|p| key_of(&p.name)).collect(), span: (f.span.start, f.span.end), diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 54d1a1029..eb94e678a 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -163,6 +163,8 @@ fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() { assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(3.0)); let source = "function outer() {\n return inner();\n}\nfunction inner() {\n return h.stack;\n}\nvar f = function () { return outer(); };\nf()"; assert_eq!(text(&mut r, source), "[global]:8\n[anonymous]:7\nouter:2\ninner:5\n"); + let source = "function maker() {\n function named() {\n return h.stack;\n }\n return named;\n}\nvar later = maker();\nlater()"; + assert_eq!(text(&mut r, source), "[global]:8\nnamed:3\n", "a declared function keeps its name after its declaring function returns"); let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0]"; let lines = text(&mut r, deep); assert_eq!(lines, "66,…", "64 frames, the line that some are left out, and the last line's end"); diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 3c98078b1..0dbe94988 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -529,7 +529,7 @@ fn mark_code(code: &Code, strings: &mut StringSizes) { strings.add(flags); } let keys = code.names.iter().chain(&code.params).chain(&code.vars).chain(code.fun_decls.iter().map(|(k, _)| k)); - for k in keys.chain(&code.self_name) { + for k in keys.chain(&code.self_name).chain(&code.decl_name) { strings.add_key(k); } stack.extend(code.functions.iter().map(|f| &**f)); diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index b6fec2637..d2a12b5fb 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -843,6 +843,7 @@ fn code(ops: Vec) -> Code { max_stack: 0, strict: false, self_name: None, + decl_name: None, params: Vec::new(), vars: Vec::new(), fun_decls: Vec::new(), From c746841839e464f1dc801a2528ee5fc8b401018f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 04:48:13 -0800 Subject: [PATCH 4/4] Scripts: $.error keeps the last error thrown, and $.sleep throws in callbacks on the web An exception that a finally block passes on, or one that leaves a built-in's callback, no longer becomes $.error again over an error thrown after it; throwing a caught error again still makes it the last one. $.sleep with more than 0 inside a getter, a conversion or a built-in's callback now throws whether or not the platform has a clock, so the web build throws there as the other builds do. --- crates/engine/src/script/mod.rs | 27 +++++++++++++++--------- crates/engine/src/tests/script_dollar.rs | 15 ++++++++++++- crates/js/src/extension_tests.rs | 9 ++++++++ crates/js/src/interp.rs | 14 ++++++++++-- crates/js/src/realm.rs | 1 + 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index d82cccaf1..125464d9f 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -591,6 +591,10 @@ impl Host { /// The time since the run started; none without a clock. fn elapsed(&self) -> Option { + #[cfg(test)] + if TEST_NO_CLOCK.with(Cell::get) { + return None; + } #[cfg(test)] if let Some(d) = TEST_ELAPSED.with(Cell::get) { return Some(d); @@ -600,17 +604,18 @@ impl Host { /// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock /// it does not wait. A getter, a conversion, a built-in's callback or a file `$.evalFile` runs - /// cannot stop at the call, so a wait there is an error. + /// cannot stop at the call, so a wait there is an error, with or without a clock. fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> { - if let Some(now) = self.elapsed() - && !d.is_zero() - { - if !self.can_wait.get() { - return Err(HostError::Error( - "$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code" - .into(), - )); - } + if d.is_zero() { + return Ok(()); + } + if !self.can_wait.get() { + return Err(HostError::Error( + "$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code" + .into(), + )); + } + if let Some(now) = self.elapsed() { self.wake_at.set(Some(now.saturating_add(d))); self.pause.set(true); } @@ -1752,4 +1757,6 @@ thread_local! { pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. pub(crate) static TEST_ELAPSED: Cell> = const { Cell::new(None) }; + /// Tests: runs have no clock, as in a wasm build. + pub(crate) static TEST_NO_CLOCK: Cell = const { Cell::new(false) }; } diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index 07dbb5381..d5b962450 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -5,7 +5,7 @@ use std::time::Duration; use serde_json::{Value, json}; -use crate::script::TEST_ELAPSED; +use crate::script::{TEST_ELAPSED, TEST_NO_CLOCK}; use crate::*; /// The values `source` gives `vc.log`, in a run of its text. @@ -232,3 +232,16 @@ fn sleep_throws_where_the_run_cannot_wait_instead_of_running_on() { let message = "Error: $.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"; assert_eq!(r["log"], json!([message, message, "done"])); } + +#[test] +fn sleep_throws_where_the_run_cannot_wait_without_a_clock_too() { + let mut s = Session::new(); + TEST_NO_CLOCK.set(true); + let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); return 1; } }); + try { o.x; vc.log('no error'); } catch (e) { vc.log(e.name); } + $.sleep(250); + vc.log('done');"; + let log = logged(&mut s, source); + TEST_NO_CLOCK.set(false); + assert_eq!(log, json!(["Error", "done"]), "the script's own code does not wait without a clock"); +} diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index eb94e678a..19a20ab5b 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -159,6 +159,15 @@ fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() { let source = "var before = h.error; try { null.x; } catch (e) {} var caught = h.error; try { throw 'text'; } catch (e) {} [before === null, caught instanceof TypeError, h.error === caught].join()"; assert_eq!(text(&mut r, source), "true,true,true"); + let source = "try { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } } catch (e) {} + h.error.message"; + assert_eq!(text(&mut r, source), "second", "an exception a finally block passes on is not thrown again"); + let source = "try { [1].map(function () { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } }); } catch (e) {} + h.error.message"; + assert_eq!(text(&mut r, source), "second", "nor is one that leaves a built-in's callback"); + let source = "var kept; try { throw new RangeError('first'); } catch (e) { kept = e; } try { throw new TypeError('second'); } catch (e) {} + try { throw kept; } catch (e) {} h.error.message"; + assert_eq!(text(&mut r, source), "first", "throwing a caught error again makes it the last one"); assert_eq!(r.run("h.error").unwrap(), JsValue::Null, "each run starts without one"); assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(3.0)); let source = "function outer() {\n return inner();\n}\nfunction inner() {\n return h.stack;\n}\nvar f = function () { return outer(); };\nf()"; diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index ff7100277..18ccb3f0d 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -166,6 +166,9 @@ pub(crate) struct Interp { pub(crate) last_thrown: Value, /// The last `Error` object thrown in this run, caught or not, or `null` (`$.error`). pub(crate) last_error: Value, + /// An exception passed on rather than thrown again: one a `finally` block restores, or one + /// leaving a nested run. [`Interp::unwind`] does not make it [`Interp::last_error`] again. + pub(crate) passing: Value, /// The locale `localize` and `Object.prototype.toString` read. pub(crate) localization: Rc, /// The state of `Math.random`'s generator. @@ -386,6 +389,7 @@ impl Interp { throw_site: None, last_thrown: Value::Undefined, last_error: Value::Null, + passing: Value::Undefined, localization: Rc::default(), random: random_seed(), join_stack: Vec::new(), @@ -562,6 +566,7 @@ impl Interp { roots.values.extend(self.temp_roots.iter()); roots.values.push(&self.last_thrown); roots.values.push(&self.last_error); + roots.values.push(&self.passing); for f in &self.frames { roots.values.push(&f.this); roots.values.push(&f.completion); @@ -647,7 +652,9 @@ impl Interp { /// boundary frame. Returns the exception when no handler takes it before the boundary frame. fn unwind(&mut self, v: Value) -> JsResult<()> { self.note_site(); - if let Value::Object(o) = v + let passed = matches!((&v, std::mem::replace(&mut self.passing, Value::Undefined)), (Value::Object(a), Value::Object(b)) if *a == b); + if !passed + && let Value::Object(o) = v && self.heap.object(o).is_some_and(|o| matches!(o.class, Class::Error)) { self.last_error = v.clone(); @@ -674,6 +681,7 @@ impl Interp { let Some(frame) = self.frames.pop() else { return Err(Abrupt::Throw(v)) }; self.stack.truncate(frame.base); if frame.boundary { + self.passing = v.clone(); return Err(Abrupt::Throw(v)); } } @@ -868,7 +876,9 @@ impl Interp { return self.pop().map(drop); } if kind == 1.0 { - return Err(Abrupt::Throw(self.pop()?)); + let v = self.pop()?; + self.passing = v.clone(); + return Err(Abrupt::Throw(v)); } let k = (kind - 2.0) as usize; let target = self.frame()?.code.finally_tables.get(table as usize).and_then(|t| t.get(k)).copied(); diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 62abd7a79..8951bbf2d 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -258,6 +258,7 @@ impl Realm { interp.temp_roots.clear(); interp.join_stack.clear(); interp.waiting = false; + interp.passing = Value::Undefined; match result { Ok(v) => Ok(interp.to_js_value(&v)), Err(Abrupt::Throw(v)) => {