Merge pull request #6563 from dotCooCoo/feat/script-dollar-object

Scripts: $ has the Dollar object's other members, with localize and toSource
This commit is contained in:
BFlat
2026-11-11 21:59:38 +07:00
committed by GitHub
22 changed files with 1415 additions and 16 deletions
+3 -1
View File
@@ -161,7 +161,9 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
_ => 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}));
+307
View File
@@ -0,0 +1,307 @@
//! The members of a script's `$` object besides `$.write`, `$.writeln`, `$.evalFile`, `$.fileName`
//! and `$.includePath`, from the Dollar object
//! page (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>), and
//! the locale the global `localize` reads
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/>).
//!
//! 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<String> {
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<String>)> {
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<String, HostError> {
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<String>) -> Data {
Data::String(s.into())
}
/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`.
pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localization>) -> 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<dyn Fn() -> Data>| HostItem::Getter(function(move |_| Ok(f())));
let property = |get: Rc<dyn Fn() -> Data>, set: Rc<dyn Fn(Data) -> 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";
+92 -9
View File
@@ -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<String, Value>,
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<DialogAnswer>,
}
@@ -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<Vec<(Arc<str>, u32, u32)>>,
/// When the run started, where the platform has a clock.
started: Option<std::time::Instant>,
/// The time since [`Host::started`] at which a `$.sleep` ends.
wake_at: Cell<Option<std::time::Duration>>,
/// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]).
can_wait: Rc<Cell<bool>>,
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
eval_strict: Cell<Option<bool>>,
}
@@ -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<std::time::Duration> {
#[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<std::time::Duration> {
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<Host>) -> Vec<(String, HostItem)> {
/// being run and `$.includePath` the `#includepath` list. Its other members are
/// [`dollar::members`].
fn dollar_object(host: &Rc<Host>, info: dollar::RunInfo, localization: &Rc<vectorcraft_js::Localization>) -> 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<Host>) -> 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<std::time::Duration> {
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<ScriptDialog> {
@@ -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<Option<u64>> = const { Cell::new(None) };
/// Tests: the `JSINCLUDE` folders in place of the environment's.
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
/// Tests: the time since a run started, in place of its clock's.
pub(crate) static TEST_ELAPSED: Cell<Option<std::time::Duration>> = const { Cell::new(None) };
/// Tests: runs have no clock, as in a wasm build.
pub(crate) static TEST_NO_CLOCK: Cell<bool> = const { Cell::new(false) };
}
+1
View File
@@ -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;
+247
View File
@@ -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");
}
+8
View File
@@ -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<Value> {
// 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",
+400
View File
@@ -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<String>,
platform: Cell<Option<&'static str>>,
enabled: Cell<bool>,
}
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<String> {
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<Option<Value>> {
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<Value> {
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<Value> {
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<Option<Rc<[u16]>>> = 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<Value> {
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<Value> {
write_source(interp, this)
}
fn write_source(interp: &mut Interp, v: Value) -> JsResult<Value> {
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<ObjId>,
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::<Vec<_>>()))
}
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<Value> {
Ok(interp.last_error.clone())
}
fn line(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
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<Value> {
let frames: Vec<(Rc<Code>, 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(),
}
}
+2
View File
@@ -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<Key>,
/// A function declaration's name, which `$.stack` shows.
pub(crate) decl_name: Option<Key>,
pub(crate) params: Vec<Key>,
pub(crate) vars: Vec<Key>,
/// Function declarations to instantiate on entry: their names and indices in `functions`.
+3
View File
@@ -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<Key>,
decl_name: Option<Key>,
params: Vec<Key>,
span: (u32, u32),
}
@@ -1231,6 +1233,7 @@ pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc<Source>,
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),
+202
View File
@@ -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<RefCell<Vec<Data>>> = 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()));
}
+1 -1
View File
@@ -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));
+35
View File
@@ -109,6 +109,12 @@ pub enum HostItem {
Converted(Vec<Arg>, 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<dyn Fn()>,
strict_writes: Rc<dyn Fn() -> Option<bool>>,
},
/// 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)
}
+27
View File
@@ -164,6 +164,13 @@ pub(crate) struct Interp {
pub(crate) throw_site: Option<ThrowSite>,
/// 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<crate::builtins::extensions::Localization>,
/// 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<Item = (&Rc<Code>, 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)
{
+1
View File
@@ -843,6 +843,7 @@ fn code(ops: Vec<Op>) -> Code {
max_stack: 0,
strict: false,
self_name: None,
decl_name: None,
params: Vec::new(),
vars: Vec::new(),
fun_decls: Vec::new(),
+4 -1
View File
@@ -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;
+18
View File
@@ -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<crate::Localization> {
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) {
@@ -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);
}
+2
View File
@@ -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";
+10 -1
View File
@@ -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();
+5 -1
View File
@@ -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);
+46
View File
@@ -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 <version>"`; 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
+1
View File
@@ -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`).