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