diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index cb97caffc..493ba68db 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -161,7 +161,9 @@ 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, answers }; + 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, answers }; 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..3e6b00584 --- /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 7c082739c..213cff53f 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -31,13 +31,15 @@ //! 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`]. //! //! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the //! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. pub mod console; pub mod dialogs; +pub mod dollar; pub mod include; pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog}; @@ -94,7 +96,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", "dialogs"]; +pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs", "dollar"]; /// The most commands a run's report lists in `slowestCommands`. pub const SLOWEST_COMMANDS: usize = 5; @@ -153,6 +155,8 @@ pub(crate) struct Request { pub source: String, pub args: Map, pub fuel: u64, + /// The locale the run starts with (`$.locale`). + pub locale: String, /// The answers to the dialogs of a run with nobody to ask, in order. pub answers: Vec, } @@ -505,6 +509,12 @@ struct Host { /// The file, line and column of each error `$.evalFile` raised for a file it could not run, in /// order: the error's mark ([`HostError::Marked`]) is its index. eval_failures: RefCell, u32, u32)>>, + /// 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>, + /// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]). + can_wait: Rc>, /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). eval_strict: Cell>, } @@ -581,6 +591,51 @@ 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 TEST_NO_CLOCK.with(Cell::get) { + return None; + } + #[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. 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, with or without a clock. + fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> { + 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); + } + Ok(()) + } + + /// 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()) } @@ -948,8 +1003,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| { @@ -978,13 +1034,15 @@ fn dollar_object(host: &Rc) -> Vec<(String, HostItem)> { strict_writes: Rc::new(move || strict.eval_strict.get()), } }; - 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 @@ -1192,6 +1250,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 dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's /// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it. pub fn script_dialog(&self) -> Option { @@ -1298,6 +1365,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()); @@ -1330,20 +1398,26 @@ impl Session { script_map: RefCell::default(), eval_maps: RefCell::default(), eval_failures: RefCell::default(), + started: clock(), + wake_at: Cell::new(None), + can_wait: realm.wait_flag(), eval_strict: 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.set_strict_writes(expanded.strict == Some(true)); 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(|()| dialogs::install(&mut realm, &host)) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) @@ -1376,7 +1450,7 @@ impl Session { if let Some(e) = job.host.dialogs.take_failure() { return Some(self.script_failed(job, &e)); } - if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { + if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); return None; @@ -1518,6 +1592,11 @@ impl Session { if self.script_waiting() || self.script_dialog().is_some() { 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))); + } } } } @@ -1705,4 +1784,8 @@ 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) }; + /// 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.rs b/crates/engine/src/tests.rs index 2af42d292..9c6de6a89 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -152,6 +152,7 @@ mod saveoptions; mod scalestrokes; mod script_console; mod script_dialogs; +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..d5b962450 --- /dev/null +++ b/crates/engine/src/tests/script_dollar.rs @@ -0,0 +1,247 @@ +//! 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, TEST_NO_CLOCK}; +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!(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" + }; + 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}"); + } +} + +#[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"])); +} + +#[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/builtins.rs b/crates/js/src/builtins.rs index 5738beb94..dfb2cf9f7 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..15ebbe734 --- /dev/null +++ b/crates/js/src/builtins/extensions.rs @@ -0,0 +1,400 @@ +//! 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<()> { + 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(()) + } + + 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. 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() { + 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.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, "\"") + } + + 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(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 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 new file mode 100644 index 000000000..19a20ab5b --- /dev/null +++ b/crates/js/src/extension_tests.rs @@ -0,0 +1,202 @@ +//! 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_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(); + 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"); + 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()"; + 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"); + 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/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/host.rs b/crates/js/src/host.rs index 2a12baf3c..4ec8f2c72 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -109,6 +109,12 @@ pub enum HostItem { Converted(Vec, 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 @@ -123,6 +129,23 @@ pub enum HostItem { end: Rc, strict_writes: Rc Option>, }, + /// 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 { @@ -484,6 +507,11 @@ impl Interp { let prop = match item { HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, + HostItem::Property { get, set } => Prop::Accessor { + get: Some(self.host_function(get, &full, ArgRead::Data)), + set: Some(self.host_function(set, &full, ArgRead::Data)), + flags: ENUMERABLE, + }, HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Data)), flags: ENUMERABLE }, HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Text)), flags: ENUMERABLE }, @@ -496,6 +524,13 @@ impl Interp { let f = self.heap.alloc_object(Object::new(Class::Function(func), 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 cacd5a95b..be5760e8f 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -164,6 +164,13 @@ 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, + /// 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. pub(crate) random: u64, /// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a @@ -391,6 +398,9 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + last_error: Value::Null, + passing: Value::Undefined, + localization: Rc::default(), random: random_seed(), join_stack: Vec::new(), held_bytes: 0, @@ -576,6 +586,8 @@ 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); + roots.values.push(&self.passing); roots.objects.extend(self.finally_marks.iter().map(|&(id, _, _)| id)); for f in &self.frames { roots.values.push(&f.this); @@ -662,6 +674,13 @@ 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(); + 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(); + } 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)) { @@ -690,11 +709,18 @@ 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)); } } } + /// 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); @@ -882,6 +908,7 @@ impl Interp { } if kind == 1.0 { let v = self.pop()?; + self.passing = v.clone(); if let Value::Object(o) = v && let Some(i) = self.finally_marks.iter().rposition(|&(id, _, _)| id == o) { 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(), diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3936154eb..bfb5d5c0c 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::{Arg, Data, DataLimits, HostError, HostFunction, HostItem}; +pub use host::{Arg, 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 13ed7d262..1c2bb6ecf 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -229,6 +229,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.waiting = false; interp.error_tag = UNKNOWN_SOURCE; @@ -262,6 +263,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)) => { @@ -398,6 +400,17 @@ 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), + } + } + /// Defines the global function `name`, whose arguments arrive converted as `args` says /// ([`crate::HostItem::Converted`]). Unlike a host object, a script may replace or delete it, as /// it may a built-in function. @@ -408,6 +421,11 @@ impl Realm { } } + /// 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 974ef2405..7b69fbfb8 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() && self.session.script_dialog().is_none() { - 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 9c2a286b7..998828620 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1621,6 +1621,52 @@ VectorCraft reads them by these rules of its own: throws for a file it cannot run, such as a syntax error, when no `catch` takes it. When a `catch` takes that error and a later `throw` raises it again, the error is reported where that `throw` is, as any other thrown error is. +`$` 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; 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 +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 c8fdfcb7e..07d626e5a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -732,6 +732,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - 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: 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: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.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, `#strict on` turns on strict write checks (below), and `#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`).