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:
dotCooCoo
2026-11-11 01:59:37 -08:00
parent e1c199a184
commit 179bd81566
18 changed files with 1317 additions and 15 deletions
+307
View File
@@ -0,0 +1,307 @@
//! The members of a script's `$` object besides `$.write`, `$.writeln`, `$.evalFile`, `$.fileName`
//! and `$.includePath`, from the Dollar object
//! page (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>), and
//! the locale the global `localize` reads
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/>).
//!
//! Every value here belongs to one run: a run starts with the locale of the interface, the
//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero,
//! and what a script changes ends with its run. `$.setenv` never changes the process's environment.
//!
//! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and
//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is
//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0
//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the
//! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no
//! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own
//! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most
//! [`MAX_ENV_BYTES`] bytes of letters, digits and `_ . - @`; and `$.sleep` waits at most
//! [`MAX_SLEEP_MS`] milliseconds, between two frames of the app, and does not wait on the web,
//! which has no clock.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem};
use super::{Host, function};
/// The longest `$.sleep`, in milliseconds.
pub const MAX_SLEEP_MS: f64 = 1_000.0;
/// The environment variables `$.getenv` reads and `$.setenv` sets.
pub const ENV_NAMES: &[&str] = &["LANG", "LC_MESSAGES", "LC_ALL", "OS"];
/// The most bytes of an environment value.
pub const MAX_ENV_BYTES: usize = 64;
/// The value of `$.flags` that turns localization on, the only one a script may set.
pub const LOCALIZE_FLAG: f64 = 512.0;
/// What `$` knows of a run.
pub(crate) struct RunInfo {
/// The locale the run starts with, such as `de_DE`.
pub locale: String,
/// The realm reads scripts as strict mode code.
pub strict: bool,
}
/// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language,
/// with its region after `_` where the code gives one (`pt_BR`; `zh_CN` for `zh-hans` and `zh_TW`
/// for `zh-hant`); `en` for a code that is not a language.
pub(crate) fn ui_locale(code: &str) -> String {
let mut parts = code.split(['-', '_']);
let language = parts.next().unwrap_or_default().to_ascii_lowercase();
if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_lowercase()) {
return "en".into();
}
let region = match parts.next().map(str::to_ascii_lowercase).as_deref() {
Some("hans") => Some("CN".to_string()),
Some("hant") => Some("TW".to_string()),
Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_lowercase()) => Some(r.to_ascii_uppercase()),
_ => None,
};
region.map_or(language.clone(), |r| format!("{language}_{r}"))
}
/// A locale a script gives `$.locale`, as `ll` or `ll_RR`: a language of two or three letters and
/// an optional region of two letters or three digits, joined by `_` or `-`, in any case.
fn script_locale(text: &str) -> Option<String> {
let mut parts = text.split(['-', '_']);
let language = parts.next()?;
let region = parts.next();
if parts.next().is_some() || !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
let language = language.to_ascii_lowercase();
match region {
None => Some(language),
Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_alphabetic()) => Some(format!("{language}_{}", r.to_ascii_uppercase())),
Some(r) if r.len() == 3 && r.bytes().all(|b| b.is_ascii_digit()) => Some(format!("{language}_{r}")),
Some(_) => None,
}
}
/// The languages whose numbers are written with a decimal comma (VectorCraft's own list).
const DECIMAL_COMMA: &[&str] = &[
"bg", "ca", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hr", "hu", "id", "it", "lt", "lv", "nb", "nl", "no", "pl", "pt", "ro", "ru", "sk",
"sl", "sr", "sv", "tr", "uk", "vi",
];
/// The character `locale` writes before a number's fraction.
pub(crate) fn decimal_point(locale: &str) -> &'static str {
let language = locale.split('_').next().unwrap_or_default();
if DECIMAL_COMMA.contains(&language) { "," } else { "." }
}
/// The platform suffix of a localization object's names on this system.
pub(crate) fn platform() -> Option<&'static str> {
if cfg!(target_arch = "wasm32") {
None
} else if cfg!(windows) {
Some("Win")
} else if cfg!(target_os = "macos") {
Some("Mac")
} else {
Some("Unix")
}
}
/// `$.os`: the system, without its version.
fn os() -> &'static str {
if cfg!(target_arch = "wasm32") {
"Web"
} else if cfg!(windows) {
"Windows"
} else if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "linux") {
"Linux"
} else if cfg!(target_os = "freebsd") {
"FreeBSD"
} else {
"Unix"
}
}
/// Is `value` one an environment value may be?
fn env_value_ok(value: &str) -> bool {
value.len() <= MAX_ENV_BYTES && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b'@'))
}
/// The values of [`ENV_NAMES`] in the process's environment, each left out when it is not one an
/// environment value may be.
fn process_env() -> Vec<(&'static str, Option<String>)> {
ENV_NAMES.iter().map(|&n| (n, std::env::var(n).ok().filter(|v| env_value_ok(v)))).collect()
}
fn one_string(args: &[Data], name: &str) -> Result<String, HostError> {
match args.first() {
Some(Data::String(s)) => Ok(s.clone()),
_ => Err(HostError::Type(format!("$.{name}: give a name, as a string"))),
}
}
fn value(v: Data) -> HostItem {
HostItem::Value(v)
}
fn text(s: impl Into<String>) -> Data {
Data::String(s.into())
}
/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`.
pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localization>) -> Vec<(String, HostItem)> {
let version = env!("CARGO_PKG_VERSION");
let host_locale = info.locale.clone();
localization.set_locale(&info.locale);
localization.set_platform(platform());
let env = Rc::new(RefCell::new(process_env()));
let timer = Rc::new(Cell::new(Duration::ZERO));
let getter = |f: Rc<dyn Fn() -> Data>| HostItem::Getter(function(move |_| Ok(f())));
let property = |get: Rc<dyn Fn() -> Data>, set: Rc<dyn Fn(Data) -> Result<(), HostError>>| HostItem::Property {
get: function(move |_| Ok(get())),
set: function(move |mut a| {
set(if a.is_empty() { Data::Undefined } else { a.swap_remove(0) })?;
Ok(Data::Undefined)
}),
};
let fail = |message: &'static str| HostItem::Function(function(move |_| Err(HostError::Error(message.to_string()))));
let locale = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || text(l.locale())),
Rc::new(move |v| {
let locale = match v {
Data::Null | Data::Undefined => host_locale.clone(),
Data::String(t) if t.is_empty() => host_locale.clone(),
Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?,
_ => return Err(HostError::Type(BAD_LOCALE.to_string())),
};
s.set_locale(&locale);
Ok(())
}),
)
};
let localize = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || Data::Bool(l.enabled())),
Rc::new(move |v| match v {
Data::Bool(on) => {
s.set_enabled(on);
Ok(())
}
_ => Err(HostError::Type("$.localize: give true or false".into())),
}),
)
};
let flags = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || Data::Number(if l.enabled() { LOCALIZE_FLAG } else { 0.0 })),
Rc::new(move |v| match v {
Data::Number(n) if n == 0.0 || n == LOCALIZE_FLAG => {
s.set_enabled(n == LOCALIZE_FLAG);
Ok(())
}
Data::Number(_) => Err(HostError::Range("$.flags: only 512, which turns localization on, or 0 can be set".into())),
_ => Err(HostError::Type("$.flags: give a number".into())),
}),
)
};
let decimal = {
let l = localization.clone();
getter(Rc::new(move || text(decimal_point(&l.locale()))))
};
let app_encoding = property(
Rc::new(|| text("UTF-8")),
Rc::new(|v| match v {
Data::String(t) if t.eq_ignore_ascii_case("utf-8") || t.eq_ignore_ascii_case("utf8") => Ok(()),
Data::String(_) => Err(HostError::Range("$.appEncoding: scripts here read and write UTF-8 only".into())),
_ => Err(HostError::Type("$.appEncoding: give an encoding's name, as a string".into())),
}),
);
let mem_cache =
property(Rc::new(|| Data::Number(0.0)), Rc::new(|_| Err(HostError::Error("$.memCache: the memory cache size cannot be set".into()))));
let hires_timer = {
let (h, last) = (host.clone(), timer.clone());
getter(Rc::new(move || {
let Some(now) = h.elapsed() else { return Data::Number(0.0) };
let since = now.saturating_sub(last.replace(now));
#[allow(clippy::cast_precision_loss)]
Data::Number(since.as_micros() as f64)
}))
};
let getenv = {
let (h, env) = (host.clone(), env.clone());
function(move |a| {
let name = one_string(&a, "getenv")?;
h.charge_call(name.len() as u64 + 2)?;
let env = env.borrow();
let found = env.iter().find(|(n, _)| *n == name).and_then(|(_, v)| v.clone());
Ok(found.map_or(Data::Null, Data::String))
})
};
let setenv = {
let (h, env) = (host.clone(), env.clone());
function(move |a| {
let name = one_string(&a, "setenv")?;
let Some(Data::String(v)) = a.get(1) else { return Err(HostError::Type("$.setenv: give a value, as a string".into())) };
h.charge_call((name.len() + v.len()) as u64 + 4)?;
let mut env = env.borrow_mut();
let Some(slot) = env.iter_mut().find(|(n, _)| *n == name) else {
return Err(HostError::Error(format!("$.setenv: only {} can be set", ENV_NAMES.join(", "))));
};
if !env_value_ok(v) {
return Err(HostError::Range(format!("$.setenv: a value has at most {MAX_ENV_BYTES} letters, digits and _ . - @")));
}
slot.1 = Some(v.clone());
Ok(Data::Undefined)
})
};
let sleep = {
let h = host.clone();
function(move |a| {
let ms = match a.first() {
Some(Data::Number(n)) if (0.0..=MAX_SLEEP_MS).contains(n) => *n,
Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))),
_ => return Err(HostError::Type("$.sleep: give a number of milliseconds".into())),
};
h.charge_call(8)?;
h.sleep(Duration::from_secs_f64(ms / 1000.0));
Ok(Data::Undefined)
})
};
let about = text(format!("VectorCraft script engine {version}"));
vec![
("about".into(), HostItem::Function(function(move |_| Ok(about.clone())))),
("appEncoding".into(), app_encoding),
("bp".into(), fail("$.bp: scripts here have no debugger")),
("build".into(), value(text(version))),
("buildDate".into(), value(Data::Null)),
("colorPicker".into(), fail("$.colorPicker: scripts cannot show a dialog yet")),
("decimalPoint".into(), decimal),
("engineName".into(), value(text("VectorCraft"))),
("error".into(), HostItem::Script(ScriptItem::LastError)),
("flags".into(), flags),
("gc".into(), HostItem::Function(function(|_| Ok(Data::Undefined)))),
("getenv".into(), HostItem::Function(getenv)),
("global".into(), HostItem::Script(ScriptItem::Global)),
("hiresTimer".into(), hires_timer),
("level".into(), value(Data::Number(0.0))),
("line".into(), HostItem::Script(ScriptItem::Line)),
("locale".into(), locale),
("localize".into(), localize),
("memCache".into(), mem_cache),
("os".into(), value(text(os()))),
("screens".into(), value(Data::Array(Vec::new()))),
("setenv".into(), HostItem::Function(setenv)),
("sleep".into(), HostItem::Function(sleep)),
("stack".into(), HostItem::Script(ScriptItem::Stack)),
("strict".into(), value(Data::Bool(info.strict))),
("version".into(), value(text(version))),
]
}
const BAD_LOCALE: &str = "$.locale: give a locale such as \"en\" or \"de_DE\", or null for the interface's";
+74 -9
View File
@@ -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) };
}