mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 18:15:49 +00:00
Scripts: the $ object's other members, localize and toSource
A script's $ object now has the other members the Dollar object page lists: about, appEncoding, bp, build, buildDate, colorPicker, decimalPoint, engineName, error, flags, gc, getenv, setenv, global, hiresTimer, level, line, locale, localize, memCache, os, screens, sleep, stack, strict and version. Scripts also get the global localize(object, ...values), toSource(value) and Object.prototype.toSource(). Each run starts with the interface's locale and its own $ state; $.setenv changes only the run's copy of LANG, LC_MESSAGES, LC_ALL and OS, and $.getenv returns null for every other name. $.sleep waits from 0 to 1,000 ms between frames, and script.stop ends the wait. toSource writes plain data only and throws on cycles, getters, host objects and values past the data limits. The script checker reports $.localize as a $ member instead of the global localize function.
This commit is contained in:
@@ -162,7 +162,8 @@ 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 };
|
||||
let locale = script::dollar::ui_locale(s.ui_language.as_deref().unwrap_or(&s.prefs.interface_language));
|
||||
let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, locale };
|
||||
let name = s.script_start(request)?;
|
||||
if s.script_frames {
|
||||
return Ok(json!({"running": true, "name": name}));
|
||||
|
||||
@@ -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,9 +31,11 @@
|
||||
//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line
|
||||
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
|
||||
//! line and a report line; the run's report holds its own output and error, its time, its host
|
||||
//! calls by kind and its slowest commands.
|
||||
//! calls by kind and its slowest commands. The rest of `$`, and the global `localize` and
|
||||
//! `toSource`, are in [`dollar`].
|
||||
|
||||
pub mod console;
|
||||
pub mod dollar;
|
||||
pub mod include;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
@@ -88,7 +90,7 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30;
|
||||
/// The version `vc.api.version` gives.
|
||||
pub const API_VERSION: &str = "1.0";
|
||||
/// The features `vc.api.has` knows.
|
||||
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console"];
|
||||
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dollar"];
|
||||
/// The most commands a run's report lists in `slowestCommands`.
|
||||
pub const SLOWEST_COMMANDS: usize = 5;
|
||||
|
||||
@@ -147,6 +149,8 @@ pub(crate) struct Request {
|
||||
pub source: String,
|
||||
pub args: Map<String, Value>,
|
||||
pub fuel: u64,
|
||||
/// The locale the run starts with (`$.locale`).
|
||||
pub locale: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -489,6 +493,10 @@ struct Host {
|
||||
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
|
||||
/// column that error is at.
|
||||
eval_failure: RefCell<Option<EvalFailure>>,
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
|
||||
@@ -566,6 +574,38 @@ impl Host {
|
||||
pending.end_line(|line, cut| self.line(console::Entry::Output(line), cut));
|
||||
}
|
||||
}
|
||||
|
||||
/// The time since the run started; none without a clock.
|
||||
fn elapsed(&self) -> Option<std::time::Duration> {
|
||||
#[cfg(test)]
|
||||
if let Some(d) = TEST_ELAPSED.with(Cell::get) {
|
||||
return Some(d);
|
||||
}
|
||||
self.started.map(|s| s.elapsed())
|
||||
}
|
||||
|
||||
/// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock
|
||||
/// it does not wait.
|
||||
fn sleep(&self, d: std::time::Duration) {
|
||||
if let Some(now) = self.elapsed()
|
||||
&& !d.is_zero()
|
||||
{
|
||||
self.wake_at.set(Some(now.saturating_add(d)));
|
||||
self.pause.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The time left of a `$.sleep`, or none when the run is not asleep.
|
||||
fn asleep(&self) -> Option<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())
|
||||
}
|
||||
@@ -924,8 +964,9 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) {
|
||||
/// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)`
|
||||
/// gives them, one after another, and `$.writeln(...values)` does the same and ends the line.
|
||||
/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file
|
||||
/// being run and `$.includePath` the `#includepath` list.
|
||||
fn dollar_object(host: &Rc<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| {
|
||||
@@ -949,13 +990,15 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
|
||||
let end = host.clone();
|
||||
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
|
||||
};
|
||||
vec![
|
||||
let mut items = vec![
|
||||
("write".into(), writer(false)),
|
||||
("writeln".into(), writer(true)),
|
||||
("evalFile".into(), eval_file),
|
||||
("fileName".into(), getter(include::Files::file_name)),
|
||||
("includePath".into(), getter(include::Files::include_path)),
|
||||
]
|
||||
];
|
||||
items.extend(dollar::members(host, info, localization));
|
||||
items
|
||||
}
|
||||
|
||||
/// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being
|
||||
@@ -1163,6 +1206,15 @@ impl Session {
|
||||
!self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The time left of the running script's `$.sleep`, or none when it is not asleep. A run asked
|
||||
/// to stop is not asleep: the next [`Session::script_pump`] ends it.
|
||||
pub fn script_sleep_left(&self) -> Option<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 name of the running script.
|
||||
pub fn script_name(&self) -> Option<String> {
|
||||
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone()))
|
||||
@@ -1243,6 +1295,7 @@ impl Session {
|
||||
};
|
||||
let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() };
|
||||
let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() };
|
||||
let strict = options.strict;
|
||||
let mut realm = Realm::with_options(options, limits);
|
||||
realm.share_interrupt(&self.script_stop);
|
||||
realm.set_data_limits(DataLimits::default());
|
||||
@@ -1274,18 +1327,23 @@ impl Session {
|
||||
script_map: RefCell::default(),
|
||||
eval_maps: RefCell::default(),
|
||||
eval_failure: RefCell::default(),
|
||||
started: clock(),
|
||||
wake_at: Cell::new(None),
|
||||
});
|
||||
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
|
||||
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
|
||||
let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start };
|
||||
self.script_console.push(line, false);
|
||||
let info = dollar::RunInfo { locale: request.locale, strict };
|
||||
let localization = realm.localization();
|
||||
let started = match expanded {
|
||||
Err(f) => Err(preprocessor_error(f)),
|
||||
Ok(expanded) => {
|
||||
*host.script_map.borrow_mut() = expanded.map;
|
||||
realm
|
||||
.define_host_object("vc", vc_object(&host, args))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
|
||||
.add_script_extensions()
|
||||
.and_then(|()| realm.define_host_object("vc", vc_object(&host, args)))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization)))
|
||||
.and_then(|()| realm.start(&expanded.source))
|
||||
.map_err(|e| located(&host, realm.error_source(), &e))
|
||||
}
|
||||
@@ -1312,7 +1370,7 @@ impl Session {
|
||||
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else {
|
||||
return Some(Err(EngineError::Other(JOB_GONE.to_string())));
|
||||
};
|
||||
if job.host.waiting.get() && !self.script_stop.is_set() {
|
||||
if (job.host.waiting.get() || job.host.asleep().is_some()) && !self.script_stop.is_set() {
|
||||
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job));
|
||||
self.script_key = Some(key);
|
||||
return None;
|
||||
@@ -1441,6 +1499,11 @@ impl Session {
|
||||
if self.script_waiting() {
|
||||
self.script_stop.set();
|
||||
}
|
||||
// A `$.sleep` waits here, where nothing is drawn, instead of turning the loop over.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(left) = self.script_sleep_left() {
|
||||
std::thread::sleep(left.min(std::time::Duration::from_millis(100)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1620,4 +1683,6 @@ thread_local! {
|
||||
pub(crate) static TEST_GROWTH: Cell<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) };
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ mod save;
|
||||
mod saveoptions;
|
||||
mod scalestrokes;
|
||||
mod script_console;
|
||||
mod script_dollar;
|
||||
mod script_folder;
|
||||
mod script_include;
|
||||
mod script_run;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! The `$` object's members besides `$.write` and `$.writeln`, the global `localize` and
|
||||
//! `toSource`, and what of them one run keeps to itself.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::script::TEST_ELAPSED;
|
||||
use crate::*;
|
||||
|
||||
/// The values `source` gives `vc.log`, in a run of its text.
|
||||
fn logged(s: &mut Session, source: &str) -> Value {
|
||||
s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap_or_else(|e| panic!("{source}: {e}"))["log"].clone()
|
||||
}
|
||||
|
||||
/// The one value `expr` gives, logged.
|
||||
fn value(s: &mut Session, expr: &str) -> Value {
|
||||
logged(s, &format!("vc.log({expr});"))[0].clone()
|
||||
}
|
||||
|
||||
/// The name of the error `statement` throws and its message, as `Name: message`.
|
||||
fn thrown(s: &mut Session, statement: &str) -> String {
|
||||
value(s, &format!("(function () {{ try {{ {statement}; return 'no error'; }} catch (e) {{ return e.name + ': ' + e.message; }} }})()"))
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_member_has_its_value() {
|
||||
let mut s = Session::new();
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let os = if cfg!(windows) {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
} else if cfg!(target_os = "freebsd") {
|
||||
"FreeBSD"
|
||||
} else {
|
||||
"Unix"
|
||||
};
|
||||
for (expr, want) in [
|
||||
("$.about()", json!(format!("VectorCraft script engine {version}"))),
|
||||
("$.appEncoding", json!("UTF-8")),
|
||||
("$.build", json!(version)),
|
||||
("$.buildDate", json!(null)),
|
||||
("$.decimalPoint", json!(".")),
|
||||
("$.engineName", json!("VectorCraft")),
|
||||
("$.error", json!(null)),
|
||||
("$.flags", json!(0)),
|
||||
("$.gc() === undefined", json!(true)),
|
||||
("$.global === this", json!(true)),
|
||||
("typeof $.hiresTimer === 'number' && $.hiresTimer >= 0", json!(true)),
|
||||
("$.level", json!(0)),
|
||||
("\n$.line", json!(2)),
|
||||
("$.locale", json!("en")),
|
||||
("$.localize", json!(false)),
|
||||
("$.memCache", json!(0)),
|
||||
("$.os", json!(os)),
|
||||
("$.screens.length", json!(0)),
|
||||
("$.stack", json!("[global]:1\n")),
|
||||
("$.strict", json!(false)),
|
||||
("$.version", json!(version)),
|
||||
("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")),
|
||||
("vc.api.has('dollar')", json!(true)),
|
||||
] {
|
||||
assert_eq!(value(&mut s, expr), want, "{expr}");
|
||||
}
|
||||
assert_eq!(thrown(&mut s, "$.bp()"), "Error: $.bp: scripts here have no debugger");
|
||||
assert_eq!(thrown(&mut s, "$.colorPicker(0)"), "Error: $.colorPicker: scripts cannot show a dialog yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_members_keep_their_values_and_settable_ones_check_what_they_are_given() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(value(&mut s, "($.build = 'x', $.level = 3, $.os = 'y', [$.build === 'x', $.level, $.os === 'y'])"), json!([false, 0, false]));
|
||||
assert!(thrown(&mut s, "(function () { 'use strict'; $.version = '9'; })()").starts_with("TypeError"));
|
||||
assert!(thrown(&mut s, "(function () { 'use strict'; $.error = null; })()").starts_with("TypeError"));
|
||||
assert_eq!(value(&mut s, "($.appEncoding = 'utf-8', $.appEncoding)"), json!("UTF-8"));
|
||||
assert!(thrown(&mut s, "$.appEncoding = 'ISO-8859-1'").starts_with("RangeError"));
|
||||
assert!(thrown(&mut s, "$.appEncoding = 8").starts_with("TypeError"));
|
||||
assert!(thrown(&mut s, "$.memCache = 1024").starts_with("Error"));
|
||||
assert_eq!(value(&mut s, "($.flags = 512, [$.flags, $.localize])"), json!([512, true]));
|
||||
assert_eq!(value(&mut s, "($.flags = 512, $.flags = 0, [$.flags, $.localize])"), json!([0, false]));
|
||||
for bad in ["$.flags = 2", "$.flags = 513", "$.flags = -512"] {
|
||||
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
|
||||
}
|
||||
assert!(thrown(&mut s, "$.flags = '512'").starts_with("TypeError"));
|
||||
assert!(thrown(&mut s, "$.localize = 1").starts_with("TypeError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_locale_starts_as_the_interfaces_and_a_change_lasts_one_run() {
|
||||
let mut s = Session::new();
|
||||
for (ui, want) in [("de", "de"), ("pt-br", "pt_BR"), ("zh-hant", "zh_TW"), ("zh-hans", "zh_CN"), ("auto", "en"), ("x", "en")] {
|
||||
s.ui_language = Some(ui.into());
|
||||
assert_eq!(value(&mut s, "$.locale"), json!(want), "{ui}");
|
||||
}
|
||||
s.ui_language = None;
|
||||
s.prefs.interface_language = "fr".into();
|
||||
assert_eq!(value(&mut s, "$.locale"), json!("fr"), "without a language from the app, the preference");
|
||||
s.ui_language = Some("de".into());
|
||||
let lookup = "localize({en: 'e', de: 'd', fr: 'f', fr_CA: 'c'})";
|
||||
assert_eq!(value(&mut s, &format!("[{lookup}, $.decimalPoint]")), json!(["d", ","]));
|
||||
let changed = format!(
|
||||
"$.locale = 'FR-ca'; var a = [$.locale, {lookup}]; $.locale = 'fr'; a.push({lookup}); $.locale = null; a.push($.locale, {lookup}); a"
|
||||
);
|
||||
assert_eq!(value(&mut s, &format!("(function () {{ {changed}; return a; }})()")), json!(["fr_CA", "c", "f", "de", "d"]));
|
||||
assert_eq!(value(&mut s, "($.locale = '', $.locale)"), json!("de"), "an empty locale is the interface's");
|
||||
assert_eq!(value(&mut s, "($.locale = 'en_US', $.locale)"), json!("en_US"));
|
||||
assert_eq!(value(&mut s, "$.locale"), json!("de"), "the next run starts with the interface's again");
|
||||
for bad in ["$.locale = 'german'", "$.locale = 'de_DE_x'", "$.locale = 'd'"] {
|
||||
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
|
||||
}
|
||||
assert!(thrown(&mut s, "$.locale = 5").starts_with("TypeError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localized_to_string_is_on_only_while_the_run_turns_it_on() {
|
||||
let mut s = Session::new();
|
||||
s.ui_language = Some("de".into());
|
||||
let text = "String({en: 'Open', de: 'Öffnen'})";
|
||||
assert_eq!(value(&mut s, text), json!("[object Object]"));
|
||||
assert_eq!(value(&mut s, &format!("($.localize = true, {text})")), json!("Öffnen"));
|
||||
assert_eq!(value(&mut s, text), json!("[object Object]"), "the next run starts with it off");
|
||||
assert_eq!(value(&mut s, "localize('%1 of %2', 3, 7)"), json!("3 of 7"), "localize works with it off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_source_writes_plain_data() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(value(&mut s, "toSource({a: [1, 'x'], b: null})"), json!(r#"({"a":[1, "x"], "b":null})"#));
|
||||
assert_eq!(value(&mut s, "[true, 2].toSource()"), json!("[true, 2]"));
|
||||
assert!(thrown(&mut s, "var o = {}; o.o = o; toSource(o)").starts_with("TypeError"));
|
||||
assert!(thrown(&mut s, "toSource(vc)").starts_with("TypeError"), "a host object is not plain data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() {
|
||||
let mut s = Session::new();
|
||||
for name in ["HOME", "PATH", "USERPROFILE", "TEMP", "lang", ""] {
|
||||
assert_eq!(value(&mut s, &format!("$.getenv('{name}')")), json!(null), "{name}");
|
||||
}
|
||||
let process = std::env::var("OS").ok();
|
||||
let before = value(&mut s, "$.getenv('OS')");
|
||||
if let Some(os) = process.as_deref().filter(|v| v.len() <= 64 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-@".contains(&b))) {
|
||||
assert_eq!(before, json!(os));
|
||||
}
|
||||
assert_eq!(value(&mut s, "($.setenv('OS', 'Test-1'), $.getenv('OS'))"), json!("Test-1"));
|
||||
assert_eq!(std::env::var("OS").ok(), process, "the process's environment stays as it was");
|
||||
assert_eq!(value(&mut s, "$.getenv('OS')"), before, "the next run reads the process's value again");
|
||||
assert!(thrown(&mut s, "$.setenv('HOME', 'x')").starts_with("Error"));
|
||||
assert!(thrown(&mut s, "$.setenv('LANG', 'a b')").starts_with("RangeError"));
|
||||
assert!(thrown(&mut s, &format!("$.setenv('LANG', '{}')", "a".repeat(65))).starts_with("RangeError"));
|
||||
assert!(thrown(&mut s, "$.setenv('LANG')").starts_with("TypeError"));
|
||||
assert!(thrown(&mut s, "$.getenv(1)").starts_with("TypeError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_line_and_stack_follow_the_running_code() {
|
||||
let mut s = Session::new();
|
||||
let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());";
|
||||
assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true]));
|
||||
assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_waits_between_slices_until_its_time_has_passed() {
|
||||
let mut s = Session::new();
|
||||
s.script_frames = true;
|
||||
TEST_ELAPSED.set(Some(Duration::ZERO));
|
||||
s.execute("script.run", &json!({"source": "vc.log(1); $.sleep(250); vc.log(2);", "name": "t.js"})).unwrap();
|
||||
let logs =
|
||||
|s: &mut Session| s.execute("script.console", &json!({})).unwrap()["lines"].as_array().unwrap().iter().filter(|l| l["kind"] == "log").count();
|
||||
assert!(s.script_pump().is_none());
|
||||
assert_eq!(logs(&mut s), 1);
|
||||
TEST_ELAPSED.set(Some(Duration::from_millis(249)));
|
||||
assert!(s.script_pump().is_none());
|
||||
assert!(s.script_pump().is_none());
|
||||
assert_eq!(logs(&mut s), 1, "the run waits");
|
||||
assert_eq!(s.script_sleep_left(), Some(Duration::from_millis(1)), "the app can wait this long before the next frame");
|
||||
TEST_ELAPSED.set(Some(Duration::from_millis(250)));
|
||||
let r = s.script_pump().unwrap().unwrap();
|
||||
assert_eq!(r["log"], json!([1, 2]));
|
||||
TEST_ELAPSED.set(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sleeping_run_stops_when_asked() {
|
||||
let mut s = Session::new();
|
||||
s.script_frames = true;
|
||||
TEST_ELAPSED.set(Some(Duration::ZERO));
|
||||
s.execute("script.run", &json!({"source": "$.sleep(1000); vc.log(2);", "name": "t.js"})).unwrap();
|
||||
assert!(s.script_pump().is_none());
|
||||
assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1)));
|
||||
s.execute("script.stop", &json!({})).unwrap();
|
||||
assert_eq!(s.script_sleep_left(), None, "a run asked to stop does not wait");
|
||||
assert!(s.script_pump().unwrap().is_err());
|
||||
assert_eq!(s.script_report().unwrap()["kind"], "stopped");
|
||||
TEST_ELAPSED.set(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_takes_milliseconds_from_0_to_1000() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(value(&mut s, "($.sleep(0), $.sleep(1), 'woke')"), json!("woke"));
|
||||
for bad in ["$.sleep(-1)", "$.sleep(1001)"] {
|
||||
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
|
||||
}
|
||||
for bad in ["$.sleep()", "$.sleep('5')", "$.sleep(NaN)", "$.sleep(Infinity)"] {
|
||||
assert!(thrown(&mut s, bad).starts_with("TypeError"), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -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,393 @@
|
||||
//! The script extensions a host adds with [`crate::Realm::add_script_extensions`]: the global
|
||||
//! `localize`, the global `toSource` and `Object.prototype.toSource`, and the getters behind
|
||||
//! [`crate::ScriptItem`]. `localize` and `Object.prototype.toString` read the locale of
|
||||
//! [`Localization`].
|
||||
//!
|
||||
//! `toSource` writes primitives, arrays and plain objects as the text of a JavaScript expression
|
||||
//! that makes the same value: strings in double quotes, property names quoted, properties in the
|
||||
//! order the object lists them, a hole in an array as an empty element. It never runs a getter or
|
||||
//! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed
|
||||
//! primitive, an array with properties besides its elements, an object whose prototype is not
|
||||
//! `Object.prototype`, a property that is not enumerable or any other object that is not plain
|
||||
//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the
|
||||
//! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::{Builder, arg, method};
|
||||
use crate::heap::{Class, NativeFn, ObjId, Prop, key};
|
||||
use crate::interp::{ErrorType, Interp, JsResult};
|
||||
use crate::value::{Value, array_index, index_key, number_to_str};
|
||||
use crate::{ScriptItem, bytecode::Code};
|
||||
|
||||
/// The locale `localize` looks a localization object up in, and whether `toString` gives an
|
||||
/// object's localized text (`$.localize`). A host changes it through [`crate::Realm::localization`].
|
||||
#[derive(Debug)]
|
||||
pub struct Localization {
|
||||
locale: RefCell<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<()> {
|
||||
if self.out.units.len() > interp.data_limits.bytes {
|
||||
return Err(interp.throw(ErrorType::Range, "toSource: the source is too long"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> {
|
||||
self.entries = self.entries.saturating_add(n);
|
||||
if self.entries > interp.data_limits.entries {
|
||||
return Err(interp.throw(ErrorType::Range, "toSource: the value has too many entries"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn value(&mut self, interp: &mut Interp, v: &Value) -> JsResult<()> {
|
||||
interp.use_fuel()?;
|
||||
match v {
|
||||
Value::Undefined => self.push(interp, "(void 0)"),
|
||||
Value::Null => self.push(interp, "null"),
|
||||
Value::Bool(b) => self.push(interp, if *b { "true" } else { "false" }),
|
||||
Value::Number(n) if *n == 0.0 && n.is_sign_negative() => self.push(interp, "-0"),
|
||||
Value::Number(n) => {
|
||||
self.out.push_units(interp, &number_to_str(*n).flat())?;
|
||||
self.check_length(interp)
|
||||
}
|
||||
Value::String(s) => self.quoted(interp, &s.flat()),
|
||||
Value::Object(id) => self.object(interp, *id),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` in double quotes, with the characters a string literal cannot hold escaped.
|
||||
fn quoted(&mut self, interp: &mut Interp, s: &[u16]) -> JsResult<()> {
|
||||
self.out.push_str(interp, "\"")?;
|
||||
let mut start = 0;
|
||||
for (i, &c) in s.iter().enumerate() {
|
||||
let escape = match c {
|
||||
0x22 => "\\\"".to_string(),
|
||||
0x5C => "\\\\".to_string(),
|
||||
0x0A => "\\n".to_string(),
|
||||
0x0D => "\\r".to_string(),
|
||||
0x09 => "\\t".to_string(),
|
||||
0x08 => "\\b".to_string(),
|
||||
0x0C => "\\f".to_string(),
|
||||
0..=0x1F | 0x7F | 0x2028 | 0x2029 | 0xD800..=0xDFFF => format!("\\u{c:04X}"),
|
||||
_ => continue,
|
||||
};
|
||||
self.out.push_units(interp, s.get(start..i).unwrap_or_default())?;
|
||||
self.out.push_str(interp, &escape)?;
|
||||
start = i + 1;
|
||||
}
|
||||
self.out.push_units(interp, s.get(start..).unwrap_or_default())?;
|
||||
self.push(interp, "\"")
|
||||
}
|
||||
|
||||
fn object(&mut self, interp: &mut Interp, id: ObjId) -> JsResult<()> {
|
||||
if self.ancestors.contains(&id) {
|
||||
return Err(interp.type_error("toSource: the value contains itself"));
|
||||
}
|
||||
if self.ancestors.len() >= interp.data_limits.depth {
|
||||
return Err(interp.throw(ErrorType::Range, "toSource: the value is nested too deep"));
|
||||
}
|
||||
let (object_proto, array_proto) = (interp.intrinsics.object_proto, interp.intrinsics.array_proto);
|
||||
let o = interp.object(id)?;
|
||||
// The text makes an array or an object from `Object.prototype` with enumerable properties
|
||||
// only, so an array with other properties, an object with another prototype and a property
|
||||
// that is not enumerable are refused.
|
||||
let enumerable = o.props.iter().all(|(_, p)| p.enumerable());
|
||||
let shape = match &o.class {
|
||||
Class::Array { length, .. }
|
||||
if o.proto == Some(array_proto) && enumerable && o.props.iter().all(|(k, _)| array_index(k).is_some_and(|i| i < *length)) =>
|
||||
{
|
||||
Some(Err(*length))
|
||||
}
|
||||
Class::Array { .. } => return Err(interp.type_error(NOT_PLAIN_ARRAY)),
|
||||
Class::Ordinary if o.proto == Some(object_proto) && enumerable => {
|
||||
Some(Ok(o.props.iter().map(|(k, p)| (k.clone(), p.clone())).collect::<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(&frames, code) } else { "[global]".to_string() };
|
||||
let line = code.line_column(pc.saturating_sub(1)).0;
|
||||
text.push_str(&format!("{name}:{line}\n"));
|
||||
}
|
||||
let mut out = Builder::new();
|
||||
out.push_str(interp, &text)?;
|
||||
Ok(out.finish(interp))
|
||||
}
|
||||
|
||||
/// The name `code` has: its own as a function expression's, or the one a function declaration of
|
||||
/// a frame's code gives it, cut to [`STACK_NAME_UNITS`]; `[anonymous]` without one.
|
||||
fn function_name(frames: &[(Rc<Code>, usize, bool)], code: &Rc<Code>) -> String {
|
||||
let declared = frames.iter().find_map(|(outer, ..)| {
|
||||
outer.fun_decls.iter().find(|(_, i)| outer.functions.get(*i as usize).is_some_and(|f| Rc::ptr_eq(f, code))).map(|(name, _)| name.clone())
|
||||
});
|
||||
match code.self_name.clone().or(declared) {
|
||||
Some(name) => String::from_utf16_lossy(name.get(..STACK_NAME_UNITS).unwrap_or(&*name)),
|
||||
None => "[anonymous]".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! The script extensions: `localize`, `toSource`, host properties with setters and the items that
|
||||
//! read the interpreter's own state.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn realm() -> Realm {
|
||||
let mut r = Realm::new();
|
||||
r.add_script_extensions().unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
fn text(r: &mut Realm, source: &str) -> String {
|
||||
match r.run(source) {
|
||||
Ok(JsValue::String(s)) => s,
|
||||
other => panic!("{source}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn thrown(r: &mut Realm, source: &str) -> String {
|
||||
match r.run(source) {
|
||||
Err(e) if e.kind == RunErrorKind::Thrown => e.message,
|
||||
other => panic!("{source}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localize_looks_up_the_locale_with_its_platform_then_its_language_then_english() {
|
||||
let mut r = realm();
|
||||
let l = r.localization();
|
||||
l.set_locale("de_DE");
|
||||
l.set_platform(Some("Win"));
|
||||
assert_eq!(l.keys(), ["de_DE_Win", "de_DE", "de_Win", "de", "en"]);
|
||||
let all = "({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd', de_DE_Win: 'ddw'})";
|
||||
assert_eq!(text(&mut r, &format!("localize({all})")), "ddw");
|
||||
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd'})"), "dd");
|
||||
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw'})"), "dw");
|
||||
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', DE: 'x'})"), "d", "names are case sensitive");
|
||||
assert_eq!(text(&mut r, "localize({en: 'e', fr: 'f'})"), "e");
|
||||
l.set_platform(None);
|
||||
assert_eq!(text(&mut r, &format!("localize({all})")), "dd");
|
||||
l.set_locale("en");
|
||||
assert_eq!(l.keys(), ["en"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localize_replaces_numbered_values_once_and_leaves_the_rest() {
|
||||
let mut r = realm();
|
||||
assert_eq!(text(&mut r, "localize({en: '%2 before %1, %1 again'}, 'a', 'b')"), "b before a, a again");
|
||||
assert_eq!(text(&mut r, "localize('%1%% %3 %0 %', 'x', '%1')"), "x%% %3 %0 %");
|
||||
assert_eq!(text(&mut r, "localize('%2', 'x', '%1')"), "%1", "a value is not replaced again");
|
||||
assert_eq!(text(&mut r, "localize('%1', {toString: function () { return 'o'; }})"), "o");
|
||||
assert_eq!(text(&mut r, "var n = 0; var v = {toString: function () { n++; return 'v'; }}; localize('%1%1', v) + n"), "vv1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localize_gives_back_an_object_without_the_locale_and_runs_no_getter() {
|
||||
let mut r = realm();
|
||||
r.localization().set_locale("fr_FR");
|
||||
assert_eq!(r.run("var o = {de: 'd'}; localize(o) === o").unwrap(), JsValue::Bool(true));
|
||||
let source = "var hit = false; var o = {get fr() { hit = true; return 'g'; }, en: 'e'}; localize(o) + ',' + hit";
|
||||
assert_eq!(text(&mut r, source), "e,false");
|
||||
assert_eq!(text(&mut r, "localize({fr: 1, en: 'e'})"), "e", "a value that is not a string is passed over");
|
||||
for bad in ["localize()", "localize(1)", "localize(null)"] {
|
||||
assert!(thrown(&mut r, bad).starts_with("TypeError"), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_string_localizes_an_object_only_while_localization_is_on() {
|
||||
let mut r = realm();
|
||||
r.localization().set_locale("ja");
|
||||
let source = "String({en: 'Hello', ja: 'こんにちは'}) + '|' + String({x: 1})";
|
||||
assert_eq!(text(&mut r, source), "[object Object]|[object Object]");
|
||||
r.localization().set_enabled(true);
|
||||
assert_eq!(text(&mut r, source), "こんにちは|[object Object]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_source_writes_plain_data_as_an_expression_that_makes_it_again() {
|
||||
let mut r = realm();
|
||||
let source = r#"toSource({a: [1, "x\n\"", null, true, -0, 1.5e300], "b c": {d: undefined}, e: {}, f: []})"#;
|
||||
let want = r#"({"a":[1, "x\n\"", null, true, -0, 1.5e+300], "b c":({"d":(void 0)}), "e":({}), "f":[]})"#;
|
||||
assert_eq!(text(&mut r, source), want);
|
||||
assert_eq!(text(&mut r, "[1, , 3].toSource() + '|' + [1, ,].toSource() + '|' + [,].toSource()"), "[1, , 3]|[1, ,]|[,]");
|
||||
assert_eq!(
|
||||
text(&mut r, "toSource(NaN) + toSource(-Infinity) + toSource() + toSource('\\u2028\\ud800\\x7f')"),
|
||||
"NaN-Infinity(void 0)\"\\u2028\\uD800\\u007F\""
|
||||
);
|
||||
assert_eq!(text(&mut r, "(5).toSource() + 'a'.toSource() + true.toSource()"), "5\"a\"true");
|
||||
// Parsed back, the text makes the same value: holes, -0 and undefined included.
|
||||
let round = r#"var v = {a: [1, , "q\u0001'\\"], b: {"0": -0, "": undefined, "x y": [[]]}};
|
||||
var w = eval(toSource(v));
|
||||
[JSON.stringify(w) === JSON.stringify(v), w.a.length, 1 in w.a, 1 / w.b[0], "" in w.b].join()"#;
|
||||
assert_eq!(text(&mut r, round), "true,3,false,-Infinity,true");
|
||||
let shared = "var s = {k: 1}; toSource([s, s])";
|
||||
assert_eq!(text(&mut r, shared), r#"[({"k":1}), ({"k":1})]"#, "a value seen twice that is not its own ancestor is written twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_source_refuses_what_is_not_plain_data() {
|
||||
let mut r = realm();
|
||||
for (source, kind) in [
|
||||
("var o = {}; o.o = o; toSource(o)", "TypeError"),
|
||||
("var a = [1]; a.push([a]); toSource(a)", "TypeError"),
|
||||
("toSource({f: function () {}})", "TypeError"),
|
||||
("toSource({get g() { return 1; }})", "TypeError"),
|
||||
("toSource(new Date(0))", "TypeError"),
|
||||
("toSource([/x/])", "TypeError"),
|
||||
("toSource(new Error('e'))", "TypeError"),
|
||||
("toSource(new String('s'))", "TypeError"),
|
||||
("var a = [1]; a.extra = 2; toSource(a)", "TypeError"),
|
||||
("toSource(Object.create(Array.prototype))", "TypeError"),
|
||||
("var o = {}; Object.defineProperty(o, 'hidden', {value: 1}); toSource(o)", "TypeError"),
|
||||
("var a = [0]; Object.defineProperty(a, '0', {value: 1, enumerable: false}); toSource(a)", "TypeError"),
|
||||
("toSource(Object.create({a: 1}))", "TypeError"),
|
||||
("toSource(Object.create(null))", "TypeError"),
|
||||
("function F() { this.a = 1; } toSource(new F())", "TypeError"),
|
||||
("var d = []; for (var i = 0; i < 70; i++) d = [d]; toSource(d)", "RangeError"),
|
||||
("var a = []; a.length = 2000000; toSource(a)", "RangeError"),
|
||||
] {
|
||||
assert!(thrown(&mut r, source).starts_with(kind), "{source}");
|
||||
}
|
||||
assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_source_stops_at_the_fuel_limit() {
|
||||
let mut r = realm();
|
||||
r.set_limits(Limits { fuel: 200_000, ..Limits::default() });
|
||||
let e = r.run("var a = []; for (var i = 0; i < 1000; i++) a.push(i); while (true) toSource(a);").unwrap_err();
|
||||
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() {
|
||||
let mut r = realm();
|
||||
let items = ["global", "error", "line", "stack"]
|
||||
.into_iter()
|
||||
.zip([ScriptItem::Global, ScriptItem::LastError, ScriptItem::Line, ScriptItem::Stack])
|
||||
.map(|(n, i)| (n.to_string(), HostItem::Script(i)))
|
||||
.collect();
|
||||
r.define_host_object("h", items).unwrap();
|
||||
assert_eq!(r.run("var later = 2; h.global === this && h.global.later === 2").unwrap(), JsValue::Bool(true));
|
||||
let source = "var before = h.error; try { null.x; } catch (e) {} var caught = h.error; try { throw 'text'; } catch (e) {}
|
||||
[before === null, caught instanceof TypeError, h.error === caught].join()";
|
||||
assert_eq!(text(&mut r, source), "true,true,true");
|
||||
assert_eq!(r.run("h.error").unwrap(), JsValue::Null, "each run starts without one");
|
||||
assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(3.0));
|
||||
let source = "function outer() {\n return inner();\n}\nfunction inner() {\n return h.stack;\n}\nvar f = function () { return outer(); };\nf()";
|
||||
assert_eq!(text(&mut r, source), "[global]:8\n[anonymous]:7\nouter:2\ninner:5\n");
|
||||
let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0]";
|
||||
let lines = text(&mut r, deep);
|
||||
assert_eq!(lines, "66,…", "64 frames, the line that some are left out, and the last line's end");
|
||||
assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() {
|
||||
let mut r = Realm::new();
|
||||
let seen: Rc<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()));
|
||||
}
|
||||
@@ -74,6 +74,12 @@ pub enum HostItem {
|
||||
TextFunction(HostFunction),
|
||||
/// A property whose value the function gives each time it is read.
|
||||
Getter(HostFunction),
|
||||
/// A property whose value `get` gives each time it is read, and that `set` is given each value
|
||||
/// assigned to it, as its one argument; an error from `set` is thrown to the script.
|
||||
Property {
|
||||
get: HostFunction,
|
||||
set: HostFunction,
|
||||
},
|
||||
/// A frozen object with these members.
|
||||
Object(Vec<(String, HostItem)>),
|
||||
/// A function whose host function gives JavaScript source as a string, which then runs as
|
||||
@@ -86,6 +92,23 @@ pub enum HostItem {
|
||||
source: HostFunction,
|
||||
end: Rc<dyn Fn()>,
|
||||
},
|
||||
/// A read-only property that reads the interpreter's own state.
|
||||
Script(ScriptItem),
|
||||
}
|
||||
|
||||
/// The interpreter's own state a host object can show.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ScriptItem {
|
||||
/// The global object itself, with every global the script has made.
|
||||
Global,
|
||||
/// The last `Error` object thrown in the current run, caught or not, or `null` before one.
|
||||
LastError,
|
||||
/// The line of the script's code that reads it, counted from 1.
|
||||
Line,
|
||||
/// The running frames of the script's code, outermost first, one a line as `name:line`: the
|
||||
/// global code is `[global]` and a function without a name `[anonymous]`. At most 64 frames
|
||||
/// are listed, the innermost ones, after a line `…` when some are left out.
|
||||
Stack,
|
||||
}
|
||||
|
||||
enum Step {
|
||||
@@ -410,12 +433,24 @@ impl Interp {
|
||||
HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, false)), flags: ENUMERABLE },
|
||||
HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE },
|
||||
HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE },
|
||||
HostItem::Property { get, set } => Prop::Accessor {
|
||||
get: Some(self.host_function(get, &full, false)),
|
||||
set: Some(self.host_function(set, &full, false)),
|
||||
flags: ENUMERABLE,
|
||||
},
|
||||
HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE },
|
||||
HostItem::Evaluator { source, end } => {
|
||||
let proto = self.intrinsics.function_proto;
|
||||
let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto)));
|
||||
Prop::Data { value: Value::Object(f), flags: ENUMERABLE }
|
||||
}
|
||||
HostItem::Script(item) => match crate::builtins::extensions::getter(item) {
|
||||
None => Prop::Data { value: Value::Object(self.global), flags: ENUMERABLE },
|
||||
Some((name, f)) => {
|
||||
let get = crate::builtins::native(self, name, f, 0, crate::heap::NativeKind::Plain, false)?;
|
||||
Prop::Accessor { get: Some(get), set: None, flags: ENUMERABLE }
|
||||
}
|
||||
},
|
||||
};
|
||||
self.define(o, key(&name), prop)
|
||||
}
|
||||
|
||||
@@ -164,6 +164,10 @@ 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,
|
||||
/// 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
|
||||
@@ -373,6 +377,8 @@ impl Interp {
|
||||
source_units: 0,
|
||||
throw_site: None,
|
||||
last_thrown: Value::Undefined,
|
||||
last_error: Value::Null,
|
||||
localization: Rc::default(),
|
||||
random: random_seed(),
|
||||
join_stack: Vec::new(),
|
||||
held_bytes: 0,
|
||||
@@ -543,6 +549,7 @@ impl Interp {
|
||||
roots.values.extend(self.stack.iter());
|
||||
roots.values.extend(self.temp_roots.iter());
|
||||
roots.values.push(&self.last_thrown);
|
||||
roots.values.push(&self.last_error);
|
||||
for f in &self.frames {
|
||||
roots.values.push(&f.this);
|
||||
roots.values.push(&f.completion);
|
||||
@@ -628,6 +635,11 @@ impl Interp {
|
||||
/// boundary frame. Returns the exception when no handler takes it before the boundary frame.
|
||||
fn unwind(&mut self, v: Value) -> JsResult<()> {
|
||||
self.note_site();
|
||||
if let Value::Object(o) = v
|
||||
&& self.heap.object(o).is_some_and(|o| matches!(o.class, Class::Error))
|
||||
{
|
||||
self.last_error = v.clone();
|
||||
}
|
||||
loop {
|
||||
let Some(frame) = self.frames.last_mut() else { return Err(Abrupt::Throw(v)) };
|
||||
if let Some(h) = frame.code.handler(frame.pc.saturating_sub(1)) {
|
||||
@@ -655,6 +667,12 @@ impl Interp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each running frame of script code, outermost first: its code, the index of its next
|
||||
/// instruction, and whether it runs a function (not global or eval code).
|
||||
pub(crate) fn script_frames(&self) -> impl Iterator<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);
|
||||
|
||||
@@ -65,8 +65,9 @@ pub use ast::{
|
||||
AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property,
|
||||
PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl,
|
||||
};
|
||||
pub use builtins::extensions::Localization;
|
||||
pub use error::{SyntaxError, TOO_DEEP, TOO_LONG};
|
||||
pub use host::{Data, DataLimits, HostError, HostFunction, HostItem};
|
||||
pub use host::{Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem};
|
||||
pub use lexer::{Goal, Lexer};
|
||||
pub use number::to_string as number_to_string;
|
||||
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE};
|
||||
@@ -187,6 +188,8 @@ impl LineTable {
|
||||
#[cfg(test)]
|
||||
mod builtins_tests;
|
||||
#[cfg(test)]
|
||||
mod extension_tests;
|
||||
#[cfg(test)]
|
||||
mod host_tests;
|
||||
#[cfg(test)]
|
||||
mod interp_tests;
|
||||
|
||||
@@ -221,6 +221,7 @@ impl Realm {
|
||||
interp.source_units = 0;
|
||||
interp.throw_site = None;
|
||||
interp.last_thrown = Value::Undefined;
|
||||
interp.last_error = Value::Null;
|
||||
interp.host_abort = None;
|
||||
interp.error_tag = UNKNOWN_SOURCE;
|
||||
let strict = interp.options.strict;
|
||||
@@ -339,6 +340,22 @@ impl Realm {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the script extensions: the global `localize(object, ...values)`, which gives a
|
||||
/// localization object's text for the locale of [`Realm::localization`], the global
|
||||
/// `toSource(value)` and `Object.prototype.toSource()`, which write plain data as source text,
|
||||
/// and the localized `Object.prototype.toString` while [`crate::Localization::enabled`].
|
||||
pub fn add_script_extensions(&mut self) -> Result<(), RunError> {
|
||||
match crate::builtins::extensions::install(&mut self.interp) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => self.outcome(Err(e)).map(drop),
|
||||
}
|
||||
}
|
||||
|
||||
/// The locale `localize` reads, which the host's functions may change during a run.
|
||||
pub fn localization(&self) -> std::rc::Rc<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() {
|
||||
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
@@ -1597,6 +1597,52 @@ VectorCraft reads them by these rules of its own:
|
||||
- A missing or refused included file ends the run before any of it runs. An error in an included file or a file `$.evalFile`
|
||||
ran is reported at that file's line and column, and the report's `file` names the file.
|
||||
|
||||
`$` also has the other members the Dollar object page lists, besides `$.evalFile`, `$.fileName` and `$.includePath`
|
||||
above. Each run
|
||||
starts with its own `$`: what a script changes there lasts until the run ends. The values and rules below are
|
||||
VectorCraft's own, and `vc.api.has("dollar")` is true where they are available:
|
||||
|
||||
| Member | In VectorCraft |
|
||||
|--------|----------------|
|
||||
| `about()` | `"VectorCraft script engine <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 |
|
||||
| `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
|
||||
|
||||
@@ -730,6 +730,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`.
|
||||
- Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`.
|
||||
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
|
||||
- Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`.
|
||||
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
|
||||
- Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).
|
||||
|
||||
Reference in New Issue
Block a user