mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
Scripts: #include and the other preprocessor lines run, and $.evalFile runs a file
Before a script runs, `script.run` reads its preprocessor lines (`#` or `//@` at the start of a line, outside strings and comments): - `#include` and `//@include` put another file's text in place of the line, looked for beside the including file, then in each `#includepath` folder, then in each `JSINCLUDE` folder. A later `#includepath` replaces the list. - `#script` names the run unless `name` is given; `#target` takes any value; `#strict off` is taken. `#strict on`, `#targetengine` and `#engine` are refused as unsupported, and any other `#name` as unknown. `$.evalFile(path, timeout?)` runs a file in the same global environment and returns its last value, `$.fileName` is the file being run and `$.includePath` the include folders. A script run from a file reads these files only in its folder and the folders below it. Paths with a `..` name, links or junctions that lead out, device names, streams and network paths are refused; a script given as text reads none. A run reads at most 64 files 16 levels deep, 32 MiB of source in all and 1,024 paths looked up. An error in an included or evaluated file is reported at that file's line and column, and the report's new `file` field names it. Docs: docs/mcp.md (script.run, Script Console), docs/target-app-parity.md.
This commit is contained in:
+191
-27
@@ -23,12 +23,18 @@
|
||||
//! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default,
|
||||
//! and the console's ([`console`]).
|
||||
//!
|
||||
//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the
|
||||
//! place of their lines, and an error in one is reported at its own file, line and column.
|
||||
//! `$.evalFile(path)` runs another file in the same global environment and gives its last value,
|
||||
//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list.
|
||||
//!
|
||||
//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line
|
||||
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
|
||||
//! line and a report line; the run's report holds its own output and error, its time, its host
|
||||
//! calls by kind and its slowest commands.
|
||||
|
||||
pub mod console;
|
||||
pub mod include;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
@@ -131,7 +137,11 @@ pub(crate) fn refused_text(id: &str) -> String {
|
||||
|
||||
/// A request to run a script.
|
||||
pub(crate) struct Request {
|
||||
/// The run's name; unless `name_given`, a `#script` line in the script replaces it.
|
||||
pub name: String,
|
||||
pub name_given: bool,
|
||||
/// The file the script was read from, which its included files are read beside.
|
||||
pub file: Option<String>,
|
||||
pub source: String,
|
||||
pub args: Map<String, Value>,
|
||||
pub fuel: u64,
|
||||
@@ -468,8 +478,20 @@ struct Host {
|
||||
commands: RefCell<HashMap<&'static str, (u64, u128, u128)>>,
|
||||
/// The time the run's slices took, in nanoseconds.
|
||||
run_nanos: Cell<u128>,
|
||||
/// The files the run reads: its includes and `$.evalFile`'s.
|
||||
files: RefCell<include::Files>,
|
||||
/// Where the lines of the script's expanded source came from.
|
||||
script_map: RefCell<include::SourceMap>,
|
||||
/// The same for each source `$.evalFile` gave, in order.
|
||||
eval_maps: RefCell<Vec<include::SourceMap>>,
|
||||
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
|
||||
/// column that error is at.
|
||||
eval_failure: RefCell<Option<EvalFailure>>,
|
||||
}
|
||||
|
||||
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
|
||||
type EvalFailure = (String, Arc<str>, u32, u32);
|
||||
|
||||
/// A run's host calls by kind, as its report gives them in `hostCallCounts`.
|
||||
#[derive(Default)]
|
||||
struct Counts {
|
||||
@@ -899,6 +921,8 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) {
|
||||
|
||||
/// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)`
|
||||
/// gives them, one after another, and `$.writeln(...values)` does the same and ends the line.
|
||||
/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file
|
||||
/// being run and `$.includePath` the `#includepath` list.
|
||||
fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
|
||||
let writer = |end: bool| {
|
||||
let h = host.clone();
|
||||
@@ -910,7 +934,67 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
|
||||
Ok(Data::Undefined)
|
||||
}))
|
||||
};
|
||||
vec![("write".into(), writer(false)), ("writeln".into(), writer(true))]
|
||||
let getter = |read: fn(&include::Files) -> String| {
|
||||
let h = host.clone();
|
||||
HostItem::Getter(function(move |_| {
|
||||
let text = read(&h.files.borrow());
|
||||
h.charge_call(text.len() as u64 + 2)?;
|
||||
Ok(Data::String(text))
|
||||
}))
|
||||
};
|
||||
let eval_file = {
|
||||
let h = host.clone();
|
||||
let end = host.clone();
|
||||
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
|
||||
};
|
||||
vec![
|
||||
("write".into(), writer(false)),
|
||||
("writeln".into(), writer(true)),
|
||||
("evalFile".into(), eval_file),
|
||||
("fileName".into(), getter(include::Files::file_name)),
|
||||
("includePath".into(), getter(include::Files::include_path)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being
|
||||
/// run, with its preprocessor lines read, which the interpreter then runs. `timeout`, in
|
||||
/// milliseconds, is taken and not waited on: the file is read at once.
|
||||
fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostError> {
|
||||
let Some(Data::String(path)) = args.first().filter(|p| !matches!(p, Data::String(s) if s.is_empty())) else {
|
||||
return Err(HostError::Type(EVAL_NO_PATH.to_string()));
|
||||
};
|
||||
match args.get(1) {
|
||||
None | Some(Data::Undefined) => {}
|
||||
Some(Data::Number(ms)) if ms.is_finite() && *ms >= 0.0 => {}
|
||||
Some(Data::Number(_)) => return Err(HostError::Range(EVAL_TIMEOUT.to_string())),
|
||||
Some(_) => return Err(HostError::Type(EVAL_TIMEOUT.to_string())),
|
||||
}
|
||||
if args.len() > 2 {
|
||||
return Err(HostError::Type(EVAL_ARGUMENTS.to_string()));
|
||||
}
|
||||
host.charge_call(path.len() as u64 + 2)?;
|
||||
let expanded = host.files.borrow_mut().eval_file(path).map_err(|f| {
|
||||
let message = if f.line > 0 { format!("{}:{}:{}: {}", f.file, f.line, f.column, f.message) } else { f.message };
|
||||
if f.line > 0 {
|
||||
*host.eval_failure.borrow_mut() = Some((message.clone(), f.file.clone(), f.line, f.column));
|
||||
}
|
||||
match f.kind {
|
||||
include::FailKind::Limit => HostError::Abort(message),
|
||||
include::FailKind::Syntax => HostError::Syntax(message),
|
||||
include::FailKind::Error => HostError::Error(message),
|
||||
}
|
||||
})?;
|
||||
// A file without preprocessor lines is parsed here first, so its syntax error names its place.
|
||||
let options = vectorcraft_js::Options { max_source_units: include::MAX_TOTAL_BYTES, ..vectorcraft_js::Options::default() };
|
||||
if let Err(e) = vectorcraft_js::parse(&expanded.source, &options) {
|
||||
host.files.borrow_mut().end_eval();
|
||||
let (_, file, line, column) = expanded.map.locate(e.line, e.column);
|
||||
let message = format!("{file}:{line}:{column}: {}", e.message);
|
||||
*host.eval_failure.borrow_mut() = Some((message.clone(), file, line, column));
|
||||
return Err(HostError::Syntax(message));
|
||||
}
|
||||
host.eval_maps.borrow_mut().push(expanded.map);
|
||||
Ok(Data::String(expanded.source))
|
||||
}
|
||||
|
||||
fn function(f: impl Fn(Vec<Data>) -> std::result::Result<Data, HostError> + 'static) -> HostFunction {
|
||||
@@ -974,21 +1058,67 @@ fn kind_id(kind: &RunErrorKind) -> &'static str {
|
||||
}
|
||||
|
||||
/// `message`, with what to do about a failure that comes from a script written for another program.
|
||||
fn explained(kind: &RunErrorKind, message: &str, source: &str, line: u32) -> String {
|
||||
fn explained(kind: &RunErrorKind, message: &str) -> String {
|
||||
const HOST_OBJECTS: &[&str] = &["app", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
|
||||
let names_host_object =
|
||||
message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o)));
|
||||
let directive =
|
||||
*kind == RunErrorKind::Syntax && line > 0 && source.lines().nth(line as usize - 1).is_some_and(|l| l.trim_start().starts_with('#'));
|
||||
if *kind == RunErrorKind::Thrown && names_host_object {
|
||||
format!("{message} (scripts here use the `vc` object; objects other applications give their scripts are not available)")
|
||||
} else if directive {
|
||||
format!("{message} (preprocessor lines such as `#target` and `#include` are not read yet)")
|
||||
} else {
|
||||
message.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of a run that stopped in its preprocessor lines (`f`), as the realm gives its own.
|
||||
fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) {
|
||||
let (kind, message) = match f.kind {
|
||||
include::FailKind::Syntax if f.line > 0 => {
|
||||
(RunErrorKind::Syntax, format!("SyntaxError: {} (line {}, column {})", f.message, f.line, f.column))
|
||||
}
|
||||
include::FailKind::Syntax => (RunErrorKind::Syntax, format!("SyntaxError: {}", f.message)),
|
||||
include::FailKind::Error => (RunErrorKind::Thrown, format!("Error: {}", f.message)),
|
||||
include::FailKind::Limit => (RunErrorKind::Limit(LimitKind::Host), f.message),
|
||||
};
|
||||
let file = (f.index > 0).then_some(f.file);
|
||||
(RunError { kind, message, line: f.line, column: f.column }, file)
|
||||
}
|
||||
|
||||
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]), at the line and column of
|
||||
/// the file it is in, with that file's name unless it is the script's own.
|
||||
fn located(host: &Host, tag: u32, e: &RunError) -> (RunError, Option<Arc<str>>) {
|
||||
let map = |m: &include::SourceMap, own: bool| {
|
||||
let (index, file, line, column) = m.locate(e.line, e.column);
|
||||
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
|
||||
(RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file))
|
||||
};
|
||||
// An error `$.evalFile` raised for a file it could not run is at that file's place.
|
||||
if let Some((_, file, line, column)) = host.eval_failure.borrow().as_ref().filter(|(thrown, ..)| e.message.contains(thrown.as_str())) {
|
||||
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, *line, *column) } else { e.message.clone() };
|
||||
return (RunError { kind: e.kind.clone(), message, line: *line, column: *column }, Some(file.clone()));
|
||||
}
|
||||
if e.line == 0 {
|
||||
return (e.clone(), None);
|
||||
}
|
||||
match tag {
|
||||
0 => map(&host.script_map.borrow(), true),
|
||||
n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) {
|
||||
Some(m) => map(m, false),
|
||||
None => (e.clone(), None),
|
||||
},
|
||||
_ => (e.clone(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A syntax error's `message`, which ends with its place `(line L, column C)`, with the place
|
||||
/// given as `line`, `column`.
|
||||
fn relocated(message: &str, old_line: u32, old_column: u32, line: u32, column: u32) -> String {
|
||||
let old = format!(" (line {old_line}, column {old_column})");
|
||||
match message.strip_suffix(&old) {
|
||||
Some(m) => format!("{m} (line {line}, column {column})"),
|
||||
None => message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Is a script running? While one is, the commands that change anything are refused.
|
||||
pub fn script_running(&self) -> bool {
|
||||
@@ -1091,8 +1221,8 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Begins a run of `request`.
|
||||
pub(crate) fn script_start(&mut self, request: Request) -> Result<()> {
|
||||
/// Begins a run of `request`. → the run's name.
|
||||
pub(crate) fn script_start(&mut self, request: Request) -> Result<String> {
|
||||
self.settle_for_script()?;
|
||||
// In a build where a fault ends the program, every unsaved document has a copy first.
|
||||
if self.script_copy_first && self.docs.iter().any(DocState::is_dirty) && cmd::recovery::save_copies(self).is_err() {
|
||||
@@ -1103,8 +1233,14 @@ impl Session {
|
||||
self.script_high_water.retain(|uid, _| open.contains(uid));
|
||||
self.script_stop.clear();
|
||||
self.script_report = None;
|
||||
let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env());
|
||||
let expanded = files.expand_script(&request.source);
|
||||
let name = match &expanded {
|
||||
Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(),
|
||||
_ => request.name,
|
||||
};
|
||||
let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() };
|
||||
let options = Options { max_source_units: MAX_SOURCE_BYTES.saturating_mul(2), ..Options::default() };
|
||||
let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() };
|
||||
let mut realm = Realm::with_options(options, limits);
|
||||
realm.share_interrupt(&self.script_stop);
|
||||
realm.set_data_limits(DataLimits::default());
|
||||
@@ -1126,34 +1262,45 @@ impl Session {
|
||||
log_bytes: Cell::new(0),
|
||||
written: RefCell::new(Vec::new()),
|
||||
run: self.script_runs,
|
||||
name: console::line_name(&request.name),
|
||||
name: console::line_name(&name),
|
||||
console: RefCell::default(),
|
||||
pending: RefCell::default(),
|
||||
counts: Counts::default(),
|
||||
commands: RefCell::default(),
|
||||
run_nanos: Cell::new(0),
|
||||
files: RefCell::new(files),
|
||||
script_map: RefCell::default(),
|
||||
eval_maps: RefCell::default(),
|
||||
eval_failure: RefCell::default(),
|
||||
});
|
||||
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
|
||||
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
|
||||
let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start };
|
||||
self.script_console.push(line, false);
|
||||
let started = realm
|
||||
.define_host_object("vc", vc_object(&host, args))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
|
||||
.and_then(|()| realm.start(&request.source));
|
||||
if let Err(e) = started {
|
||||
let mut report = failure_report(&request.name, &e, &request.source, &host, 0, 0);
|
||||
let started = match expanded {
|
||||
Err(f) => Err(preprocessor_error(f)),
|
||||
Ok(expanded) => {
|
||||
*host.script_map.borrow_mut() = expanded.map;
|
||||
realm
|
||||
.define_host_object("vc", vc_object(&host, args))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
|
||||
.and_then(|()| realm.start(&expanded.source))
|
||||
.map_err(|e| located(&host, realm.error_source(), &e))
|
||||
}
|
||||
};
|
||||
if let Err((e, file)) = started {
|
||||
let mut report = failure_report(&name, &e, file.as_deref(), &host, 0, 0);
|
||||
close_run(self, &host, &mut report, ending(&e.kind));
|
||||
let message = failure_text(&report);
|
||||
self.script_report = Some(report);
|
||||
return Err(EngineError::Other(message));
|
||||
}
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name, scope };
|
||||
let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope };
|
||||
let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
JOBS.with(|jobs| jobs.borrow_mut().insert(id, job));
|
||||
self.script_key = Some(JobKey(id));
|
||||
Ok(())
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Advances the running script by one slice. `None` while it is still running; otherwise the
|
||||
@@ -1194,7 +1341,8 @@ impl Session {
|
||||
|
||||
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
|
||||
let fuel_left = job.realm.fuel_left();
|
||||
let mut report = failure_report(&job.name, e, "", &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
|
||||
let (e, file) = located(&job.host, job.realm.error_source(), e);
|
||||
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
|
||||
close_run(self, &job.host, &mut report, ending(&e.kind));
|
||||
drop(job);
|
||||
raise_ids(self);
|
||||
@@ -1308,13 +1456,16 @@ const TOO_MANY_ARGUMENTS: &str = "vc.run: takes a command id and an object of pa
|
||||
const PARAMS_NOT_OBJECT: &str = "vc.run: the parameters must be an object";
|
||||
const LOG_FULL: &str = "vc.log: the log of this run is full";
|
||||
const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the script was not run";
|
||||
const EVAL_NO_PATH: &str = "$.evalFile requires a file path";
|
||||
const EVAL_TIMEOUT: &str = "$.evalFile: the timeout must be a finite number of milliseconds, 0 or more";
|
||||
const EVAL_ARGUMENTS: &str = "$.evalFile: takes a file path and a timeout";
|
||||
|
||||
/// The report of a run that failed.
|
||||
fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left: u64, fuel_used: u64) -> Value {
|
||||
json!({
|
||||
/// The report of a run that failed; `file` is the included file the error is in.
|
||||
fn failure_report(name: &str, e: &RunError, file: Option<&str>, host: &Host, fuel_left: u64, fuel_used: u64) -> Value {
|
||||
let mut report = json!({
|
||||
"ok": false,
|
||||
"kind": kind_id(&e.kind),
|
||||
"message": explained(&e.kind, &e.message, source, e.line),
|
||||
"message": explained(&e.kind, &e.message),
|
||||
"name": name,
|
||||
"line": e.line,
|
||||
"column": e.column,
|
||||
@@ -1323,7 +1474,11 @@ fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left
|
||||
"fuelLeft": fuel_left,
|
||||
"fuelUsed": fuel_used,
|
||||
"hostCalls": host.calls.get(),
|
||||
})
|
||||
});
|
||||
if let (Some(file), Some(o)) = (file, report.as_object_mut()) {
|
||||
o.insert("file".into(), json!(file));
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// How the run that failed with `kind` ended, as its console end line gives it.
|
||||
@@ -1361,8 +1516,11 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
|
||||
if report["ok"] == false {
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let message = report["message"].as_str().unwrap_or_default();
|
||||
let text =
|
||||
if line > 0 { format!("{}:{line}:{column}: {message}", host.name) } else { format!("{}: location unavailable: {message}", host.name) };
|
||||
let text = match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name),
|
||||
_ if line > 0 => format!("{}:{line}:{column}: {message}", host.name),
|
||||
_ => format!("{}: location unavailable: {message}", host.name),
|
||||
};
|
||||
each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut));
|
||||
}
|
||||
let timing = clock().is_some();
|
||||
@@ -1386,7 +1544,11 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
|
||||
fn failure_text(report: &Value) -> String {
|
||||
let s = |k: &str| report[k].as_str().unwrap_or_default();
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let at = if line > 0 { format!(" line {line}, column {column}") } else { String::new() };
|
||||
let at = match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!(" line {line}, column {column} of {file}"),
|
||||
_ if line > 0 => format!(" line {line}, column {column}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let written = report["written"].as_array().map_or(0, Vec::len);
|
||||
let kept = if written > 0 {
|
||||
let paths: Vec<&str> = report["written"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
@@ -1454,4 +1616,6 @@ 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 `JSINCLUDE` folders in place of the environment's.
|
||||
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user