mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 08:52:26 +00:00
Scripts: #targetengine runs a script in a named engine that keeps its globals
A script whose first lines name an engine with `#targetengine name` (or
`//@targetengine name`) runs in that engine, and a later script that names
it sees the globals and functions earlier runs left, until the engine is
dropped or the session ends. A script without the line, and
`#targetengine main`, still get a new engine for each run.
- A session keeps at most 8 named engines, each with a 64 MiB heap. A run
that goes past the heap ends with the heap limit, and the engine stays
usable. Names are 1 to 64 bytes. The line comes once, in the script
itself, before its code and its #include lines.
- An uncaught error, a limit or script.stop puts the documents back and
keeps the globals the run set. An internal error drops the engine. A run
stopped at a dialog or left paused ends where it was before the engine
is kept. An error in a function an earlier run defined is reported
without a line and column.
- `vc`, `$`, `alert`, `confirm` and `prompt` are made once for each engine
and act for the run that calls them, including copies an earlier run kept
in a global. `vc.args` gives a read-only copy of the calling run's
`args`, and what a run sets on `$` lasts for that run only.
- `script.engines` lists the named engines with their heap, and
`script.engine.drop {name}` drops one. Scripts cannot run either.
- The engines stay on the thread that made them. A session used on
another thread starts there with no named engines, and the old ones are
dropped on their own thread.
- The web build has no named engines: a run that names one other than
`main` ends with an error before any of it runs.
- `Realm::end` ends a run where it is, `Realm::error_source` counts from
the realm's last script, and `HostItem::FrozenGetter` gives a frozen
value each time it is read.
- The script checker lists `#targetengine` as a supported directive.
Docs: docs/mcp.md, docs/target-app-parity.md, docs/gaps.md.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -258,6 +258,8 @@ pub struct Session {
|
||||
pub recovery: cmd::recovery::Recovery,
|
||||
/// The running script's job, while there is one: the mutation lease ([`script`]).
|
||||
pub(crate) script_key: Option<script::JobKey>,
|
||||
/// The named script engines this session keeps ([`script`]).
|
||||
pub(crate) script_engines: script::EnginesKey,
|
||||
/// Stops the running script at its next instruction; the same flag for every run.
|
||||
pub(crate) script_stop: vectorcraft_js::Interrupt,
|
||||
/// The report of the run that ended last.
|
||||
|
||||
@@ -290,12 +290,12 @@ impl Dialogs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `alert`, `confirm` and `prompt` to the realm.
|
||||
pub(super) fn install(realm: &mut Realm, host: &Rc<Host>) -> Result<(), RunError> {
|
||||
/// Adds `alert`, `confirm` and `prompt` to the realm, for the run `link` holds.
|
||||
pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> {
|
||||
use Arg::{Bool, Text, TextOrUndefined};
|
||||
let call = |kind: DialogKind| -> HostFunction {
|
||||
let h = host.clone();
|
||||
Rc::new(move |args| open(&h, kind, &args))
|
||||
let l = link.clone();
|
||||
Rc::new(move |args| open(&*super::current(&l)?, kind, &args))
|
||||
};
|
||||
realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?;
|
||||
realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?;
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
//! the locale the global `localize` reads
|
||||
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/>).
|
||||
//!
|
||||
//! Every value here belongs to one run: a run starts with the locale of the interface, the
|
||||
//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero,
|
||||
//! and what a script changes ends with its run. `$.setenv` never changes the process's environment.
|
||||
//! Every value here belongs to one run, in a named engine too ([`RunState`]): a run starts with the
|
||||
//! locale of the interface, the environment values of [`ENV_NAMES`] read from the process,
|
||||
//! localization off and a timer at zero, and what a script changes ends with its run. `$.setenv`
|
||||
//! never changes the process's environment.
|
||||
//!
|
||||
//! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and
|
||||
//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is
|
||||
//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0
|
||||
//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the
|
||||
//! `$.version` give VectorCraft's version, `$.engineName` is the name `#targetengine` gave, or
|
||||
//! `VectorCraft` without one, and `$.buildDate` is `null`; `$.flags` takes only 512, which turns
|
||||
//! localization on; `$.level` and `$.memCache` are 0 and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the
|
||||
//! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no
|
||||
//! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own
|
||||
//! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most
|
||||
@@ -25,7 +26,7 @@ use std::time::Duration;
|
||||
|
||||
use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem};
|
||||
|
||||
use super::{Host, function};
|
||||
use super::{Link, current, function};
|
||||
|
||||
/// The longest `$.sleep`, in milliseconds.
|
||||
pub const MAX_SLEEP_MS: f64 = 1_000.0;
|
||||
@@ -36,12 +37,34 @@ pub const MAX_ENV_BYTES: usize = 64;
|
||||
/// The value of `$.flags` that turns localization on, the only one a script may set.
|
||||
pub const LOCALIZE_FLAG: f64 = 512.0;
|
||||
|
||||
/// What `$` knows of a run.
|
||||
/// What `$` knows of its realm.
|
||||
pub(crate) struct RunInfo {
|
||||
/// The locale the run starts with, such as `de_DE`.
|
||||
pub locale: String,
|
||||
/// The realm reads scripts as strict mode code.
|
||||
pub strict: bool,
|
||||
/// The name `#targetengine` gave the realm's engine.
|
||||
pub engine: Option<String>,
|
||||
}
|
||||
|
||||
/// The values of `$` that belong to one run, which the run's host holds.
|
||||
pub(crate) struct RunState {
|
||||
/// The locale the run starts with, such as `de_DE`.
|
||||
locale: String,
|
||||
env: RefCell<Vec<(&'static str, Option<String>)>>,
|
||||
/// The time since the run started at which `$.hiresTimer` was last read.
|
||||
timer: Cell<Duration>,
|
||||
}
|
||||
|
||||
impl RunState {
|
||||
pub(crate) fn new(locale: &str) -> RunState {
|
||||
RunState { locale: locale.to_string(), env: RefCell::new(process_env()), timer: Cell::new(Duration::ZERO) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `localization` as a run starts: `locale`, this system's platform and localization off.
|
||||
pub(super) fn start_run(localization: &Localization, locale: &str) {
|
||||
localization.set_enabled(false);
|
||||
localization.set_locale(locale);
|
||||
localization.set_platform(platform());
|
||||
}
|
||||
|
||||
/// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language,
|
||||
@@ -148,14 +171,10 @@ fn text(s: impl Into<String>) -> Data {
|
||||
Data::String(s.into())
|
||||
}
|
||||
|
||||
/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`.
|
||||
pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localization>) -> Vec<(String, HostItem)> {
|
||||
/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. They
|
||||
/// act for the run that `link` holds when they are called.
|
||||
pub(super) fn members(link: &Link, info: RunInfo, localization: &Rc<Localization>) -> Vec<(String, HostItem)> {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let host_locale = info.locale.clone();
|
||||
localization.set_locale(&info.locale);
|
||||
localization.set_platform(platform());
|
||||
let env = Rc::new(RefCell::new(process_env()));
|
||||
let timer = Rc::new(Cell::new(Duration::ZERO));
|
||||
let getter = |f: Rc<dyn Fn() -> Data>| HostItem::Getter(function(move |_| Ok(f())));
|
||||
let property = |get: Rc<dyn Fn() -> Data>, set: Rc<dyn Fn(Data) -> Result<(), HostError>>| HostItem::Property {
|
||||
get: function(move |_| Ok(get())),
|
||||
@@ -167,13 +186,13 @@ pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localiza
|
||||
let fail = |message: &'static str| HostItem::Function(function(move |_| Err(HostError::Error(message.to_string()))));
|
||||
|
||||
let locale = {
|
||||
let (l, s) = (localization.clone(), localization.clone());
|
||||
let (l, s, run) = (localization.clone(), localization.clone(), link.clone());
|
||||
property(
|
||||
Rc::new(move || text(l.locale())),
|
||||
Rc::new(move |v| {
|
||||
let locale = match v {
|
||||
Data::Null | Data::Undefined => host_locale.clone(),
|
||||
Data::String(t) if t.is_empty() => host_locale.clone(),
|
||||
Data::Null | Data::Undefined => current(&run)?.dollar.locale.clone(),
|
||||
Data::String(t) if t.is_empty() => current(&run)?.dollar.locale.clone(),
|
||||
Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?,
|
||||
_ => return Err(HostError::Type(BAD_LOCALE.to_string())),
|
||||
};
|
||||
@@ -224,31 +243,34 @@ pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localiza
|
||||
let mem_cache =
|
||||
property(Rc::new(|| Data::Number(0.0)), Rc::new(|_| Err(HostError::Error("$.memCache: the memory cache size cannot be set".into()))));
|
||||
let hires_timer = {
|
||||
let (h, last) = (host.clone(), timer.clone());
|
||||
let l = link.clone();
|
||||
getter(Rc::new(move || {
|
||||
let Ok(h) = current(&l) else { return Data::Number(0.0) };
|
||||
let Some(now) = h.elapsed() else { return Data::Number(0.0) };
|
||||
let since = now.saturating_sub(last.replace(now));
|
||||
let since = now.saturating_sub(h.dollar.timer.replace(now));
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
Data::Number(since.as_micros() as f64)
|
||||
}))
|
||||
};
|
||||
let getenv = {
|
||||
let (h, env) = (host.clone(), env.clone());
|
||||
let l = link.clone();
|
||||
function(move |a| {
|
||||
let name = one_string(&a, "getenv")?;
|
||||
let h = current(&l)?;
|
||||
h.charge_call(name.len() as u64 + 2)?;
|
||||
let env = env.borrow();
|
||||
let env = h.dollar.env.borrow();
|
||||
let found = env.iter().find(|(n, _)| *n == name).and_then(|(_, v)| v.clone());
|
||||
Ok(found.map_or(Data::Null, Data::String))
|
||||
})
|
||||
};
|
||||
let setenv = {
|
||||
let (h, env) = (host.clone(), env.clone());
|
||||
let l = link.clone();
|
||||
function(move |a| {
|
||||
let name = one_string(&a, "setenv")?;
|
||||
let Some(Data::String(v)) = a.get(1) else { return Err(HostError::Type("$.setenv: give a value, as a string".into())) };
|
||||
let h = current(&l)?;
|
||||
h.charge_call((name.len() + v.len()) as u64 + 4)?;
|
||||
let mut env = env.borrow_mut();
|
||||
let mut env = h.dollar.env.borrow_mut();
|
||||
let Some(slot) = env.iter_mut().find(|(n, _)| *n == name) else {
|
||||
return Err(HostError::Error(format!("$.setenv: only {} can be set", ENV_NAMES.join(", "))));
|
||||
};
|
||||
@@ -260,8 +282,9 @@ pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localiza
|
||||
})
|
||||
};
|
||||
let sleep = {
|
||||
let h = host.clone();
|
||||
let l = link.clone();
|
||||
function(move |a| {
|
||||
let h = current(&l)?;
|
||||
let ms = match a.first() {
|
||||
Some(Data::Number(n)) if (0.0..=MAX_SLEEP_MS).contains(n) => *n,
|
||||
Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))),
|
||||
@@ -282,7 +305,7 @@ pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localiza
|
||||
("buildDate".into(), value(Data::Null)),
|
||||
("colorPicker".into(), fail("$.colorPicker: scripts cannot show a dialog yet")),
|
||||
("decimalPoint".into(), decimal),
|
||||
("engineName".into(), value(text("VectorCraft"))),
|
||||
("engineName".into(), value(text(info.engine.unwrap_or_else(|| "VectorCraft".into())))),
|
||||
("error".into(), HostItem::Script(ScriptItem::LastError)),
|
||||
("flags".into(), flags),
|
||||
("gc".into(), HostItem::Function(function(|_| Ok(Data::Undefined)))),
|
||||
|
||||
@@ -16,8 +16,13 @@
|
||||
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
|
||||
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
|
||||
//! `JSINCLUDE` folder outside the scope, which is passed over.
|
||||
//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as
|
||||
//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment.
|
||||
//! - `#target` takes any value and changes nothing. `#engine` is refused as unsupported, and any
|
||||
//! other `#name` as unknown; `//@name` with another name stays a comment.
|
||||
//! - `#targetengine name` names the engine the script runs in ([`Expanded::engine`]). Only the
|
||||
//! script itself may have one, at most once, and only before its first code or `#include` line:
|
||||
//! blanks, comments and the other preprocessor lines may come before it. The name is an unquoted
|
||||
//! name of ASCII letters, digits and `_` that does not start with a digit, or a quoted one of 1 to
|
||||
//! [`MAX_ENGINE_NAME_BYTES`] bytes without control characters, compared as written.
|
||||
//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks
|
||||
//! for the whole run; without one they are off. A write to a property that has only a getter is
|
||||
//! checked as a read-only one, the objects that take no new properties are those that are not
|
||||
@@ -50,6 +55,8 @@ pub const MAX_TOTAL_BYTES: usize = 32 << 20;
|
||||
pub const MAX_PROBES: usize = 1024;
|
||||
/// The longest value a preprocessor line may have, in bytes.
|
||||
const MAX_VALUE_BYTES: usize = 4096;
|
||||
/// The longest name `#targetengine` may give, in bytes of UTF-8.
|
||||
pub const MAX_ENGINE_NAME_BYTES: usize = 64;
|
||||
|
||||
/// Where the lines of an expanded source came from: file 0 is the file expanded, the others the
|
||||
/// files it included.
|
||||
@@ -107,12 +114,78 @@ pub(crate) struct Failure {
|
||||
}
|
||||
|
||||
/// A source with its preprocessor lines read: the text to run, where its lines came from, the
|
||||
/// name its `#script` line gave, and what its last `#strict` line set (`None` without one).
|
||||
/// name its `#script` line gave, what its last `#strict` line set (`None` without one), and the
|
||||
/// engine its `#targetengine` line named, with that line's line and column.
|
||||
pub(crate) struct Expanded {
|
||||
pub source: String,
|
||||
pub map: SourceMap,
|
||||
pub name: Option<String>,
|
||||
pub strict: Option<bool>,
|
||||
pub engine: Option<(String, u32, u32)>,
|
||||
}
|
||||
|
||||
/// What the preprocessor lines of a file have named so far: its `#script` name and its engine, and
|
||||
/// whether code or an `#include` line has come before.
|
||||
#[derive(Default)]
|
||||
struct Named {
|
||||
name: Option<String>,
|
||||
engine: Option<(String, u32, u32)>,
|
||||
code_seen: bool,
|
||||
}
|
||||
|
||||
/// Are `units` only blanks and comments?
|
||||
fn only_blanks_and_comments(units: &[u16]) -> bool {
|
||||
let mut i = 0;
|
||||
while let Some(&u) = units.get(i) {
|
||||
match u {
|
||||
0x2F if units.get(i + 1) == Some(&0x2F) => {
|
||||
while units.get(i).is_some_and(|&c| !matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029)) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
0x2F if units.get(i + 1) == Some(&0x2A) => {
|
||||
let Some(end) = units.get(i + 2..).and_then(|rest| rest.windows(2).position(|w| w == [0x2A, 0x2F])) else { return false };
|
||||
i += end + 4;
|
||||
}
|
||||
0xFEFF => i += 1,
|
||||
u if char::from_u32(u32::from(u)).is_some_and(char::is_whitespace) => i += 1,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The engine name of a `#targetengine` line's value `value` (as written, quotes included), or what
|
||||
/// is wrong with it.
|
||||
fn engine_name(value: &str) -> Result<String, &'static str> {
|
||||
let name = match value.chars().next() {
|
||||
None => return Err("needs a name"),
|
||||
Some(q @ ('"' | '\'')) => {
|
||||
let inner = value.get(1..).and_then(|v| v.strip_suffix(q)).ok_or("needs a closing quote after the name")?;
|
||||
if inner.contains(q) {
|
||||
return Err("takes one name");
|
||||
}
|
||||
if inner.chars().any(char::is_control) {
|
||||
return Err("needs a name without control characters");
|
||||
}
|
||||
inner
|
||||
}
|
||||
_ => {
|
||||
let mut chars = value.chars();
|
||||
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
|
||||
if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return Err("needs a name of letters, digits and _, or a quoted name");
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err("needs a name");
|
||||
}
|
||||
if name.len() > MAX_ENGINE_NAME_BYTES {
|
||||
return Err("needs a name of at most 64 bytes");
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// A file being run: its name for people and the folder its relative paths start from (none for a
|
||||
@@ -342,8 +415,8 @@ impl Files {
|
||||
/// its `#script` line names the run.
|
||||
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
|
||||
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None };
|
||||
let mut name = None;
|
||||
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
|
||||
let mut named = Named::default();
|
||||
self.file(&mut out, shown, dir.as_deref(), text, script, &mut named)?;
|
||||
let source = String::from_utf16(&out.units).map_err(|_| Failure {
|
||||
kind: FailKind::Syntax,
|
||||
message: "the script is not valid text".into(),
|
||||
@@ -352,20 +425,12 @@ impl Files {
|
||||
line: 0,
|
||||
column: 0,
|
||||
})?;
|
||||
Ok(Expanded { source, map: out.map, name, strict: out.strict })
|
||||
Ok(Expanded { source, map: out.map, name: named.name, strict: out.strict, engine: named.engine })
|
||||
}
|
||||
|
||||
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
|
||||
/// of their lines.
|
||||
fn file(
|
||||
&mut self,
|
||||
out: &mut Out,
|
||||
shown: Arc<str>,
|
||||
dir: Option<&Path>,
|
||||
text: &str,
|
||||
script: bool,
|
||||
name: &mut Option<String>,
|
||||
) -> Result<(), Failure> {
|
||||
fn file(&mut self, out: &mut Out, shown: Arc<str>, dir: Option<&Path>, text: &str, script: bool, named: &mut Named) -> Result<(), Failure> {
|
||||
let index = out.map.files.len();
|
||||
out.map.files.push(shown.clone());
|
||||
let units: Vec<u16> = text.encode_utf16().collect();
|
||||
@@ -385,7 +450,11 @@ impl Files {
|
||||
units.get(..start).and_then(|u| u.iter().rposition(|&c| matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029))).map_or(0, |i| i + 1);
|
||||
let column = u32::try_from(start - line_start + 1).unwrap_or(u32::MAX);
|
||||
let at = |kind, message: String| fail(kind, message, d.line, column);
|
||||
out.emit(units.get(cursor..start).unwrap_or_default());
|
||||
let before = units.get(cursor..start).unwrap_or_default();
|
||||
if !named.code_seen && !only_blanks_and_comments(before) {
|
||||
named.code_seen = true;
|
||||
}
|
||||
out.emit(before);
|
||||
cursor = end;
|
||||
let label = format!("{}{}", d.form, d.name);
|
||||
if d.value.len() > MAX_VALUE_BYTES {
|
||||
@@ -397,11 +466,12 @@ impl Files {
|
||||
if value.is_empty() {
|
||||
return Err(at(FailKind::Syntax, format!("{label} needs a file name")));
|
||||
}
|
||||
named.code_seen = true;
|
||||
let (real, bytes) = self.open(value, dir, true).map_err(|(kind, message)| at(kind, message))?;
|
||||
let included = self.admit(&real, bytes, value).map_err(|(kind, message)| at(kind, message))?;
|
||||
self.chain.push(real.clone());
|
||||
self.levels += 1;
|
||||
let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, name);
|
||||
let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, named);
|
||||
self.chain.pop();
|
||||
self.levels -= 1;
|
||||
r?;
|
||||
@@ -416,7 +486,7 @@ impl Files {
|
||||
return Err(at(FailKind::Syntax, format!("{label} needs a name")));
|
||||
}
|
||||
if script && index == 0 {
|
||||
*name = Some(value.to_string());
|
||||
named.name = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
Some(DirectiveName::Target) => {
|
||||
@@ -429,7 +499,19 @@ impl Files {
|
||||
"on" => out.strict = Some(true),
|
||||
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
|
||||
},
|
||||
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
|
||||
Some(DirectiveName::TargetEngine) => {
|
||||
if !script || index > 0 {
|
||||
return Err(at(FailKind::Syntax, format!("{label} may only be in the script itself, not in a file it runs")));
|
||||
}
|
||||
if named.engine.is_some() {
|
||||
return Err(at(FailKind::Syntax, format!("the script has more than one {label} line")));
|
||||
}
|
||||
if named.code_seen {
|
||||
return Err(at(FailKind::Syntax, format!("{label} must come before the script's code and #include lines")));
|
||||
}
|
||||
let engine = engine_name(&d.value).map_err(|why| at(FailKind::Syntax, format!("{label} {why}")))?;
|
||||
named.engine = Some((engine, d.line, column));
|
||||
}
|
||||
None if d.name.eq_ignore_ascii_case("engine") => {
|
||||
return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}")));
|
||||
}
|
||||
|
||||
+362
-31
@@ -36,6 +36,13 @@
|
||||
//!
|
||||
//! `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`.
|
||||
//!
|
||||
//! 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;
|
||||
@@ -193,6 +200,150 @@ 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, and its heap after its last run.
|
||||
struct Engine {
|
||||
name: String,
|
||||
realm: Option<Realm>,
|
||||
link: Link,
|
||||
heap: usize,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
@@ -517,6 +668,10 @@ struct Host {
|
||||
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`.
|
||||
@@ -693,6 +848,10 @@ impl Host {
|
||||
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`].
|
||||
@@ -1005,10 +1164,11 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) {
|
||||
/// `$.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(host: &Rc<Host>, info: dollar::RunInfo, localization: &Rc<vectorcraft_js::Localization>) -> Vec<(String, HostItem)> {
|
||||
fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc<vectorcraft_js::Localization>) -> Vec<(String, HostItem)> {
|
||||
let writer = |end: bool| {
|
||||
let h = host.clone();
|
||||
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);
|
||||
@@ -1017,21 +1177,24 @@ fn dollar_object(host: &Rc<Host>, info: dollar::RunInfo, localization: &Rc<vecto
|
||||
}))
|
||||
};
|
||||
let getter = |read: fn(&include::Files) -> String| {
|
||||
let h = host.clone();
|
||||
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 h = host.clone();
|
||||
let end = host.clone();
|
||||
let strict = host.clone();
|
||||
let (l, end, strict) = (link.clone(), link.clone(), link.clone());
|
||||
HostItem::Evaluator {
|
||||
source: function(move |a| eval_file(&h, a)),
|
||||
end: Rc::new(move || end.files.borrow_mut().end_eval()),
|
||||
strict_writes: Rc::new(move || strict.eval_strict.get()),
|
||||
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![
|
||||
@@ -1041,7 +1204,7 @@ fn dollar_object(host: &Rc<Host>, info: dollar::RunInfo, localization: &Rc<vecto
|
||||
("fileName".into(), getter(include::Files::file_name)),
|
||||
("includePath".into(), getter(include::Files::include_path)),
|
||||
];
|
||||
items.extend(dollar::members(host, info, localization));
|
||||
items.extend(dollar::members(link, info, localization));
|
||||
items
|
||||
}
|
||||
|
||||
@@ -1088,32 +1251,46 @@ fn function(f: impl Fn(Vec<Data>) -> std::result::Result<Data, HostError> + 'sta
|
||||
Rc::new(f)
|
||||
}
|
||||
|
||||
fn vc_object(host: &Rc<Host>, args: Data) -> Vec<(String, HostItem)> {
|
||||
/// 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 h = host.clone();
|
||||
function(move |a| vc_run(&h, a))
|
||||
let l = link.clone();
|
||||
function(move |a| vc_run(¤t(&l)?, a))
|
||||
};
|
||||
let document = {
|
||||
let h = host.clone();
|
||||
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 h = host.clone();
|
||||
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 h = host.clone();
|
||||
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)
|
||||
@@ -1125,7 +1302,7 @@ fn vc_object(host: &Rc<Host>, args: Data) -> Vec<(String, HostItem)> {
|
||||
("document".into(), HostItem::Getter(document)),
|
||||
("commands".into(), HostItem::Getter(commands)),
|
||||
("log".into(), HostItem::Function(log)),
|
||||
("args".into(), HostItem::Value(args)),
|
||||
("args".into(), HostItem::FrozenGetter(args)),
|
||||
(
|
||||
"api".into(),
|
||||
HostItem::Object(vec![("version".into(), HostItem::Value(Data::String(API_VERSION.into()))), ("has".into(), HostItem::Function(has))]),
|
||||
@@ -1188,13 +1365,16 @@ fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError,
|
||||
if e.line == 0 {
|
||||
return (e.clone(), None);
|
||||
}
|
||||
// An error in code from another source, such as a function an earlier run of a named engine
|
||||
// defined, has no place in this run's files, so it is given without one.
|
||||
let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None);
|
||||
match tag {
|
||||
0 => map(&host.script_map.borrow(), true),
|
||||
n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) {
|
||||
Some(m) => map(m, false),
|
||||
None => (e.clone(), None),
|
||||
None => unplaced(),
|
||||
},
|
||||
_ => (e.clone(), None),
|
||||
_ => unplaced(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1214,6 +1394,95 @@ impl Session {
|
||||
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: 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<(Option<Realm>, Link), (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()));
|
||||
}
|
||||
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));
|
||||
engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 });
|
||||
Ok((None, link))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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`, or is dropped without one.
|
||||
fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option<Realm>) {
|
||||
let host = link.borrow_mut().take();
|
||||
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 {
|
||||
@@ -1326,6 +1595,13 @@ impl Session {
|
||||
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();
|
||||
@@ -1363,10 +1639,40 @@ impl Session {
|
||||
Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(),
|
||||
_ => request.name,
|
||||
};
|
||||
let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() };
|
||||
// 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) = match kept {
|
||||
None => (None, None, Rc::new(RefCell::new(None))),
|
||||
Some((engine, (realm, link))) => (Some(engine), realm, link),
|
||||
};
|
||||
// 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 = Realm::with_options(options, limits);
|
||||
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;
|
||||
@@ -1402,28 +1708,46 @@ impl Session {
|
||||
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)),
|
||||
});
|
||||
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
|
||||
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
|
||||
*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 info = dollar::RunInfo { locale: request.locale, strict };
|
||||
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));
|
||||
realm
|
||||
.add_script_extensions()
|
||||
.and_then(|()| realm.define_host_object("vc", vc_object(&host, args)))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization)))
|
||||
.and_then(|()| dialogs::install(&mut realm, &host))
|
||||
(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(|()| realm.start(&expanded.source))
|
||||
.map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &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);
|
||||
@@ -1432,7 +1756,7 @@ impl Session {
|
||||
}
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
let (run, line_name) = (host.run, host.name.clone());
|
||||
let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope };
|
||||
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 });
|
||||
@@ -1497,7 +1821,7 @@ impl Session {
|
||||
let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e);
|
||||
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
|
||||
close_run(self, &job.host, &mut report, ending(&e.kind));
|
||||
drop(job);
|
||||
self.end_job(job, e.kind == RunErrorKind::Internal);
|
||||
raise_ids(self);
|
||||
self.script_stop.clear();
|
||||
self.reset_tool();
|
||||
@@ -1579,6 +1903,7 @@ impl Session {
|
||||
"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)
|
||||
}
|
||||
@@ -1617,6 +1942,10 @@ const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the scr
|
||||
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 {
|
||||
@@ -1782,6 +2111,8 @@ thread_local! {
|
||||
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 `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.
|
||||
|
||||
@@ -134,6 +134,7 @@ impl Session {
|
||||
global_edit_options: Default::default(),
|
||||
keyboard_text: false,
|
||||
script_key: None,
|
||||
script_engines: crate::script::EnginesKey::new(),
|
||||
script_stop: vectorcraft_js::Interrupt::new(),
|
||||
script_report: None,
|
||||
script_console: Default::default(),
|
||||
|
||||
@@ -153,6 +153,7 @@ mod scalestrokes;
|
||||
mod script_console;
|
||||
mod script_dialogs;
|
||||
mod script_dollar;
|
||||
mod script_engines;
|
||||
mod script_folder;
|
||||
mod script_include;
|
||||
mod script_run;
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! `#targetengine`: named engines whose globals last between the runs that name them, their names
|
||||
//! and places, their limits, `script.engines` and `script.engine.drop`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for};
|
||||
use crate::*;
|
||||
|
||||
fn run(s: &mut Session, source: &str) -> Result<Value> {
|
||||
s.execute("script.run", &json!({"source": source, "name": "t.js"}))
|
||||
}
|
||||
|
||||
fn log(s: &mut Session, source: &str) -> Value {
|
||||
run(s, source).unwrap()["log"].clone()
|
||||
}
|
||||
|
||||
/// A session with one document holding one rectangle.
|
||||
fn one_doc() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn objects(s: &Session) -> usize {
|
||||
s.documents()[0].doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum()
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
let r = s.execute("script.engines", &json!({})).unwrap();
|
||||
r["engines"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
const RECT: &str = "{x: 100, y: 0, width: 5, height: 5}";
|
||||
|
||||
#[test]
|
||||
fn a_named_engine_keeps_its_globals_and_functions_between_runs() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(log(&mut s, "#targetengine \"session\"\nvar n = 41;\nfunction f() { return n + 1; }\nvc.log($.engineName);"), json!(["session"]));
|
||||
assert_eq!(log(&mut s, "//@targetengine session\nvc.log(f()); n = 1;"), json!([42]));
|
||||
assert_eq!(log(&mut s, "#targetengine 'session'\nvc.log(n);"), json!([1]), "single quotes name the same engine");
|
||||
assert_eq!(log(&mut s, "#targetengine Session\nvc.log(typeof n);"), json!(["undefined"]), "names are matched as written");
|
||||
assert_eq!(log(&mut s, "#targetengine other\nvc.log(typeof f);"), json!(["undefined"]));
|
||||
assert_eq!(log(&mut s, "vc.log(typeof n, $.engineName);\nvc.log($.engineName);"), json!(["undefined", "VectorCraft"]));
|
||||
// `main` is new for each run, as a script without the line is.
|
||||
log(&mut s, "#targetengine main\nvar m = 1;");
|
||||
assert_eq!(log(&mut s, "#targetengine main\nvc.log(typeof m, $.engineName);\nvc.log($.engineName);"), json!(["undefined", "main"]));
|
||||
assert_eq!(names(&mut s), ["session", "Session", "other"]);
|
||||
assert_eq!(log(&mut s, "#targetengine \"Ünï cödé 1\"\nvc.log($.engineName);"), json!(["Ünï cödé 1"]));
|
||||
let r = s.execute("script.run", &json!({"source": "#targetengine session\n$.engineName = 'x'; vc.log($.engineName);"})).unwrap();
|
||||
assert_eq!(r["log"], json!(["session"]), "$.engineName is read-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_names_and_places_are_checked_before_anything_runs() {
|
||||
let mut s = Session::new();
|
||||
let long = "a".repeat(65);
|
||||
let control = "#targetengine \"a\u{1}b\"";
|
||||
let cases: Vec<(String, &str, u64)> = vec![
|
||||
("#targetengine".into(), "#targetengine needs a name", 1),
|
||||
("#targetengine \"\"".into(), "#targetengine needs a name", 1),
|
||||
("#targetengine a b".into(), "#targetengine needs a name of letters, digits and _", 1),
|
||||
("#targetengine 9a".into(), "#targetengine needs a name of letters, digits and _", 1),
|
||||
("#targetengine \"a\" \"b\"".into(), "#targetengine takes one name", 1),
|
||||
(format!("#targetengine \"{long}\""), "#targetengine needs a name of at most 64 bytes", 1),
|
||||
(format!("#targetengine {long}"), "#targetengine needs a name of at most 64 bytes", 1),
|
||||
(control.into(), "#targetengine needs a name without control characters", 1),
|
||||
("#targetengine a\n//@targetengine a".into(), "the script has more than one //@targetengine line", 2),
|
||||
("var x;\n#targetengine a".into(), "#targetengine must come before the script's code and #include lines", 2),
|
||||
("/* a */ 1;\n#targetengine a".into(), "#targetengine must come before the script's code and #include lines", 2),
|
||||
];
|
||||
for (source, message, line) in &cases {
|
||||
let e = run(&mut s, &format!("{source}\nvc.log(1);")).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{source}: {e}");
|
||||
assert_eq!(s.script_report().unwrap()["line"], *line, "{source}");
|
||||
assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}");
|
||||
}
|
||||
assert!(names(&mut s).is_empty(), "no failed line made an engine");
|
||||
let ok = format!(
|
||||
"// a comment\n/* and\n another */\n#target vectorcraft\n#script \"Named\"\n #targetengine \"{}\"\nvc.log($.engineName.length);",
|
||||
"b".repeat(64)
|
||||
);
|
||||
assert_eq!(log(&mut s, &ok), json!([64]));
|
||||
// A script that does not start: an engine it would make is not kept.
|
||||
assert!(run(&mut s, "#targetengine broken\nvar = 1;").is_err());
|
||||
assert_eq!(names(&mut s), ["b".repeat(64)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_script_itself_names_its_engine() {
|
||||
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(base.join("lib.jsxinc"), "#targetengine lib\nvar a = 1;").unwrap();
|
||||
let main = base.join("main.jsx");
|
||||
let mut s = Session::new();
|
||||
for text in ["#include \"lib.jsxinc\"\n", "$.evalFile('lib.jsxinc');\n"] {
|
||||
std::fs::write(&main, text).unwrap();
|
||||
let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("#targetengine may only be in the script itself, not in a file it runs"), "{text}: {e}");
|
||||
assert!(s.script_report().unwrap()["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{text}");
|
||||
}
|
||||
std::fs::write(base.join("ok.jsxinc"), "var a = 1;").unwrap();
|
||||
std::fs::write(&main, "#include \"ok.jsxinc\"\n#targetengine e\n").unwrap();
|
||||
let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("#targetengine must come before the script's code and #include lines"), "{e}");
|
||||
assert_eq!(s.script_report().unwrap()["line"], 2);
|
||||
// An error in a file $.evalFile runs is at that file's place in a kept engine's later runs too.
|
||||
std::fs::write(base.join("bad.jsxinc"), "var b;\nthrow new Error('here');").unwrap();
|
||||
std::fs::write(&main, "#targetengine e\n$.evalFile('ok.jsxinc');").unwrap();
|
||||
s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap();
|
||||
std::fs::write(&main, "#targetengine e\n$.evalFile('bad.jsxinc');").unwrap();
|
||||
assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err());
|
||||
let report = s.script_report().unwrap().clone();
|
||||
assert!(report["file"].as_str().is_some_and(|f| f.ends_with("bad.jsxinc")), "{report}");
|
||||
assert_eq!(report["line"], 2, "{report}");
|
||||
assert_eq!(names(&mut s), ["e"]);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
// An error in a function an earlier run defined has no place in the run that calls it.
|
||||
run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap();
|
||||
assert!(run(&mut s, "#targetengine e\nbad();").is_err());
|
||||
let report = s.script_report().unwrap().clone();
|
||||
assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}");
|
||||
}
|
||||
|
||||
/// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to
|
||||
/// that run's script, and as `$.evalFile` does in a run given as text.
|
||||
#[test]
|
||||
fn a_kept_eval_file_reads_in_the_calling_run_scope() {
|
||||
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-scope-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir_all(base.join(dir)).unwrap();
|
||||
std::fs::write(base.join(dir).join("lib.jsxinc"), format!("'{dir}'")).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
let a = base.join("a").join("main.jsx");
|
||||
std::fs::write(&a, "#targetengine e\nvar ev = $.evalFile;\nvc.log(ev('lib.jsxinc'));").unwrap();
|
||||
assert_eq!(s.execute("script.run", &json!({"path": a.to_string_lossy()})).unwrap()["log"], json!(["a"]));
|
||||
let b = base.join("b").join("main.jsx");
|
||||
std::fs::write(&b, "#targetengine e\nvc.log(ev('lib.jsxinc'));").unwrap();
|
||||
assert_eq!(s.execute("script.run", &json!({"path": b.to_string_lossy()})).unwrap()["log"], json!(["b"]), "the kept function reads beside b");
|
||||
let kept = run(&mut s, "#targetengine e\nvc.log(ev('lib.jsxinc'));").map_err(|e| e.to_string());
|
||||
let direct = run(&mut s, "vc.log($.evalFile('lib.jsxinc'));").map_err(|e| e.to_string());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
assert!(direct.is_err(), "{direct:?}");
|
||||
assert_eq!(kept.unwrap_err().split(": ").last(), direct.unwrap_err().split(": ").last(), "a run given as text reads no file beside a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_or_stopped_run_keeps_the_globals_it_set_and_puts_the_documents_back() {
|
||||
let mut s = one_doc();
|
||||
let undo = s.documents()[0].history.undo.len();
|
||||
let e = run(&mut s, &format!("#targetengine e\nvar n = 1;\nvc.run('shape.rectangle', {RECT});\nthrow new Error('boom');"));
|
||||
assert!(e.is_err());
|
||||
assert_eq!((objects(&s), s.documents()[0].history.undo.len()), (1, undo), "the documents went back");
|
||||
assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([1]));
|
||||
// A stop.
|
||||
s.script_frames = true;
|
||||
assert_eq!(run(&mut s, &format!("#targetengine e\nn = 10;\nvc.run('shape.rectangle', {RECT});\nwhile (true) {{}}")).unwrap()["running"], true);
|
||||
assert!(s.script_pump().is_none());
|
||||
assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true);
|
||||
assert!(s.script_pump().unwrap().is_err());
|
||||
s.script_frames = false;
|
||||
assert_eq!(objects(&s), 1);
|
||||
// An error in another engine leaves this one as it was.
|
||||
assert!(run(&mut s, "#targetengine f\nvar n = 99;\nthrow 1;").is_err());
|
||||
assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([10]));
|
||||
// A run that ends well is one undo step.
|
||||
run(&mut s, &format!("#targetengine e\nvc.run('shape.rectangle', {RECT});")).unwrap();
|
||||
assert_eq!((objects(&s), s.documents()[0].history.undo.len()), (2, undo + 1));
|
||||
assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script");
|
||||
}
|
||||
|
||||
/// A run stopped at a dialog, or left paused, ends where it was: the engine keeps its globals and
|
||||
/// the next run starts afresh.
|
||||
#[test]
|
||||
fn a_run_stopped_at_a_dialog_or_left_paused_ends_where_it_was() {
|
||||
let mut s = Session::new();
|
||||
s.script_frames = true;
|
||||
run(&mut s, "#targetengine e\nvar n = 20;\nfunction f() { var big = new Array(4000001).join('x'); alert('x'); n = big.length; }\nf();").unwrap();
|
||||
for _ in 0..1_000 {
|
||||
if s.script_dialog().is_some() {
|
||||
break;
|
||||
}
|
||||
assert!(s.script_pump().is_none());
|
||||
}
|
||||
assert!(s.script_dialog().is_some());
|
||||
s.execute("script.stop", &json!({})).unwrap();
|
||||
assert!(s.script_pump().unwrap().is_err());
|
||||
let heap = s.execute("script.engines", &json!({})).unwrap()["engines"][0]["heapBytes"].as_u64().unwrap();
|
||||
assert!(heap < 4 << 20, "the stopped run's frames, and the string they held, are gone: {heap} bytes");
|
||||
run(&mut s, "#targetengine e\nn += 1;\nwhile (true) {}").unwrap();
|
||||
assert!(s.script_pump().is_none());
|
||||
assert!(s.script_abandon());
|
||||
s.script_frames = false;
|
||||
for _ in 0..3 {
|
||||
assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([21]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_host_function_acts_for_the_run_that_calls_it() {
|
||||
let mut s = one_doc();
|
||||
run(&mut s, "#targetengine e\nvar oldRun = vc.run, oldVc = vc, oldWrite = $.writeln, oldAlert = alert;").unwrap();
|
||||
// The functions an earlier run kept work in this run's transaction, which goes back.
|
||||
let e = run(&mut s, &format!("#targetengine e\noldRun('shape.rectangle', {RECT});\noldVc.log('kept');\nthrow new Error('x');"));
|
||||
assert!(e.is_err());
|
||||
assert_eq!(objects(&s), 1, "the kept vc.run's change went back with the run");
|
||||
assert_eq!(s.script_report().unwrap()["log"], json!(["kept"]));
|
||||
let r = s
|
||||
.execute(
|
||||
"script.run",
|
||||
&json!({"source": format!("#targetengine e\noldRun('shape.rectangle', {RECT});\noldWrite('out');\noldAlert('hi');"), "answers": [{"kind": "alert", "value": "ok"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((objects(&s), r["dialogCount"].as_u64()), (2, Some(1)));
|
||||
assert!(r["console"]["lines"].as_array().unwrap().iter().any(|l| l == "out"), "{r}");
|
||||
assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script");
|
||||
let r = s
|
||||
.execute(
|
||||
"script.run",
|
||||
&json!({"source": "#targetengine e\nvc.log([oldVc.args.n, vc === oldVc, Object.isFrozen(vc.args)]);", "args": {"n": 2}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["log"], json!([[2, true, true]]), "a kept vc gives the calling run's args, frozen");
|
||||
}
|
||||
|
||||
/// The values a run sets on `$` in a named engine end with that run, as in a run without one, and
|
||||
/// a `$` an earlier run kept gives the values of the run that reads it.
|
||||
#[test]
|
||||
fn the_values_of_the_dollar_object_belong_to_each_run_of_a_named_engine() {
|
||||
let mut s = Session::new();
|
||||
let read = "vc.log([$.getenv('OS'), $.getenv('LANG'), $.localize, $.flags, $.locale]);";
|
||||
let fresh = log(&mut s, read);
|
||||
let set = "$.setenv('OS', 'set_here');\n$.setenv('LANG', 'set_here');\n$.localize = true;\n$.locale = 'fr_FR';";
|
||||
run(&mut s, &format!("#targetengine e\nvar d = $;\n{set}\n{read}")).unwrap();
|
||||
assert_eq!(log(&mut s, &format!("#targetengine e\n{read}")), fresh);
|
||||
let os = fresh[0][0].clone();
|
||||
assert_eq!(log(&mut s, "#targetengine e\nvc.log([d === $, d.engineName, d.getenv('OS'), d.localize]);"), json!([[true, "e", os, false]]));
|
||||
assert_eq!(log(&mut s, "#targetengine e\nd.setenv('OS', 'again');\nvc.log(d.getenv('OS'));"), json!(["again"]));
|
||||
assert_eq!(log(&mut s, &format!("#targetengine e\n{read}")), fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_engine_does_not_keep_a_closed_document() {
|
||||
let mut s = one_doc();
|
||||
let name = s.documents()[0].title();
|
||||
run(&mut s, "#targetengine e\nvar snap = vc.document;\nvar read = function () { return vc.document; };").unwrap();
|
||||
let weak = Arc::downgrade(&s.documents()[0].doc);
|
||||
s.execute("file.close", &json!({})).unwrap();
|
||||
assert!(weak.upgrade().is_none(), "nothing holds the closed document");
|
||||
assert_eq!(log(&mut s, "#targetengine e\nvc.log(read());\nvc.log(snap.name);"), json!([null, name]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_keeps_eight_engines_and_drops_them_on_request() {
|
||||
let mut s = Session::new();
|
||||
for i in 0..8 {
|
||||
run(&mut s, &format!("#targetengine e{i}\nvar v = {i};")).unwrap();
|
||||
}
|
||||
let e = run(&mut s, "#targetengine e8\nvc.log(1);").unwrap_err().to_string();
|
||||
assert!(e.contains("a session keeps at most 8 script engines"), "{e}");
|
||||
assert_eq!((s.script_report().unwrap()["kind"].as_str(), s.script_report().unwrap()["log"].clone()), (Some("limit"), json!([])));
|
||||
assert_eq!(log(&mut s, "#targetengine e3\nvc.log(v);"), json!([3]), "the eight still run");
|
||||
let list = s.execute("script.engines", &json!({})).unwrap();
|
||||
assert_eq!((list["limit"].as_u64(), list["heapLimit"].as_u64()), (Some(8), Some(64 << 20)));
|
||||
for (i, e) in list["engines"].as_array().unwrap().iter().enumerate() {
|
||||
assert_eq!(e["name"], format!("e{i}"));
|
||||
assert_eq!(e["running"], false);
|
||||
assert!(e["heapBytes"].as_u64().unwrap() > 0, "{e}");
|
||||
}
|
||||
assert_eq!(s.execute("script.engine.drop", &json!({"name": "e2"})).unwrap(), json!({"dropped": true, "name": "e2"}));
|
||||
assert_eq!(names(&mut s), ["e0", "e1", "e3", "e4", "e5", "e6", "e7"]);
|
||||
run(&mut s, "#targetengine e8\nvar v = 8;").unwrap();
|
||||
assert!(run(&mut s, "#targetengine e2\n1").unwrap_err().to_string().contains("at most 8"));
|
||||
s.execute("script.engine.drop", &json!({"name": "e8"})).unwrap();
|
||||
assert_eq!(log(&mut s, "#targetengine e2\nvc.log(typeof v);"), json!(["undefined"]), "a dropped engine comes back new");
|
||||
for (params, message) in [
|
||||
(json!({"name": "main"}), "the main script engine is new for each run"),
|
||||
(json!({"name": "nope"}), "there is no script engine named \"nope\""),
|
||||
(json!({}), "`name` must be the name of a script engine"),
|
||||
(json!({"name": 3}), "`name` must be the name of a script engine"),
|
||||
] {
|
||||
let e = s.execute("script.engine.drop", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{params}: {e}");
|
||||
}
|
||||
// The running script's engine cannot be dropped; another one can.
|
||||
s.script_frames = true;
|
||||
assert_eq!(run(&mut s, "#targetengine e0\nwhile (true) {}").unwrap()["running"], true);
|
||||
assert!(s.script_pump().is_none());
|
||||
assert!(matches!(s.execute("script.engine.drop", &json!({"name": "e0"})), Err(EngineError::Busy)));
|
||||
let list = s.execute("script.engines", &json!({})).unwrap();
|
||||
assert_eq!(list["engines"][0]["running"], true);
|
||||
s.execute("script.engine.drop", &json!({"name": "e1"})).unwrap();
|
||||
s.execute("script.stop", &json!({})).unwrap();
|
||||
assert!(s.script_pump().unwrap().is_err());
|
||||
assert_eq!(names(&mut s), ["e0", "e3", "e4", "e5", "e6", "e7", "e2"]);
|
||||
// A script cannot drop or list engines itself.
|
||||
s.script_frames = false;
|
||||
let e = run(&mut s, "vc.run('script.engine.drop', {name: 'e0'});").unwrap_err().to_string();
|
||||
assert!(e.contains("a script cannot run `script.engine.drop`"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_engine_has_its_own_heap_limit_and_stays_usable() {
|
||||
TEST_ENGINE_HEAP.with(|h| h.set(Some(8 << 20)));
|
||||
let mut s = Session::new();
|
||||
let big = run(&mut s, "#targetengine big\nvar marker = 'set', keep = [];\nfor (var i = 0; i < 10000000; i++) keep.push('item ' + i);");
|
||||
// The next run starts on the nearly full heap even with large `args`, and can free memory.
|
||||
let big_args = json!({"big": "x".repeat(1 << 19)});
|
||||
let free = json!({"source": "#targetengine big\nkeep = null;\nvc.log(marker);", "name": "t.js", "args": big_args});
|
||||
let kept = s.execute("script.run", &free).unwrap()["log"].clone();
|
||||
let after = s.execute("script.engines", &json!({})).unwrap();
|
||||
TEST_ENGINE_HEAP.with(|h| h.set(None));
|
||||
assert!(big.unwrap_err().to_string().contains("Heap"));
|
||||
assert_eq!(kept, json!(["set"]), "the globals set before the limit stay, and the engine still runs");
|
||||
assert!(after["engines"][0]["heapBytes"].as_u64().unwrap() < 8 << 20, "{after}");
|
||||
// A run without #targetengine keeps the larger heap of one run.
|
||||
assert_eq!(log(&mut s, "var keep = [];\nfor (var i = 0; i < 300000; i++) keep.push('item ' + i);\nvc.log(keep.length);"), json!([300000]));
|
||||
}
|
||||
|
||||
/// The engines stay on the thread that made them: a session used on another thread starts there
|
||||
/// with none, and the engines it left, or that a session dropped elsewhere held, are dropped on
|
||||
/// their own thread when it next uses its engines.
|
||||
#[test]
|
||||
fn a_session_moved_to_another_thread_starts_with_no_engines_and_lets_the_old_ones_go() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "#targetengine e\nvar kept = 1;").unwrap();
|
||||
assert_eq!(engines_on_this_thread(), 1);
|
||||
let (mut s, there, listed, thread) = std::thread::spawn(move || {
|
||||
let there = log(&mut s, "#targetengine e\nvc.log(typeof kept);");
|
||||
let listed = names(&mut s);
|
||||
(s, there, listed, std::thread::current().id())
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!((there, listed), (json!(["undefined"]), vec!["e".to_string()]));
|
||||
assert!(!releases_kept_for(thread), "a thread that ended keeps no list of engines to let go");
|
||||
// Back here, the first engine has been let go: the run gets a new one.
|
||||
assert_eq!(log(&mut s, "#targetengine e\nvc.log(typeof kept);"), json!(["undefined"]));
|
||||
assert_eq!(engines_on_this_thread(), 1);
|
||||
assert!(!releases_kept_for(thread), "letting go of the ended thread's engines adds nothing for it");
|
||||
std::thread::spawn(move || drop(s)).join().unwrap();
|
||||
let mut other = Session::new();
|
||||
assert!(names(&mut other).is_empty());
|
||||
assert_eq!(engines_on_this_thread(), 0, "the engine of the session dropped on another thread is dropped here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_internal_error_drops_the_engine() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(log(&mut s, "#targetengine p\nvar a = 1;\nvc.log(a);"), json!([1]));
|
||||
assert_eq!(log(&mut s, "#targetengine p\nvc.log(a);"), json!([1]));
|
||||
s.script_frames = true;
|
||||
run(&mut s, "#targetengine p\nwhile (true) {}").unwrap();
|
||||
PANIC_IN_SLICE.with(|p| p.set(true));
|
||||
let r = s.script_pump().unwrap();
|
||||
PANIC_IN_SLICE.with(|p| p.set(false));
|
||||
assert!(r.is_err());
|
||||
assert!(names(&mut s).is_empty());
|
||||
s.script_frames = false;
|
||||
assert_eq!(log(&mut s, "#targetengine p\nvc.log(typeof a);"), json!(["undefined"]));
|
||||
}
|
||||
|
||||
/// A run whose job is gone took its engine's realm with it: the engine is dropped, so the next run
|
||||
/// that names it starts it new and other engines stay.
|
||||
#[test]
|
||||
fn a_run_whose_job_is_gone_drops_its_engine() {
|
||||
for abandon in [true, false] {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "#targetengine kept\nvar k = 1;").unwrap();
|
||||
run(&mut s, "#targetengine lost\nvar a = 1;").unwrap();
|
||||
s.script_frames = true;
|
||||
run(&mut s, "#targetengine lost\nwhile (true) {}").unwrap();
|
||||
assert!(s.script_pump().is_none());
|
||||
assert!(crate::script::forget_job(&s));
|
||||
if abandon {
|
||||
assert!(s.script_abandon());
|
||||
} else {
|
||||
assert!(s.script_pump().unwrap().is_err());
|
||||
}
|
||||
s.script_frames = false;
|
||||
assert_eq!(names(&mut s), ["kept"], "abandon: {abandon}");
|
||||
assert_eq!(log(&mut s, "#targetengine lost\nvc.log(typeof a);"), json!(["undefined"]), "abandon: {abandon}");
|
||||
assert_eq!(log(&mut s, "#targetengine kept\nvc.log(k);"), json!([1]), "abandon: {abandon}");
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ fn script_target_other_app_does_not_route() {
|
||||
assert_eq!(r["log"], json!([1]));
|
||||
for (source, message) in [
|
||||
("#target\nvc.log(1);", "#target needs a value"),
|
||||
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
|
||||
("#targetengine \"session\"", "#targetengine must come before the script's code and #include lines"),
|
||||
("#engine \"main\"", "unsupported script directive: #engine"),
|
||||
("#strict maybe", "#strict takes on or off"),
|
||||
("#frobnicate 1", "unknown script directive: #frobnicate"),
|
||||
|
||||
@@ -166,7 +166,7 @@ fn an_error_names_the_script_its_place_and_kind() {
|
||||
let e = run(&mut s, "var = 1;").unwrap_err().to_string();
|
||||
assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}");
|
||||
assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`"));
|
||||
assert!(run(&mut s, "#targetengine x\n1").unwrap_err().to_string().contains("unsupported script directive: #targetengine"));
|
||||
assert!(run(&mut s, "#targetengine 9x\n1").unwrap_err().to_string().contains("#targetengine needs a name of letters, digits and _"));
|
||||
assert_eq!(s.script_report().unwrap()["line"], 1);
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -85,11 +85,13 @@ pub enum Arg {
|
||||
Bool,
|
||||
}
|
||||
|
||||
/// How the arguments of a host function are read.
|
||||
/// How the arguments of a host function are read, and its result given.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum ArgRead {
|
||||
/// Each as [`Data`].
|
||||
Data,
|
||||
/// Each as [`Data`], with the result given frozen, as [`HostItem::Value`] gives its value.
|
||||
Frozen,
|
||||
/// Each as the string `String(value)` gives.
|
||||
Text,
|
||||
/// One by one as the list says; those past its end are not read.
|
||||
@@ -109,6 +111,9 @@ pub enum HostItem {
|
||||
Converted(Vec<Arg>, HostFunction),
|
||||
/// A property whose value the function gives each time it is read.
|
||||
Getter(HostFunction),
|
||||
/// A property whose value the function gives each time it is read, frozen as
|
||||
/// [`HostItem::Value`] gives its value.
|
||||
FrozenGetter(HostFunction),
|
||||
/// A property whose value `get` gives each time it is read, and that `set` is given each value
|
||||
/// assigned to it, as its one argument; an error from `set` is thrown to the script.
|
||||
Property {
|
||||
@@ -359,7 +364,7 @@ impl Interp {
|
||||
let result = f(data);
|
||||
self.can_wait.set(false);
|
||||
match result {
|
||||
Ok(d) => self.write_data(d, 0, false),
|
||||
Ok(d) => self.write_data(d, 0, matches!(how, ArgRead::Frozen)),
|
||||
Err(HostError::Wait) if can_wait => {
|
||||
self.waiting = true;
|
||||
self.pause.set(true);
|
||||
@@ -412,14 +417,14 @@ impl Interp {
|
||||
let mut data = Vec::with_capacity(args.len());
|
||||
// A conversion can run the script's own `toString`, which may collect garbage.
|
||||
let mark = self.temp_roots.len();
|
||||
if !matches!(how, ArgRead::Data) {
|
||||
if !matches!(how, ArgRead::Data | ArgRead::Frozen) {
|
||||
self.temp_roots.extend(args.iter().cloned());
|
||||
}
|
||||
let mut read = Ok(());
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX)));
|
||||
let d = match how {
|
||||
ArgRead::Data => self.read_data(a, &mut reading),
|
||||
ArgRead::Data | ArgRead::Frozen => self.read_data(a, &mut reading),
|
||||
ArgRead::Text => self.read_text(a, &mut reading),
|
||||
ArgRead::Each(list) => match list.get(i) {
|
||||
Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined),
|
||||
@@ -507,6 +512,7 @@ impl Interp {
|
||||
let prop = match item {
|
||||
HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE },
|
||||
HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE },
|
||||
HostItem::FrozenGetter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Frozen)), set: None, flags: ENUMERABLE },
|
||||
HostItem::Property { get, set } => Prop::Accessor {
|
||||
get: Some(self.host_function(get, &full, ArgRead::Data)),
|
||||
set: Some(self.host_function(set, &full, ArgRead::Data)),
|
||||
|
||||
@@ -58,6 +58,23 @@ fn state_survives_a_pause_and_a_collection_between_slices() {
|
||||
assert_eq!(finish(&mut r, 50).0.unwrap(), JsValue::Number(1500.0));
|
||||
}
|
||||
|
||||
/// A paused run that is ended leaves no frames behind: the realm starts the next run with its
|
||||
/// globals, as many times as it is ended.
|
||||
#[test]
|
||||
fn an_ended_run_leaves_the_realm_ready_for_the_next() {
|
||||
let mut r = realm(u64::MAX);
|
||||
for _ in 0..3 {
|
||||
r.start("var kept = 'yes'; function deep(n) { return n ? deep(n - 1) : (function () { while (true) {} })(); } deep(50);").unwrap();
|
||||
assert_eq!(r.advance(500).unwrap(), Progress::Paused);
|
||||
r.end();
|
||||
assert!(!r.is_running());
|
||||
}
|
||||
assert!(r.interp().frames.is_empty() && r.interp().stack.is_empty());
|
||||
r.start("kept").unwrap();
|
||||
assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("yes".into()));
|
||||
r.end();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fuel_limit_covers_every_slice_together() {
|
||||
let mut r = realm(5_000);
|
||||
@@ -223,6 +240,18 @@ fn a_getter_is_read_each_time() {
|
||||
assert_eq!(r.run("host.n + host.n").unwrap(), JsValue::Number(3.0));
|
||||
}
|
||||
|
||||
/// A frozen getter gives a new value each time it is read, frozen to its leaves.
|
||||
#[test]
|
||||
fn a_frozen_getter_gives_a_frozen_value_each_time() {
|
||||
let mut r = realm(1_000_000);
|
||||
let f: HostFunction = Rc::new(|_| Ok(Data::Object(vec![("a".into(), Data::Array(vec![Data::Number(1.0)]))])));
|
||||
let g = f.clone();
|
||||
r.define_host_object("host", vec![("v".into(), HostItem::FrozenGetter(f)), ("open".into(), HostItem::Getter(g))]).unwrap();
|
||||
let source = "var v = host.v; v.b = 2; try { v.a.push(2); } catch (e) {} \
|
||||
[Object.isFrozen(v), Object.isFrozen(v.a), v.b === undefined, v.a.length, host.v === host.v, Object.isFrozen(host.open)].join()";
|
||||
assert_eq!(r.run(source).unwrap(), JsValue::String("true,true,true,1,false,false".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_function_takes_each_argument_as_string_gives_it() {
|
||||
let mut r = realm(1_000_000);
|
||||
@@ -408,6 +437,25 @@ fn a_converted_function_reads_each_argument_as_its_list_says() {
|
||||
assert_eq!(out, JsValue::String("function 1".into()), "a script may replace the function");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unknown() {
|
||||
let mut r = realm(10_000_000);
|
||||
let source: HostFunction = Rc::new(|_| Ok(Data::String("\nthrow new Error('in eval')".into())));
|
||||
let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) };
|
||||
r.define_host_object("host", vec![("load".into(), load)]).unwrap();
|
||||
r.run("function late() { throw new Error('late'); }").unwrap();
|
||||
assert!(r.run("host.load()").is_err());
|
||||
assert_eq!(r.error_source(), 1, "the first source the evaluator gave in this run");
|
||||
assert!(r.run("\nlate()").is_err());
|
||||
assert_eq!(r.error_source(), UNKNOWN_SOURCE, "an earlier script defined late");
|
||||
assert!(r.run("\nthrow 1").is_err());
|
||||
assert_eq!(r.error_source(), 0);
|
||||
assert!(r.run("host.load()").is_err());
|
||||
assert_eq!(r.error_source(), 1, "counted again from this run's script");
|
||||
assert!(r.run("var = 1").is_err());
|
||||
assert_eq!(r.error_source(), 0, "a syntax error is in the script");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// A slice size does not change what a script computes.
|
||||
#[test]
|
||||
|
||||
+25
-6
@@ -148,6 +148,10 @@ impl From<SyntaxError> for RunError {
|
||||
pub struct Realm {
|
||||
interp: Interp,
|
||||
unbroken_fuel: u64,
|
||||
/// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each
|
||||
/// script gets a new one, and the sources an evaluator gives during its run get the ones after
|
||||
/// it, so code that an earlier script defined keeps a tag of its own.
|
||||
script_tag: u32,
|
||||
}
|
||||
|
||||
impl Default for Realm {
|
||||
@@ -165,7 +169,7 @@ impl Realm {
|
||||
/// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it
|
||||
/// counted together.
|
||||
pub fn with_options(options: Options, limits: Limits) -> Self {
|
||||
Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL }
|
||||
Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 }
|
||||
}
|
||||
|
||||
/// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default
|
||||
@@ -237,13 +241,15 @@ impl Realm {
|
||||
interp.finally_marks.clear();
|
||||
interp.error_mark = None;
|
||||
let strict = interp.options.strict;
|
||||
interp.source_tag = 0;
|
||||
interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1);
|
||||
self.script_tag = interp.evaluated;
|
||||
interp.source_tag = self.script_tag;
|
||||
let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global));
|
||||
interp.source_tag = UNKNOWN_SOURCE;
|
||||
match code {
|
||||
Ok(code) => Ok(code),
|
||||
Err(CompileError::Syntax(e)) => {
|
||||
interp.error_tag = 0;
|
||||
interp.error_tag = self.script_tag;
|
||||
Err(e.into())
|
||||
}
|
||||
Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }),
|
||||
@@ -325,6 +331,15 @@ impl Realm {
|
||||
!self.interp.frames.is_empty()
|
||||
}
|
||||
|
||||
/// Ends a run begun with [`Realm::start`] where it is, paused or waiting for a host function's
|
||||
/// result, as an interrupt would: its stacks are emptied, so the realm can start another run.
|
||||
/// The globals the run set stay.
|
||||
pub fn end(&mut self) {
|
||||
if self.is_running() || self.interp.waiting {
|
||||
let _ = self.outcome(Err(Abrupt::Limit(LimitKind::Interrupted)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag a host function sets to end the current slice at the next point it can pause.
|
||||
pub fn pause_flag(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
|
||||
self.interp.pause.clone()
|
||||
@@ -367,11 +382,15 @@ impl Realm {
|
||||
self.interp.interrupt = interrupt.0.clone();
|
||||
}
|
||||
|
||||
/// Which source the last error's `line` and `column` are in: 0 for the script given to
|
||||
/// Which source the last error's `line` and `column` are in: 0 for the script last given to
|
||||
/// [`Realm::run`] or [`Realm::start`], `n` for the `n`th source a
|
||||
/// [`crate::HostItem::Evaluator`] gave in this realm, and [`UNKNOWN_SOURCE`] otherwise.
|
||||
/// [`crate::HostItem::Evaluator`] gave since then, and [`UNKNOWN_SOURCE`] otherwise, which
|
||||
/// includes code from an earlier script and from the sources given during its run.
|
||||
pub fn error_source(&self) -> u32 {
|
||||
self.interp.error_tag
|
||||
match self.interp.error_tag {
|
||||
UNKNOWN_SOURCE => UNKNOWN_SOURCE,
|
||||
tag => tag.checked_sub(self.script_tag).unwrap_or(UNKNOWN_SOURCE),
|
||||
}
|
||||
}
|
||||
|
||||
/// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no
|
||||
|
||||
@@ -152,7 +152,7 @@ features! {
|
||||
DirectiveInclude "directive.include" Language,
|
||||
DirectiveOther "directive.other" Language,
|
||||
DirectiveTarget "directive.target" Language,
|
||||
DirectiveTargetengine "directive.targetengine" HostOnly,
|
||||
DirectiveTargetengine "directive.targetengine" Language,
|
||||
Dollar "dollar" Language,
|
||||
DollarEnv "dollar.env" Files,
|
||||
DollarEvalFile "dollar.evalFile" Files,
|
||||
|
||||
@@ -209,7 +209,7 @@ fn directives_are_read_at_the_start_of_a_line_in_both_forms() {
|
||||
let includes: Vec<_> = r.includes.iter().map(|i| (i.value.as_str(), i.line, i.status)).collect();
|
||||
assert_eq!(includes, [("lib/a.jsxinc", 3, IncludeStatus::NotRead)]);
|
||||
assert_eq!(problems(&r), [("unknownDirective", Some(8), Some("#pragma")), ("unknownDirective", Some(9), Some("#!"))]);
|
||||
assert_eq!(r.needs, [Need::Language, Need::HostOnly]);
|
||||
assert_eq!(r.needs, [Need::Language], "VectorCraft runs #targetengine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+3
-2
@@ -687,8 +687,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
|
||||
## G6 Automation and scripting
|
||||
|
||||
- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is
|
||||
open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry,
|
||||
- **Missing:** the document object model for scripts and the `#engine` script directive (#1183 is
|
||||
open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, `#targetengine`
|
||||
runs a script in a named engine whose globals last between runs,
|
||||
File › Scripts lists the scripts of the Scripts folder, and
|
||||
Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and
|
||||
export, and full agent control over MCP are done.
|
||||
|
||||
+42
-8
@@ -1492,10 +1492,10 @@ top-level `import` or `export`, such as a React `.jsx` file), `notText` (a NUL b
|
||||
control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, the reply holds these lists:
|
||||
|
||||
- `needs` lists what the script needs from the app that runs it: `language` (the script language's own objects
|
||||
and directives, such as `$`, `alert`, `UnitValue` and `#target`), `dom` (the reference app's document model),
|
||||
`files`, `menuCommands`, `dialogs`, and `hostOnly` (features only the reference app has: palettes,
|
||||
`#targetengine`, BridgeTalk, sockets, native code, XMP, the XML object and E4X, operator overloading,
|
||||
reflection, actions and `selectTool`).
|
||||
and directives, such as `$`, `alert`, `UnitValue`, `#target` and `#targetengine`), `dom` (the reference app's
|
||||
document model), `files`, `menuCommands`, `dialogs`, and `hostOnly` (features only the reference app has:
|
||||
palettes, BridgeTalk, sockets, native code, XMP, the XML object and E4X, operator overloading, reflection, actions
|
||||
and `selectTool`).
|
||||
- `directives` lists the `#include`, `#includepath`, `#script`, `#strict`, `#target` and `#targetengine` lines,
|
||||
and `targets` splits each `#target` line's application specifier into `app`, `instance`, `version` and
|
||||
`locale`. A directive is read where it starts a line, written `#name` or `//@name`.
|
||||
@@ -1577,10 +1577,11 @@ app through the global object `vc`, and writes to the Script Console and runs ot
|
||||
is read at once and `timeout` is not waited on. The run's fuel and limits apply to the file. An empty path, a
|
||||
missing file and a refused path throw errors the script can catch.
|
||||
- `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a
|
||||
script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`.
|
||||
script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. `$.engineName` is
|
||||
the name `#targetengine` gave the engine the script runs in (`VectorCraft` without the line) and is read-only.
|
||||
- `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])`
|
||||
open a dialog, described below. `vc.api.has("dialogs")` is true.
|
||||
- `vc.args` is the `args` object.
|
||||
- `vc.args` gives a read-only copy of the `args` object each time it is read.
|
||||
- `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature.
|
||||
|
||||
Before any of the script runs, its preprocessor lines are read. The help page
|
||||
@@ -1610,8 +1611,12 @@ VectorCraft reads them by these rules of its own:
|
||||
and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of
|
||||
its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the
|
||||
caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`.
|
||||
- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an
|
||||
unknown-directive error. A `//@` line with another name is a comment.
|
||||
- `#targetengine name` and `//@targetengine name` run the script in the named engine. The help page above describes
|
||||
the line: the engine it names lasts for the application session, and a later script that names it sees the globals
|
||||
and functions earlier ones left. A script without the line, and `#targetengine main`, get a new engine for each
|
||||
run, as before. The rest are VectorCraft's own rules, described under Script engines below.
|
||||
- `#engine` ends the run with an unsupported-directive error, and any other `#name` with an unknown-directive error.
|
||||
A `//@` line with another name is a comment.
|
||||
- A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and
|
||||
the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a
|
||||
Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as
|
||||
@@ -1689,6 +1694,35 @@ change anything outside the documents: `script.*` except `script.check`, `plugin
|
||||
an error. With Data Recovery off for complex documents, a run in the web build does not start while a complex document
|
||||
has unsaved changes.
|
||||
|
||||
### Script engines
|
||||
|
||||
A named engine (`#targetengine`) keeps its globals and functions between the runs that name it, until it is dropped
|
||||
or the session ends. These are VectorCraft's own rules:
|
||||
|
||||
- The name is either a name of ASCII letters, digits and `_` that does not start with a digit, or a name in single or
|
||||
double quotes of 1 to 64 bytes of UTF-8 without control characters. Names are matched as written: `Session` and
|
||||
`session` are two engines. `main` is the engine of a script without the line, which is new for each run.
|
||||
- Only the script itself has the line, once, before its code and its `#include` lines; blanks, comments and the other
|
||||
preprocessor lines may come before it. A second line, a late line, a bad name, or the line in an included file or a
|
||||
file `$.evalFile` runs ends the run at that line before any of it runs, and makes no engine.
|
||||
- A session keeps at most 8 named engines. A run that names a ninth ends with a limit error before any of it runs,
|
||||
and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run
|
||||
that goes past it ends with the heap limit, and the engine stays usable.
|
||||
- The documents go back after an uncaught error, a limit or `script.stop` as they do for any run, and the engine keeps
|
||||
the globals the run set before it ended. An internal error drops the engine. An error in a function an earlier run
|
||||
defined is reported without a line and column.
|
||||
- `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a
|
||||
global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine
|
||||
holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`,
|
||||
`$.localize`, `$.flags`, `$.setenv` values and `$.hiresTimer`) lasts for that run only, as in a run without the line.
|
||||
- `script.engines {}` lists the named engines in the order they were made: `{engines: [{name, heapBytes, running}],
|
||||
limit: 8, heapLimit: 67108864}`, where `heapBytes` is the engine's heap after its last run.
|
||||
- `script.engine.drop {name}` drops a named engine and its globals, and returns `{dropped: true, name}`. The next run
|
||||
that names it gets a new engine. `main`, a name with no engine, and the engine of the running script are refused;
|
||||
the last one ends with the busy error.
|
||||
- A script cannot run `script.engines` or `script.engine.drop`.
|
||||
- The web build has no named engines: a run that names one other than `main` ends with an error before any of it runs.
|
||||
|
||||
VectorCraft sets these limits itself:
|
||||
|
||||
| Limit | Value |
|
||||
|
||||
@@ -735,7 +735,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`.
|
||||
- Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`).
|
||||
- Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`.
|
||||
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
|
||||
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
|
||||
- Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`.
|
||||
- Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`).
|
||||
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
|
||||
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
|
||||
|
||||
Reference in New Issue
Block a user