mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 18:15:49 +00:00
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:
@@ -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}));
|
||||
|
||||
@@ -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";
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user