Files
vectorcraft/crates/engine/src/script/mod.rs
T
dotCooCoo 2212739c54 MCP: plug-in, recovery and script report replies leave out paths outside the read roots
A client confined by --automation-read-root saw paths outside its read
roots that the person or an earlier request had put in the session:

- plugin.list: the plug-in folder last loaded and its failed files;
  plugin.list and plugin.info: a plug-in's source file; plugin.list,
  plugin.safeMode and plugin.setEnabled: the file of a plug-in turned
  off for the next launch.
- file.recovery.list and file.recovery.restore: a document's original
  file; file.recovery.list and file.recovery.save: the recovery folder.
- ui.inspect's scriptReport: the files a run wrote and the included
  file a failed run stopped in.

A folder outside the read roots is now null, and a file outside them is
named by its file name. plugin.setEnabled {file} takes that name. The
restored documents keep their files, and unconfined clients and the
app's own UI see the same replies as before.
2026-11-12 06:03:01 -08:00

2268 lines
97 KiB
Rust

//! Running a script: `script.run`, `script.stop` and the job behind them.
//!
//! A run works on its own copy of the open documents and the session state commands change (the
//! working copy). Between two slices of a run, the session holds the state as it was before the
//! run: what is drawn, what a read-only command returns and what Data Recovery copies never show a
//! script's unfinished work. Each slice swaps the working copy in, advances the interpreter by one
//! measured slice of fuel and swaps it out. A commit makes the working copy the session's state,
//! with one undo step for each document whose art changed. A rollback drops the working copy.
//!
//! While a job exists, [`Session::execute`] refuses any command that can edit, select, save or give
//! a document a new revision, and the tool events are refused the same way (the mutation lease).
//! The script's own commands run through [`Session::execute`] inside a slice, each with its own
//! parameter checks, enabled rule, undo history and journal entry, and a command that fails leaves
//! no trace of itself. Inside a run, `edit.undo` reaches only steps the script made.
//!
//! Files a script's commands saved or exported before it failed stay written, and the failure names
//! them. Ids given out in a run are never given out again for a document, also after an in-run
//! undo or a rollback.
//!
//! The values here are VectorCraft's own limits: [`MAX_SOURCE_BYTES`], [`DEFAULT_FUEL`],
//! [`MAX_FUEL`], [`SLICE_FUEL`], [`HEAP_BYTES`], [`MAX_HOST_CALLS`], [`GROWTH_OBJECTS`],
//! [`GROWTH_BYTES`], [`MAX_CONTINUES`], [`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], [`MAX_ARGS_BYTES`],
//! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default,
//! and the console's ([`console`]).
//!
//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the
//! place of their lines, and an error in one is reported at its own file, line and column.
//! `$.evalFile(path)` runs another file in the same global environment and gives its last value,
//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list.
//!
//! 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. The rest of `$`, and the global `localize` and
//! `toSource`, are in [`dollar`].
//!
//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the
//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`.
//!
//! `File` and `Folder` read files inside the script's own folder ([`files`]).
//!
//! A script with `#targetengine name` runs in that named engine, which the session keeps with the
//! globals its runs leave, at most [`MAX_ENGINES`] of them with [`ENGINE_HEAP_BYTES`] of heap each
//! (VectorCraft's own limits). A script without the line, or with `main`, gets a new realm for each
//! run. An engine keeps no document and no run: its host functions reach the run through a link
//! that holds the run's host only while the run goes on. An error, a limit or a stop puts the
//! documents back and keeps the engine's globals; an internal error drops the engine.
pub mod console;
pub mod dialogs;
pub mod dollar;
pub mod include;
pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_js::{
Data, DataLimits, HostError, HostFunction, HostItem, Interrupt, LimitKind, Limits, Options, Progress, Realm, RunError, RunErrorKind,
};
use crate::{
Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit,
};
pub mod files;
pub mod folder;
/// The most bytes of source a run takes, as a file or as `source`.
pub const MAX_SOURCE_BYTES: usize = 8 << 20;
/// The instructions a run may execute when `maxFuel` is left out.
pub const DEFAULT_FUEL: u64 = 1_000_000_000;
/// The largest `maxFuel`.
pub const MAX_FUEL: u64 = 100_000_000_000;
/// The instructions one slice of a run executes before the interface draws again.
pub const SLICE_FUEL: u64 = 500_000;
/// The heap one run may use.
#[cfg(not(target_arch = "wasm32"))]
pub const HEAP_BYTES: usize = 256 << 20;
/// The heap one run may use in a wasm build.
#[cfg(target_arch = "wasm32")]
pub const HEAP_BYTES: usize = 128 << 20;
/// The most `vc.run` calls and getter reads of one run.
pub const MAX_HOST_CALLS: u64 = 2_000_000;
/// The objects a run may make before it is asked to continue or stop.
pub const GROWTH_OBJECTS: u64 = 100_000;
/// The bytes of new document data a run may keep before it is asked to continue or stop.
pub const GROWTH_BYTES: u64 = 512 << 20;
/// How many times a person may let a run go past its growth allowance; each time adds one more.
pub const MAX_CONTINUES: u32 = 8;
/// The most `vc.log` entries of one run.
pub const MAX_LOG_LINES: usize = 10_000;
/// The most bytes of `vc.log` entries of one run.
pub const MAX_LOG_BYTES: usize = 4 << 20;
/// The most bytes of `args` given to one run, as JSON.
pub const MAX_ARGS_BYTES: usize = 1 << 20;
/// The `vc.run` calls one slice makes before the interface draws again.
const SLICE_HOST_CALLS: u32 = 256;
/// The bytes that may cross between a script and the host in one run.
const MAX_BRIDGE_BYTES: u64 = 4 << 30;
/// The version `vc.api.version` gives.
pub const API_VERSION: &str = "1.0";
/// The features `vc.api.has` knows.
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs", "dollar"];
/// The most commands a run's report lists in `slowestCommands`.
pub const SLOWEST_COMMANDS: usize = 5;
/// What the step of a run's one undo step is called.
pub const STEP_LABEL: &str = "Run Script";
/// `id`, as `vc.run` is given it, is one a script may not run: a command that would leave the run's
/// transaction, change something outside the documents that a rollback could not put back, or clear
/// the undo steps from before the run, which the run keeps.
pub fn refused_to_scripts(id: &str) -> bool {
const PREFIXES: &[&str] = &[
"script.",
"plugin.",
"prefs.",
"actionSet.",
"shortcutSet.",
"help.",
"print.",
"library.",
"importMatches.",
"edit.undoGroup.",
"file.recovery.",
"stroke.widthProfile.",
];
const IDS: &[&str] = &[
"text.addFontFiles",
"text.findFontFiles",
"text.rescanFonts",
"links.findFiles",
"file.place.queue",
"color.loadProfile",
"color.removeProfile",
"file.print",
"view.proofSetup",
"view.proofColors",
"view.overprintPreview",
"view.separationsPreview",
"edit.clearHistory",
];
let saved_preset = (id.contains("resets.") || id.contains(".preset.")) && !id.ends_with(".list") && !id.ends_with(".apply");
id != "script.check" && (saved_preset || PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id))
}
/// The error for a script's command that [`refused_to_scripts`] names.
pub(crate) fn refused_text(id: &str) -> String {
format!("a script cannot run `{id}`")
}
/// A request to run a script.
pub(crate) struct Request {
/// The run's name; unless `name_given`, a `#script` line in the script replaces it.
pub name: String,
pub name_given: bool,
/// The file the script was read from, which its included files are read beside.
pub file: Option<String>,
pub source: String,
pub args: Map<String, Value>,
pub fuel: u64,
/// The locale the run starts with (`$.locale`).
pub locale: String,
/// The answers to the dialogs of a run with nobody to ask, in order.
pub answers: Vec<DialogAnswer>,
}
thread_local! {
/// The running jobs of this thread's sessions. A job is held here, and not in its session,
/// because it holds the interpreter, which stays on the thread that started it.
static JOBS: RefCell<HashMap<u64, Job>> = RefCell::new(HashMap::new());
}
static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// A session's hold on its running job: while one exists the lease is held. Dropping it ends the job.
/// It keeps the run's number and its name as console lines give it, so the run's console entry can
/// end when the job is gone.
#[derive(Debug)]
pub struct JobKey {
id: u64,
run: u64,
name: Arc<str>,
}
impl Drop for JobKey {
fn drop(&mut self) {
let _ = JOBS.try_with(|jobs| {
if let Ok(mut jobs) = jobs.try_borrow_mut() {
jobs.remove(&self.id);
}
});
}
}
fn with_job<T>(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option<T> {
JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f))
}
/// The most named engines (`#targetengine`) a session keeps.
pub const MAX_ENGINES: usize = 8;
/// The heap a named engine may use, with what it keeps between runs.
pub const ENGINE_HEAP_BYTES: usize = 64 << 20;
/// The engine of a script without `#targetengine`, which `#targetengine main` also names: a new
/// one for each run, never kept.
pub const MAIN_ENGINE: &str = "main";
/// The run a script's host functions act for. A named engine keeps its functions between runs, so
/// they find the run through this link, which holds the run's [`Host`] only while the run goes on.
type Link = Rc<RefCell<Option<Rc<Host>>>>;
/// The host of the run `link` holds, or an error when no run holds it.
fn current(link: &Link) -> std::result::Result<Rc<Host>, HostError> {
link.borrow().clone().ok_or_else(|| HostError::Error(NO_RUN.to_string()))
}
/// A named engine: its interpreter, with the globals its runs left (`None` while a run has it),
/// the link its host functions use, its heap after its last run, and the source maps of the code
/// its earlier runs compiled.
struct Engine {
name: String,
realm: Option<Realm>,
link: Link,
heap: usize,
maps: Rc<RefCell<KeptMaps>>,
}
/// A named engine taken for a run: its realm (`None` for an engine made now), its link and its
/// kept source maps.
type TakenEngine = (Option<Realm>, Link, Rc<RefCell<KeptMaps>>);
/// The most bytes of source maps ([`include::SourceMap::bytes`]) a named engine keeps from its
/// earlier runs.
pub const KEPT_MAP_BYTES: usize = 8 << 20;
/// A source map an engine kept from an earlier run: the realm's tag for the source
/// ([`Realm::error_tag`]), the map, and for the run's own script, that run's name, which stands for
/// a script given as text.
struct KeptMap {
tag: u32,
map: include::SourceMap,
script: Option<Arc<str>>,
bytes: usize,
}
/// The source maps a named engine kept from its earlier runs, by tag, oldest first, in at most
/// [`KEPT_MAP_BYTES`]: the oldest go first to make room, and a map larger than that is not kept.
#[derive(Default)]
struct KeptMaps {
maps: std::collections::VecDeque<KeptMap>,
bytes: usize,
}
impl KeptMaps {
fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option<Arc<str>>) {
let bytes = map.bytes() + script.as_ref().map_or(0, |s| s.len());
let limit = kept_map_bytes();
if bytes > limit || self.maps.back().is_some_and(|m| m.tag >= tag) {
return;
}
while self.bytes + bytes > limit {
let Some(old) = self.maps.pop_front() else { break };
self.bytes -= old.bytes;
}
self.bytes += bytes;
self.maps.push_back(KeptMap { tag, map, script, bytes });
}
fn get(&self, tag: u32) -> Option<&KeptMap> {
self.maps.binary_search_by_key(&tag, |m| m.tag).ok().and_then(|i| self.maps.get(i))
}
}
/// The bytes of source maps a named engine keeps.
fn kept_map_bytes() -> usize {
#[cfg(test)]
if let Some(n) = TEST_KEPT_MAP_BYTES.with(Cell::get) {
return n;
}
KEPT_MAP_BYTES
}
/// The named engines of one thread's sessions, by their key, each in the order they were made.
struct ThreadEngines {
thread: std::thread::ThreadId,
by_key: HashMap<u64, Vec<Engine>>,
}
impl ThreadEngines {
fn new() -> Self {
let thread = std::thread::current().id();
released().insert(thread, Vec::new());
Self { thread, by_key: HashMap::new() }
}
}
impl Drop for ThreadEngines {
fn drop(&mut self) {
released().remove(&self.thread);
}
}
thread_local! {
/// The named engines of this thread's sessions. They are held here, and not in their session,
/// because they hold interpreters, which stay on this thread.
static ENGINES: RefCell<ThreadEngines> = RefCell::new(ThreadEngines::new());
}
static NEXT_ENGINES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// For each thread that holds engines, the keys of the ones sessions let go of on another thread.
/// The thread drops them the next time it uses its engines; a thread that has ended has no entry,
/// since its engines ended with it.
static RELEASED_ENGINES: std::sync::LazyLock<std::sync::Mutex<HashMap<std::thread::ThreadId, Vec<u64>>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
fn released() -> std::sync::MutexGuard<'static, HashMap<std::thread::ThreadId, Vec<u64>>> {
RELEASED_ENGINES.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Lets the engines with key `id` go, for `thread`, which holds them, to drop.
fn release_engines(thread: std::thread::ThreadId, id: u64) {
if let Some(ids) = released().get_mut(&thread) {
ids.push(id);
}
}
/// A session's hold on its named engines: their key, and the thread that holds them once a run
/// made one. Dropping it drops them. The engines stay on that thread, so a session used on another
/// thread lets them go and starts there with none.
#[derive(Debug)]
pub struct EnginesKey(std::sync::Mutex<(u64, Option<std::thread::ThreadId>)>);
impl EnginesKey {
pub(crate) fn new() -> Self {
Self(std::sync::Mutex::new((NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), None)))
}
/// The key of the session's engines on this thread.
fn here(&self) -> u64 {
let mut key = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let thread = std::thread::current().id();
match key.1 {
Some(t) if t != thread => {
release_engines(t, key.0);
*key = (NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), Some(thread));
}
Some(_) => {}
None => key.1 = Some(thread),
}
key.0
}
}
impl Drop for EnginesKey {
fn drop(&mut self) {
let (id, thread) = *self.0.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(t) = thread.filter(|t| *t != std::thread::current().id()) {
release_engines(t, id);
return;
}
let gone = ENGINES.try_with(|engines| engines.try_borrow_mut().ok().and_then(|mut e| e.by_key.remove(&id)));
drop(gone);
}
}
fn with_engines<T>(key: &EnginesKey, f: impl FnOnce(&mut Vec<Engine>) -> T) -> T {
let id = key.here();
ENGINES.with(|engines| {
let gone: Vec<Vec<Engine>> = {
let mut here = engines.borrow_mut();
let ids = released().get_mut(&here.thread).map(std::mem::take).unwrap_or_default();
ids.iter().filter_map(|id| here.by_key.remove(id)).collect()
};
drop(gone);
f(engines.borrow_mut().by_key.entry(id).or_default())
})
}
/// Tests: the named engines this thread holds, for every session.
#[cfg(test)]
pub(crate) fn engines_on_this_thread() -> usize {
ENGINES.with(|engines| engines.borrow().by_key.values().map(Vec::len).sum())
}
/// Tests: whether engines can still be let go for `thread`, which holds an entry while it lives.
#[cfg(test)]
pub(crate) fn releases_kept_for(thread: std::thread::ThreadId) -> bool {
released().contains_key(&thread)
}
/// The heap of a named engine.
fn engine_heap() -> usize {
#[cfg(test)]
if let Some(n) = TEST_ENGINE_HEAP.with(Cell::get) {
return n;
}
ENGINE_HEAP_BYTES
}
/// The state a script changes that a rollback puts back.
struct World {
docs: Vec<DocState>,
active: Option<usize>,
paint: PaintDefaults,
fill_active: bool,
draw_mode: DrawMode,
draw_inside: Option<NodeId>,
clipboard: Clipboard,
untitled_counter: u32,
menu: cmd::menucmds::MenuState,
last_solid: crate::Color,
last_gradient: crate::GradientPaint,
recent_colors: Vec<crate::Color>,
pending_paint: Option<crate::Paint>,
choices: Choices,
journal: Journal,
/// The preferences when the run started.
start_prefs: crate::Prefs,
/// The Caps Lock state when the run started.
start_caps_lock: bool,
}
/// The choices outside the documents that a script's commands can change: the preferences, the
/// global edit options, the recent URLs, the chosen gradient stop, freeform point and appearance
/// item, the Eyedropper's sample, the envelope defaults and the Caps Lock state.
#[derive(Clone)]
struct Choices {
prefs: crate::Prefs,
caps_lock: bool,
eyedropper_sample: Option<Arc<vectorcraft_doc::Node>>,
global_edit_options: cmd::globaledit::Options,
recent_urls: Vec<String>,
gradient_stop: Option<(usize, cmd::gradient::StopOwner)>,
freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
active_appearance_item: Option<cmd::appearance::ActiveItem>,
envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
}
impl Choices {
fn of(s: &Session) -> Self {
Self {
prefs: s.prefs.clone(),
caps_lock: s.caps_lock,
eyedropper_sample: s.eyedropper_sample.clone(),
global_edit_options: s.global_edit_options.clone(),
recent_urls: s.recent_urls.clone(),
gradient_stop: s.gradient_stop,
freeform_point: s.freeform_point,
active_appearance_item: s.active_appearance_item.clone(),
envelope_defaults: s.envelope_defaults,
}
}
fn put(self, s: &mut Session) {
s.prefs = self.prefs;
s.caps_lock = self.caps_lock;
s.eyedropper_sample = self.eyedropper_sample;
s.global_edit_options = self.global_edit_options;
s.recent_urls = self.recent_urls;
s.gradient_stop = self.gradient_stop;
s.freeform_point = self.freeform_point;
s.active_appearance_item = self.active_appearance_item;
s.envelope_defaults = self.envelope_defaults;
}
fn swap(&mut self, s: &mut Session) {
use std::mem::swap;
swap(&mut self.prefs, &mut s.prefs);
swap(&mut self.caps_lock, &mut s.caps_lock);
swap(&mut self.eyedropper_sample, &mut s.eyedropper_sample);
swap(&mut self.global_edit_options, &mut s.global_edit_options);
swap(&mut self.recent_urls, &mut s.recent_urls);
swap(&mut self.gradient_stop, &mut s.gradient_stop);
swap(&mut self.freeform_point, &mut s.freeform_point);
swap(&mut self.active_appearance_item, &mut s.active_appearance_item);
swap(&mut self.envelope_defaults, &mut s.envelope_defaults);
}
}
/// The preferences after a run: `current`, the session's, which the app may have changed while the
/// run went on, with each preference the run changed from `start` set to the run's value.
fn merged_prefs(start: &crate::Prefs, run: &crate::Prefs, current: &crate::Prefs) -> crate::Prefs {
let (Ok(Value::Object(start)), Ok(Value::Object(run)), Ok(Value::Object(mut out))) =
(serde_json::to_value(start), serde_json::to_value(run), serde_json::to_value(current))
else {
return current.clone();
};
for (key, value) in run {
if start.get(&key) != Some(&value) {
out.insert(key, value);
}
}
serde_json::from_value(Value::Object(out)).unwrap_or_else(|_| current.clone())
}
impl World {
/// A working copy of the session's state: its documents, each with an undo history of its own
/// that starts empty, and an empty journal.
fn working(s: &Session) -> Self {
let mut docs = s.docs.clone();
for st in &mut docs {
st.history.undo.clear();
st.history.redo.clear();
}
Self {
docs,
active: s.active,
paint: s.paint.clone(),
fill_active: s.fill_active,
draw_mode: s.draw_mode,
draw_inside: s.draw_inside,
clipboard: s.clipboard.clone(),
untitled_counter: s.untitled_counter,
menu: s.menu.clone(),
last_solid: s.last_solid,
last_gradient: s.last_gradient.clone(),
recent_colors: s.recent_colors.clone(),
pending_paint: s.pending_paint.clone(),
choices: Choices::of(s),
journal: Journal::default(),
start_prefs: s.prefs.clone(),
start_caps_lock: s.caps_lock,
}
}
/// Exchanges this state with the session's.
fn swap(&mut self, s: &mut Session) {
use std::mem::swap;
swap(&mut self.docs, &mut s.docs);
swap(&mut self.active, &mut s.active);
swap(&mut self.paint, &mut s.paint);
swap(&mut self.fill_active, &mut s.fill_active);
swap(&mut self.draw_mode, &mut s.draw_mode);
swap(&mut self.draw_inside, &mut s.draw_inside);
swap(&mut self.clipboard, &mut s.clipboard);
swap(&mut self.untitled_counter, &mut s.untitled_counter);
swap(&mut self.menu, &mut s.menu);
swap(&mut self.last_solid, &mut s.last_solid);
swap(&mut self.last_gradient, &mut s.last_gradient);
swap(&mut self.recent_colors, &mut s.recent_colors);
swap(&mut self.pending_paint, &mut s.pending_paint);
self.choices.swap(s);
swap(&mut self.journal, &mut s.journal);
}
}
/// The commands that can change the clipboard and still fail, and that do not put it back
/// themselves. The clipboard can hold a great deal, so a savepoint copies it only for these.
const CAN_FAIL_AFTER_COPYING: &[&str] = &["action.batch", "edit.cut"];
/// The commands that remove a document's undo history, or the document: a savepoint keeps a whole
/// copy of the histories for these. Any other command that removes or changes a step under the
/// newest undo or redo step and then fails ends the run ([`HISTORY_LOST`]).
const DROP_HISTORY: &[&str] = &["file.close", "file.closeAll", "document.close", "file.revert"];
/// A document's state before a command: everything but its undo history, which `mark` gives back,
/// or `whole` for the commands in [`DROP_HISTORY`].
struct SavedDoc {
state: DocState,
mark: crate::history::HistoryMark,
whole: Option<crate::History>,
}
/// What one command may need to put back when it fails inside a run: the state of [`World`] the
/// command can reach.
pub(crate) struct Savepoint {
docs: Vec<SavedDoc>,
active: Option<usize>,
paint: PaintDefaults,
fill_active: bool,
draw_mode: DrawMode,
draw_inside: Option<NodeId>,
clipboard: Option<Clipboard>,
untitled_counter: u32,
menu: cmd::menucmds::MenuState,
last_solid: crate::Color,
last_gradient: crate::GradientPaint,
recent_colors: Vec<crate::Color>,
pending_paint: Option<crate::Paint>,
choices: Choices,
journal: usize,
}
impl Savepoint {
/// The state before command `id`. Until [`Savepoint::keep`] or [`Savepoint::restore`], the
/// documents' undo histories let no step go.
pub(crate) fn take(s: &mut Session, id: &str) -> Self {
let whole = DROP_HISTORY.contains(&id);
let docs = s
.docs
.iter_mut()
.map(|st| {
let history = std::mem::take(&mut st.history);
let state = st.clone();
st.history = history;
let whole = whole.then(|| st.history.clone());
SavedDoc { state, mark: st.history.mark(), whole }
})
.collect();
Self {
docs,
active: s.active,
paint: s.paint.clone(),
fill_active: s.fill_active,
draw_mode: s.draw_mode,
draw_inside: s.draw_inside,
clipboard: CAN_FAIL_AFTER_COPYING.contains(&id).then(|| s.clipboard.clone()),
untitled_counter: s.untitled_counter,
menu: s.menu.clone(),
last_solid: s.last_solid,
last_gradient: s.last_gradient.clone(),
recent_colors: s.recent_colors.clone(),
pending_paint: s.pending_paint.clone(),
choices: Choices::of(s),
journal: s.journal.len(),
}
}
/// Keeps what the command did: the histories' limits apply again.
pub(crate) fn keep(self, s: &mut Session) {
for saved in &self.docs {
if let Some(st) = s.docs.iter_mut().find(|d| d.uid == saved.state.uid) {
st.history.release(&saved.mark);
}
}
}
/// Puts back the state before the command. → `false` when an undo history could not be given
/// back as it was (a step under the newest one was removed or changed in place, or its
/// document was closed); the rest is put back all the same.
pub(crate) fn restore(self, s: &mut Session) -> bool {
let mut exact = true;
let mut docs = Vec::with_capacity(self.docs.len());
for SavedDoc { mut state, mark, whole } in self.docs {
let now = s.docs.iter_mut().find(|d| d.uid == state.uid).map(|d| std::mem::take(&mut d.history));
state.history = match (whole, now) {
(Some(history), _) => history,
(None, Some(mut history)) => {
exact &= history.put_back(mark);
history
}
(None, None) => {
exact = false;
let mut history = crate::History::default();
history.put_back(mark);
history
}
};
docs.push(state);
}
s.docs = docs;
s.active = self.active;
s.paint = self.paint;
s.fill_active = self.fill_active;
s.draw_mode = self.draw_mode;
s.draw_inside = self.draw_inside;
if let Some(clipboard) = self.clipboard {
s.clipboard = clipboard;
}
s.untitled_counter = self.untitled_counter;
s.menu = self.menu;
s.last_solid = self.last_solid;
s.last_gradient = self.last_gradient;
s.recent_colors = self.recent_colors;
s.pending_paint = self.pending_paint;
self.choices.put(s);
s.journal.truncate(self.journal);
exact
}
}
/// The counters and the session a script's host functions share. Between slices `session` holds a
/// spare session; during a slice it holds the real one, because the interpreter cannot be given a
/// borrow of it.
struct Host {
session: Rc<RefCell<Session>>,
pause: Rc<Cell<bool>>,
/// The run is sliced by frames, so the person at the computer can be asked to continue.
framed: bool,
calls: Cell<u64>,
calls_this_slice: Cell<u32>,
objects: Cell<u64>,
bytes_kept: Cell<u64>,
bridge: Cell<u64>,
allowance_objects: Cell<u64>,
allowance_bytes: Cell<u64>,
continues: Cell<u32>,
/// Past the growth allowance, waiting for the person's answer.
waiting: Cell<bool>,
log: RefCell<Vec<Value>>,
log_bytes: Cell<usize>,
written: RefCell<Vec<String>>,
/// The run's number in its session, and the script's name, which its console lines carry.
run: u64,
name: Arc<str>,
/// The run's own output and error, for its report.
console: RefCell<console::Console>,
/// The line `$.write` has not ended yet.
pending: RefCell<console::Pending>,
counts: Counts,
/// Each command `vc.run` ran: its calls, its total time and its longest call, in nanoseconds.
commands: RefCell<HashMap<&'static str, (u64, u128, u128)>>,
/// The time the run's slices took, in nanoseconds.
run_nanos: Cell<u128>,
/// `alert`, `confirm` and `prompt`: their answers and the list the report gives.
dialogs: dialogs::Dialogs,
/// The files the run reads: its includes and `$.evalFile`'s.
files: RefCell<include::Files>,
/// What `File` and `Folder` have open and have read.
disk: RefCell<files::Disk>,
/// Where the lines of the script's expanded source came from.
script_map: RefCell<include::SourceMap>,
/// The same for each source `$.evalFile` gave, in order.
eval_maps: RefCell<Vec<include::SourceMap>>,
/// The file, line and column of each error `$.evalFile` raised for a file it could not run, in
/// order: the error's mark ([`HostError::Marked`]) is its index.
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
/// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and
/// [`vectorcraft_js::UNKNOWN_SOURCE`] before.
script_tag: Cell<u32>,
/// The run's name as its report gives it, which [`Host::name`] shortens for console lines.
report_name: Arc<str>,
/// The source maps the named engine of the run kept from its earlier runs; `None` for a run
/// without one.
kept_maps: Option<Rc<RefCell<KeptMaps>>>,
/// When the run started, where the platform has a clock.
started: Option<std::time::Instant>,
/// The time since [`Host::started`] at which a `$.sleep` ends.
wake_at: Cell<Option<std::time::Duration>>,
/// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]).
can_wait: Rc<Cell<bool>>,
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
eval_strict: Cell<Option<bool>>,
/// The values of `$` that belong to the run.
dollar: dollar::RunState,
/// The run's `args`, which `vc.args` gives, and their size as `vc.args` charges it.
args: RefCell<(Data, u64)>,
}
/// A run's host calls by kind, as its report gives them in `hostCallCounts`.
#[derive(Default)]
struct Counts {
vc_run: Cell<u64>,
document_reads: Cell<u64>,
command_list_reads: Cell<u64>,
vc_log: Cell<u64>,
console_writes: Cell<u64>,
/// Calls of `File` and `Folder` that reach the host ([`files`]).
file_calls: Cell<u64>,
/// `execute` calls, which open nothing.
file_executes: Cell<u64>,
}
impl Counts {
fn json(&self) -> Value {
json!({
"vcRun": self.vc_run.get(),
"documentReads": self.document_reads.get(),
"commandListReads": self.command_list_reads.get(),
"vcLog": self.vc_log.get(),
"consoleWrites": self.console_writes.get(),
"fileCalls": self.file_calls.get(),
"fileExecutes": self.file_executes.get(),
})
}
}
fn bump(c: &Cell<u64>) {
c.set(c.get().saturating_add(1));
}
/// Now, where the platform has a clock (not in a wasm build).
fn clock() -> Option<std::time::Instant> {
#[cfg(not(target_arch = "wasm32"))]
{
Some(std::time::Instant::now())
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
/// Whole milliseconds, rounded down.
fn millis(nanos: u128) -> u64 {
u64::try_from(nanos / 1_000_000).unwrap_or(u64::MAX)
}
/// The [`SLOWEST_COMMANDS`] commands of `times` that took longest in all, longest first and then by
/// id.
pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec<console::Slow> {
let mut all: Vec<(&&str, &(u64, u128, u128))> = times.iter().collect();
all.sort_by(|a, b| b.1.1.cmp(&a.1.1).then_with(|| a.0.cmp(b.0)));
all.into_iter()
.take(SLOWEST_COMMANDS)
.map(|(id, &(calls, total, max))| console::Slow { id: (*id).to_string(), calls, total_ms: millis(total), max_ms: millis(max) })
.collect()
}
impl Host {
/// Gives the named engine of the run the source maps of the code the run compiled: its script
/// and each file `$.evalFile` gave. A run that never started its script compiled nothing.
fn keep_maps(&self) {
let (Some(kept), tag) = (&self.kept_maps, self.script_tag.get()) else { return };
if tag == vectorcraft_js::UNKNOWN_SOURCE {
return;
}
let mut kept = kept.borrow_mut();
kept.keep(tag, std::mem::take(&mut *self.script_map.borrow_mut()), Some(self.report_name.clone()));
for (n, map) in std::mem::take(&mut *self.eval_maps.borrow_mut()).into_iter().enumerate() {
match u32::try_from(n + 1).ok().and_then(|n| tag.checked_add(n)).filter(|t| *t != vectorcraft_js::UNKNOWN_SOURCE) {
Some(t) => kept.keep(t, map, None),
None => break,
}
}
}
/// Adds a line to the run's console and, during a slice, to the session's.
fn line(&self, entry: console::Entry, cut: bool) {
let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry };
if let Ok(mut s) = self.session.try_borrow_mut() {
s.script_console.push(line.clone(), cut);
}
self.console.borrow_mut().push(line, cut);
}
/// `$.write` and `$.writeln`: `text`, then with `end` a line break.
fn write(&self, text: &str, end: bool) {
let mut pending = self.pending.borrow_mut();
pending.write(text, |line, cut| self.line(console::Entry::Output(line), cut));
if end {
pending.end_line(|line, cut| self.line(console::Entry::Output(line), cut));
}
}
/// The time since the run started; none without a clock.
fn elapsed(&self) -> Option<std::time::Duration> {
#[cfg(test)]
if TEST_NO_CLOCK.with(Cell::get) {
return None;
}
#[cfg(test)]
if let Some(d) = TEST_ELAPSED.with(Cell::get) {
return Some(d);
}
self.started.map(|s| s.elapsed())
}
/// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock
/// it does not wait. A getter, a conversion, a built-in's callback or a file `$.evalFile` runs
/// cannot stop at the call, so a wait there is an error, with or without a clock.
fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> {
if d.is_zero() {
return Ok(());
}
if !self.can_wait.get() {
return Err(HostError::Error(
"$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"
.into(),
));
}
if let Some(now) = self.elapsed() {
self.wake_at.set(Some(now.saturating_add(d)));
self.pause.set(true);
}
Ok(())
}
/// The time left of a `$.sleep`, or none when the run is not asleep.
fn asleep(&self) -> Option<std::time::Duration> {
let wake = self.wake_at.get()?;
let left = self.elapsed().map_or(std::time::Duration::ZERO, |now| wake.saturating_sub(now));
if left.is_zero() {
self.wake_at.set(None);
return None;
}
Some(left)
}
fn over(&self, limit: &str) -> HostError {
HostError::Abort(limit.to_string())
}
/// `error`, marked as an error at `file`, `line` and `column`, the place [`located`] gives when
/// the run ends with it uncaught.
fn placed(&self, error: HostError, file: Arc<str>, line: u32, column: u32) -> HostError {
let mut places = self.eval_failures.borrow_mut();
let Ok(mark) = u32::try_from(places.len()) else { return error };
places.push((file, line, column));
HostError::Marked { error: Box::new(error), mark }
}
fn charge_call(&self, bytes: u64) -> std::result::Result<(), HostError> {
let calls = self.calls.get() + 1;
self.calls.set(calls);
if calls > MAX_HOST_CALLS {
return Err(self.over(OVER_CALLS));
}
let bridge = self.bridge.get().saturating_add(bytes);
self.bridge.set(bridge);
if bridge > MAX_BRIDGE_BYTES {
return Err(self.over(OVER_BRIDGE));
}
let n = self.calls_this_slice.get() + 1;
self.calls_this_slice.set(n);
if n >= SLICE_HOST_CALLS {
self.pause.set(true);
}
Ok(())
}
/// Counts what a command added to the documents, past which the person is asked or, without
/// anyone to ask, the run ends. A run that goes on past twice its allowance within one slice
/// ends too.
fn charge_growth(&self, objects: u64, bytes: u64) -> std::result::Result<(), HostError> {
self.objects.set(self.objects.get().saturating_add(objects));
self.bytes_kept.set(self.bytes_kept.get().saturating_add(bytes));
let (o, b) = (self.objects.get(), self.bytes_kept.get());
let (limit_o, limit_b) = (self.allowance_objects.get(), self.allowance_bytes.get());
if o <= limit_o && b <= limit_b {
return Ok(());
}
let may_ask = self.framed && self.continues.get() < MAX_CONTINUES;
if !may_ask || o > limit_o.saturating_mul(2) || b > limit_b.saturating_mul(2) {
return Err(self.over(OVER_GROWTH));
}
self.waiting.set(true);
self.pause.set(true);
Ok(())
}
}
/// A run in progress.
struct Job {
realm: Realm,
host: Rc<Host>,
/// The link the realm's host functions find `host` through.
link: Link,
/// The named engine the realm belongs to, which gets it back when the run ends.
engine: Option<String>,
/// Between slices the working copy; during the last slice's commit the state before the run.
world: World,
/// The documents open when the run began, by [`DocState::uid`].
start_uids: Vec<u64>,
name: String,
/// The file-access scope `script.run` came in, which every slice runs in.
scope: crate::file_access::ScopeHandle,
}
/// The `Data` for a JSON value, within `limits`. When it does not fit, the error is the place in
/// `v` where it stopped fitting, such as `.layers[2].name`, and empty for `v` itself.
pub(crate) fn to_data(v: &Value, limits: &DataLimits, depth: usize, used: &mut (usize, usize)) -> std::result::Result<Data, String> {
if depth > limits.depth {
return Err(String::new());
}
used.0 = used.0.saturating_add(16);
used.1 = used.1.saturating_add(1);
if used.0 > limits.bytes || used.1 > limits.entries {
return Err(String::new());
}
Ok(match v {
Value::Null => Data::Null,
Value::Bool(b) => Data::Bool(*b),
Value::Number(n) => Data::Number(n.as_f64().unwrap_or(f64::NAN)),
Value::String(s) => {
used.0 = used.0.saturating_add(s.len());
if used.0 > limits.bytes {
return Err(String::new());
}
Data::String(s.clone())
}
Value::Array(a) => Data::Array(
a.iter()
.enumerate()
.map(|(i, x)| to_data(x, limits, depth + 1, used).map_err(|place| format!("[{i}]{place}")))
.collect::<std::result::Result<_, _>>()?,
),
Value::Object(o) => Data::Object(
o.iter()
.map(|(k, x)| {
used.0 = used.0.saturating_add(k.len());
to_data(x, limits, depth + 1, used).map(|d| (k.clone(), d)).map_err(|place| format!(".{k}{place}"))
})
.collect::<std::result::Result<_, _>>()?,
),
})
}
/// The JSON for a value a script gave; a whole number within a double's exact range is an integer,
/// which is how commands read ids and counts.
fn to_json(d: &Data) -> Value {
match d {
Data::Undefined | Data::Null => Value::Null,
Data::Bool(b) => Value::Bool(*b),
Data::Number(n) =>
{
#[allow(clippy::cast_possible_truncation)]
if n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_992.0 {
json!(*n as i64)
} else {
serde_json::Number::from_f64(*n).map_or(Value::Null, Value::Number)
}
}
Data::String(s) => Value::String(s.clone()),
Data::Array(a) => Value::Array(a.iter().map(to_json).collect()),
Data::Object(o) => Value::Object(o.iter().map(|(k, v)| (k.clone(), to_json(v))).collect()),
}
}
/// A step into a JSON value: a key or an index.
enum Step<'a> {
Name(&'a str),
Index(usize),
}
/// The place in `v` that is more than [`DataLimits`]' depth levels deep, such as `.layers[2].name`,
/// or `None`. It walks `v` with a list of its own, so any depth fits on the stack.
pub(crate) fn too_deep(v: &Value) -> Option<String> {
let limit = DataLimits::default().depth;
// `steps[i - 1]` is the entry that holds entry `i` and the step into it; entry 0 is `v`.
let mut steps: Vec<(usize, Step<'_>)> = Vec::new();
let mut todo = vec![(v, 0usize, 0usize)];
while let Some((v, depth, entry)) = todo.pop() {
if depth > limit {
let mut place = Vec::new();
let mut at = entry;
while let Some((parent, step)) = at.checked_sub(1).and_then(|i| steps.get(i)) {
place.push(match step {
Step::Name(k) => format!(".{k}"),
Step::Index(i) => format!("[{i}]"),
});
at = *parent;
}
place.reverse();
return Some(place.concat());
}
let children: Box<dyn Iterator<Item = (Step<'_>, &Value)>> = match v {
Value::Array(a) => Box::new(a.iter().enumerate().map(|(i, x)| (Step::Index(i), x))),
Value::Object(o) => Box::new(o.iter().map(|(k, x)| (Step::Name(k.as_str()), x))),
_ => continue,
};
for (step, child) in children {
steps.push((entry, step));
todo.push((child, depth + 1, steps.len()));
}
}
None
}
/// Estimated bytes of `d`.
fn data_size(d: &Data) -> u64 {
match d {
Data::Undefined | Data::Null | Data::Bool(_) => 4,
Data::Number(_) => 8,
Data::String(s) => s.len() as u64 + 2,
Data::Array(a) => 8 + a.iter().map(data_size).sum::<u64>(),
Data::Object(o) => 8 + o.iter().map(|(k, v)| k.len() as u64 + 2 + data_size(v)).sum::<u64>(),
}
}
/// Brings each open document's next id up to the highest one the session has given out for it.
fn raise_ids(s: &mut Session) {
let marks = std::mem::take(&mut s.script_high_water);
for st in &mut s.docs {
if let Some(&mark) = marks.get(&st.uid)
&& mark > st.doc.peek_next_id()
{
st.change_unsaved(|d| Arc::make_mut(d).raise_next_id(mark));
}
}
s.script_high_water = marks;
}
/// Every object in `d`: all its art ([`crate::Document::art_roots`]), opacity masks included.
fn all_objects(d: &crate::Document) -> u64 {
d.art_roots().map(|n| n.count_with_masks() as u64).sum()
}
fn note_ids(s: &mut Session) {
for st in &s.docs {
let mark = s.script_high_water.entry(st.uid).or_insert(0);
*mark = (*mark).max(st.doc.peek_next_id());
}
}
/// Runs command `id` inside a slice. A command that fails puts everything back it had done.
/// → its result, the objects it added, the bytes of document data it added and the state before
/// it, for a result that cannot cross to the script.
fn step(s: &mut Session, host: &Host, id: &str, params: &Value) -> Result<(Value, u64, u64, Option<Savepoint>)> {
raise_ids(s);
let mutating = waits_for_tool_edit(id);
let save = mutating.then(|| Savepoint::take(s, id));
let before: Vec<(u64, Arc<crate::Document>, u64)> =
if mutating { s.docs.iter().map(|d| (d.uid, d.doc.clone(), d.doc.peek_next_id())).collect() } else { Vec::new() };
crate::file_access::start_recording_writes();
let r = s.execute(id, params);
// A file a command wrote before it failed is on disk all the same.
let mut written = host.written.borrow_mut();
for path in crate::file_access::finish_recording_writes(r.is_ok()) {
if !written.contains(&path) {
written.push(path);
}
}
drop(written);
match r {
Ok(v) => {
note_ids(s);
let (mut objects, mut bytes) = (0, 0);
for st in &s.docs {
match before.iter().find(|(uid, ..)| *uid == st.uid) {
Some((_, d, ids)) if !Arc::ptr_eq(d, &st.doc) => {
objects += st.doc.peek_next_id().saturating_sub(*ids);
bytes += d.retained_bytes_each(&st.doc).1 as u64;
}
Some(_) => {}
// A document the command opened or made counts whole.
None if mutating => {
objects += all_objects(&st.doc);
bytes += crate::Document::new(1.0, 1.0).retained_bytes_each(&st.doc).1 as u64;
}
None => {}
}
}
Ok((v, objects, bytes, save))
}
Err(e) => {
if let Some(save) = save
&& !save.restore(s)
{
return Err(EngineError::Internal { cmd: id.to_string(), msg: HISTORY_LOST.to_string() });
}
Err(e)
}
}
}
fn vc_run(host: &Rc<Host>, args: Vec<Data>) -> std::result::Result<Data, HostError> {
let Some(Data::String(id)) = args.first() else { return Err(HostError::Type(NO_COMMAND_ID.to_string())) };
if args.len() > 2 {
return Err(HostError::Type(TOO_MANY_ARGUMENTS.to_string()));
}
let id = id.clone();
let params = match args.get(1) {
None | Some(Data::Undefined) => json!({}),
Some(d @ Data::Object(_)) => to_json(d),
Some(_) => return Err(HostError::Type(PARAMS_NOT_OBJECT.to_string())),
};
let bytes = args.iter().map(data_size).sum();
host.charge_call(bytes)?;
bump(&host.counts.vc_run);
let session = host.session.clone();
let started = clock();
let r = {
let mut s = session.borrow_mut();
step(&mut s, host, &id, &params)
};
// Only commands that exist are timed, so the table holds at most one row per command.
if let (Some(started), Some(spec)) = (started, crate::find_command(&id)) {
let nanos = started.elapsed().as_nanos();
let mut times = host.commands.borrow_mut();
let row = times.entry(spec.id).or_insert((0, 0, 0));
*row = (row.0 + 1, row.1.saturating_add(nanos), row.2.max(nanos));
}
let (value, objects, added, save) = match r {
Ok(done) => done,
// A bug is not something a script can handle, and neither is a request to stop.
Err(e @ EngineError::Internal { .. }) => return Err(HostError::Abort(e.to_string())),
Err(EngineError::Cancelled) => return Err(HostError::Abort("canceled".into())),
Err(e) => return Err(HostError::Error(e.to_string())),
};
let crossed = host.charge_growth(objects, added).and_then(|()| {
let data = to_data(&value, &result_limits(), 0, &mut (0, 0))
.map_err(|place| HostError::Range(format!("vc.run: the result{place} is too large for a script")))?;
host.charge_call(data_size(&data))?;
Ok(data)
});
// A command whose result does not reach the script is put back, like one that failed.
if let Some(save) = save {
let mut s = session.borrow_mut();
if crossed.is_ok() {
save.keep(&mut s);
} else if !save.restore(&mut s) {
return Err(HostError::Abort(HISTORY_LOST.to_string()));
}
}
crossed
}
/// A bounded snapshot of the active document, or `null` with none open. A snapshot past the limits
/// of a crossing throws a `RangeError`.
fn vc_document(host: &Host) -> std::result::Result<Data, HostError> {
let s = host.session.borrow();
let Some(st) = s.active() else { return Ok(Data::Null) };
let ids: Vec<u64> = st.selection.objects.iter().take(1_000).map(|i| i.0).collect();
let v = json!({
"name": st.title(),
"path": st.path,
"modified": st.is_dirty(),
"artboards": st.doc.artboards.len(),
"layers": st.doc.layers.len(),
"selection": ids,
"selectionCount": st.selection.objects.len(),
});
to_data(&v, &DataLimits::default(), 0, &mut (0, 0)).map_err(|place| HostError::Range(format!("vc.document{place} is too large for a script")))
}
/// Every command a script may run: its id, label and parameters.
fn vc_commands() -> Data {
Data::Array(
command_specs()
.iter()
.filter(|c| !refused_to_scripts(c.id))
.map(|c| {
Data::Object(vec![
("id".into(), Data::String(c.id.into())),
("label".into(), Data::String(c.label.into())),
("params".into(), Data::String(c.params.into())),
])
})
.collect(),
)
}
fn vc_log(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostError> {
let entry = args.first().unwrap_or(&Data::Undefined);
let bytes = data_size(entry) as usize;
if host.log.borrow().len() >= MAX_LOG_LINES || host.log_bytes.get().saturating_add(bytes) > MAX_LOG_BYTES {
return Err(HostError::Range(LOG_FULL.to_string()));
}
host.log_bytes.set(host.log_bytes.get() + bytes);
let value = to_json(entry);
let text = match &value {
Value::String(s) => s.clone(),
v => v.to_string(),
};
host.log.borrow_mut().push(value);
each_line(&text, |line, cut| host.line(console::Entry::Log(line), cut));
Ok(Data::Undefined)
}
/// Calls `done` with each console line of `text` and whether that line was cut, one at a time.
fn each_line(text: &str, mut done: impl FnMut(String, bool)) {
let mut pending = console::Pending::default();
pending.write(text, &mut done);
pending.end_line(done);
}
/// 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. Its other members are
/// [`dollar::members`].
fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc<vectorcraft_js::Localization>) -> Vec<(String, HostItem)> {
let writer = |end: bool| {
let l = link.clone();
HostItem::TextFunction(function(move |a| {
let h = current(&l)?;
let text: String = a.iter().map(|d| if let Data::String(s) = d { s.as_str() } else { "" }).collect();
h.charge_call(text.len() as u64 + 2)?;
bump(&h.counts.console_writes);
h.write(&text, end);
Ok(Data::Undefined)
}))
};
let getter = |read: fn(&include::Files) -> String| {
let l = link.clone();
HostItem::Getter(function(move |_| {
let h = current(&l)?;
let text = read(&h.files.borrow());
h.charge_call(text.len() as u64 + 2)?;
Ok(Data::String(text))
}))
};
let eval_file = {
let (l, end, strict) = (link.clone(), link.clone(), link.clone());
HostItem::Evaluator {
source: function(move |a| eval_file(&*current(&l)?, a)),
end: Rc::new(move || {
if let Ok(h) = current(&end) {
h.files.borrow_mut().end_eval();
}
}),
strict_writes: Rc::new(move || current(&strict).ok().and_then(|h| h.eval_strict.get())),
}
};
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(link, info, localization));
items
}
/// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being
/// run, with its preprocessor lines read, which the interpreter then runs. `timeout`, in
/// milliseconds, is taken and not waited on: the file is read at once.
fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostError> {
let Some(Data::String(path)) = args.first().filter(|p| !matches!(p, Data::String(s) if s.is_empty())) else {
return Err(HostError::Type(EVAL_NO_PATH.to_string()));
};
match args.get(1) {
None | Some(Data::Undefined) => {}
Some(Data::Number(ms)) if ms.is_finite() && *ms >= 0.0 => {}
Some(Data::Number(_)) => return Err(HostError::Range(EVAL_TIMEOUT.to_string())),
Some(_) => return Err(HostError::Type(EVAL_TIMEOUT.to_string())),
}
if args.len() > 2 {
return Err(HostError::Type(EVAL_ARGUMENTS.to_string()));
}
host.charge_call(path.len() as u64 + 2)?;
let expanded = host.files.borrow_mut().eval_file(path).map_err(|f| {
let message = if f.line > 0 { format!("{}:{}:{}: {}", f.file, f.line, f.column, f.message) } else { f.message };
let error = match f.kind {
include::FailKind::Limit => HostError::Abort(message),
include::FailKind::Syntax => HostError::Syntax(message),
include::FailKind::Error => HostError::Error(message),
};
if f.line > 0 { host.placed(error, f.file, f.line, f.column) } else { error }
})?;
// A file without preprocessor lines is parsed here first, so its syntax error names its place.
let options = vectorcraft_js::Options { max_source_units: include::MAX_TOTAL_BYTES, ..vectorcraft_js::Options::default() };
if let Err(e) = vectorcraft_js::parse(&expanded.source, &options) {
host.files.borrow_mut().end_eval();
let (_, file, line, column) = expanded.map.locate(e.line, e.column);
let message = format!("{file}:{line}:{column}: {}", e.message);
return Err(host.placed(HostError::Syntax(message), file, line, column));
}
host.eval_maps.borrow_mut().push(expanded.map);
host.eval_strict.set(expanded.strict);
Ok(Data::String(expanded.source))
}
fn function(f: impl Fn(Vec<Data>) -> std::result::Result<Data, HostError> + 'static) -> HostFunction {
Rc::new(f)
}
/// The `vc` object, made once for each realm: its members act for the run `link` holds when they
/// are called.
fn vc_object(link: &Link) -> Vec<(String, HostItem)> {
let args = {
let l = link.clone();
function(move |_| {
let h = current(&l)?;
let (args, size) = &*h.args.borrow();
h.charge_call(*size)?;
Ok(args.clone())
})
};
let run = {
let l = link.clone();
function(move |a| vc_run(&current(&l)?, a))
};
let document = {
let l = link.clone();
function(move |_| {
let h = current(&l)?;
h.charge_call(64)?;
bump(&h.counts.document_reads);
vc_document(&h)
})
};
let commands = {
let l = link.clone();
let list = Rc::new(vc_commands());
let size = data_size(&list);
function(move |_| {
let h = current(&l)?;
h.charge_call(size)?;
bump(&h.counts.command_list_reads);
Ok((*list).clone())
})
};
let log = {
let l = link.clone();
function(move |a| {
let h = current(&l)?;
h.charge_call(data_size(a.first().unwrap_or(&Data::Undefined)))?;
bump(&h.counts.vc_log);
vc_log(&h, a)
})
};
let has = function(|a| Ok(Data::Bool(matches!(a.first(), Some(Data::String(f)) if API_FEATURES.contains(&f.as_str())))));
vec![
("run".into(), HostItem::Function(run)),
("document".into(), HostItem::Getter(document)),
("commands".into(), HostItem::Getter(commands)),
("log".into(), HostItem::Function(log)),
("args".into(), HostItem::FrozenGetter(args)),
(
"api".into(),
HostItem::Object(vec![("version".into(), HostItem::Value(Data::String(API_VERSION.into()))), ("has".into(), HostItem::Function(has))]),
),
]
}
/// A plain-words kind of a run's failure, as the report gives it.
fn kind_id(kind: &RunErrorKind) -> &'static str {
match kind {
RunErrorKind::Syntax => "syntax",
RunErrorKind::Thrown => "thrown",
RunErrorKind::Limit(LimitKind::Interrupted) => "stopped",
RunErrorKind::Limit(_) => "limit",
RunErrorKind::Internal => "internal",
}
}
/// `message`, with what to do about a failure that comes from a script written for another program.
fn explained(kind: &RunErrorKind, message: &str) -> String {
const HOST_OBJECTS: &[&str] = &["app", "Window", "BridgeTalk", "activeDocument", "documents"];
let names_host_object =
message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o)));
if *kind == RunErrorKind::Thrown && names_host_object {
format!("{message} (scripts here use the `vc` object; objects other applications give their scripts are not available)")
} else {
message.to_string()
}
}
/// The error of a run that stopped in its preprocessor lines (`f`), as the realm gives its own.
fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) {
let (kind, message) = match f.kind {
include::FailKind::Syntax if f.line > 0 => {
(RunErrorKind::Syntax, format!("SyntaxError: {} (line {}, column {})", f.message, f.line, f.column))
}
include::FailKind::Syntax => (RunErrorKind::Syntax, format!("SyntaxError: {}", f.message)),
include::FailKind::Error => (RunErrorKind::Thrown, format!("Error: {}", f.message)),
include::FailKind::Limit => (RunErrorKind::Limit(LimitKind::Host), f.message),
};
let file = (f.index > 0).then_some(f.file);
(RunError { kind, message, line: f.line, column: f.column }, file)
}
/// `e`, the error `realm` raised in the run of `host`, at the line and column of the file it is in,
/// with that file's name unless it is the run's own script.
fn located(host: &Host, realm: &Realm, e: &RunError) -> (RunError, Option<Arc<str>>) {
let map = |m: &include::SourceMap| {
let (index, file, line, column) = m.locate(e.line, e.column);
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
(RunError { kind: e.kind.clone(), message, line, column }, index, file)
};
// An error `$.evalFile` raised for a file it could not run, and no script caught, is at that
// file's place.
if let Some((file, line, column)) = realm.error_mark().and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) {
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
return (RunError { kind: e.kind.clone(), message, line, column }, Some(file));
}
if e.line == 0 {
return (e.clone(), None);
}
// An error in code from a source whose map is not at hand, such as code that `eval` compiled,
// is given without a place.
let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None);
match realm.error_source() {
0 => {
let (e, index, file) = map(&host.script_map.borrow());
(e, (index > 0).then_some(file))
}
n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) {
Some(m) => {
let (e, _, file) = map(m);
(e, Some(file))
}
None => unplaced(),
},
// Code an earlier run of the named engine compiled, such as a function it defined, is at
// its place in that run's files: the run's script, a file it included, or a file
// `$.evalFile` gave. A script given as text is named by that run's name.
_ => {
let kept = host.kept_maps.as_ref().map(|k| k.borrow());
match kept.as_ref().and_then(|k| k.get(realm.error_tag())) {
Some(kept) => {
let (e, index, file) = map(&kept.map);
let file = match &kept.script {
Some(name) if index == 0 && file.is_empty() => name.clone(),
_ => file,
};
(e, Some(file))
}
None => unplaced(),
}
}
}
}
/// A syntax error's `message`, which ends with its place `(line L, column C)`, with the place
/// given as `line`, `column`.
fn relocated(message: &str, old_line: u32, old_column: u32, line: u32, column: u32) -> String {
let old = format!(" (line {old_line}, column {old_column})");
match message.strip_suffix(&old) {
Some(m) => format!("{m} (line {line}, column {column})"),
None => message.to_string(),
}
}
impl Session {
/// Is a script running? While one is, the commands that change anything are refused.
pub fn script_running(&self) -> bool {
self.script_key.is_some()
}
/// The named engine of the running script.
fn running_engine(&self) -> Option<String> {
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.engine.clone())).flatten()
}
/// The named engines (`#targetengine`) this session keeps, in the order they were made: each
/// one's name, its heap after its last run in bytes, and whether a run has it now.
pub fn script_engines(&self) -> Vec<(String, usize, bool)> {
with_engines(&self.script_engines, |engines| engines.iter().map(|e| (e.name.clone(), e.heap, e.realm.is_none())).collect())
}
/// Drops the named engine `name` and the globals its runs left; a later run that names it gets a
/// new one.
pub fn drop_script_engine(&mut self, name: &str) -> Result<()> {
if name == MAIN_ENGINE {
return Err(EngineError::Other(DROP_MAIN.to_string()));
}
if self.running_engine().as_deref() == Some(name) {
return Err(EngineError::Busy);
}
let gone = with_engines(&self.script_engines, |engines| {
let at = engines.iter().position(|e| e.name == name)?;
Some(engines.remove(at))
});
match gone {
Some(engine) => {
drop(engine);
Ok(())
}
None => Err(EngineError::Other(format!("there is no script engine named {name:?}"))),
}
}
/// The realm of the named engine `name` for a run, with the link its host functions use and the
/// source maps it kept: the realm its last run left, or `None` for an engine made now. → the
/// kind and text of the failure when the engine cannot be had.
fn take_engine(&mut self, name: &str) -> std::result::Result<TakenEngine, (include::FailKind, String)> {
if cfg!(target_arch = "wasm32") {
return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string()));
}
with_engines(&self.script_engines, |engines| {
if let Some(e) = engines.iter_mut().find(|e| e.name == name) {
let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?;
return Ok((Some(realm), e.link.clone(), e.maps.clone()));
}
if engines.len() >= MAX_ENGINES {
return Err((
include::FailKind::Limit,
format!("a session keeps at most {MAX_ENGINES} script engines; drop one with script.engine.drop"),
));
}
let link: Link = Rc::new(RefCell::new(None));
let maps = Rc::new(RefCell::new(KeptMaps::default()));
engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0, maps: maps.clone() });
Ok((None, link, maps))
})
}
/// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer
/// holds the run, and the engine keeps `realm`, with the source maps of the code the run
/// compiled, or is dropped without one.
fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option<Realm>) {
let host = link.borrow_mut().take();
if let (Some(h), Some(_)) = (&host, &realm) {
h.keep_maps();
}
drop(host);
let Some(name) = engine else { return };
let mut realm = realm;
let heap = realm.as_mut().map(Realm::collect_garbage);
let gone = with_engines(&self.script_engines, |engines| {
let at = engines.iter().position(|e| e.name == name)?;
match (realm.take(), engines.get_mut(at)) {
(Some(r), Some(e)) => {
e.realm = Some(r);
e.heap = heap.unwrap_or(0);
None
}
_ => Some(engines.remove(at)),
}
});
drop(gone);
drop(realm);
}
/// The end of `job`: its engine gets its realm back, with a run that was stopped where it was
/// ended, unless the run ended with an internal error, which drops the engine.
fn end_job(&self, job: Job, internal: bool) {
let Job { mut realm, link, engine, host, .. } = job;
drop(host);
realm.end();
self.return_engine(engine.as_deref(), &link, (!internal).then_some(realm));
}
/// The flag that stops the script running in this session from any thread, at its next
/// instruction.
pub fn script_interrupt(&self) -> Interrupt {
self.script_stop.clone()
}
/// The report of the run that ended last (`script.run`'s result, or its failure).
pub fn script_report(&self) -> Option<&Value> {
self.script_report.as_ref()
}
/// The Script Console: the lines of this session's runs, oldest first.
pub fn script_console(&self) -> &console::Console {
&self.script_console
}
/// Empties the Script Console and sets its counts to zero. The last run's report, and the
/// report of a running script, keep their lines. → the lines removed.
pub fn clear_script_console(&mut self) -> usize {
let n = self.script_console.lines().len();
self.script_console.clear();
n
}
/// The error text of the last run when it failed: its name, place, message and kind, and the
/// files it left written.
pub fn script_failure_text(&self) -> Option<String> {
self.script_report.as_ref().filter(|r| r["ok"] == false).map(failure_text)
}
/// Has the running script used up its growth allowance, so a person must answer? A run asked
/// to stop no longer waits: the next [`Session::script_pump`] ends it.
pub fn script_waiting(&self) -> bool {
!self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false)
}
/// The time left of the running script's `$.sleep`, or none when it is not asleep. A run asked
/// to stop is not asleep: the next [`Session::script_pump`] ends it.
pub fn script_sleep_left(&self) -> Option<std::time::Duration> {
if self.script_stop.is_set() {
return None;
}
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.asleep())).flatten()
}
/// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's
/// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it.
pub fn script_dialog(&self) -> Option<ScriptDialog> {
if self.script_stop.is_set() {
return None;
}
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.dialogs.waiting())).flatten()
}
/// Gives the dialog the running script waits at its answer; the next [`Session::script_pump`]
/// goes on from the call. → false when nothing waits, the answer is for another kind of
/// dialog, or a prompt's text is longer than [`dialogs::MAX_PROMPT_BYTES`].
pub fn script_answer(&mut self, answer: DialogAnswer) -> bool {
let Some(k) = self.script_key.as_ref().filter(|_| !self.script_stop.is_set()) else { return false };
with_job(k, |j| j.host.dialogs.answer(&mut j.realm, answer)).unwrap_or(false)
}
/// 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()))
}
/// Lets the run go past its growth allowance by one more allowance.
pub fn script_continue(&mut self) {
if let Some(k) = &self.script_key {
with_job(k, |j| {
let h = &j.host;
if h.waiting.replace(false) {
h.continues.set(h.continues.get() + 1);
h.allowance_objects.set(h.allowance_objects.get().saturating_add(growth_objects()));
h.allowance_bytes.set(h.allowance_bytes.get().saturating_add(GROWTH_BYTES));
}
});
}
}
/// Asks the running script to stop; it is rolled back at the next slice. False with none running.
pub fn script_request_stop(&mut self) -> bool {
if self.script_key.is_none() {
return false;
}
self.script_stop.set();
true
}
/// Stops the running script at once and puts everything back, with the report of a stopped run
/// ([`Self::script_report`]). False with none running.
pub fn script_abandon(&mut self) -> bool {
let Some(key) = self.script_key.take() else { return false };
let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else {
self.script_gone(key, console::Ending::Stopped);
return true;
};
drop(key);
let kind = LimitKind::Interrupted;
let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 };
let _ = self.script_failed(job, &e);
true
}
/// Ends the run whose job is no longer held: its console entry ends with `ending`, with no
/// report line, since its time and host calls went with the job, and the session's state is
/// put back as at the end of any run.
fn script_gone(&mut self, key: JobKey, ending: console::Ending) {
let line = console::ConsoleLine { run: key.run, name: key.name.clone(), entry: console::Entry::End(ending) };
self.script_console.push(line, false);
drop(key);
// The realm of the job's named engine went with the job, so the engine is dropped.
let lost: Vec<Engine> = with_engines(&self.script_engines, |engines| {
let (lost, kept) = std::mem::take(engines).into_iter().partition(|e| e.realm.is_none());
*engines = kept;
lost
});
drop(lost);
raise_ids(self);
self.script_stop.clear();
self.reset_tool();
cmd::compositefonts::sync(self);
}
/// A script can't start while a document holds a drag in progress or an undo group, which its
/// one undo step could not take in.
fn settle_for_script(&mut self) -> Result<()> {
self.finish_tool_edit_later()?;
if self.active().is_some() {
cmd::settle_interaction(self)?;
}
if self.explicit_undo_group.is_some() || self.docs.iter().any(|d| d.undo_group.is_some() || d.interaction.is_some()) {
return Err(EngineError::Other(OPEN_EDIT.to_string()));
}
Ok(())
}
/// Begins a run of `request`. → the run's name.
pub(crate) fn script_start(&mut self, request: Request) -> Result<String> {
self.settle_for_script()?;
// In a build where a fault ends the program, every unsaved document has a copy first.
if self.script_copy_first && self.docs.iter().any(DocState::is_dirty) && cmd::recovery::save_copies(self).is_err() {
return Err(EngineError::Other(NO_RECOVERY_COPY.to_string()));
}
self.reset_tool();
let open: Vec<u64> = self.docs.iter().map(|d| d.uid).collect();
self.script_high_water.retain(|uid, _| open.contains(uid));
self.script_stop.clear();
self.script_report = None;
let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env());
let expanded = files.expand_script(&request.source);
let name = match &expanded {
Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(),
_ => request.name,
};
// The engine named by `#targetengine`, other than `main`: its realm, kept from its last run,
// or none for an engine this run makes.
let named = match &expanded {
Ok(include::Expanded { engine: Some((engine, line, column)), .. }) if engine != MAIN_ENGINE => Some((engine.clone(), *line, *column)),
_ => None,
};
let (expanded, kept) = match named {
None => (expanded, None),
Some((engine, line, column)) => match self.take_engine(&engine) {
Ok(kept) => (expanded, Some((engine, kept))),
Err((kind, message)) => {
let file = Arc::from(files.file_name());
(Err(include::Failure { kind, message, index: 0, file, line, column }), None)
}
},
};
let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES };
let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() };
let (engine, realm, link, kept_maps) = match kept {
None => (None, None, Rc::new(RefCell::new(None)), None),
Some((engine, (realm, link, maps))) => (Some(engine), realm, link, Some(maps)),
};
// A realm made here gets the script extensions, `$` and the dialogs; a kept one has them
// from its first run, and they act for the run that calls them.
let fresh = realm.is_none();
let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() };
let strict = options.strict;
let mut realm = match realm {
Some(mut realm) => {
realm.set_limits(limits);
realm
}
None => Realm::with_options(options, limits),
};
realm.share_interrupt(&self.script_stop);
realm.set_data_limits(DataLimits::default());
self.script_runs += 1;
let host = Rc::new(Host {
session: Rc::new(RefCell::new(Session::new())),
pause: realm.pause_flag(),
framed: self.script_frames,
calls: Cell::new(0),
calls_this_slice: Cell::new(0),
objects: Cell::new(0),
bytes_kept: Cell::new(0),
bridge: Cell::new(0),
allowance_objects: Cell::new(growth_objects()),
allowance_bytes: Cell::new(GROWTH_BYTES),
continues: Cell::new(0),
waiting: Cell::new(false),
log: RefCell::new(Vec::new()),
log_bytes: Cell::new(0),
written: RefCell::new(Vec::new()),
run: self.script_runs,
name: console::line_name(&name),
console: RefCell::default(),
pending: RefCell::default(),
counts: Counts::default(),
commands: RefCell::default(),
run_nanos: Cell::new(0),
dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers),
disk: RefCell::new(files::Disk::new(files.scope())),
files: RefCell::new(files),
script_map: RefCell::default(),
eval_maps: RefCell::default(),
eval_failures: RefCell::default(),
script_tag: Cell::new(vectorcraft_js::UNKNOWN_SOURCE),
report_name: Arc::from(name.as_str()),
kept_maps,
started: clock(),
wake_at: Cell::new(None),
can_wait: realm.wait_flag(),
eval_strict: Cell::new(None),
dollar: dollar::RunState::new(&request.locale),
args: RefCell::new((Data::Undefined, 0)),
});
*link.borrow_mut() = Some(host.clone());
let args = match to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) {
Ok(args) => args,
Err(place) => {
self.return_engine(engine.as_deref(), &link, (!fresh).then_some(realm));
return Err(EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") });
}
};
let size = data_size(&args);
*host.args.borrow_mut() = (args, size);
let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start };
self.script_console.push(line, false);
let engine_name = match &expanded {
Ok(include::Expanded { engine: Some((name, ..)), .. }) => Some(name.clone()),
_ => None,
};
let info = dollar::RunInfo { strict, engine: engine_name };
let localization = realm.localization();
dollar::start_run(&localization, &request.locale);
let started = match expanded {
Err(f) => Err(preprocessor_error(f)),
Ok(expanded) => {
*host.script_map.borrow_mut() = expanded.map;
realm.set_strict_writes(expanded.strict == Some(true));
(if fresh { realm.add_script_extensions() } else { Ok(()) })
.and_then(|()| if fresh { realm.define_host_object("vc", vc_object(&link)) } else { Ok(()) })
.and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) })
.and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) })
.and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() })
.and_then(|()| {
let r = realm.start(&expanded.source);
host.script_tag.set(realm.script_tag());
r
})
.map_err(|e| located(&host, &realm, &e))
}
};
if let Err((e, file)) = started {
// An engine this run made is not kept: only a run that starts makes one. An internal
// error drops a kept one.
let keep = !fresh && e.kind != RunErrorKind::Internal;
self.return_engine(engine.as_deref(), &link, keep.then_some(realm));
let mut report = failure_report(&name, &e, file.as_deref(), &host, 0, 0);
close_run(self, &host, &mut report, ending(&e.kind));
let message = failure_text(&report);
self.script_report = Some(report);
return Err(EngineError::Other(message));
}
let scope = crate::file_access::ScopeHandle::capture();
let (run, line_name) = (host.run, host.name.clone());
let job = Job { realm, host, link, engine, world: World::working(self), start_uids: open, name: name.clone(), scope };
let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
JOBS.with(|jobs| jobs.borrow_mut().insert(id, job));
self.script_key = Some(JobKey { id, run, name: line_name });
Ok(name)
}
/// Advances the running script by one slice. `None` while it is still running; otherwise the
/// run has ended: its report, or the error that ended it, with the documents put back.
pub fn script_pump(&mut self) -> Option<Result<Value>> {
let key = self.script_key.take()?;
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else {
self.script_gone(key, console::Ending::Internal);
return Some(Err(EngineError::Other(JOB_GONE.to_string())));
};
if let Some(e) = job.host.dialogs.take_failure() {
return Some(self.script_failed(job, &e));
}
// A canceled request ends a sleep or a wait at this pump.
if vectorcraft_doc::cancel::check().is_err() {
self.script_stop.set();
}
if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job));
self.script_key = Some(key);
return None;
}
// A stop while a dialog waits ends the run there; the interpreter cannot go on from the call.
if job.realm.waits() {
let kind = LimitKind::Interrupted;
let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 };
return Some(self.script_failed(job, &e));
}
job.host.calls_this_slice.set(0);
let outcome = run_slice(self, &mut job);
match outcome {
Err(panic) => {
let e = RunError { kind: RunErrorKind::Internal, message: panic, line: 0, column: 0 };
Some(self.script_failed(job, &e))
}
Ok(Err(e)) => Some(self.script_failed(job, &e)),
Ok(Ok(Progress::Paused)) => {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job));
self.script_key = Some(key);
None
}
Ok(Ok(Progress::Done(_))) if job.host.dialogs.unused() > 0 => {
let n = job.host.dialogs.unused();
let kind = LimitKind::Host;
let message = format!("`answers` has {n} answer{} the script did not use", if n == 1 { "" } else { "s" });
Some(self.script_failed(job, &RunError { kind: RunErrorKind::Limit(kind), message, line: 0, column: 0 }))
}
Ok(Ok(Progress::Done(_))) => {
let r = self.script_commit(job);
self.trim_histories();
Some(r)
}
}
}
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
job.host.dialogs.stopped();
let fuel_left = job.realm.fuel_left();
let (e, file) = located(&job.host, &job.realm, e);
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
close_run(self, &job.host, &mut report, ending(&e.kind));
self.end_job(job, e.kind == RunErrorKind::Internal);
raise_ids(self);
self.script_stop.clear();
self.reset_tool();
cmd::compositefonts::sync(self);
let message = failure_text(&report);
self.script_report = Some(report);
Err(EngineError::Other(message))
}
/// Makes the working copy the session's state: one undo step for each document whose art
/// changed, nothing for the others.
fn script_commit(&mut self, mut job: Job) -> Result<Value> {
// The working copy goes into the session; `job.world` holds the state before the run.
job.world.swap(self);
self.prefs = merged_prefs(&job.world.start_prefs, &self.prefs, &job.world.choices.prefs);
// The Caps Lock state takes the run's value only when the run changed it.
if self.caps_lock == job.world.start_caps_lock {
self.caps_lock = job.world.choices.caps_lock;
}
note_ids(self);
raise_ids(self);
let mut start = std::mem::take(&mut job.world.docs);
for st in &self.docs {
let changed = start.iter().find(|b| b.uid == st.uid).is_none_or(|b| !st.same_content(&b.doc));
if changed && (!crate::sanity::doc_sane(&st.doc, &st.selection) || st.doc.too_deep(None)) {
job.world.docs = start;
job.world.swap(self);
let e = RunError { kind: RunErrorKind::Internal, message: INVALID_RESULT.to_string(), line: 0, column: 0 };
return self.script_failed(job, &e);
}
}
let mut changed_names = Vec::new();
for st in &mut self.docs {
let Some(b) = start.iter_mut().find(|b| b.uid == st.uid) else {
// A document the script opened or made starts with no undo steps.
st.history = crate::History { limit: st.history.limit, ..crate::History::default() };
st.revision += 1;
changed_names.push(st.title());
continue;
};
let changed = !st.same_content(&b.doc);
st.history = std::mem::take(&mut b.history);
st.recovery.clone_from(&b.recovery);
if changed {
st.push_undo(STEP_LABEL.to_string(), b.doc.clone(), Arc::new(b.selection.clone()));
changed_names.push(st.title());
}
if changed || st.revision != b.revision {
st.revision = st.revision.max(b.revision) + 1;
}
st.sync_new_art();
}
// A document a frontend opened while the run went on stays open.
for st in start.iter().filter(|b| !job.start_uids.contains(&b.uid)) {
self.docs.push(st.clone());
}
// A document the script closed is gone now, with its recovery copy.
for b in start.iter().filter(|b| job.start_uids.contains(&b.uid) && !self.docs.iter().any(|d| d.uid == b.uid)) {
cmd::recovery::forget_closed(self, b);
}
// A document the script saved has nothing left to recover.
cmd::recovery::forget_clean(self);
let entries: Vec<_> = self.journal.iter().cloned().collect();
self.journal = std::mem::take(&mut job.world.journal);
for (id, p) in entries {
self.journal.push((id, p));
}
self.script_stop.clear();
self.reset_tool();
cmd::compositefonts::sync(self);
let host = &job.host;
let mut report = json!({
"ok": true,
"name": job.name,
"changed": changed_names,
"written": *host.written.borrow(),
"log": *host.log.borrow(),
"fuelUsed": job.realm.limits().fuel - job.realm.fuel_left(),
"hostCalls": host.calls.get(),
});
close_run(self, host, &mut report, console::Ending::Finished);
self.end_job(job, false);
self.script_report = Some(report.clone());
Ok(report)
}
/// Runs the running script until it ends.
pub(crate) fn script_run_to_end(&mut self) -> Result<Value> {
loop {
if let Some(result) = self.script_pump() {
return result;
}
if self.script_waiting() || self.script_dialog().is_some() {
self.script_stop.set();
}
// A `$.sleep` waits here, where nothing is drawn, instead of turning the loop over.
#[cfg(not(target_arch = "wasm32"))]
if let Some(left) = self.script_sleep_left() {
std::thread::sleep(left.min(std::time::Duration::from_millis(100)));
}
}
}
}
const OPEN_EDIT: &str = "a drag or an undo group is open; finish it before running a script";
const INVALID_RESULT: &str = "the script left a document that cannot be kept";
const JOB_GONE: &str = "the script's run is no longer available";
const HISTORY_LOST: &str = "a failed command changed undo steps that could not be put back, so the script was stopped";
// Text a script reads from the errors it catches, or that ends its run with its name and place.
const OVER_CALLS: &str = "the script made more host calls than one run may make";
const OVER_BRIDGE: &str = "the script passed more data to and from commands than one run may pass";
const OVER_GROWTH: &str = "the script added more objects and data to the documents than one run may add";
const NO_COMMAND_ID: &str = "vc.run: give a command id, as a string";
const TOO_MANY_ARGUMENTS: &str = "vc.run: takes a command id and an object of parameters";
const PARAMS_NOT_OBJECT: &str = "vc.run: the parameters must be an object";
const LOG_FULL: &str = "vc.log: the log of this run is full";
const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the script was not run";
const EVAL_NO_PATH: &str = "$.evalFile requires a file path";
const EVAL_TIMEOUT: &str = "$.evalFile: the timeout must be a finite number of milliseconds, 0 or more";
const EVAL_ARGUMENTS: &str = "$.evalFile: takes a file path and a timeout";
const NO_RUN: &str = "this function's script run has ended";
const DROP_MAIN: &str = "the main script engine is new for each run and is never kept, so it cannot be dropped";
const NO_ENGINES_ON_WEB: &str = "named script engines (#targetengine) are not available on the web";
const ENGINE_BUSY: &str = "the script engine is in use by another run";
/// The report of a run that failed; `file` is the included file the error is in.
fn failure_report(name: &str, e: &RunError, file: Option<&str>, host: &Host, fuel_left: u64, fuel_used: u64) -> Value {
let mut report = json!({
"ok": false,
"kind": kind_id(&e.kind),
"message": explained(&e.kind, &e.message),
"name": name,
"line": e.line,
"column": e.column,
"written": *host.written.borrow(),
"log": *host.log.borrow(),
"fuelLeft": fuel_left,
"fuelUsed": fuel_used,
"hostCalls": host.calls.get(),
});
if let (Some(file), Some(o)) = (file, report.as_object_mut()) {
o.insert("file".into(), json!(file));
}
report
}
/// How the run that failed with `kind` ended, as its console end line gives it.
fn ending(kind: &RunErrorKind) -> console::Ending {
match kind {
RunErrorKind::Syntax | RunErrorKind::Thrown => console::Ending::Error,
RunErrorKind::Limit(LimitKind::Fuel) => console::Ending::Fuel,
RunErrorKind::Limit(LimitKind::Host) => console::Ending::HostLimit,
RunErrorKind::Limit(LimitKind::Interrupted) => console::Ending::Stopped,
RunErrorKind::Limit(_) => console::Ending::Limit,
RunErrorKind::Internal => console::Ending::Internal,
}
}
/// Adds a line of `host`'s run to the session's console and to the run's own.
fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry };
s.script_console.push(line.clone(), cut);
host.console.borrow_mut().push(line, cut);
}
/// The error of the failed run `report` describes, as its console lines give it; `name` is the
/// run's name in those lines ([`console::line_name`]).
fn error_text(name: &str, report: &Value) -> String {
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
let message = report["message"].as_str().unwrap_or_default();
match report["file"].as_str() {
Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"),
_ if line > 0 => format!("{name}:{line}:{column}: {message}"),
_ => format!("{name}: location unavailable: {message}"),
}
}
/// The console lines of the error of the failed run `report` describes, each as the console keeps
/// it, cut to its limit.
pub(crate) fn error_lines(report: &Value) -> Vec<String> {
let name = console::line_name(report["name"].as_str().unwrap_or_default());
let mut lines = vec![];
each_line(&error_text(&name, report), |line, _| lines.push(line));
lines
}
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
/// console and dialogs to `report`.
fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::Ending) {
let mut pending = std::mem::take(&mut *host.pending.borrow_mut());
if !pending.is_empty() {
let mut last = None;
pending.end_line(|line, cut| last = Some((line, cut)));
if let Some((line, cut)) = last {
line_to(s, host, console::Entry::Output(line), cut);
}
}
if report["ok"] == false {
each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut));
}
let timing = clock().is_some();
let ms = millis(host.run_nanos.get());
let slow = slowest(&host.commands.borrow());
let slow_json: Vec<Value> = slow.iter().map(|c| json!({"id": c.id, "calls": c.calls, "totalMs": c.total_ms, "maxMs": c.max_ms})).collect();
let line = |entry| console::ConsoleLine { run: host.run, name: host.name.clone(), entry };
s.script_console.push(line(console::Entry::End(ending)), false);
let summary = console::Entry::Report { ms: timing.then_some(ms), host_calls: host.calls.get(), slowest: slow };
s.script_console.push(line(summary), false);
if let Some(o) = report.as_object_mut() {
o.insert("runTimeMs".into(), json!(if timing { ms } else { 0 }));
o.insert("timingAvailable".into(), json!(timing));
o.insert("hostCallCounts".into(), host.counts.json());
o.insert("slowestCommands".into(), Value::Array(slow_json));
o.insert("console".into(), host.console.borrow().report());
host.dialogs.report(o);
}
}
/// The text of a failed run's error.
fn failure_text(report: &Value) -> String {
let s = |k: &str| report[k].as_str().unwrap_or_default();
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
let at = match report["file"].as_str() {
Some(file) if line > 0 => format!(" line {line}, column {column} of {file}"),
_ if line > 0 => format!(" line {line}, column {column}"),
_ => String::new(),
};
let written = report["written"].as_array().map_or(0, Vec::len);
let kept = if written > 0 {
let paths: Vec<&str> = report["written"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
format!(" (these files stay written: {})", paths.join(", "))
} else {
String::new()
};
format!("{}:{at}: {} ({}){kept}", s("name"), s("message"), s("kind"))
}
/// A run's slice: the working copy in, the real session lent to the host functions, one measured
/// slice run in the file-access scope the run began in, and everything back. A panic in the
/// interpreter is the outer `Err`.
fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result<std::result::Result<Progress, RunError>, String> {
let depth = std::mem::replace(&mut s.depth, 0);
job.world.swap(s);
s.script_in_run = true;
let cell = job.host.session.clone();
std::mem::swap(s, &mut *cell.borrow_mut());
let scope = job.scope.clone();
let realm = &mut job.realm;
let started = clock();
let r = scope.run(|| {
guard::catch_panic(|| {
#[cfg(test)]
if PANIC_IN_SLICE.with(Cell::get) {
panic!("a slice failed");
}
realm.advance(SLICE_FUEL)
})
});
if let Some(started) = started {
job.host.run_nanos.set(job.host.run_nanos.get().saturating_add(started.elapsed().as_nanos()));
}
std::mem::swap(s, &mut *cell.borrow_mut());
s.script_in_run = false;
job.world.swap(s);
s.depth = depth;
r
}
/// Tests: removes the session's running job while the session keeps its hold on it. → whether a
/// job was removed.
#[cfg(test)]
pub(crate) fn forget_job(s: &Session) -> bool {
s.script_key.as_ref().is_some_and(|k| JOBS.with(|jobs| jobs.borrow_mut().remove(&k.id)).is_some())
}
/// The objects a run may add before it is asked to continue or stop.
fn growth_objects() -> u64 {
#[cfg(test)]
if let Some(n) = TEST_GROWTH.with(Cell::get) {
return n;
}
GROWTH_OBJECTS
}
/// The limits of a command's result on its way to the script.
fn result_limits() -> DataLimits {
#[cfg(test)]
if let Some(bytes) = TEST_RESULT_BYTES.with(Cell::get) {
return DataLimits { bytes, ..DataLimits::default() };
}
DataLimits::default()
}
#[cfg(test)]
thread_local! {
/// Tests: the bytes a command's result may hold on its way to the script.
pub(crate) static TEST_RESULT_BYTES: Cell<Option<usize>> = const { Cell::new(None) };
/// Tests: makes the next slice panic.
pub(crate) static PANIC_IN_SLICE: Cell<bool> = const { Cell::new(false) };
/// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`].
pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) };
/// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`].
pub(crate) static TEST_ENGINE_HEAP: Cell<Option<usize>> = const { Cell::new(None) };
/// Tests: the bytes of source maps a named engine keeps, in place of [`KEPT_MAP_BYTES`].
pub(crate) static TEST_KEPT_MAP_BYTES: Cell<Option<usize>> = const { Cell::new(None) };
/// Tests: the `JSINCLUDE` folders in place of the environment's.
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
/// Tests: the time since a run started, in place of its clock's.
pub(crate) static TEST_ELAPSED: Cell<Option<std::time::Duration>> = const { Cell::new(None) };
/// Tests: runs have no clock, as in a wasm build.
pub(crate) static TEST_NO_CLOCK: Cell<bool> = const { Cell::new(false) };
}