//! 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, pub source: String, pub args: Map, 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, } 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> = 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, } 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(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { 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>>>; /// The host of the run `link` holds, or an error when no run holds it. fn current(link: &Link) -> std::result::Result, 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, link: Link, heap: usize, maps: Rc>, } /// 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, Link, Rc>); /// 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>, 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, bytes: usize, } impl KeptMaps { fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option>) { 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>, } 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 = 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::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); fn released() -> std::sync::MutexGuard<'static, HashMap>> { 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)>); 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(key: &EnginesKey, f: impl FnOnce(&mut Vec) -> T) -> T { let id = key.here(); ENGINES.with(|engines| { let gone: Vec> = { 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, active: Option, paint: PaintDefaults, fill_active: bool, draw_mode: DrawMode, draw_inside: Option, clipboard: Clipboard, untitled_counter: u32, menu: cmd::menucmds::MenuState, last_solid: crate::Color, last_gradient: crate::GradientPaint, recent_colors: Vec, pending_paint: Option, 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>, global_edit_options: cmd::globaledit::Options, recent_urls: Vec, gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, freeform_point: Option<(usize, cmd::gradient::StopOwner)>, active_appearance_item: Option, 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, } /// 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, active: Option, paint: PaintDefaults, fill_active: bool, draw_mode: DrawMode, draw_inside: Option, clipboard: Option, untitled_counter: u32, menu: cmd::menucmds::MenuState, last_solid: crate::Color, last_gradient: crate::GradientPaint, recent_colors: Vec, pending_paint: Option, 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>, pause: Rc>, /// The run is sliced by frames, so the person at the computer can be asked to continue. framed: bool, calls: Cell, calls_this_slice: Cell, objects: Cell, bytes_kept: Cell, bridge: Cell, allowance_objects: Cell, allowance_bytes: Cell, continues: Cell, /// Past the growth allowance, waiting for the person's answer. waiting: Cell, log: RefCell>, log_bytes: Cell, written: RefCell>, /// The run's number in its session, and the script's name, which its console lines carry. run: u64, name: Arc, /// The run's own output and error, for its report. console: RefCell, /// The line `$.write` has not ended yet. pending: RefCell, counts: Counts, /// Each command `vc.run` ran: its calls, its total time and its longest call, in nanoseconds. commands: RefCell>, /// The time the run's slices took, in nanoseconds. run_nanos: Cell, /// `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, /// What `File` and `Folder` have open and have read. disk: RefCell, /// Where the lines of the script's expanded source came from. script_map: RefCell, /// The same for each source `$.evalFile` gave, in order. eval_maps: RefCell>, /// 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, 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, /// The run's name as its report gives it, which [`Host::name`] shortens for console lines. report_name: Arc, /// The source maps the named engine of the run kept from its earlier runs; `None` for a run /// without one. kept_maps: Option>>, /// When the run started, where the platform has a clock. started: Option, /// The time since [`Host::started`] at which a `$.sleep` ends. wake_at: Cell>, /// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]). can_wait: Rc>, /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). eval_strict: Cell>, /// 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, document_reads: Cell, command_list_reads: Cell, vc_log: Cell, console_writes: Cell, /// Calls of `File` and `Folder` that reach the host ([`files`]). file_calls: Cell, /// `execute` calls, which open nothing. file_executes: Cell, } 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) { c.set(c.get().saturating_add(1)); } /// Now, where the platform has a clock (not in a wasm build). fn clock() -> Option { #[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 { 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 { #[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 { 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, 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, /// 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, /// 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, 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 { 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::>()?, ), 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::>()?, ), }) } /// 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 { 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, &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::(), Data::Object(o) => 8 + o.iter().map(|(k, v)| k.len() as u64 + 2 + data_size(v)).sum::(), } } /// 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)> { raise_ids(s); let mutating = waits_for_tool_edit(id); let save = mutating.then(|| Savepoint::take(s, id)); let before: Vec<(u64, Arc, 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, args: Vec) -> std::result::Result { 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, ¶ms) }; // 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 { let s = host.session.borrow(); let Some(st) = s.active() else { return Ok(Data::Null) }; let ids: Vec = 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) -> std::result::Result { 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) -> 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) -> std::result::Result { 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) -> std::result::Result + '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(¤t(&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>) { 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>) { 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 { 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 { 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) { 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 { 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 { 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 { 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 { 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 = 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 { 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 = 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> { 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 { 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 { // 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 { 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 { 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 = 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, 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> = const { Cell::new(None) }; /// Tests: makes the next slice panic. pub(crate) static PANIC_IN_SLICE: Cell = const { Cell::new(false) }; /// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`]. pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; /// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. pub(crate) static TEST_ENGINE_HEAP: Cell> = 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> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. pub(crate) static TEST_ELAPSED: Cell> = const { Cell::new(None) }; /// Tests: runs have no clock, as in a wasm build. pub(crate) static TEST_NO_CLOCK: Cell = const { Cell::new(false) }; }