mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
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.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
//! The names the checker looks for, each list with the public page it comes from, and the need each
|
||||
//! feature sets. Only names are taken from these pages; no description or example is copied.
|
||||
|
||||
use crate::Need;
|
||||
|
||||
// The preprocessor directives, written `#name` or `//@name`:
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/
|
||||
pub(crate) const DIRECTIVES: &[&str] = &["include", "includepath", "script", "strict", "target", "targetengine"];
|
||||
|
||||
// The `$` object's environment and file functions:
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/
|
||||
pub(crate) const DOLLAR: &str = "$";
|
||||
pub(crate) const DOLLAR_ENV: &[&str] = &["getenv", "setenv"];
|
||||
pub(crate) const DOLLAR_EVAL_FILE: &str = "evalFile";
|
||||
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/
|
||||
pub(crate) const NOTIFY: &[&str] = &["alert", "confirm", "prompt"];
|
||||
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/
|
||||
pub(crate) const LOCALIZE: &str = "localize";
|
||||
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/specifying-measurement-values/
|
||||
pub(crate) const UNIT_VALUE: &str = "UnitValue";
|
||||
|
||||
// File and Folder objects, their functions and their open modes:
|
||||
// https://extendscript.docsforadobe.dev/file-system-access/file-object/
|
||||
// https://extendscript.docsforadobe.dev/file-system-access/folder-object/
|
||||
pub(crate) const FILE: &str = "File";
|
||||
pub(crate) const FOLDER: &str = "Folder";
|
||||
pub(crate) const FILE_READS: &[&str] = &["read", "readch", "readln"];
|
||||
pub(crate) const FOLDER_READS: &[&str] = &["getFiles"];
|
||||
pub(crate) const FILE_WRITES: &[&str] = &["write", "writeln", "remove", "rename", "copy", "createAlias", "create"];
|
||||
pub(crate) const FILE_DIALOGS: &[&str] = &["openDialog", "saveDialog"];
|
||||
pub(crate) const FOLDER_DIALOGS: &[&str] = &["selectDialog"];
|
||||
pub(crate) const INSTANCE_DIALOGS: &[&str] = &["openDlg", "saveDlg", "selectDlg"];
|
||||
pub(crate) const FOLDER_PLACES: &[&str] =
|
||||
&["appData", "appPackage", "commonFiles", "current", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"];
|
||||
pub(crate) const EXECUTE: &str = "execute";
|
||||
pub(crate) const OPEN: &str = "open";
|
||||
/// The open modes that write: write, append and edit.
|
||||
pub(crate) const WRITE_MODES: &[&str] = &["w", "a", "e"];
|
||||
|
||||
// Windows and their types, and drawing:
|
||||
// https://extendscript.docsforadobe.dev/user-interface-tools/window-object/
|
||||
// https://extendscript.docsforadobe.dev/user-interface-tools/resource-specifications/
|
||||
// https://extendscript.docsforadobe.dev/user-interface-tools/graphic-customization-objects/
|
||||
pub(crate) const WINDOW: &str = "Window";
|
||||
pub(crate) const ON_DRAW: &str = "onDraw";
|
||||
pub(crate) const DRAWING: &[&str] = &["newPen", "newBrush"];
|
||||
|
||||
// https://extendscript.docsforadobe.dev/interapplication-communication/bridgetalk-message-object/
|
||||
pub(crate) const BRIDGE_TALK: &str = "BridgeTalk";
|
||||
// https://extendscript.docsforadobe.dev/external-communication/socket-object/
|
||||
pub(crate) const SOCKET: &str = "Socket";
|
||||
// https://extendscript.docsforadobe.dev/integrating-external-libraries/externalobject-object/
|
||||
pub(crate) const EXTERNAL_OBJECT: &str = "ExternalObject";
|
||||
// https://extendscript.docsforadobe.dev/scripting-xmp/xmpscript-object-reference/
|
||||
pub(crate) const XMP: &[&str] = &["XMPMeta", "XMPFile", "XMPUtils", "XMPConst", "XMPDateTime"];
|
||||
// https://extendscript.docsforadobe.dev/integrating-xml/the-xml-object/
|
||||
pub(crate) const XML: &[&str] = &["XML", "XMLList"];
|
||||
// The operators a script can define for its own classes:
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/operator-overloading/
|
||||
pub(crate) const OPERATORS: &[&str] = &["+", "-", "*", "/", "%", "^", "<", "<=", "==", "<<", ">>", ">>>", "&", "|", "===", "~"];
|
||||
// https://extendscript.docsforadobe.dev/extendscript-tools-features/extendscript-reflection-interface/
|
||||
pub(crate) const REFLECT: &str = "reflect";
|
||||
|
||||
// The roots of the document model: https://ai-scripting.docsforadobe.dev/jsobjref/Application/
|
||||
pub(crate) const DOM_ROOTS: [&str; 4] = ["app", "activeDocument", "documents", "selection"];
|
||||
|
||||
// Application methods that page doesn't list, named in public forum posts:
|
||||
// https://community.adobe.com/t5/illustrator-discussions/changes-in-cs6/td-p/4173362 (executeMenuCommand,
|
||||
// doScript, loadAction, unloadAction) and
|
||||
// https://community.adobe.com/t5/illustrator-discussions/what-s-new-in-illustrator-scripting-cc2020/m-p/10773439
|
||||
// (selectTool).
|
||||
pub(crate) const MENU_COMMAND: &str = "executeMenuCommand";
|
||||
pub(crate) const ACTIONS: &[&str] = &["doScript", "loadAction", "unloadAction"];
|
||||
pub(crate) const SELECT_TOOL: &str = "selectTool";
|
||||
/// The application methods above, which the report lists as features, not as member chains.
|
||||
pub(crate) const APP_METHODS: &[&str] = &["executeMenuCommand", "doScript", "loadAction", "unloadAction", "selectTool"];
|
||||
/// Text an action set written out at run time holds.
|
||||
pub(crate) const ACTION_TEXT: &str = "/actionCount";
|
||||
|
||||
/// Constructors of the language (ECMA-262 3rd edition, section 15) and of E4X (ECMA-357 2nd edition,
|
||||
/// section 13), and the platform's own: `new` of these is no document-model type.
|
||||
pub(crate) const CONSTRUCTORS: &[&str] = &[
|
||||
"Object",
|
||||
"Function",
|
||||
"Array",
|
||||
"String",
|
||||
"Boolean",
|
||||
"Number",
|
||||
"Date",
|
||||
"RegExp",
|
||||
"Error",
|
||||
"EvalError",
|
||||
"RangeError",
|
||||
"ReferenceError",
|
||||
"SyntaxError",
|
||||
"TypeError",
|
||||
"URIError",
|
||||
"Namespace",
|
||||
"QName",
|
||||
"XML",
|
||||
"XMLList",
|
||||
"File",
|
||||
"Folder",
|
||||
"Window",
|
||||
"Socket",
|
||||
"BridgeTalk",
|
||||
"ExternalObject",
|
||||
"UnitValue",
|
||||
"XMPMeta",
|
||||
"XMPFile",
|
||||
"XMPDateTime",
|
||||
];
|
||||
|
||||
/// Every feature id a report can list, in the order the report lists them.
|
||||
pub const FEATURES: &[&str] = &[
|
||||
"actions",
|
||||
"bridgeTalk",
|
||||
"directive.include",
|
||||
"directive.other",
|
||||
"directive.target",
|
||||
"directive.targetengine",
|
||||
"dollar",
|
||||
"dollar.env",
|
||||
"dollar.evalFile",
|
||||
"dynamicCode",
|
||||
"e4x",
|
||||
"externalObject",
|
||||
"file.dialog",
|
||||
"file.execute",
|
||||
"file.objects",
|
||||
"file.places",
|
||||
"file.read",
|
||||
"file.write",
|
||||
"hostCheck",
|
||||
"localize",
|
||||
"menuCommand",
|
||||
"notify",
|
||||
"operatorOverloading",
|
||||
"preferences",
|
||||
"reflection",
|
||||
"scriptui.dialog",
|
||||
"scriptui.drawing",
|
||||
"scriptui.palette",
|
||||
"scriptui.window",
|
||||
"selectTool",
|
||||
"socket",
|
||||
"unitValue",
|
||||
"xml",
|
||||
"xmp",
|
||||
];
|
||||
|
||||
/// The need feature `id` sets.
|
||||
pub(crate) fn need(id: &str) -> Option<Need> {
|
||||
Some(match id {
|
||||
"directive.target" | "directive.include" | "directive.other" | "dollar" | "dynamicCode" | "notify" | "localize" | "unitValue"
|
||||
| "preferences" | "hostCheck" => Need::Language,
|
||||
"dollar.env" | "dollar.evalFile" | "file.objects" | "file.read" | "file.write" | "file.dialog" | "file.places" | "file.execute" => {
|
||||
Need::Files
|
||||
}
|
||||
"menuCommand" => Need::MenuCommands,
|
||||
"scriptui.dialog" | "scriptui.window" | "scriptui.drawing" => Need::Dialogs,
|
||||
"directive.targetengine"
|
||||
| "scriptui.palette"
|
||||
| "bridgeTalk"
|
||||
| "socket"
|
||||
| "externalObject"
|
||||
| "xmp"
|
||||
| "xml"
|
||||
| "e4x"
|
||||
| "operatorOverloading"
|
||||
| "reflection"
|
||||
| "actions"
|
||||
| "selectTool" => Need::HostOnly,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Preprocessor lines (`#target acme-12`, `//@include "lib.jsxinc"`): their names and values, the
|
||||
//! parts of a target's application specifier, and the folders an `#includepath` line names.
|
||||
|
||||
use crate::catalog;
|
||||
|
||||
/// A preprocessor line.
|
||||
pub(crate) struct Line<'s> {
|
||||
/// `#` or `//@`.
|
||||
pub form: &'static str,
|
||||
/// The name as written.
|
||||
pub name: &'s str,
|
||||
/// The documented directive the name is (compared without case), if it is one.
|
||||
pub known: Option<&'static str>,
|
||||
/// Its value: the text after the name, without quotes around it, or else without a trailing
|
||||
/// `//` comment and `;`.
|
||||
pub value: &'s str,
|
||||
}
|
||||
|
||||
/// The line `text`, which starts with `#` or `//@`.
|
||||
pub(crate) fn parse(text: &str) -> Line<'_> {
|
||||
let (form, rest) = match text.strip_prefix("//@") {
|
||||
Some(rest) => ("//@", rest),
|
||||
None => ("#", text.strip_prefix('#').unwrap_or(text)),
|
||||
};
|
||||
let end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len());
|
||||
let (name, rest) = (rest.get(..end).unwrap_or(rest), rest.get(end..).unwrap_or_default());
|
||||
let known = catalog::DIRECTIVES.iter().copied().find(|d| d.eq_ignore_ascii_case(name));
|
||||
Line { form, name, known, value: value(rest) }
|
||||
}
|
||||
|
||||
fn value(rest: &str) -> &str {
|
||||
let v = rest.trim();
|
||||
if let Some(quote) = v.chars().next().filter(|c| matches!(c, '"' | '\'')) {
|
||||
let inner = v.get(1..).unwrap_or_default();
|
||||
return inner.find(quote).and_then(|i| inner.get(..i)).unwrap_or(inner);
|
||||
}
|
||||
let v = v.find("//").and_then(|i| v.get(..i)).unwrap_or(v);
|
||||
v.trim_end().trim_end_matches(';').trim_end()
|
||||
}
|
||||
|
||||
/// The parts of an application specifier, `name_instance-version-locale`, every part after the name
|
||||
/// optional, as
|
||||
/// https://extendscript.docsforadobe.dev/interapplication-communication/application-and-namespace-specifiers/
|
||||
/// lays it out.
|
||||
pub(crate) struct Specifier<'s> {
|
||||
pub app: &'s str,
|
||||
pub instance: Option<&'s str>,
|
||||
pub version: Option<&'s str>,
|
||||
pub locale: Option<&'s str>,
|
||||
}
|
||||
|
||||
pub(crate) fn target(value: &str) -> Specifier<'_> {
|
||||
let (head, tail) = match value.split_once('-') {
|
||||
Some((head, tail)) => (head, Some(tail)),
|
||||
None => (value, None),
|
||||
};
|
||||
let (app, instance) = match head.split_once('_') {
|
||||
Some((app, instance)) => (app, Some(instance)),
|
||||
None => (head, None),
|
||||
};
|
||||
let (version, locale) = match tail.map(|t| t.split_once('-').map_or((t, None), |(v, l)| (v, Some(l)))) {
|
||||
Some((version, locale)) => (Some(version), locale),
|
||||
None => (None, None),
|
||||
};
|
||||
fn part(s: Option<&str>) -> Option<&str> {
|
||||
s.filter(|s| !s.is_empty())
|
||||
}
|
||||
Specifier { app, instance: part(instance), version: part(version), locale: part(locale) }
|
||||
}
|
||||
|
||||
/// The most folders an `#includepath` line names that are kept.
|
||||
const MAX_PATHS: usize = 64;
|
||||
|
||||
/// The longest `#include` name or `#includepath` folder followed, in bytes.
|
||||
pub(crate) const MAX_PATH: usize = 4096;
|
||||
|
||||
/// The folders an `#includepath` value names, separated by `;`: the first [`MAX_PATHS`] of those
|
||||
/// no longer than [`MAX_PATH`].
|
||||
pub(crate) fn include_paths(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(';')
|
||||
.map(|p| p.trim().trim_matches(['"', '\'']))
|
||||
.filter(|p| !p.is_empty() && p.len() <= MAX_PATH)
|
||||
.take(MAX_PATHS)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Following `#include` lines: the file each one names, found inside the script's folder and read
|
||||
//! through the caller's [`Loader`].
|
||||
//!
|
||||
//! The preprocessor page (https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)
|
||||
//! says that a name is looked for as given and then in each `#includepath` folder in turn, that a
|
||||
//! name starting with `/` is absolute, and that `JSINCLUDE` is a fallback; it doesn't say which
|
||||
//! folder a relative name starts from. The checker takes a name, and an `#includepath` folder, from
|
||||
//! the folder of the file the line is in. It never follows an absolute name, a name or folder that
|
||||
//! leads out of the script's folder (`..`), a name or folder longer than [`MAX_PATH`] bytes, or
|
||||
//! `JSINCLUDE`. Each file is read once. A queue holds the files still to follow, in the order they
|
||||
//! were read, and nothing recurses: each file is reached first at its smallest depth. At most
|
||||
//! [`crate::Limits::lookups`] paths are looked up. Once every file is read, an
|
||||
//! `#include` line is a cycle when the file it names includes the file the line is in, directly or
|
||||
//! through other files.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::analyze::{self, IncludeRef, Known};
|
||||
use crate::directives::MAX_PATH;
|
||||
use crate::report::{Acc, IncludeStatus};
|
||||
|
||||
/// An included file that was read and is checked: its path inside the script's folder, its text
|
||||
/// (and whether it held bytes that aren't UTF-8), and the `#includepath` folders in force at its
|
||||
/// start.
|
||||
pub(crate) struct ReadFile {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
pub invalid: bool,
|
||||
pub paths: Rc<[String]>,
|
||||
}
|
||||
|
||||
/// What a [`Loader`] found at a path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Loaded {
|
||||
/// The file's first bytes (at most the `max` asked for).
|
||||
Bytes(Vec<u8>),
|
||||
/// No file there, or it can't be read.
|
||||
Missing,
|
||||
/// A file the caller may not read (outside the folders automation may read).
|
||||
Refused,
|
||||
}
|
||||
|
||||
/// Where the files a script includes come from: the folder the script is in.
|
||||
pub trait Loader {
|
||||
/// The first `max` bytes of the file at `path`, a path inside the script's folder with `/`
|
||||
/// between its names (`lib/util.jsxinc`).
|
||||
fn load(&mut self, path: &str, max: usize) -> Loaded;
|
||||
}
|
||||
|
||||
/// Follow the script's `includes`: `script` is its file name and `bytes` its size. Each file read
|
||||
/// is scanned here for its own includes and for what its variables hold, with its tallies left
|
||||
/// out; it starts with what `known` holds, which then gains what the file adds → the files to
|
||||
/// check, in the order they were read.
|
||||
pub(crate) fn follow(
|
||||
acc: &mut Acc,
|
||||
includes: Vec<IncludeRef>,
|
||||
loader: &mut dyn Loader,
|
||||
script: &str,
|
||||
bytes: usize,
|
||||
known: &mut Known,
|
||||
) -> Vec<ReadFile> {
|
||||
/// The `#include` lines of a file that was read, still to follow.
|
||||
struct Work {
|
||||
/// The file the lines are in: its index in `files` (0: the script).
|
||||
file: usize,
|
||||
includes: Vec<IncludeRef>,
|
||||
depth: usize,
|
||||
}
|
||||
/// An `#include` line as the report lists it.
|
||||
struct Row {
|
||||
value: String,
|
||||
line: usize,
|
||||
/// The file the line is in.
|
||||
from: usize,
|
||||
status: IncludeStatus,
|
||||
path: Option<String>,
|
||||
/// The file it names, when that file was read.
|
||||
to: Option<usize>,
|
||||
}
|
||||
let limits = acc.limits;
|
||||
// The files read, by their path inside the script's folder; the script first.
|
||||
let mut files: Vec<String> = vec![script.to_string()];
|
||||
// What became of each path found, and the file read there: a file is read once.
|
||||
let mut read: BTreeMap<String, (IncludeStatus, Option<usize>)> = BTreeMap::from([(script.to_string(), (IncludeStatus::Checked, Some(0)))]);
|
||||
// The files still to follow, in the order they were read: each file is reached first at its
|
||||
// smallest depth.
|
||||
let mut work = VecDeque::from([Work { file: 0, includes, depth: 0 }]);
|
||||
let mut rows: Vec<Row> = vec![];
|
||||
// Which file includes which.
|
||||
let mut edges: BTreeSet<(usize, usize)> = BTreeSet::new();
|
||||
// The bytes read and the included files read, those not checked too, and the paths looked up.
|
||||
let (mut total, mut loaded, mut lookups) = (bytes, 0usize, 0usize);
|
||||
// The files to check.
|
||||
let mut checked: Vec<ReadFile> = vec![];
|
||||
while let Some(w) = work.pop_front() {
|
||||
let folder = if w.file == 0 { String::new() } else { files.get(w.file).map_or("", |f| parent(f)).to_string() };
|
||||
for inc in w.includes {
|
||||
acc.steps = acc.steps.saturating_add(1);
|
||||
let mut status = IncludeStatus::NotFollowed;
|
||||
let (mut found, mut to): (Option<String>, Option<usize>) = (None, None);
|
||||
let mut refused = false;
|
||||
for path in candidates(&folder, &inc.value, &inc.paths) {
|
||||
acc.steps = acc.steps.saturating_add(1);
|
||||
if let Some(&(before, file)) = read.get(&path) {
|
||||
(status, found, to) = (before, Some(path), file);
|
||||
break;
|
||||
}
|
||||
if w.depth + 1 > limits.include_depth || loaded >= limits.includes || total >= limits.total_bytes || lookups >= limits.lookups {
|
||||
(status, found) = (IncludeStatus::Limit, None);
|
||||
break;
|
||||
}
|
||||
lookups += 1;
|
||||
status = IncludeStatus::Missing;
|
||||
// One byte more than a file or the rest of the total may take shows that it is too large.
|
||||
let max = limits.file_bytes.min(limits.total_bytes.saturating_sub(total)).saturating_add(1);
|
||||
let b = match loader.load(&path, max) {
|
||||
Loaded::Bytes(b) => b,
|
||||
Loaded::Missing => continue,
|
||||
Loaded::Refused => {
|
||||
refused = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Every file read counts, also one that is too large, compiled or not text.
|
||||
loaded += 1;
|
||||
total = total.saturating_add(b.len());
|
||||
acc.steps = acc.steps.saturating_add(b.len());
|
||||
status = if b.len() > limits.file_bytes || total > limits.total_bytes {
|
||||
IncludeStatus::Limit
|
||||
} else if crate::is_compiled(&path, &b) || !crate::is_text(&b) {
|
||||
IncludeStatus::Unreadable
|
||||
} else {
|
||||
IncludeStatus::Checked
|
||||
};
|
||||
if status != IncludeStatus::Checked {
|
||||
read.insert(path.clone(), (status, None));
|
||||
found = Some(path);
|
||||
break;
|
||||
}
|
||||
files.push(path.clone());
|
||||
let file = files.len() - 1;
|
||||
read.insert(path.clone(), (status, Some(file)));
|
||||
let (text, invalid) = crate::decode(&b);
|
||||
let mut tallies = Acc::new(limits);
|
||||
let scan = analyze::scan(&text, Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
|
||||
acc.steps = acc.steps.saturating_add(tallies.steps);
|
||||
known.add(scan.known, limits.entries);
|
||||
checked.push(ReadFile { path: path.clone(), text: text.into_owned(), invalid, paths: Rc::clone(&inc.paths) });
|
||||
work.push_back(Work { file, includes: scan.includes, depth: w.depth + 1 });
|
||||
(found, to) = (Some(path), Some(file));
|
||||
break;
|
||||
}
|
||||
if status == IncludeStatus::Missing && refused {
|
||||
status = IncludeStatus::NotFollowed;
|
||||
}
|
||||
if let Some(to) = to {
|
||||
edges.insert((w.file, to));
|
||||
}
|
||||
// One row more than the report keeps marks it truncated.
|
||||
if rows.len() <= limits.entries {
|
||||
rows.push(Row { value: acc.echo(&inc.value), line: inc.line, from: w.file, status, path: found, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
let reach = reachable(files.len(), &edges, acc);
|
||||
for r in rows {
|
||||
let cycle = r.to.and_then(|to| reach.get(to)).and_then(|reached| reached.get(r.from)).copied().unwrap_or(false);
|
||||
let status = if cycle { IncludeStatus::Cycle } else { r.status };
|
||||
let file = files.get(r.from).filter(|_| r.from > 0).map(String::as_str);
|
||||
acc.include(&r.value, r.line, file, status, r.path.as_deref());
|
||||
}
|
||||
checked
|
||||
}
|
||||
|
||||
/// For each of `n` files, the files it includes directly or through other files (itself too),
|
||||
/// with `edges` giving the files each one includes.
|
||||
fn reachable(n: usize, edges: &BTreeSet<(usize, usize)>, acc: &mut Acc) -> Vec<Vec<bool>> {
|
||||
let mut next: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
for &(from, to) in edges {
|
||||
if let Some(v) = next.get_mut(from) {
|
||||
v.push(to);
|
||||
}
|
||||
}
|
||||
(0..n)
|
||||
.map(|start| {
|
||||
let mut seen = vec![false; n];
|
||||
let mut stack = vec![start];
|
||||
while let Some(f) = stack.pop() {
|
||||
acc.steps = acc.steps.saturating_add(1);
|
||||
match seen.get_mut(f) {
|
||||
Some(s) if !*s => *s = true,
|
||||
_ => continue,
|
||||
}
|
||||
stack.extend(next.get(f).into_iter().flatten().copied());
|
||||
}
|
||||
seen
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List the script's `includes` as not read.
|
||||
pub(crate) fn not_read(acc: &mut Acc, includes: Vec<IncludeRef>) {
|
||||
for inc in includes {
|
||||
acc.include(&inc.value, inc.line, None, IncludeStatus::NotRead, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder part of `path` (`lib/a.jsxinc` → `lib`, `a.jsxinc` → ``).
|
||||
fn parent(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or("", |(folder, _)| folder)
|
||||
}
|
||||
|
||||
/// A name the checker doesn't follow: absolute (`/x`, `\x`, `C:x`, `~/x`), naming an environment
|
||||
/// variable (`%APPDATA%`, `$HOME`), or longer than [`MAX_PATH`].
|
||||
fn unfollowed(name: &str) -> bool {
|
||||
name.starts_with(['/', '\\', '~', '$']) || name.contains([':', '%']) || name.len() > MAX_PATH
|
||||
}
|
||||
|
||||
/// `rel` taken from `folder`, both inside the script's folder, as a path from that folder (empty
|
||||
/// for the folder itself): none when it leads out of it.
|
||||
fn walk(folder: &str, rel: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = folder.split('/').filter(|s| !s.is_empty()).collect();
|
||||
for part in rel.split(['/', '\\']) {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
p => parts.push(p),
|
||||
}
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
/// The file `rel` names from `folder`, both inside the script's folder: none when it leads out of
|
||||
/// it, or names the folder itself.
|
||||
fn join(folder: &str, rel: &str) -> Option<String> {
|
||||
walk(folder, rel).filter(|path| !path.is_empty())
|
||||
}
|
||||
|
||||
/// The paths an `#include` of `name` may mean, from `folder`, in the order they are tried and each
|
||||
/// once: the name itself, then the name in each `#includepath` folder. Each is worked out when it
|
||||
/// is tried. Names and folders the checker doesn't follow, and names or folders that lead out of
|
||||
/// the script's folder, give none.
|
||||
fn candidates<'a>(folder: &'a str, name: &'a str, paths: &'a [String]) -> impl Iterator<Item = String> + 'a {
|
||||
let name = Some(name).filter(|n| !unfollowed(n));
|
||||
let first = name.and_then(|n| join(folder, n));
|
||||
let inside = name
|
||||
.into_iter()
|
||||
.flat_map(move |n| paths.iter().filter(|p| !unfollowed(p)).filter_map(move |p| walk(folder, p)).filter_map(move |p| join(&p, n)));
|
||||
let mut tried: Vec<String> = vec![];
|
||||
first.into_iter().chain(inside).filter(move |p| {
|
||||
let new = !tried.contains(p);
|
||||
if new {
|
||||
tried.push(p.clone());
|
||||
}
|
||||
new
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tried(folder: &str, name: &str, paths: &[String]) -> Vec<String> {
|
||||
candidates(folder, name, paths).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_taken_from_the_including_files_folder() {
|
||||
assert_eq!(tried("", "a.jsxinc", &[]), ["a.jsxinc"]);
|
||||
assert_eq!(tried("lib", "b.jsxinc", &[]), ["lib/b.jsxinc"]);
|
||||
assert_eq!(tried("lib/deep", "../c.jsxinc", &[]), ["lib/c.jsxinc"]);
|
||||
assert_eq!(tried("lib", "./x\\y.jsxinc", &[]), ["lib/x/y.jsxinc"]);
|
||||
// Then each include path, inside the folder.
|
||||
let paths = ["shared".to_string(), "/abs".to_string(), "../out".to_string(), "shared".to_string()];
|
||||
assert_eq!(tried("", "u.jsxinc", &paths), ["u.jsxinc", "shared/u.jsxinc"]);
|
||||
// An include path may lead back to the script's own folder.
|
||||
assert_eq!(tried("lib", "shared.jsxinc", &["..".to_string()]), ["lib/shared.jsxinc", "shared.jsxinc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_names_and_names_leading_out_are_not_followed() {
|
||||
for name in
|
||||
["/etc/x.jsx", "\\\\server\\x.jsx", "C:/x.jsx", "~/x.jsx", "%APPDATA%/x.jsx", "$HOME/x.jsx", "../x.jsx", "a/../../x.jsx", ".", "a/.."]
|
||||
{
|
||||
assert!(tried("", name, &[]).is_empty(), "{name}");
|
||||
}
|
||||
assert!(tried("lib", "../../x.jsx", &[]).is_empty());
|
||||
// Names and folders longer than a path can be.
|
||||
let long = "d".repeat(MAX_PATH + 1);
|
||||
assert!(tried("", &long, &[]).is_empty());
|
||||
assert_eq!(tried("", "a.jsxinc", &[long]), ["a.jsxinc"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
//! The lexer: script text into tokens that cover it exactly once, blanks, line breaks and comments
|
||||
//! included. It reads ECMAScript 3 with E4X XML literals, the preprocessor lines (`#name …` at the
|
||||
//! start of a line, and `//@name …` comments there that name a directive), and the later syntax a
|
||||
//! script may hold (template literals, `=>`, `?.`), which the analysis reports. An XML literal's
|
||||
//! markup gives tokens of its own, and the code in its braces gives the same tokens as code anywhere
|
||||
//! else. Each step moves forward through the text, and nothing recurses: an XML literal's elements
|
||||
//! are counted, and the open parentheses, the open braces, the `?` of conditionals at each bracket
|
||||
//! level and the levels of XML literals and their code are kept on stacks capped at the nesting
|
||||
//! limit.
|
||||
|
||||
use crate::catalog;
|
||||
use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE};
|
||||
|
||||
/// What a token is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum T {
|
||||
/// Blanks: spaces, tabs and other white space.
|
||||
Space,
|
||||
/// One line break: LF, CR LF, CR, U+2028 or U+2029.
|
||||
Newline,
|
||||
/// A `//` or `/* */` comment.
|
||||
Comment,
|
||||
/// A preprocessor line: `#name …` at the start of a line, or a `//@name …` comment there that
|
||||
/// names a directive.
|
||||
Directive,
|
||||
Ident,
|
||||
Number,
|
||||
/// A `'…'` or `"…"` string literal.
|
||||
Str,
|
||||
/// A `` `…` `` template literal.
|
||||
Template,
|
||||
RegExp,
|
||||
/// The start of an E4X XML literal (`<a b="1">…</a>`, `<>…</>`, `<!-- … -->`): its markup up to
|
||||
/// its end, or up to and with the `{` of the code in it.
|
||||
Xml,
|
||||
/// More of an XML literal's markup: from the `}` that ends the code in it up to the literal's
|
||||
/// end or the next `{`.
|
||||
XmlMore,
|
||||
Punct,
|
||||
/// A character no token starts with, or `#` and a name away from the start of a line.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A token: what it is, its text and the line it starts on (from 1), with the problem it has.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Token<'s> {
|
||||
pub kind: T,
|
||||
pub text: &'s str,
|
||||
pub line: usize,
|
||||
pub problem: Option<&'static str>,
|
||||
/// A statement may start at the token (a `function` there is a declaration).
|
||||
pub stmt: bool,
|
||||
/// An expression may start at the token (a `/` there starts a regular expression).
|
||||
pub expr: bool,
|
||||
/// The byte offset in the text where the token starts.
|
||||
pub start: usize,
|
||||
}
|
||||
|
||||
/// The kinds of line break the text has used.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct Breaks {
|
||||
pub lf: bool,
|
||||
pub crlf: bool,
|
||||
pub cr: bool,
|
||||
}
|
||||
|
||||
/// Punctuators, longest first: ECMAScript 3's, E4X's (`..`, `.@`, `::`, `@`) and later ones (`=>`,
|
||||
/// `...`, `?.`, `??`, `**` and the assignments built on them), which the analysis reports.
|
||||
const PUNCTS: &[&str] = &[
|
||||
">>>=", "===", "!==", ">>>", "<<=", ">>=", "**=", "...", "&&=", "||=", "??=", "==", "!=", "<=", ">=", "&&", "||", "++", "--", "+=", "-=", "*=",
|
||||
"/=", "%=", "&=", "|=", "^=", "<<", ">>", "=>", "**", "?.", "??", "::", "..", ".@", "{", "}", "(", ")", "[", "]", ";", ",", "<", ">", "+", "-",
|
||||
"*", "/", "%", "&", "|", "^", "!", "~", "?", ":", "=", ".", "@",
|
||||
];
|
||||
|
||||
pub(crate) struct Lexer<'s> {
|
||||
src: &'s str,
|
||||
pos: usize,
|
||||
line: usize,
|
||||
/// Only blanks since the last line break (or the start): a `#` here starts a preprocessor line.
|
||||
line_start: bool,
|
||||
/// An expression may start here: a `/` starts a regular expression and a `<` an XML literal.
|
||||
expr: bool,
|
||||
/// A statement may start here: a `{` here opens a block. A `{` where only an expression may
|
||||
/// start opens an object literal.
|
||||
stmt: bool,
|
||||
/// The last significant token was `return`, `throw`, `break` or `continue`: a line break here
|
||||
/// ends the statement (ECMA-262 3rd edition, section 7.9.1).
|
||||
restricted: bool,
|
||||
/// The last significant token was `.`, `..`, `.@` or `::`: a name here is a property's, also when
|
||||
/// it is a keyword such as `return`.
|
||||
member: bool,
|
||||
/// The last significant token was `if`, `while`, `for`, `with` or the `each` of `for each`: a
|
||||
/// `(` here opens a condition.
|
||||
cond: bool,
|
||||
/// For each `(` still open, whether it opened a condition, up to `nesting` of them; `deeper`
|
||||
/// counts the ones past that.
|
||||
parens: Vec<bool>,
|
||||
deeper: usize,
|
||||
/// For each `{` still open, what it opened, up to `nesting` of them; `deeper_braces` counts the
|
||||
/// ones past that.
|
||||
braces: Vec<Brace>,
|
||||
deeper_braces: usize,
|
||||
/// `function` was read and its body's `{` is still to come: whether the function is an
|
||||
/// expression (it starts where no statement may).
|
||||
function: Option<bool>,
|
||||
/// For the text outside any bracket and each bracket still open, how many `?` of a conditional
|
||||
/// (`? :`) wait for their `:` there, up to `nesting` levels; `deeper_questions` counts the
|
||||
/// levels past that.
|
||||
questions: Vec<usize>,
|
||||
deeper_questions: usize,
|
||||
/// The XML literal being read, its innermost level last: its markup, the code in a `{…}` in
|
||||
/// it, an XML literal in that code, and so on. Empty outside an XML literal.
|
||||
xml: Vec<Level>,
|
||||
/// The line the outermost XML literal being read starts on.
|
||||
xml_line: usize,
|
||||
/// The most open parentheses kept, and the most levels in `xml`.
|
||||
nesting: usize,
|
||||
/// The line of the XML literal the text ended in, when it ended in one.
|
||||
pub unclosed_xml: Option<usize>,
|
||||
pub breaks: Breaks,
|
||||
/// One per character read (and per token): the work done.
|
||||
pub steps: usize,
|
||||
}
|
||||
|
||||
fn is_break(c: char) -> bool {
|
||||
matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
|
||||
}
|
||||
|
||||
fn is_blank(c: char) -> bool {
|
||||
!is_break(c) && (c.is_whitespace() || c == '\u{feff}')
|
||||
}
|
||||
|
||||
fn ident_start(c: char) -> bool {
|
||||
c == '$' || c == '_' || c.is_ascii_alphabetic() || (!c.is_ascii() && c.is_alphabetic())
|
||||
}
|
||||
|
||||
/// A character a name may hold after its first.
|
||||
pub(crate) fn ident_part(c: char) -> bool {
|
||||
ident_start(c) || c.is_ascii_digit() || (!c.is_ascii() && c.is_alphanumeric())
|
||||
}
|
||||
|
||||
/// After `<`, the start of an XML literal: a name, `>` (a list), `!` (a comment or CDATA), `?` (a
|
||||
/// processing instruction) or `{` (a computed name).
|
||||
fn xml_start(c: char) -> bool {
|
||||
c.is_ascii_alphabetic() || matches!(c, '_' | '>' | '!' | '?' | '{')
|
||||
}
|
||||
|
||||
/// Whether an expression may start after a significant token. After a `}`, the brace's kind decides
|
||||
/// it ([`Lexer::brace`]), after an XML literal's markup [`Lexer::markup_token`] sets it, and after a
|
||||
/// property's name, a keyword included ([`Lexer::member`]), none starts.
|
||||
pub(crate) fn expr_after(kind: T, text: &str) -> bool {
|
||||
match kind {
|
||||
T::Ident => matches!(text, "return" | "typeof" | "instanceof" | "in" | "new" | "delete" | "void" | "throw" | "case" | "do" | "else"),
|
||||
T::Number | T::Str | T::Template | T::RegExp => false,
|
||||
T::Punct => !matches!(text, ")" | "]" | "++" | "--"),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `rest`, the text after `//@`, starts with a directive's name.
|
||||
fn names_directive(rest: &str) -> bool {
|
||||
let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len());
|
||||
let name = rest.get(..end).unwrap_or_default();
|
||||
catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Where in its markup an XML literal is read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum At {
|
||||
/// The `<` of a tag, comment, CDATA section or processing instruction.
|
||||
Markup,
|
||||
/// A start tag (`end` false) or an end tag, after its `<`.
|
||||
Tag { end: bool },
|
||||
/// An element's text.
|
||||
Text,
|
||||
}
|
||||
|
||||
/// A level of the XML literal being read.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Level {
|
||||
/// The markup of an XML literal, `open` of its elements open.
|
||||
Xml { open: usize, at: At },
|
||||
/// The code in braces, `braces` of them open.
|
||||
Code { braces: usize },
|
||||
}
|
||||
|
||||
/// What a `{` opened.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Brace {
|
||||
/// A block, or a function declaration's body: a statement may start after its `}`.
|
||||
Block,
|
||||
/// An object literal: a value.
|
||||
Object,
|
||||
/// A function expression's body: statements inside, and a value after its `}`.
|
||||
Function,
|
||||
}
|
||||
|
||||
/// What reading a little more of an XML literal's markup led to.
|
||||
enum Step {
|
||||
/// The markup goes on.
|
||||
On,
|
||||
/// The literal ends: none of its elements is open.
|
||||
Up,
|
||||
/// Code in braces starts at the next character, a `{`.
|
||||
Code,
|
||||
/// The text ended first.
|
||||
End,
|
||||
}
|
||||
|
||||
impl<'s> Lexer<'s> {
|
||||
/// A lexer of `src` that keeps at most `nesting` open parentheses, open braces, bracket levels
|
||||
/// of conditionals and levels of an XML literal ([`crate::Limits::nesting`]).
|
||||
pub fn new(src: &'s str, nesting: usize) -> Self {
|
||||
Self {
|
||||
src,
|
||||
pos: 0,
|
||||
line: 1,
|
||||
line_start: true,
|
||||
expr: true,
|
||||
stmt: true,
|
||||
restricted: false,
|
||||
member: false,
|
||||
cond: false,
|
||||
parens: vec![],
|
||||
deeper: 0,
|
||||
braces: vec![],
|
||||
deeper_braces: 0,
|
||||
function: None,
|
||||
questions: vec![0],
|
||||
deeper_questions: 0,
|
||||
xml: vec![],
|
||||
xml_line: 0,
|
||||
nesting,
|
||||
unclosed_xml: None,
|
||||
breaks: Breaks::default(),
|
||||
steps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines read so far: the line breaks, and one more for a last line without one.
|
||||
pub fn lines(&self) -> usize {
|
||||
let last = self.src.chars().next_back();
|
||||
self.line - usize::from(last.is_none_or(is_break))
|
||||
}
|
||||
|
||||
fn rest(&self) -> &'s str {
|
||||
self.src.get(self.pos..).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.rest().chars().next()
|
||||
}
|
||||
|
||||
fn peek_at(&self, n: usize) -> Option<char> {
|
||||
self.rest().chars().nth(n)
|
||||
}
|
||||
|
||||
fn starts(&self, s: &str) -> bool {
|
||||
self.rest().starts_with(s)
|
||||
}
|
||||
|
||||
fn bump(&mut self, c: char) {
|
||||
self.pos += c.len_utf8();
|
||||
self.steps += 1;
|
||||
}
|
||||
|
||||
/// Read `n` ASCII characters.
|
||||
fn skip(&mut self, n: usize) {
|
||||
self.pos += n;
|
||||
self.steps += 1;
|
||||
}
|
||||
|
||||
/// A line break here, read: false when there is none.
|
||||
fn line_break(&mut self) -> bool {
|
||||
match self.peek() {
|
||||
Some('\n') => {
|
||||
self.pos += 1;
|
||||
self.breaks.lf = true;
|
||||
}
|
||||
Some('\r') if self.starts("\r\n") => {
|
||||
self.pos += 2;
|
||||
self.breaks.crlf = true;
|
||||
}
|
||||
Some('\r') => {
|
||||
self.pos += 1;
|
||||
self.breaks.cr = true;
|
||||
}
|
||||
Some(c @ ('\u{2028}' | '\u{2029}')) => self.pos += c.len_utf8(),
|
||||
_ => return false,
|
||||
}
|
||||
self.line += 1;
|
||||
self.steps += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Up to the next line break.
|
||||
fn until_line_break(&mut self) {
|
||||
while let Some(c) = self.peek().filter(|&c| !is_break(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a name (its first character included): letters, digits, `$`, `_` and `\u`
|
||||
/// escapes.
|
||||
fn name(&mut self) {
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some('\\') if self.peek_at(1) == Some('u') => {
|
||||
self.skip(2);
|
||||
for _ in 0..4 {
|
||||
match self.peek() {
|
||||
Some(h) if h.is_ascii_hexdigit() => self.bump(h),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(c) if ident_part(c) => self.bump(c),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn digits(&mut self, ok: fn(char) -> bool) {
|
||||
while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') {
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// A number: decimal (with a fraction and an exponent), hexadecimal, or the later binary,
|
||||
/// octal and `n` (big integer) forms.
|
||||
fn number(&mut self) {
|
||||
let b = self.rest().as_bytes();
|
||||
match (b.first(), b.get(1)) {
|
||||
(Some(b'0'), Some(b'x' | b'X')) => {
|
||||
self.skip(2);
|
||||
self.digits(|c| c.is_ascii_hexdigit());
|
||||
}
|
||||
(Some(b'0'), Some(b'b' | b'B' | b'o' | b'O')) => {
|
||||
self.skip(2);
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
}
|
||||
_ => {
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
if self.peek() == Some('.') {
|
||||
self.bump('.');
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
}
|
||||
let signed = matches!(self.peek_at(1), Some('+' | '-')) && self.peek_at(2).is_some_and(|c| c.is_ascii_digit());
|
||||
if matches!(self.peek(), Some('e' | 'E')) && (self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) || signed) {
|
||||
self.skip(if signed { 2 } else { 1 });
|
||||
self.digits(|c| c.is_ascii_digit());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.peek() == Some('n') {
|
||||
self.bump('n');
|
||||
}
|
||||
}
|
||||
|
||||
/// A `'…'` or `"…"` string, which ends at its line's end when it isn't closed.
|
||||
fn string(&mut self, quote: char) -> Option<&'static str> {
|
||||
self.bump(quote);
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Some(UNTERMINATED_STRING),
|
||||
Some(c) if c == quote => {
|
||||
self.bump(c);
|
||||
return None;
|
||||
}
|
||||
Some('\\') => {
|
||||
self.bump('\\');
|
||||
// A backslash before a line break carries the string on to the next line.
|
||||
if !self.line_break()
|
||||
&& let Some(c) = self.peek()
|
||||
{
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
Some(c) if is_break(c) => return Some(UNTERMINATED_STRING),
|
||||
Some(c) => self.bump(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn template(&mut self) -> Option<&'static str> {
|
||||
self.bump('`');
|
||||
loop {
|
||||
if self.line_break() {
|
||||
continue;
|
||||
}
|
||||
match self.peek() {
|
||||
None => return Some(UNTERMINATED_TEMPLATE),
|
||||
Some('`') => {
|
||||
self.bump('`');
|
||||
return None;
|
||||
}
|
||||
Some('\\') => {
|
||||
self.bump('\\');
|
||||
if !self.line_break()
|
||||
&& let Some(c) = self.peek()
|
||||
{
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
Some(c) => self.bump(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A regular expression and its flags, which ends at its line's end when it isn't closed.
|
||||
fn regexp(&mut self) -> Option<&'static str> {
|
||||
self.bump('/');
|
||||
let mut class = false;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Some(UNTERMINATED_REGEXP),
|
||||
Some(c) if is_break(c) => return Some(UNTERMINATED_REGEXP),
|
||||
Some('\\') => {
|
||||
self.bump('\\');
|
||||
if let Some(c) = self.peek().filter(|&c| !is_break(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
Some('/') if !class => {
|
||||
self.bump('/');
|
||||
break;
|
||||
}
|
||||
Some(c) => {
|
||||
match c {
|
||||
'[' => class = true,
|
||||
']' => class = false,
|
||||
_ => {}
|
||||
}
|
||||
self.bump(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some(c) = self.peek().filter(|&c| ident_part(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn block_comment(&mut self) -> Option<&'static str> {
|
||||
self.skip(2);
|
||||
loop {
|
||||
if self.starts("*/") {
|
||||
self.skip(2);
|
||||
return None;
|
||||
}
|
||||
if self.line_break() {
|
||||
continue;
|
||||
}
|
||||
match self.peek() {
|
||||
Some(c) => self.bump(c),
|
||||
None => return Some(UNTERMINATED_COMMENT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to and past `end`: false when the text ends first.
|
||||
fn past(&mut self, end: &str) -> bool {
|
||||
loop {
|
||||
if self.starts(end) {
|
||||
self.skip(end.len());
|
||||
return true;
|
||||
}
|
||||
if self.line_break() {
|
||||
continue;
|
||||
}
|
||||
match self.peek() {
|
||||
Some(c) => self.bump(c),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A token of `kind` that starts at `start` on `line`: the markup of the XML literal being
|
||||
/// read, up to the literal's end or up to and with the `{` of the code in it. A `{` whose code
|
||||
/// would take a level past `nesting` is read as markup, and the token has the problem
|
||||
/// [`TOO_DEEP`]. When the text ends first, the literal is left unclosed
|
||||
/// ([`Lexer::unclosed_xml`]). `stmt`: a statement may start at the token; `expr`: an expression
|
||||
/// may start at it.
|
||||
fn markup_token(&mut self, start: usize, line: usize, kind: T, stmt: bool, expr: bool) -> Token<'s> {
|
||||
let mut problem = None;
|
||||
while let Some(&Level::Xml { mut open, mut at }) = self.xml.last() {
|
||||
let step = self.markup(&mut open, &mut at);
|
||||
if let Some(level) = self.xml.last_mut() {
|
||||
*level = Level::Xml { open, at };
|
||||
}
|
||||
match step {
|
||||
Step::On => {}
|
||||
Step::Code if self.xml.len() < self.nesting => {
|
||||
self.bump('{');
|
||||
self.xml.push(Level::Code { braces: 1 });
|
||||
self.open_level();
|
||||
self.expr = true;
|
||||
break;
|
||||
}
|
||||
Step::Code => {
|
||||
self.bump('{');
|
||||
problem = Some(TOO_DEEP);
|
||||
}
|
||||
Step::Up => {
|
||||
// An XML literal is a value: no expression starts after it.
|
||||
self.xml.pop();
|
||||
self.expr = false;
|
||||
break;
|
||||
}
|
||||
Step::End => {
|
||||
self.xml.clear();
|
||||
self.unclosed_xml = Some(self.xml_line);
|
||||
self.expr = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The code in an XML literal's braces is an expression, and so is the literal: no statement
|
||||
// starts after either.
|
||||
self.line_start = false;
|
||||
self.stmt = false;
|
||||
self.cond = false;
|
||||
self.restricted = false;
|
||||
self.member = false;
|
||||
Token { kind, text: self.src.get(start..self.pos).unwrap_or_default(), line, problem, stmt, expr, start }
|
||||
}
|
||||
|
||||
/// A little more of an XML literal's markup, `open` of its elements open and `at` where in it:
|
||||
/// a comment, CDATA section or processing instruction whole, or one character of a tag or of
|
||||
/// an element's text.
|
||||
fn markup(&mut self, open: &mut usize, at: &mut At) -> Step {
|
||||
match *at {
|
||||
At::Markup => {
|
||||
let read = if self.starts("<!--") {
|
||||
self.past("-->")
|
||||
} else if self.starts("<![CDATA[") {
|
||||
self.past("]]>")
|
||||
} else if self.starts("<?") {
|
||||
self.past("?>")
|
||||
} else {
|
||||
let end = self.starts("</");
|
||||
self.skip(if end { 2 } else { 1 });
|
||||
*at = At::Tag { end };
|
||||
return Step::On;
|
||||
};
|
||||
*at = At::Text;
|
||||
match (read, *open) {
|
||||
(false, _) => Step::End,
|
||||
(true, 0) => Step::Up,
|
||||
(true, _) => Step::On,
|
||||
}
|
||||
}
|
||||
At::Tag { end } => {
|
||||
if self.line_break() {
|
||||
return Step::On;
|
||||
}
|
||||
// `>` ends a tag, and `/>` a tag whose element closes itself.
|
||||
let closes_itself = match self.peek() {
|
||||
None => return Step::End,
|
||||
Some('>') => {
|
||||
self.bump('>');
|
||||
false
|
||||
}
|
||||
Some('/') if self.peek_at(1) == Some('>') => {
|
||||
self.skip(2);
|
||||
true
|
||||
}
|
||||
Some(q @ ('"' | '\'')) => {
|
||||
self.bump(q);
|
||||
return if self.past(if q == '"' { "\"" } else { "'" }) { Step::On } else { Step::End };
|
||||
}
|
||||
Some('{') => return Step::Code,
|
||||
Some(c) => {
|
||||
self.bump(c);
|
||||
return Step::On;
|
||||
}
|
||||
};
|
||||
*open = match (end, closes_itself) {
|
||||
(true, _) => open.saturating_sub(1),
|
||||
(false, false) => open.saturating_add(1),
|
||||
(false, true) => *open,
|
||||
};
|
||||
*at = At::Text;
|
||||
if *open == 0 { Step::Up } else { Step::On }
|
||||
}
|
||||
At::Text => {
|
||||
if self.line_break() {
|
||||
return Step::On;
|
||||
}
|
||||
match self.peek() {
|
||||
None => Step::End,
|
||||
Some('<') => {
|
||||
*at = At::Markup;
|
||||
Step::On
|
||||
}
|
||||
Some('{') => Step::Code,
|
||||
Some(c) => {
|
||||
self.bump(c);
|
||||
Step::On
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn punct(&self) -> Option<&'static str> {
|
||||
let rest = self.rest();
|
||||
// `?.` before a digit is `?` and a number (`a ?.5 : 1`).
|
||||
PUNCTS.iter().copied().find(|p| rest.starts_with(p) && !(*p == "?." && rest.as_bytes().get(2).is_some_and(u8::is_ascii_digit)))
|
||||
}
|
||||
|
||||
/// The parentheses after the punctuator `punct`: `(` opens one, a condition when `cond` is set,
|
||||
/// and `)` closes the last one open. True when `punct` closes a condition.
|
||||
fn paren(&mut self, punct: &str) -> bool {
|
||||
match punct {
|
||||
"(" if self.parens.len() < self.nesting => self.parens.push(self.cond),
|
||||
"(" => self.deeper = self.deeper.saturating_add(1),
|
||||
")" if self.deeper > 0 => self.deeper -= 1,
|
||||
")" => return self.parens.pop().unwrap_or(false),
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The braces after the punctuator `punct`: `{` opens one and `}` closes the last one open. For
|
||||
/// `{` and `}`, what the brace opened: the body of the function just read, a block where a
|
||||
/// statement may start or no expression may, else an object literal. A brace past `nesting` is
|
||||
/// taken as a block's when it closes.
|
||||
fn brace(&mut self, punct: &str) -> Option<Brace> {
|
||||
match punct {
|
||||
"{" => {
|
||||
let brace = match self.function.take() {
|
||||
Some(true) => Brace::Function,
|
||||
Some(false) => Brace::Block,
|
||||
None if self.stmt || !self.expr => Brace::Block,
|
||||
None => Brace::Object,
|
||||
};
|
||||
if self.braces.len() < self.nesting {
|
||||
self.braces.push(brace);
|
||||
} else {
|
||||
self.deeper_braces = self.deeper_braces.saturating_add(1);
|
||||
}
|
||||
Some(brace)
|
||||
}
|
||||
"}" if self.deeper_braces > 0 => {
|
||||
self.deeper_braces -= 1;
|
||||
Some(Brace::Block)
|
||||
}
|
||||
"}" => Some(self.braces.pop().unwrap_or(Brace::Block)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A bracket opens: the `?` inside it are counted apart from the ones outside.
|
||||
fn open_level(&mut self) {
|
||||
if self.questions.len() <= self.nesting {
|
||||
self.questions.push(0);
|
||||
} else {
|
||||
self.deeper_questions = self.deeper_questions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The innermost bracket closes.
|
||||
fn close_level(&mut self) {
|
||||
if self.deeper_questions > 0 {
|
||||
self.deeper_questions -= 1;
|
||||
} else if self.questions.len() > 1 {
|
||||
self.questions.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The punctuator `punct` at the innermost level: `?` opens a conditional there, and a `:`
|
||||
/// there while one is open is that conditional's. True for that `:`.
|
||||
fn conditional(&mut self, punct: &str) -> bool {
|
||||
if self.deeper_questions > 0 {
|
||||
return false;
|
||||
}
|
||||
let Some(open) = self.questions.last_mut() else { return false };
|
||||
match punct {
|
||||
"?" => *open = open.saturating_add(1),
|
||||
":" if *open > 0 => {
|
||||
*open -= 1;
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A line break after the last significant token. After the end of an expression a statement
|
||||
/// may start (JavaScript ends the statement at the line break when the next token can't go on
|
||||
/// with it), and after `return`, `throw`, `break` and `continue` a new statement starts.
|
||||
fn line_ended(&mut self) {
|
||||
if std::mem::take(&mut self.restricted) {
|
||||
self.expr = true;
|
||||
self.stmt = true;
|
||||
}
|
||||
self.stmt |= !self.expr;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'s> Iterator for Lexer<'s> {
|
||||
type Item = Token<'s>;
|
||||
|
||||
fn next(&mut self) -> Option<Token<'s>> {
|
||||
let (start, line, stmt, expr) = (self.pos, self.line, self.stmt, self.expr);
|
||||
let Some(c) = self.peek() else {
|
||||
// The text ends in the code of an XML literal.
|
||||
if !self.xml.is_empty() {
|
||||
self.xml.clear();
|
||||
self.unclosed_xml = Some(self.xml_line);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let mut problem = None;
|
||||
let kind = if self.line_break() {
|
||||
T::Newline
|
||||
} else if is_blank(c) {
|
||||
while let Some(c) = self.peek().filter(|&c| is_blank(c)) {
|
||||
self.bump(c);
|
||||
}
|
||||
T::Space
|
||||
} else if self.starts("//") {
|
||||
let directive = self.line_start && self.rest().strip_prefix("//@").is_some_and(names_directive);
|
||||
self.until_line_break();
|
||||
if directive { T::Directive } else { T::Comment }
|
||||
} else if self.starts("/*") {
|
||||
problem = self.block_comment();
|
||||
T::Comment
|
||||
} else if c == '#' && self.line_start {
|
||||
self.until_line_break();
|
||||
T::Directive
|
||||
} else if c == '#' && self.peek_at(1).is_some_and(ident_start) {
|
||||
self.bump('#');
|
||||
self.name();
|
||||
T::Other
|
||||
} else if c == '\'' || c == '"' {
|
||||
problem = self.string(c);
|
||||
T::Str
|
||||
} else if c == '`' {
|
||||
problem = self.template();
|
||||
T::Template
|
||||
} else if c == '/' && self.expr {
|
||||
problem = self.regexp();
|
||||
T::RegExp
|
||||
} else if c == '<' && self.expr && self.peek_at(1).is_some_and(xml_start) {
|
||||
if self.xml.len() < self.nesting {
|
||||
// An XML literal starts, in the script or in the code of another one.
|
||||
if self.xml.is_empty() {
|
||||
self.xml_line = line;
|
||||
}
|
||||
self.xml.push(Level::Xml { open: 0, at: At::Markup });
|
||||
return Some(self.markup_token(start, line, T::Xml, stmt, expr));
|
||||
}
|
||||
// An XML literal more levels deep than are kept: its `<` is read as an operator.
|
||||
self.skip(1);
|
||||
problem = Some(TOO_DEEP);
|
||||
T::Punct
|
||||
} else if c == '}' && matches!(self.xml.last(), Some(Level::Code { braces: 1 })) {
|
||||
// The `}` that ends the code in an XML literal's braces: the literal's markup goes on.
|
||||
self.xml.pop();
|
||||
self.close_level();
|
||||
self.bump('}');
|
||||
return Some(self.markup_token(start, line, T::XmlMore, stmt, expr));
|
||||
} else if ident_start(c) || (c == '\\' && self.peek_at(1) == Some('u')) {
|
||||
self.name();
|
||||
T::Ident
|
||||
} else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) {
|
||||
self.number();
|
||||
T::Number
|
||||
} else if let Some(p) = self.punct() {
|
||||
self.skip(p.len());
|
||||
T::Punct
|
||||
} else {
|
||||
self.bump(c);
|
||||
T::Other
|
||||
};
|
||||
let text = self.src.get(start..self.pos).unwrap_or_default();
|
||||
match kind {
|
||||
T::Space => {}
|
||||
T::Newline => {
|
||||
self.line_start = true;
|
||||
self.line_ended();
|
||||
}
|
||||
// A comment that holds a line break counts as one.
|
||||
T::Comment => {
|
||||
self.line_start = false;
|
||||
if text.contains(is_break) {
|
||||
self.line_ended();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.line_start = false;
|
||||
// A name after `.`, `..`, `.@` or `::` is a property's, also when it is a keyword: no
|
||||
// expression or statement starts after it.
|
||||
let property = kind == T::Ident && self.member;
|
||||
// After the `)` of a condition comes a statement, which may start with a regular
|
||||
// expression or an XML literal.
|
||||
let condition = kind == T::Punct && self.paren(text);
|
||||
// A statement may also start after the `}` of a block. After the `}` of an object
|
||||
// literal or of a function expression's body, `/` divides.
|
||||
let brace = if kind == T::Punct { self.brace(text) } else { None };
|
||||
self.expr = condition
|
||||
|| match (text, brace) {
|
||||
("}", Some(brace)) => brace == Brace::Block,
|
||||
_ => !property && expr_after(kind, text),
|
||||
};
|
||||
if kind == T::Ident && !property && text == "function" {
|
||||
self.function = Some(!self.stmt);
|
||||
}
|
||||
if kind == T::Punct {
|
||||
match text {
|
||||
"(" | "[" | "{" => self.open_level(),
|
||||
")" | "]" | "}" => self.close_level(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ternary = kind == T::Punct && self.conditional(text);
|
||||
// A statement may start inside a block or a function's body, after `;`, `=>`, a
|
||||
// preprocessor line, `else`, `do`, `try` and `finally`, and after the `:` of a label or
|
||||
// a `case` (not a conditional's, and outside an object literal).
|
||||
self.stmt = condition
|
||||
|| match (text, brace) {
|
||||
("{", Some(brace)) => brace != Brace::Object,
|
||||
("}", Some(brace)) => brace == Brace::Block,
|
||||
_ => false,
|
||||
}
|
||||
|| kind == T::Directive
|
||||
|| (kind == T::Punct && matches!(text, ";" | "=>"))
|
||||
|| (kind == T::Punct && text == ":" && !ternary && self.braces.last() != Some(&Brace::Object))
|
||||
|| (kind == T::Ident && !property && matches!(text, "else" | "do" | "try" | "finally"));
|
||||
self.cond = kind == T::Ident && !property && (matches!(text, "if" | "while" | "for" | "with") || (text == "each" && self.cond));
|
||||
self.restricted = kind == T::Ident && !property && matches!(text, "return" | "throw" | "break" | "continue");
|
||||
self.member = kind == T::Punct && matches!(text, "." | ".." | ".@" | "::");
|
||||
// The braces inside the code of an XML literal.
|
||||
if kind == T::Punct
|
||||
&& let Some(Level::Code { braces }) = self.xml.last_mut()
|
||||
{
|
||||
match text {
|
||||
"{" => *braces = braces.saturating_add(1),
|
||||
"}" => *braces = braces.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Token { kind, text, line, problem, stmt, expr, start })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,417 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user