Merge main into feat/script-dollar-object

This commit is contained in:
dotCooCoo
2026-11-11 02:35:43 -08:00
68 changed files with 3136 additions and 195 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ impl Console {
/// `c` as the console shows it: a control character as its Control Picture, a line or paragraph
/// separator as U+2424, anything else as it is.
fn shown(c: char) -> char {
pub(crate) fn shown(c: char) -> char {
match c {
'\u{0}'..='\u{1f}' => char::from_u32(0x2400 + c as u32).unwrap_or('\u{fffd}'),
'\u{7f}' => '\u{2421}',
+364
View File
@@ -0,0 +1,364 @@
//! `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and
//! `prompt(message, preset[, title])`: the dialogs a script opens while it runs.
//!
//! A message and a preset arrive as the string `String(value)` gives, a title the same way unless
//! it is left out or `undefined` ([`DEFAULT_TITLE`]), and `errorIcon` and `noAsDflt` as the
//! boolean `Boolean(value)` gives. `alert` returns `undefined`, `confirm` `true` for Yes and
//! `false` for No, and `prompt` the text in its field for OK and `null` for Cancel.
//!
//! In the app the run waits at the call for the person's answer, and spends no fuel while it
//! waits; every dialog also has Stop, which ends the run and puts everything back. A run with
//! nobody to ask takes its answers, in order, from `script.run`'s `answers`; a call with no
//! answer left, or with the next answer for another kind of dialog, ends the run. The report's
//! `dialogs` lists each dialog and its answer.
//!
//! The values here are VectorCraft's own limits: [`MAX_MESSAGE_BYTES`], [`MAX_TITLE_BYTES`],
//! [`MAX_PROMPT_BYTES`], [`MAX_ANSWERS`], [`MAX_ANSWERS_BYTES`], [`MAX_RECORDS`] and
//! [`MAX_RECORD_BYTES`].
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use serde_json::{Map, Value, json};
use vectorcraft_js::{Arg, Data, HostError, HostFunction, Realm, RunError};
use super::{Host, console};
/// The most bytes (UTF-8) of a dialog's message.
pub const MAX_MESSAGE_BYTES: usize = 16 << 10;
/// The most bytes (UTF-8) of a dialog's title.
pub const MAX_TITLE_BYTES: usize = 256;
/// The most bytes (UTF-8) of a prompt's preset and of the text it returns.
pub const MAX_PROMPT_BYTES: usize = 16 << 10;
/// The most answers `script.run` takes.
pub const MAX_ANSWERS: usize = 10_000;
/// The most bytes of `answers`, as JSON.
pub const MAX_ANSWERS_BYTES: usize = 1 << 20;
/// The most dialogs a report lists.
pub const MAX_RECORDS: usize = 10_000;
/// The most bytes of the dialogs a report lists, as JSON.
pub const MAX_RECORD_BYTES: usize = 1 << 20;
/// A dialog's title when the script gives none, in English; the app translates it when it shows it.
pub const DEFAULT_TITLE: &str = "Script Alert";
/// Which dialog a script opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DialogKind {
Alert,
Confirm,
Prompt,
}
impl DialogKind {
/// The function's name, which is also the `kind` of an answer and of a report's dialog.
pub fn id(self) -> &'static str {
match self {
Self::Alert => "alert",
Self::Confirm => "confirm",
Self::Prompt => "prompt",
}
}
}
/// A dialog a script opened, which waits for its answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptDialog {
pub kind: DialogKind,
pub message: String,
/// `None`: the script gave no title ([`DEFAULT_TITLE`]).
pub title: Option<String>,
/// `alert`: show the error icon.
pub error_icon: bool,
/// `confirm`: No is the default button.
pub no_as_default: bool,
/// `prompt`: the text its field starts with.
pub preset: String,
}
/// The answer to a [`ScriptDialog`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DialogAnswer {
/// OK, for `alert`.
Ok,
/// Yes (`true`) or No, for `confirm`.
Confirm(bool),
/// OK with the field's text, or Cancel (`None`), for `prompt`.
Prompt(Option<String>),
}
impl DialogAnswer {
pub fn kind(&self) -> DialogKind {
match self {
Self::Ok => DialogKind::Alert,
Self::Confirm(_) => DialogKind::Confirm,
Self::Prompt(_) => DialogKind::Prompt,
}
}
/// What the function returns.
fn data(&self) -> Data {
match self {
Self::Ok => Data::Undefined,
Self::Confirm(b) => Data::Bool(*b),
Self::Prompt(Some(t)) => Data::String(t.clone()),
Self::Prompt(None) => Data::Null,
}
}
/// The answer as a report gives it: `"ok"`, a boolean, a string or `null`.
fn json(&self) -> Value {
match self {
Self::Ok => json!("ok"),
Self::Confirm(b) => json!(b),
Self::Prompt(t) => json!(t),
}
}
}
/// `text` as a dialog shows it. With `lines`, a line feed, a carriage return or the two together
/// is a line break; every other control character, and with `lines` false every line break too,
/// shows as its Unicode Control Picture, as the Script Console shows it.
pub fn shown_text(text: &str, lines: bool) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' if lines => {
chars.next_if_eq(&'\n');
out.push('\n');
}
'\n' if lines => out.push('\n'),
c => out.push(console::shown(c)),
}
}
out
}
/// The longest start of `text` that is at most `max` bytes, cut at a character boundary.
pub fn cut_to(text: &str, max: usize) -> &str {
if text.len() <= max {
return text;
}
let mut end = max;
while !text.is_char_boundary(end) {
end -= 1;
}
text.get(..end).unwrap_or_default()
}
/// `script.run`'s `answers`: a list of `{kind: "alert", value: "ok"}`, `{kind: "confirm", value:
/// true | false}` and `{kind: "prompt", value: text | null}`. An empty object, which the parameter
/// took before dialogs existed, is an empty list.
pub(crate) fn parse_answers(v: Option<&Value>) -> Result<Vec<DialogAnswer>, String> {
let list = match v {
None => return Ok(Vec::new()),
Some(Value::Object(o)) if o.is_empty() => return Ok(Vec::new()),
Some(Value::Array(a)) => a,
Some(_) => return Err("`answers` must be a list of answers".into()),
};
if list.len() > MAX_ANSWERS {
return Err(format!("`answers` has more than {MAX_ANSWERS} answers"));
}
if serde_json::to_vec(list).map_or(true, |b| b.len() > MAX_ANSWERS_BYTES) {
return Err("`answers` is larger than 1 MiB".into());
}
list.iter().enumerate().map(|(i, a)| answer(a).map_err(|e| format!("`answers[{i}]` {e}"))).collect()
}
fn answer(a: &Value) -> Result<DialogAnswer, &'static str> {
let Some(o) = a.as_object() else { return Err("must be an object with `kind` and `value`") };
if o.len() != 2 || !o.contains_key("value") {
return Err("must have exactly `kind` and `value`");
}
match (o.get("kind").and_then(Value::as_str), &o["value"]) {
(Some("alert"), Value::String(s)) if s == "ok" => Ok(DialogAnswer::Ok),
(Some("alert"), _) => Err("is an alert's answer, whose `value` must be \"ok\""),
(Some("confirm"), Value::Bool(b)) => Ok(DialogAnswer::Confirm(*b)),
(Some("confirm"), _) => Err("is a confirm's answer, whose `value` must be true or false"),
(Some("prompt"), Value::Null) => Ok(DialogAnswer::Prompt(None)),
(Some("prompt"), Value::String(s)) if s.len() <= MAX_PROMPT_BYTES => Ok(DialogAnswer::Prompt(Some(s.clone()))),
(Some("prompt"), Value::String(_)) => Err("is longer than 16 KiB"),
(Some("prompt"), _) => Err("is a prompt's answer, whose `value` must be a string or null"),
_ => Err("must have `kind` alert, confirm or prompt"),
}
}
/// The dialogs of one run: the answers left for a run with nobody to ask, the dialog that waits
/// for the person's answer, and the list the report gives.
pub(crate) struct Dialogs {
/// Answers come from the person, not from `answers`.
framed: bool,
/// The realm's [`Realm::wait_flag`].
can_wait: Rc<Cell<bool>>,
answers: RefCell<VecDeque<DialogAnswer>>,
waiting: RefCell<Option<ScriptDialog>>,
/// The error that ended the run when an answer could not reach the script.
failed: RefCell<Option<RunError>>,
records: RefCell<Vec<Value>>,
record_bytes: Cell<usize>,
count: Cell<u64>,
truncated: Cell<bool>,
}
impl Dialogs {
pub(crate) fn new(framed: bool, realm: &Realm, answers: Vec<DialogAnswer>) -> Self {
Self {
framed,
can_wait: realm.wait_flag(),
answers: RefCell::new(answers.into()),
waiting: RefCell::default(),
failed: RefCell::default(),
records: RefCell::default(),
record_bytes: Cell::new(0),
count: Cell::new(0),
truncated: Cell::new(false),
}
}
/// The dialog that waits for its answer.
pub(crate) fn waiting(&self) -> Option<ScriptDialog> {
self.waiting.borrow().clone()
}
pub(crate) fn is_waiting(&self) -> bool {
self.waiting.borrow().is_some()
}
/// The error that ended the run when its answer could not reach the script.
pub(crate) fn take_failure(&self) -> Option<RunError> {
self.failed.borrow_mut().take()
}
/// How many of `answers` the run did not use.
pub(crate) fn unused(&self) -> usize {
self.answers.borrow().len()
}
/// Gives the waiting dialog `answer`, which must be for its kind and, for a prompt, at most
/// [`MAX_PROMPT_BYTES`]. → false when it is not, or nothing waits.
pub(crate) fn answer(&self, realm: &mut Realm, answer: DialogAnswer) -> bool {
let fits = match &answer {
DialogAnswer::Prompt(Some(t)) => t.len() <= MAX_PROMPT_BYTES,
_ => true,
};
let mut waiting = self.waiting.borrow_mut();
let Some(d) = waiting.as_ref().filter(|d| d.kind == answer.kind() && fits) else { return false };
self.record(d, answer.json());
*waiting = None;
if let Err(e) = realm.answer(answer.data()) {
*self.failed.borrow_mut() = Some(e);
}
true
}
/// The run ends while a dialog waits: the report lists it with the answer `"stop"`.
pub(crate) fn stopped(&self) {
if let Some(d) = self.waiting.borrow_mut().take() {
self.record(&d, json!("stop"));
}
}
fn record(&self, d: &ScriptDialog, answer: Value) {
self.count.set(self.count.get().saturating_add(1));
let mut r = Map::new();
r.insert("kind".into(), json!(d.kind.id()));
r.insert("message".into(), json!(d.message));
r.insert("title".into(), json!(d.title.as_deref().unwrap_or(DEFAULT_TITLE)));
match d.kind {
DialogKind::Alert => r.insert("errorIcon".into(), json!(d.error_icon)),
DialogKind::Confirm => r.insert("noAsDefault".into(), json!(d.no_as_default)),
DialogKind::Prompt => r.insert("preset".into(), json!(d.preset)),
};
r.insert("answer".into(), answer);
let r = Value::Object(r);
let bytes = serde_json::to_vec(&r).map_or(usize::MAX, |b| b.len());
let mut records = self.records.borrow_mut();
if self.truncated.get() || records.len() >= MAX_RECORDS || self.record_bytes.get().saturating_add(bytes) > MAX_RECORD_BYTES {
self.truncated.set(true);
return;
}
self.record_bytes.set(self.record_bytes.get() + bytes);
records.push(r);
}
/// Adds `dialogs`, `dialogCount` and `dialogsTruncated` to a report.
pub(crate) fn report(&self, report: &mut Map<String, Value>) {
report.insert("dialogs".into(), Value::Array(self.records.borrow().clone()));
report.insert("dialogCount".into(), json!(self.count.get()));
report.insert("dialogsTruncated".into(), json!(self.truncated.get()));
}
}
/// Adds `alert`, `confirm` and `prompt` to the realm.
pub(super) fn install(realm: &mut Realm, host: &Rc<Host>) -> Result<(), RunError> {
use Arg::{Bool, Text, TextOrUndefined};
let call = |kind: DialogKind| -> HostFunction {
let h = host.clone();
Rc::new(move |args| open(&h, 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))?;
realm.define_host_function("prompt", vec![Text, Text, TextOrUndefined], call(DialogKind::Prompt))
}
fn text(args: &[Data], i: usize) -> Option<String> {
match args.get(i) {
Some(Data::String(s)) => Some(s.clone()),
_ => None,
}
}
fn flag(args: &[Data], i: usize) -> bool {
matches!(args.get(i), Some(Data::Bool(true)))
}
/// A call of `alert`, `confirm` or `prompt`.
fn open(host: &Host, kind: DialogKind, args: &[Data]) -> Result<Data, HostError> {
let name = kind.id();
host.charge_call(args.iter().map(super::data_size).sum())?;
let Some(message) = text(args, 0) else { return Err(HostError::Type(format!("{name}: give a message"))) };
let (title, error_icon, no_as_default, preset) = match kind {
DialogKind::Alert => (text(args, 1), flag(args, 2), false, String::new()),
DialogKind::Confirm => (text(args, 2), false, flag(args, 1), String::new()),
DialogKind::Prompt => {
let Some(preset) = text(args, 1) else { return Err(HostError::Type(format!("{name}: give the text the field starts with"))) };
(text(args, 2), false, false, preset)
}
};
if message.len() > MAX_MESSAGE_BYTES {
return Err(HostError::Range(format!("{name}: the message is longer than 16 KiB")));
}
if title.as_ref().is_some_and(|t| t.len() > MAX_TITLE_BYTES) {
return Err(HostError::Range(format!("{name}: the title is longer than 256 bytes")));
}
if preset.len() > MAX_PROMPT_BYTES {
return Err(HostError::Range(format!("{name}: the text the field starts with is longer than 16 KiB")));
}
let dialog = ScriptDialog { kind, message, title, error_icon, no_as_default, preset };
let d = &host.dialogs;
if d.framed {
if !d.can_wait.get() {
return Err(HostError::Error(format!(
"{name}: a dialog cannot open inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"
)));
}
*d.waiting.borrow_mut() = Some(dialog);
return Err(HostError::Wait);
}
let next = d.answers.borrow_mut().pop_front();
match next {
Some(a) if a.kind() == kind => {
d.record(&dialog, a.json());
Ok(a.data())
}
Some(a) => {
d.record(&dialog, json!("missing"));
Err(HostError::Abort(format!("{name}: the next answer in `answers` is for {}, not {name}", a.kind().id())))
}
None => {
d.record(&dialog, json!("missing"));
Err(HostError::Abort(format!("{name}: `answers` has no answer left for this dialog")))
}
}
}
+52 -6
View File
@@ -33,11 +33,17 @@
//! line and a report line; the run's report holds its own output and error, its time, its host
//! calls by kind and its slowest commands. The rest of `$`, and the global `localize` and
//! `toSource`, are in [`dollar`].
//!
//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the
//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`.
pub mod console;
pub mod dialogs;
pub mod dollar;
pub mod include;
pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
@@ -90,7 +96,7 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30;
/// The version `vc.api.version` gives.
pub const API_VERSION: &str = "1.0";
/// The features `vc.api.has` knows.
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dollar"];
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs", "dollar"];
/// The most commands a run's report lists in `slowestCommands`.
pub const SLOWEST_COMMANDS: usize = 5;
@@ -151,6 +157,8 @@ pub(crate) struct Request {
pub fuel: u64,
/// The locale the run starts with (`$.locale`).
pub locale: String,
/// The answers to the dialogs of a run with nobody to ask, in order.
pub answers: Vec<DialogAnswer>,
}
thread_local! {
@@ -484,6 +492,8 @@ struct Host {
commands: RefCell<HashMap<&'static str, (u64, u128, u128)>>,
/// The time the run's slices took, in nanoseconds.
run_nanos: Cell<u128>,
/// `alert`, `confirm` and `prompt`: their answers and the list the report gives.
dialogs: dialogs::Dialogs,
/// The files the run reads: its includes and `$.evalFile`'s.
files: RefCell<include::Files>,
/// Where the lines of the script's expanded source came from.
@@ -1104,7 +1114,7 @@ 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) -> String {
const HOST_OBJECTS: &[&str] = &["app", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
let names_host_object =
message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o)));
if *kind == RunErrorKind::Thrown && names_host_object {
@@ -1215,6 +1225,23 @@ impl Session {
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.asleep())).flatten()
}
/// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's
/// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it.
pub fn script_dialog(&self) -> Option<ScriptDialog> {
if self.script_stop.is_set() {
return None;
}
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.dialogs.waiting())).flatten()
}
/// Gives the dialog the running script waits at its answer; the next [`Session::script_pump`]
/// goes on from the call. → false when nothing waits, the answer is for another kind of
/// dialog, or a prompt's text is longer than [`dialogs::MAX_PROMPT_BYTES`].
pub fn script_answer(&mut self, answer: DialogAnswer) -> bool {
let Some(k) = self.script_key.as_ref().filter(|_| !self.script_stop.is_set()) else { return false };
with_job(k, |j| j.host.dialogs.answer(&mut j.realm, answer)).unwrap_or(false)
}
/// The name of the running script.
pub fn script_name(&self) -> Option<String> {
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone()))
@@ -1323,6 +1350,7 @@ impl Session {
counts: Counts::default(),
commands: RefCell::default(),
run_nanos: Cell::new(0),
dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers),
files: RefCell::new(files),
script_map: RefCell::default(),
eval_maps: RefCell::default(),
@@ -1344,6 +1372,7 @@ impl Session {
.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))
.and_then(|()| realm.start(&expanded.source))
.map_err(|e| located(&host, realm.error_source(), &e))
}
@@ -1370,11 +1399,20 @@ impl Session {
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else {
return Some(Err(EngineError::Other(JOB_GONE.to_string())));
};
if (job.host.waiting.get() || job.host.asleep().is_some()) && !self.script_stop.is_set() {
if let Some(e) = job.host.dialogs.take_failure() {
return Some(self.script_failed(job, &e));
}
if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job));
self.script_key = Some(key);
return None;
}
// A stop while a dialog waits ends the run there; the interpreter cannot go on from the call.
if job.realm.waits() {
let kind = LimitKind::Interrupted;
let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 };
return Some(self.script_failed(job, &e));
}
if vectorcraft_doc::cancel::check().is_err() {
self.script_stop.set();
}
@@ -1391,6 +1429,12 @@ impl Session {
self.script_key = Some(key);
None
}
Ok(Ok(Progress::Done(_))) if job.host.dialogs.unused() > 0 => {
let n = job.host.dialogs.unused();
let kind = LimitKind::Host;
let message = format!("`answers` has {n} answer{} the script did not use", if n == 1 { "" } else { "s" });
Some(self.script_failed(job, &RunError { kind: RunErrorKind::Limit(kind), message, line: 0, column: 0 }))
}
Ok(Ok(Progress::Done(_))) => {
let r = self.script_commit(job);
self.trim_histories();
@@ -1400,6 +1444,7 @@ impl Session {
}
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
job.host.dialogs.stopped();
let fuel_left = job.realm.fuel_left();
let (e, file) = located(&job.host, job.realm.error_source(), e);
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
@@ -1496,7 +1541,7 @@ impl Session {
if let Some(result) = self.script_pump() {
return result;
}
if self.script_waiting() {
if self.script_waiting() || self.script_dialog().is_some() {
self.script_stop.set();
}
// A `$.sleep` waits here, where nothing is drawn, instead of turning the loop over.
@@ -1567,8 +1612,8 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands and
/// console to `report`.
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
/// console and dialogs to `report`.
fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::Ending) {
let mut pending = std::mem::take(&mut *host.pending.borrow_mut());
if !pending.is_empty() {
@@ -1602,6 +1647,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
o.insert("hostCallCounts".into(), host.counts.json());
o.insert("slowestCommands".into(), Value::Array(slow_json));
o.insert("console".into(), host.console.borrow().report());
host.dialogs.report(o);
}
}