mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
VectorCraft does not run scripts written for the reference app, and File › Scripts held only the disabled Other Script…. File › Scripts › Check Script… picks a .jsx, .jsxinc or .jsxbin file, reads it without running any of it, and shows a report: what the script needs (the script language's own objects, the document model, files, menu commands, dialogs, or features only the reference app has), what it would do outside the document, the menu-command strings it passes, the document-model member chains and type names it uses, its directives, the files it includes, and the problems that would stop it loading. Copy Report copies the report as JSON. File › Open and drag-and-drop show the report for those files too. - vectorcraft-scriptcheck, a new Standalone crate with no dependencies, holds the checker: a lexer for ECMAScript 3 with E4X literals and the #name and //@name directive lines, and an analysis of the tokens that follows variables, function scopes and the files a script includes. It never recurses: open brackets, the levels of an XML literal and the function bodies being read are kept on stacks capped at 4,096. It reads at most 8 MiB of a file and 32 MiB with the included files, follows 64 included files 16 deep, looks up at most 1,024 paths for them, keeps 1,000 entries in a list, tracks 1,000 variables holding member chains and 1,000 holding File objects, and cuts echoed text to 120 characters. A compiled script is reported as compiled and isn't read. - script.check (engine, a query) takes path, data or dataBase64. With a path and includes: true, it reads the files the #include lines name from inside the script's folder, through the automation read root, and when the script or one of those files assigns a member chain or a File object to a variable, the check treats the variable as holding it in every file. It doesn't follow absolute names, names, folders or links that lead out of the folder, names or folders longer than 4,096 bytes, or JSINCLUDE, and it reads only regular files. - ui.checkScript shows the report (dialog scriptCheck). File › Open lists Scripts (.jsx, .jsxinc, .jsxbin) and sends a script's path to script.check, which reads at most 8 MiB of it. The language rules come from ECMA-262 3rd edition and ECMA-357 2nd edition. The directive, platform and document-model root names come from the public JavaScript Tools Guide and scripting guide pages that catalog.rs cites by URL, and executeMenuCommand, doScript, loadAction, unloadAction and selectTool come from the public forum posts cited there; only names are used. Every script in the tests is written from scratch with invented names. Docs: docs/mcp.md, docs/target-app-parity.md.
252 lines
10 KiB
Rust
252 lines
10 KiB
Rust
//! Static checker for scripts written for the reference app's scripting: `.jsx`, `.jsxinc` and
|
|
//! `.js` files, and compiled `.jsxbin` files. [`check`] reads a script's text without running any
|
|
//! of it and reports what kind of file it is ([`Kind`]), its preprocessor directives, the platform
|
|
//! features and document-model member chains it uses, what it would do outside the open document,
|
|
//! the menu-command strings it passes, the files it includes and the problems that would stop it
|
|
//! loading.
|
|
//!
|
|
//! The lexer reads ECMAScript 3 (ECMA-262 3rd edition,
|
|
//! <https://ecma-international.org/wp-content/uploads/ECMA-262_3rd_edition_december_1999.pdf>) with
|
|
//! the XML literals and operators of E4X (ECMA-357 2nd edition,
|
|
//! <https://ecma-international.org/wp-content/uploads/ECMA-357_2nd_edition_december_2005.pdf>), and
|
|
//! it reads later syntax (template literals, `=>`) only to report it. The directive and platform
|
|
//! names come from the public pages `catalog.rs` cites by URL; only the names are used. Nothing
|
|
//! maps the reference app's names to VectorCraft commands.
|
|
//!
|
|
//! The checker never recurses: it reads each file once, and each scan of a file goes through its
|
|
//! tokens front to back, first for the names its functions and its global code declare and then
|
|
//! for the analysis. When it follows includes, it scans each file for the includes and what the
|
|
//! variables hold, again while that grows (at most three more times), and then for the report. It
|
|
//! keeps open brackets and the levels of an XML literal on capped stacks. Its work is linear in the
|
|
//! input's size, and no input can overflow the stack.
|
|
#![forbid(unsafe_code)]
|
|
|
|
mod analyze;
|
|
mod catalog;
|
|
mod directives;
|
|
mod includes;
|
|
mod lex;
|
|
mod report;
|
|
|
|
pub use includes::{Loaded, Loader};
|
|
pub use report::{Directive, Feature, Include, IncludeStatus, MenuString, Named, Outside, Problem, Report, Target};
|
|
|
|
/// The limits that keep a check small and quick; [`Limits::default`] holds the ones VectorCraft
|
|
/// uses.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Limits {
|
|
/// The most bytes of one file read: a larger script is [`Kind::TooLarge`], and a larger
|
|
/// included file isn't read.
|
|
pub file_bytes: usize,
|
|
/// The most bytes read from the script and its included files together.
|
|
pub total_bytes: usize,
|
|
/// The most included files read.
|
|
pub includes: usize,
|
|
/// How deep includes are followed: a file the script includes is at depth 1.
|
|
pub include_depth: usize,
|
|
/// The most paths looked up for included files, whether a file is found there or not.
|
|
pub lookups: usize,
|
|
/// The most open brackets tracked, and the most levels an XML literal is read to (its markup,
|
|
/// the code in a `{…}` inside it, an XML literal in that code). Deeper nesting is a problem, and
|
|
/// the check goes on.
|
|
pub nesting: usize,
|
|
/// The most entries in each list of the report, and in each table of names the check keeps (the
|
|
/// variables holding a member chain, the variables holding a File or Folder object, the names
|
|
/// one function declares, and the functions the global code declares).
|
|
pub entries: usize,
|
|
/// The most line numbers listed for one entry.
|
|
pub lines: usize,
|
|
/// The most characters of text echoed from a script (names, values, member chains).
|
|
pub echo: usize,
|
|
}
|
|
|
|
impl Default for Limits {
|
|
fn default() -> Self {
|
|
Self {
|
|
file_bytes: 8 << 20,
|
|
total_bytes: 32 << 20,
|
|
includes: 64,
|
|
include_depth: 16,
|
|
lookups: 1024,
|
|
nesting: 4096,
|
|
entries: 1000,
|
|
lines: 20,
|
|
echo: 120,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What kind of file a script is.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Kind {
|
|
/// Text that reads as a script.
|
|
Script,
|
|
/// A compiled script (`.jsxbin`, or text that starts with `@JSXBIN@`). It isn't read.
|
|
Compiled,
|
|
/// A JavaScript module (top-level `import` or `export`), such as a React `.jsx` file: not a
|
|
/// script for the reference app.
|
|
Module,
|
|
/// Not text: a NUL byte, or more than one byte in ten a control character, in the first 8 KiB.
|
|
NotText,
|
|
/// Larger than [`Limits::file_bytes`].
|
|
TooLarge,
|
|
}
|
|
|
|
impl Kind {
|
|
pub fn id(self) -> &'static str {
|
|
match self {
|
|
Kind::Script => "script",
|
|
Kind::Compiled => "compiled",
|
|
Kind::Module => "module",
|
|
Kind::NotText => "notText",
|
|
Kind::TooLarge => "tooLarge",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A kind of help a script needs from the app that runs it.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum Need {
|
|
/// The script language's own objects and directives (`$`, `alert`, `UnitValue`, `#target`).
|
|
Language,
|
|
/// The reference app's document model (`app.activeDocument…`).
|
|
Dom,
|
|
/// Files and folders.
|
|
Files,
|
|
/// Menu commands run by name.
|
|
MenuCommands,
|
|
/// Dialogs and windows of its own.
|
|
Dialogs,
|
|
/// Features only the reference app has (palettes, messages to other apps, network
|
|
/// connections, native code, E4X, actions).
|
|
HostOnly,
|
|
}
|
|
|
|
impl Need {
|
|
pub const ALL: [Need; 6] = [Need::Language, Need::Dom, Need::Files, Need::MenuCommands, Need::Dialogs, Need::HostOnly];
|
|
|
|
pub fn id(self) -> &'static str {
|
|
match self {
|
|
Need::Language => "language",
|
|
Need::Dom => "dom",
|
|
Need::Files => "files",
|
|
Need::MenuCommands => "menuCommands",
|
|
Need::Dialogs => "dialogs",
|
|
Need::HostOnly => "hostOnly",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every feature id a report can list, in the order the report lists them.
|
|
pub use catalog::FEATURES;
|
|
/// Every id in [`Report::outside`], in the order the report lists them.
|
|
pub use report::OUTSIDE;
|
|
/// Every problem code a report can list.
|
|
pub use report::PROBLEMS;
|
|
|
|
/// Check the script `bytes`, named `name` (a file name; only its last part is used). With a
|
|
/// `loader`, the files its `#include` lines name are read through it and checked too; without one
|
|
/// they are listed as [`IncludeStatus::NotRead`]. When the script or one of those files assigns a
|
|
/// member chain or a File object to a variable, the check treats the variable as holding it in
|
|
/// every file. Nothing in the script runs.
|
|
pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: &Limits) -> Report {
|
|
let mut acc = report::Acc::new(limits);
|
|
if bytes.len() > limits.file_bytes {
|
|
return acc.finish(Kind::TooLarge, 0);
|
|
}
|
|
let name = base_name(name);
|
|
if is_compiled(name, bytes) {
|
|
return acc.finish(Kind::Compiled, 0);
|
|
}
|
|
if !is_text(bytes) {
|
|
return acc.finish(Kind::NotText, 0);
|
|
}
|
|
let (text, invalid) = decode(bytes);
|
|
if invalid {
|
|
acc.problem(report::NOT_UTF8, None, None, None);
|
|
}
|
|
let none = analyze::Known::default();
|
|
// With a loader, this first scan finds the includes and what the script's variables hold; its
|
|
// tallies are left out.
|
|
let mut tallies = report::Acc::new(limits);
|
|
let scan = analyze::scan(&text, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc });
|
|
acc.steps = acc.steps.saturating_add(tallies.steps);
|
|
if scan.module {
|
|
// A module's report holds its kind and its lines, and none of what the scan found.
|
|
let steps = acc.steps;
|
|
let mut acc = report::Acc::new(limits);
|
|
acc.steps = steps;
|
|
return acc.finish(Kind::Module, scan.lines);
|
|
}
|
|
let Some(loader) = loader else {
|
|
includes::not_read(&mut acc, scan.includes);
|
|
return acc.finish(Kind::Script, scan.lines);
|
|
};
|
|
let mut known = scan.known;
|
|
let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known);
|
|
// A variable may get what it holds from another file's variable: the files are scanned again,
|
|
// each with what is known so far, while that grows, at most three more times.
|
|
for _ in 0..3 {
|
|
let before = known.len();
|
|
let mut tallies = report::Acc::new(limits);
|
|
let more = analyze::scan(&text, None, std::rc::Rc::from([]), &known, &mut tallies).known;
|
|
known.add(more, limits.entries);
|
|
for file in &read {
|
|
let more = analyze::scan(&file.text, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known;
|
|
known.add(more, limits.entries);
|
|
}
|
|
acc.steps = acc.steps.saturating_add(tallies.steps);
|
|
if known.len() == before {
|
|
break;
|
|
}
|
|
}
|
|
// The scans for the report: every file starts with what any file's variables hold.
|
|
let lines = analyze::scan(&text, None, std::rc::Rc::from([]), &known, &mut acc).lines;
|
|
for file in read {
|
|
if file.invalid {
|
|
acc.problem(report::NOT_UTF8, None, Some(&file.path), None);
|
|
}
|
|
analyze::scan(&file.text, Some(&file.path), file.paths, &known, &mut acc);
|
|
}
|
|
acc.finish(Kind::Script, lines)
|
|
}
|
|
|
|
/// The last part of a path (`lib/Tidy.jsx` → `Tidy.jsx`), whichever separator it uses.
|
|
pub(crate) fn base_name(name: &str) -> &str {
|
|
name.rsplit(['/', '\\']).next().unwrap_or(name)
|
|
}
|
|
|
|
/// A compiled script: named `.jsxbin`, or text that starts with `@JSXBIN@` (after a byte order mark
|
|
/// and blanks).
|
|
pub(crate) fn is_compiled(name: &str, bytes: &[u8]) -> bool {
|
|
if base_name(name).rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("jsxbin")) {
|
|
return true;
|
|
}
|
|
let b = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
|
let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len());
|
|
b.get(start..).is_some_and(|b| b.starts_with(b"@JSXBIN@"))
|
|
}
|
|
|
|
/// Text, judged by its first 8 KiB: no NUL byte, and at most one byte in ten a control character
|
|
/// other than tab, line feed, vertical tab, form feed, carriage return and escape.
|
|
pub(crate) fn is_text(bytes: &[u8]) -> bool {
|
|
let head = bytes.get(..bytes.len().min(8 << 10)).unwrap_or(bytes);
|
|
if head.contains(&0) {
|
|
return false;
|
|
}
|
|
let controls = head.iter().filter(|&&b| b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r' | 0x0b | 0x0c | 0x1b)).count();
|
|
controls * 10 <= head.len()
|
|
}
|
|
|
|
/// The text of `bytes`: UTF-8 after an optional byte order mark, with invalid bytes replaced by
|
|
/// U+FFFD (`true` when there were any).
|
|
pub(crate) fn decode(bytes: &[u8]) -> (std::borrow::Cow<'_, str>, bool) {
|
|
let b = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
|
let text = String::from_utf8_lossy(b);
|
|
let invalid = matches!(text, std::borrow::Cow::Owned(_));
|
|
(text, invalid)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|