Files
vectorcraft/crates/scriptcheck/src/report.rs
T
dotCooCoo 3374d0778d Scripts: Check Script… reports what a .jsx script needs and does, without running it
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.
2026-10-13 13:36:41 +07:00

418 lines
15 KiB
Rust

//! What a check finds ([`Report`]), and the tallies it is built from ([`Acc`]), each list capped.
use std::collections::{BTreeMap, BTreeSet};
use crate::{Kind, Limits, Need, catalog};
pub(crate) const UNTERMINATED_STRING: &str = "unterminatedString";
pub(crate) const UNTERMINATED_COMMENT: &str = "unterminatedComment";
pub(crate) const UNTERMINATED_REGEXP: &str = "unterminatedRegExp";
pub(crate) const UNTERMINATED_TEMPLATE: &str = "unterminatedTemplate";
pub(crate) const UNTERMINATED_XML: &str = "unterminatedXml";
pub(crate) const UNCLOSED_BRACKET: &str = "unclosedBracket";
pub(crate) const UNEXPECTED_BRACKET: &str = "unexpectedBracket";
pub(crate) const NEWER_SYNTAX: &str = "newerSyntax";
pub(crate) const UNKNOWN_DIRECTIVE: &str = "unknownDirective";
pub(crate) const NOT_UTF8: &str = "notUtf8";
pub(crate) const MIXED_LINE_ENDINGS: &str = "mixedLineEndings";
pub(crate) const TOO_DEEP: &str = "tooDeep";
/// Every problem code a report can list.
pub const PROBLEMS: &[&str] = &[
UNTERMINATED_STRING,
UNTERMINATED_COMMENT,
UNTERMINATED_REGEXP,
UNTERMINATED_TEMPLATE,
UNTERMINATED_XML,
UNCLOSED_BRACKET,
UNEXPECTED_BRACKET,
NEWER_SYNTAX,
UNKNOWN_DIRECTIVE,
NOT_UTF8,
MIXED_LINE_ENDINGS,
TOO_DEEP,
];
/// Every id in [`Report::outside`], in the order the report lists them: files written, moved or
/// deleted; files opened in other apps; network connections; native code; messages to other apps;
/// environment variables; code the checker can't read; the reference app's preferences changed.
pub const OUTSIDE: &[&str] = &["filesWritten", "filesOpened", "network", "nativeCode", "otherApps", "environment", "unseenCode", "preferences"];
/// What [`crate::check`] found in a script. Lists of located things (targets, directives,
/// includes, problems) give a line and, for an included file, its path (`file`); tallies (features,
/// menu strings, outside effects) count the script and its included files together and list the
/// script's own lines.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Report {
pub kind: Kind,
/// What the script needs from the app that runs it, in [`Need`]'s order.
pub needs: Vec<Need>,
/// The script's lines.
pub lines: usize,
/// Each `#target` line's application specifier.
pub targets: Vec<Target>,
pub directives: Vec<Directive>,
pub includes: Vec<Include>,
/// The platform features used, by id ([`crate::FEATURES`]).
pub features: Vec<Feature>,
/// The document-model member chains used, as written (`app.activeDocument.layers[].name`, with
/// `[]` for an index and `()` for a call), a variable assigned from a chain replaced by that
/// chain, and how often each occurs; the most used first.
pub members: Vec<Named>,
/// The type names compared with `.typename` or created with `new`, apart from the language's,
/// the platform's and the script's own functions.
pub types: Vec<Named>,
/// The menu-command strings passed as string literals; the most used first.
pub menu_strings: Vec<MenuString>,
/// Menu-command calls whose argument isn't a string literal.
pub menu_dynamic: usize,
/// What the script does outside the document, in [`OUTSIDE`]'s order.
pub outside: Vec<Outside>,
pub problems: Vec<Problem>,
/// A list of the report, or a table of names the check keeps, reached [`Limits::entries`], and
/// entries were left out.
pub truncated: bool,
/// The steps the check took: the tests check that they grow linearly with the input.
pub(crate) steps: usize,
}
/// A `#target` line's application specifier, split into its parts.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
pub value: String,
pub app: String,
pub instance: Option<String>,
pub version: Option<String>,
pub locale: Option<String>,
pub line: usize,
pub file: Option<String>,
}
/// A preprocessor line of a documented directive.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Directive {
/// The directive (`target`, `include`…).
pub name: &'static str,
pub value: String,
pub line: usize,
/// `#` or `//@`.
pub form: &'static str,
pub file: Option<String>,
}
/// What became of an `#include` line.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IncludeStatus {
/// Read and checked (or checked already, for a file included twice).
Checked,
/// No such file in the script's folder, or in the folders `#includepath` names inside it.
Missing,
/// Not followed: an absolute name, a name longer than 4,096 bytes, a name that leads out of the
/// script's folder, or a file the caller may not read.
NotFollowed,
/// The file it names includes the file the line is in, directly or through other files.
Cycle,
/// Includes weren't read (no loader).
NotRead,
/// Not read: a limit was reached (files, depth, size, paths looked up).
Limit,
/// A compiled file, or not text.
Unreadable,
}
impl IncludeStatus {
pub const ALL: [IncludeStatus; 7] = [
IncludeStatus::Checked,
IncludeStatus::Missing,
IncludeStatus::NotFollowed,
IncludeStatus::Cycle,
IncludeStatus::NotRead,
IncludeStatus::Limit,
IncludeStatus::Unreadable,
];
pub fn id(self) -> &'static str {
match self {
IncludeStatus::Checked => "checked",
IncludeStatus::Missing => "missing",
IncludeStatus::NotFollowed => "notFollowed",
IncludeStatus::Cycle => "cycle",
IncludeStatus::NotRead => "notRead",
IncludeStatus::Limit => "limit",
IncludeStatus::Unreadable => "unreadable",
}
}
}
/// An `#include` line and what became of it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Include {
/// The name as the line writes it.
pub value: String,
pub line: usize,
/// The included file the line is in.
pub file: Option<String>,
pub status: IncludeStatus,
/// The file it names, as a path inside the script's folder, when one was found.
pub path: Option<String>,
}
/// A platform feature: how often it occurs, and the script's first lines with it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Feature {
pub id: &'static str,
pub count: usize,
pub lines: Vec<usize>,
}
/// A name and how often it occurs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Named {
pub name: String,
pub count: usize,
}
/// A menu-command string: how often it is passed, and the script's first lines with it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MenuString {
pub value: String,
pub count: usize,
pub lines: Vec<usize>,
}
/// Something the script does outside the document ([`OUTSIDE`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Outside {
pub id: &'static str,
pub count: usize,
pub lines: Vec<usize>,
/// For `filesWritten`: the open modes used (`w`, `a`, `e`).
pub modes: Vec<&'static str>,
}
/// A problem that would stop the script loading or running as written.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
pub code: &'static str,
pub line: Option<usize>,
pub file: Option<String>,
/// The text concerned, as written (an unknown directive, a construct of later syntax, a
/// bracket).
pub detail: Option<String>,
}
/// A count and the first lines it was seen on.
#[derive(Clone, Debug, Default)]
struct Tally {
count: usize,
lines: Vec<usize>,
}
impl Tally {
fn add(&mut self, line: Option<usize>, max: usize) {
self.count = self.count.saturating_add(1);
if let Some(line) = line
&& self.lines.len() < max
&& self.lines.last() != Some(&line)
{
self.lines.push(line);
}
}
}
/// What a check has found so far, across the script and its included files.
pub(crate) struct Acc<'l> {
pub limits: &'l Limits,
pub steps: usize,
features: BTreeMap<&'static str, Tally>,
outside: BTreeMap<&'static str, (Tally, Vec<&'static str>)>,
menu: BTreeMap<String, Tally>,
menu_dynamic: usize,
members: BTreeMap<String, usize>,
types: BTreeMap<String, usize>,
/// Functions the script declares: `new` of one is no document-model type.
declared: BTreeSet<String>,
targets: Vec<Target>,
directives: Vec<Directive>,
includes: Vec<Include>,
problems: Vec<Problem>,
truncated: bool,
}
impl<'l> Acc<'l> {
pub fn new(limits: &'l Limits) -> Self {
Self {
limits,
steps: 0,
features: BTreeMap::new(),
outside: BTreeMap::new(),
menu: BTreeMap::new(),
menu_dynamic: 0,
members: BTreeMap::new(),
types: BTreeMap::new(),
declared: BTreeSet::new(),
targets: vec![],
directives: vec![],
includes: vec![],
problems: vec![],
truncated: false,
}
}
/// `s` as the report echoes it: at most [`Limits::echo`] characters, the last one `…` when
/// it was cut.
pub fn echo(&self, s: &str) -> String {
echo(s, self.limits.echo)
}
/// Room for one more entry in a list of the report, or in a table of names the check keeps, of
/// `len` entries: false (and the report marked truncated) when it is full.
pub fn room(&mut self, len: usize) -> bool {
let room = len < self.limits.entries;
self.truncated |= !room;
room
}
pub fn feature(&mut self, id: &'static str, line: Option<usize>) {
let max = self.limits.lines;
self.features.entry(id).or_default().add(line, max);
}
pub fn outside(&mut self, id: &'static str, line: Option<usize>, mode: Option<&'static str>) {
let max = self.limits.lines;
let (tally, modes) = self.outside.entry(id).or_default();
tally.add(line, max);
if let Some(m) = mode
&& !modes.contains(&m)
{
modes.push(m);
}
}
pub fn menu_string(&mut self, value: &str, line: Option<usize>) {
let key = self.echo(value);
if !self.menu.contains_key(&key) && !self.room(self.menu.len()) {
return;
}
let max = self.limits.lines;
self.menu.entry(key).or_default().add(line, max);
}
pub fn menu_dynamic(&mut self) {
self.menu_dynamic = self.menu_dynamic.saturating_add(1);
}
pub fn member(&mut self, path: &str) {
if !self.members.contains_key(path) && !self.room(self.members.len()) {
return;
}
let n = self.members.entry(path.to_string()).or_default();
*n = n.saturating_add(1);
}
pub fn type_name(&mut self, name: &str) {
let key = self.echo(name);
if !self.types.contains_key(&key) && !self.room(self.types.len()) {
return;
}
let n = self.types.entry(key).or_default();
*n = n.saturating_add(1);
}
pub fn declared(&mut self, name: &str) {
if self.declared.len() < self.limits.entries {
self.declared.insert(self.echo(name));
}
}
pub fn problem(&mut self, code: &'static str, line: Option<usize>, file: Option<&str>, detail: Option<&str>) {
if self.room(self.problems.len()) {
let detail = detail.map(|d| self.echo(d));
self.problems.push(Problem { code, line, file: file.map(str::to_string), detail });
}
}
pub fn directive(&mut self, name: &'static str, value: &str, line: usize, form: &'static str, file: Option<&str>) {
if self.room(self.directives.len()) {
let value = self.echo(value);
self.directives.push(Directive { name, value, line, form, file: file.map(str::to_string) });
}
}
pub fn target(&mut self, value: &str, line: usize, file: Option<&str>) {
if !self.room(self.targets.len()) {
return;
}
let t = crate::directives::target(value);
let part = |s: Option<&str>| s.map(|s| echo(s, self.limits.echo));
let target = Target {
value: self.echo(value),
app: self.echo(t.app),
instance: part(t.instance),
version: part(t.version),
locale: part(t.locale),
line,
file: file.map(str::to_string),
};
self.targets.push(target);
}
pub fn include(&mut self, value: &str, line: usize, file: Option<&str>, status: IncludeStatus, path: Option<&str>) {
if self.room(self.includes.len()) {
let include = Include { value: self.echo(value), line, file: file.map(str::to_string), status, path: path.map(|p| self.echo(p)) };
self.includes.push(include);
}
}
/// The report for a file of `kind` with `lines` lines.
pub fn finish(self, kind: Kind, lines: usize) -> Report {
let declared = self.declared;
let mut members: Vec<Named> = self.members.into_iter().map(|(name, count)| Named { name, count }).collect();
members.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
let mut types: Vec<Named> =
self.types.into_iter().filter(|(name, _)| !declared.contains(name)).map(|(name, count)| Named { name, count }).collect();
types.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
let mut menu_strings: Vec<MenuString> =
self.menu.into_iter().map(|(value, t)| MenuString { value, count: t.count, lines: t.lines }).collect();
menu_strings.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
let features: Vec<Feature> = self.features.into_iter().map(|(id, t)| Feature { id, count: t.count, lines: t.lines }).collect();
let mut outside: Vec<Outside> =
self.outside.into_iter().map(|(id, (t, modes))| Outside { id, count: t.count, lines: t.lines, modes }).collect();
outside.sort_by_key(|o| OUTSIDE.iter().position(|id| *id == o.id));
let mut needs: BTreeSet<Need> = features.iter().filter_map(|f| catalog::need(f.id)).collect();
if !members.is_empty() || !types.is_empty() {
needs.insert(Need::Dom);
}
Report {
kind,
needs: needs.into_iter().collect(),
lines,
targets: self.targets,
directives: self.directives,
includes: self.includes,
features,
members,
types,
menu_strings,
menu_dynamic: self.menu_dynamic,
outside,
problems: self.problems,
truncated: self.truncated,
steps: self.steps,
}
}
}
/// `s` cut to at most `max` characters, the last one `…` when it was cut.
pub(crate) fn echo(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
None => s.to_string(),
Some(_) => {
let keep = s.char_indices().nth(max.saturating_sub(1)).map_or(0, |(i, _)| i);
let mut out = s.get(..keep).unwrap_or_default().to_string();
out.push('…');
out
}
}
}