Files
vectorcraft/crates/engine/src/script/mod.rs
T
dotCooCoo df5d061b30 Scripts: an error $.evalFile could not run, caught and thrown again, is reported where it was thrown again
A failure $.evalFile raised for a file it could not run was placed by
matching the uncaught error's message against the last such failure. A
script that caught the failure and threw it again later, or threw a new
error whose message held its text, was reported at the failed file's line
instead of the second throw.

The failure now carries a mark (HostError::Marked) with the exception.
A catch clause drops the mark, a throw raises the value without one, and
a finally block that ends normally throws it on with its mark, nested
finally blocks included. Realm::error_mark gives the mark of the error a
run ended with, and the engine looks up the file, line and column by it.
2026-11-11 03:14:42 -08:00

1675 lines
71 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.
//!
//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the
//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`.
pub mod console;
pub mod dialogs;
pub mod 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 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"];
/// 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 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.
#[derive(Debug)]
pub struct JobKey(u64);
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.0);
}
});
}
}
fn with_job<T>(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option<T> {
JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.0).map(f))
}
/// 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>,
/// 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)>>,
}
/// 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>,
}
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(),
})
}
}
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 {
/// 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));
}
}
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>,
/// 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.
fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
let writer = |end: bool| {
let h = host.clone();
HostItem::TextFunction(function(move |a| {
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 h = host.clone();
HostItem::Getter(function(move |_| {
let text = read(&h.files.borrow());
h.charge_call(text.len() as u64 + 2)?;
Ok(Data::String(text))
}))
};
let eval_file = {
let h = host.clone();
let end = host.clone();
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
};
vec![
("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)),
]
}
/// `$.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);
Ok(Data::String(expanded.source))
}
fn function(f: impl Fn(Vec<Data>) -> std::result::Result<Data, HostError> + 'static) -> HostFunction {
Rc::new(f)
}
fn vc_object(host: &Rc<Host>, args: Data) -> Vec<(String, HostItem)> {
let run = {
let h = host.clone();
function(move |a| vc_run(&h, a))
};
let document = {
let h = host.clone();
function(move |_| {
h.charge_call(64)?;
bump(&h.counts.document_reads);
vc_document(&h)
})
};
let commands = {
let h = host.clone();
let list = Rc::new(vc_commands());
let size = data_size(&list);
function(move |_| {
h.charge_call(size)?;
bump(&h.counts.command_list_reads);
Ok((*list).clone())
})
};
let log = {
let h = host.clone();
function(move |a| {
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::Value(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", "File", "Folder", "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`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark`
/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name
/// unless it is the script's own.
fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) {
let map = |m: &include::SourceMap, own: bool| {
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 }, (!own || index > 0).then_some(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)) = 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);
}
match tag {
0 => map(&host.script_map.borrow(), true),
n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) {
Some(m) => map(m, false),
None => (e.clone(), None),
},
_ => (e.clone(), None),
}
}
/// 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 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 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.0)) else {
drop(key);
raise_ids(self);
self.script_stop.clear();
self.reset_tool();
cmd::compositefonts::sync(self);
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
}
/// 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,
};
let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() };
let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() };
let mut realm = 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),
files: RefCell::new(files),
script_map: RefCell::default(),
eval_maps: RefCell::default(),
eval_failures: RefCell::default(),
});
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start };
self.script_console.push(line, false);
let started = match expanded {
Err(f) => Err(preprocessor_error(f)),
Ok(expanded) => {
*host.script_map.borrow_mut() = expanded.map;
realm
.define_host_object("vc", vc_object(&host, args))
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
.and_then(|()| dialogs::install(&mut realm, &host))
.and_then(|()| realm.start(&expanded.source))
.map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e))
}
};
if let Err((e, file)) = started {
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 job = Job { realm, host, 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));
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.0)) else {
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));
}
if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, 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));
}
if vectorcraft_doc::cancel::check().is_err() {
self.script_stop.set();
}
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.0, 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.error_source(), job.realm.error_mark(), 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));
drop(job);
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.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();
}
}
}
}
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";
/// 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);
}
/// 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 {
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();
let text = match report["file"].as_str() {
Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name),
_ if line > 0 => format!("{}:{line}:{column}: {message}", host.name),
_ => format!("{}: location unavailable: {message}", host.name),
};
each_line(&text, |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
}
/// 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 `JSINCLUDE` folders in place of the environment's.
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
}