Files
vectorcraft/crates/engine/src/script/dollar.rs
T
dotCooCoo 0bb923fc40 Scripts: $.sleep throws where the run cannot wait, and toSource checks its length as it escapes
A getter, a conversion, a callback of a built-in function or a file
$.evalFile runs is executed on native frames, so the run cannot stop at a
$.sleep there. Such a call used to only mark the run to pause, so the
code after it ran first and the wait came later. $.sleep with more than 0
milliseconds now throws an Error there, as alert, confirm and prompt do.

toSource checked the 16,777,216-unit length limit only after a whole
string was written. A long string of characters that are escaped could
reach the heap limit first, which ends the run instead of throwing a
RangeError. The limit is now checked before each part is added.
2026-11-11 03:18:55 -08:00

308 lines
13 KiB
Rust

//! 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";