diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index aa2119e43..f16b86c57 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -28,6 +28,14 @@ use crate::directives; use crate::lex::{Lexer, T, Token, expr_after}; use crate::report::{self, Acc, OutsideId}; +mod declared; +mod features; +mod sig; + +pub(crate) use declared::{Declared, declarations}; +use features::platform_name; +use sig::*; + /// An `#include` line: what it names, its line, and the folders `#includepath` named at that point /// (one list, shared by every line it applies to). #[derive(Clone, Debug)] @@ -84,24 +92,15 @@ pub(crate) struct Scan { pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>, paths: Rc<[String]>, known: &Known, acc: &mut Acc) -> Scan { let mut lexer = Lexer::new(src, acc.limits.nesting); let mut a = Analyzer::new(acc, file, paths, known, declared); - let mut nl = false; + let mut breaks = LineBreaks::default(); for t in lexer.by_ref() { if let Some(code) = t.problem { a.problem(code, t.line); } - match t.kind { - T::Space => {} - // A comment with a line break in it ends a line, as a line break does. - T::Comment => nl |= t.text.contains(['\n', '\r', '\u{2028}', '\u{2029}']), - T::Newline => nl = true, - T::Directive => { - a.directive(&t); - nl = true; - } - _ => { - a.token(Sig::of(&t, nl)); - nl = false; - } + match breaks.read(&t) { + Read::Skip => {} + Read::Directive => a.directive(&t), + Read::Token(sig) => a.token(sig), } } if let Some(line) = lexer.unclosed_xml { @@ -175,203 +174,6 @@ fn unescape_name(text: &str) -> String { out } -/// A significant token: what it is, its text, its line, and whether a line break comes before it. -#[derive(Clone, Copy, Debug)] -struct Sig<'s> { - kind: T, - text: &'s str, - line: usize, - nl: bool, - /// The token ends a File or Folder object: the `)` that ends a call of `File` or `Folder` (with - /// or without `new`) or of `File.openDialog`, `File.saveDialog` or `Folder.selectDialog`, the - /// name in `Folder.temp` and the other places of [`catalog::FOLDER_PLACES`], or the `)` of - /// grouping parentheses that hold only such an object or a variable holding one (`(f)`). - file: bool, - /// A statement may start at the token ([`crate::lex::Token::stmt`]). - stmt: bool, - /// An expression may start at the token ([`crate::lex::Token::expr`]). - expr: bool, - /// The byte offset in the text where the token starts. - start: usize, -} - -impl<'s> Sig<'s> { - /// The significant token `t`, after a line break when `nl` is set. - fn of(t: &Token<'s>, nl: bool) -> Self { - Sig { kind: t.kind, text: t.text, line: t.line, nl, file: false, stmt: t.stmt, expr: t.expr, start: t.start } - } - - fn is(&self, punct: &str) -> bool { - self.kind == T::Punct && self.text == punct - } - - fn word(&self, word: &str) -> bool { - self.kind == T::Ident && self.text == word - } -} - -fn is(s: Option, punct: &str) -> bool { - s.is_some_and(|s| s.is(punct)) -} - -fn word(s: Option, w: &str) -> bool { - s.is_some_and(|s| s.word(w)) -} - -/// A token after which a name is a member, not a variable: `.`, `.@`, `..` and `::`. -fn member_mark(s: Option) -> bool { - s.is_some_and(|s| s.kind == T::Punct && matches!(s.text, "." | ".@" | ".." | "::")) -} - -/// A token after which an expression goes on, also past a line break: a punctuator other than `)`, -/// `]`, `}`, `++`, `--` and `;`, or `in`, `instanceof`, `typeof`, `new`, `delete` or `void`. -fn goes_on_after(s: Option) -> bool { - s.is_some_and(|s| match s.kind { - T::Punct => !matches!(s.text, ")" | "]" | "}" | "++" | "--" | ";"), - T::Ident => matches!(s.text, "in" | "instanceof" | "typeof" | "new" | "delete" | "void"), - _ => false, - }) -} - -/// A token that goes on with the expression on the line before when it starts a line: a -/// punctuator other than `{`, `}`, `;`, `++`, `--`, `!` and `~`, or `in` or `instanceof`. -fn goes_on_with(t: &Sig) -> bool { - match t.kind { - T::Punct => !matches!(t.text, "{" | "}" | ";" | "++" | "--" | "!" | "~"), - T::Ident => matches!(t.text, "in" | "instanceof"), - _ => false, - } -} - -/// `t` is the `in` of `for (var name in …)`, which ends the `var` statement's declarations: it comes -/// right after a name the statement declares (ECMA-262 3rd edition, section 12.6.4). Outside a `for` -/// statement's parentheses, `in` can't stand there. -fn for_in(t: &Sig, h0: Option, h1: Option) -> bool { - t.word("in") && h0.is_some_and(|s| s.kind == T::Ident) && (word(h1, "var") || is(h1, ",")) -} - -/// A token after which the value before it is complete: `;`, `,`, `:` (a conditional expression's -/// colon, ECMA-262 3rd edition, section 11.12), `)`, `]` or `}`, or a token on a new line that -/// doesn't go on with the expression. -fn ends_value(t: &Sig) -> bool { - (t.nl && !goes_on_with(t)) || (t.kind == T::Punct && matches!(t.text, ";" | "," | ":" | ")" | "]" | "}")) -} - -/// Punctuators of later syntax. -const NEWER: &[&str] = &["=>", "...", "?.", "??", "**", "**=", "??=", "&&=", "||="]; - -/// The compound assignment operators (ECMA-262 3rd edition, section 11.13.2): each gives the name a -/// number or a string worked out from its old value. -const COMPOUND: &[&str] = &["*=", "/=", "%=", "+=", "-=", "<<=", ">>=", ">>>=", "&=", "^=", "|="]; - -/// A number in a later form: binary, octal (`0b1`, `0o7`), with separators (`1_000`) or big (`1n`). -fn newer_number(text: &str) -> bool { - let b = text.as_bytes(); - text.contains('_') || (b.len() > 1 && b.last() == Some(&b'n')) || matches!((b.first(), b.get(1)), (Some(b'0'), Some(b'b' | b'B' | b'o' | b'O'))) -} - -/// A string literal's value, its quotes removed and its escapes undone (`\n`, `\xHH`, `\uHHHH`, -/// octal escapes, a backslash before a line break), up to `max` characters. -fn unquote(text: &str, max: usize) -> String { - let mut chars = text.chars(); - let body = match chars.next() { - Some(q @ ('"' | '\'')) => chars.as_str().strip_suffix(q).unwrap_or(chars.as_str()), - _ => text, - }; - let mut out = String::new(); - let mut n = 0; - let mut it = body.chars().peekable(); - while n < max - && let Some(c) = it.next() - { - let c = if c == '\\' { - match it.next() { - None => break, - Some('n') => '\n', - Some('t') => '\t', - Some('r') => '\r', - Some('b') => '\u{8}', - Some('f') => '\u{c}', - Some('v') => '\u{b}', - // A legacy octal escape (ECMA-262 3rd edition, annex B.1.2): up to three digits - // after `\0` to `\3`, two after `\4` to `\7`. - Some(d @ '0'..='7') => { - let mut v = d.to_digit(8).unwrap_or_default(); - for _ in 0..if v <= 3 { 2 } else { 1 } { - match it.peek().and_then(|o| o.to_digit(8)) { - Some(o) => { - v = v * 8 + o; - it.next(); - } - None => break, - } - } - char::from_u32(v).unwrap_or('\u{fffd}') - } - Some(x @ ('x' | 'u')) => { - let digits = if x == 'x' { 2 } else { 4 }; - let mut v = 0u32; - for _ in 0..digits { - match it.peek().and_then(|d| d.to_digit(16)) { - Some(d) => { - v = v * 16 + d; - it.next(); - } - None => break, - } - } - char::from_u32(v).unwrap_or('\u{fffd}') - } - Some('\r') => { - if it.peek() == Some(&'\n') { - it.next(); - } - continue; - } - Some('\n' | '\u{2028}' | '\u{2029}') => continue, - Some(c) => c, - } - } else { - c - }; - out.push(c); - n += 1; - } - out -} - -/// A global name of the platform the report looks for (`$`, `File`, `Socket`, `alert`, `eval`…). -fn platform_name(n: &str) -> bool { - matches!( - n, - catalog::DOLLAR - | catalog::LOCALIZE - | catalog::UNIT_VALUE - | catalog::FILE - | catalog::FOLDER - | catalog::WINDOW - | catalog::BRIDGE_TALK - | catalog::SOCKET - | catalog::EXTERNAL_OBJECT - | "eval" - | "Function" - ) || catalog::NOTIFY.contains(&n) - || catalog::XMP.contains(&n) - || catalog::XML.contains(&n) -} - -/// The feature of a window of type `value` (`"dialog"`, or a resource string that starts with it). -fn window_type(value: &str) -> FeatureId { - let word = value.trim_start().split(|c: char| c.is_whitespace() || c == '{').next().unwrap_or_default(); - if word.eq_ignore_ascii_case("dialog") { - FeatureId::ScriptuiDialog - } else if word.eq_ignore_ascii_case("palette") { - FeatureId::ScriptuiPalette - } else { - FeatureId::ScriptuiWindow - } -} - /// What the token after a few others decides. #[derive(Clone, Copy, Debug, Default)] enum Pending<'s> { @@ -648,202 +450,6 @@ impl Brackets { } } -// ---------- the names declared, read before the analysis ---------- - -/// The names a file's function bodies and its global code declare, read before the analysis. A -/// function's `var` statements and the function declarations directly in its body make the names -/// they declare the function's own before any statement in it runs, and the function declarations -/// directly in the global code do the same for the whole file (ECMA-262 3rd edition, section -/// 10.1.3). A `var` statement in the global code does not change a property the global object -/// already has, such as the host's `alert` (sections 10.1.3, 10.1.5 and 10.2.1). The global code's -/// `var` statements are not read here. A function declaration inside a block, which ECMAScript 3 -/// does not allow (sections 12.1 and 14), makes its name the script's own from where it stands. -#[derive(Default)] -pub(crate) struct Declared<'s> { - /// For each function body, by the offset of its `{` in the text, the names it declares, in the - /// order the bodies start. - bodies: Vec<(usize, Vec<&'s str>)>, - /// The names the function declarations directly in the global code declare. - global: HashSet<&'s str>, -} - -/// A function body [`Declarations`] is reading: the bracket depth inside it, its entry in -/// [`Declared::bodies`], the names it declares so far, and the bracket depth of the `var` statement -/// the body is in, which goes on when the body closes. -struct Body<'s> { - depth: usize, - index: usize, - names: HashSet<&'s str>, - var_depth: Option, -} - -/// The pass that finds [`Declared`]. It follows brackets, function bodies and `var` statements by -/// the rules [`Analyzer`] follows them by, so that both passes find the same function bodies. -struct Declarations<'s, 'a, 'l> { - acc: &'a mut Acc<'l>, - brackets: Brackets, - /// The two significant tokens before this one, the latest first. - h: [Option>; 2], - /// `function` was read at this bracket depth, and its body's `{` is still to come. - function: Option, - /// The bracket depth of the `var` statement being read. - var_depth: Option, - /// `name =` was read, and the first token of the value is next (`true`: after `new`). As in - /// [`Analyzer::assignment`], that token doesn't start or end a `var` statement. - assign: Option, - /// The function bodies being read, the innermost last, up to [`crate::Limits::nesting`]. - bodies: Vec>, - /// The bracket depth inside the outermost function body past that limit being read. The names - /// declared inside it are not kept. - untracked: Option, - out: Declared<'s>, -} - -/// What `src` declares ([`Declared`]), read in one pass over its tokens. -pub(crate) fn declarations<'s>(src: &'s str, acc: &mut Acc) -> Declared<'s> { - let mut lexer = Lexer::new(src, acc.limits.nesting); - let mut d = Declarations { - acc, - brackets: Brackets::default(), - h: [None; 2], - function: None, - var_depth: None, - assign: None, - bodies: vec![], - untracked: None, - out: Declared::default(), - }; - let mut nl = false; - for t in lexer.by_ref() { - match t.kind { - T::Space => {} - T::Comment => nl |= t.text.contains(['\n', '\r', '\u{2028}', '\u{2029}']), - T::Newline => nl = true, - T::Directive => { - d.directive(); - nl = true; - } - _ => { - d.token(Sig::of(&t, nl)); - nl = false; - } - } - } - let out = d.out; - acc.steps = acc.steps.saturating_add(lexer.steps); - out -} - -impl<'s> Declarations<'s, '_, '_> { - fn depth(&self) -> usize { - self.brackets.depth() - } - - fn token(&mut self, t: Sig<'s>) { - let [h0, h1] = self.h; - // As in [`Analyzer::token`], a line break ends a `var` statement when it is outside the - // statement's brackets, and the tokens on either side of it don't go on with the expression. - if t.nl && self.var_depth == Some(self.depth()) && !goes_on_after(h0) && !goes_on_with(&t) { - self.var_depth = None; - } - if t.kind == T::Punct { - self.bracket(&t); - } else if t.kind == T::Ident { - self.declaration(&t); - } - let depth = self.depth(); - if let Some(after_new) = self.assign.take() { - if t.word("new") && !after_new { - self.assign = Some(true); - } - } else { - if t.word("var") { - self.var_depth = Some(depth); - } - if (t.is(";") || for_in(&t, h0, h1)) && self.var_depth == Some(depth) { - self.var_depth = None; - } - if t.is("=") && h0.is_some_and(|s| s.kind == T::Ident) && !member_mark(h1) && !is(h1, "@") { - self.assign = Some(false); - } - } - // `function` that isn't a property's name: its body's `{` comes after its name and - // parameters. - if t.word("function") && !member_mark(h0) && !word(h0, "function") { - self.function = Some(depth); - } - self.h = [Some(t), h0]; - } - - /// An open or close bracket, by the rules of [`Analyzer::bracket`]: the first `{` after - /// `function` at the depth of `function` opens the function's body. - fn bracket(&mut self, t: &Sig<'s>) { - match t.text { - "(" | "[" | "{" => { - let body = t.text == "{" && self.function.take() == Some(self.depth()); - self.brackets.open(t, self.acc.limits.nesting); - if !body { - return; - } - if self.bodies.len() < self.acc.limits.nesting { - let index = self.out.bodies.len(); - self.out.bodies.push((t.start, vec![])); - self.bodies.push(Body { depth: self.depth(), index, names: HashSet::new(), var_depth: self.var_depth.take() }); - } else if self.untracked.is_none() { - self.untracked = Some(self.depth()); - } - } - ")" | "]" | "}" => { - if !self.brackets.close(t) { - return; - } - let depth = self.depth(); - if self.var_depth.is_some_and(|d| depth < d) { - self.var_depth = None; - } - while let Some(body) = self.bodies.pop_if(|b| depth < b.depth) { - self.var_depth = body.var_depth; - } - if self.untracked.is_some_and(|d| depth < d) { - self.untracked = None; - } - } - _ => {} - } - } - - /// The name `t`, when a `var` statement or a function declaration declares it: kept for the - /// innermost function body when the `var` statement is in that body or the declaration is - /// directly in it, and for the global code when the declaration is directly in the global code. - /// Each body keeps up to [`crate::Limits::entries`] names, and so does the global code. - fn declaration(&mut self, t: &Sig<'s>) { - let [h0, _] = self.h; - let depth = self.depth(); - let var = word(h0, "var") || (is(h0, ",") && self.var_depth == Some(depth)); - let function = word(h0, "function") && h0.is_some_and(|f| f.stmt); - if !(var || function) || self.untracked.is_some() { - return; - } - if let Some(body) = self.bodies.last_mut() { - if (var || body.depth == depth) && !body.names.contains(t.text) && self.acc.room(body.names.len()) { - body.names.insert(t.text); - if let Some((_, names)) = self.out.bodies.get_mut(body.index) { - names.push(t.text); - } - } - } else if function && depth == 0 && !self.out.global.contains(t.text) && self.acc.room(self.out.global.len()) { - self.out.global.insert(t.text); - } - } - - /// A preprocessor line ends the statement before it, as in [`Analyzer::directive`]. - fn directive(&mut self) { - self.h = [None; 2]; - self.var_depth = None; - self.assign = None; - } -} - struct Analyzer<'s, 'a, 'l> { acc: &'a mut Acc<'l>, file: Option<&'a str>, @@ -1732,310 +1338,6 @@ impl<'s, 'a, 'l> Analyzer<'s, 'a, 'l> { } } - // ---------- features ---------- - - fn features(&mut self, t: &Sig<'s>) { - let line = self.agg(t.line); - let mut pending = std::mem::take(&mut self.pending); - // A platform name that no `:` follows stands for the platform's object. - if let Pending::Name(n, l) = pending { - pending = Pending::None; - if !t.is(":") { - self.platform(n, l); - pending = std::mem::take(&mut self.pending); - } - } - match pending { - Pending::None | Pending::Name(..) => {} - Pending::Window(l) => { - if t.is("(") { - self.pending = Pending::WindowType(l); - } else { - self.acc.feature(FeatureId::ScriptuiWindow, l); - } - } - Pending::WindowType(l) => { - let feature = if t.kind == T::Str { window_type(&unquote(t.text, 64)) } else { FeatureId::ScriptuiWindow }; - self.acc.feature(feature, l); - } - Pending::Menu(l) => { - if t.kind == T::Str { - self.pending = Pending::MenuString(l, t.text); - } else { - self.menu(None, l); - } - } - Pending::MenuString(l, s) => self.menu(t.is(")").then_some(s), l), - Pending::Open(l) => { - if t.kind == T::Str { - self.open_mode(&unquote(t.text, 2), l); - } - } - } - match t.kind { - T::Ident => self.name(t, line), - T::Punct => self.punct(t, line), - T::Str => self.string(t, line), - T::Xml => self.acc.feature(FeatureId::E4x, line), - _ => {} - } - } - - /// A menu command run with the string literal `literal`, or with another value. - fn menu(&mut self, literal: Option<&str>, line: Option) { - match literal { - Some(s) => { - let value = unquote(s, self.acc.limits.echo.saturating_add(1)); - self.acc.menu_string(&value, line); - } - None => self.acc.menu_dynamic(), - } - self.acc.feature(FeatureId::MenuCommand, line); - } - - fn open_mode(&mut self, mode: &str, line: Option) { - if mode.eq_ignore_ascii_case("r") { - self.acc.feature(FeatureId::FileRead, line); - } else if let Some(m) = catalog::WRITE_MODES.iter().copied().find(|m| m.eq_ignore_ascii_case(mode)) { - self.acc.feature(FeatureId::FileWrite, line); - self.acc.outside(OutsideId::FilesWritten, line, Some(m)); - } - } - - /// What a pending feature waits for that never came. - fn settle_pending(&mut self) { - let mut pending = std::mem::take(&mut self.pending); - if let Pending::Name(n, l) = pending { - self.platform(n, l); - pending = std::mem::take(&mut self.pending); - } - match pending { - Pending::Window(l) | Pending::WindowType(l) => self.acc.feature(FeatureId::ScriptuiWindow, l), - Pending::Menu(l) | Pending::MenuString(l, _) => self.menu(None, l), - Pending::Open(_) | Pending::Name(..) | Pending::None => {} - } - } - - /// The platform name `n`, used at `line` (a tally's line): the feature it is, and what it does - /// outside the document. - fn platform(&mut self, n: &'s str, line: Option) { - match n { - catalog::UNIT_VALUE => self.acc.feature(FeatureId::UnitValue, line), - catalog::FILE | catalog::FOLDER => self.acc.feature(FeatureId::FileObjects, line), - catalog::BRIDGE_TALK => { - self.acc.feature(FeatureId::BridgeTalk, line); - self.acc.outside(OutsideId::OtherApps, line, None); - } - catalog::SOCKET => { - self.acc.feature(FeatureId::Socket, line); - self.acc.outside(OutsideId::Network, line, None); - } - catalog::EXTERNAL_OBJECT => { - self.acc.feature(FeatureId::ExternalObject, line); - self.acc.outside(OutsideId::NativeCode, line, None); - } - catalog::WINDOW => self.pending = Pending::Window(line), - _ if catalog::XMP.contains(&n) => self.acc.feature(FeatureId::Xmp, line), - _ if catalog::XML.contains(&n) => self.acc.feature(FeatureId::Xml, line), - _ => {} - } - } - - fn name(&mut self, t: &Sig<'s>, line: Option) { - let [h0, h1, h2, _] = self.h; - let n = t.text; - if is(h0, ".") { - // A member: of `$`, `File` or `Folder` when they come first and are the platform's. - let own = &self.own_names; - let of = |name: &str| word(h1, name) && !member_mark(h2) && !own.contains(name); - if of(catalog::DOLLAR) { - if catalog::DOLLAR_ENV.contains(&n) { - self.acc.feature(FeatureId::DollarEnv, line); - self.acc.outside(OutsideId::Environment, line, None); - } else if n == catalog::DOLLAR_EVAL_FILE { - self.acc.feature(FeatureId::DollarEvalFile, line); - self.acc.outside(OutsideId::UnseenCode, line, None); - } else if n == catalog::LOCALIZE { - self.acc.feature(FeatureId::Localize, line); - } else { - self.acc.feature(FeatureId::Dollar, line); - } - } else if (of(catalog::FILE) && catalog::FILE_DIALOGS.contains(&n)) || (of(catalog::FOLDER) && catalog::FOLDER_DIALOGS.contains(&n)) { - self.acc.feature(FeatureId::FileDialog, line); - } else if of(catalog::FOLDER) && catalog::FOLDER_PLACES.contains(&n) { - self.acc.feature(FeatureId::FilePlaces, line); - } - if n == catalog::ON_DRAW { - self.acc.feature(FeatureId::ScriptuiDrawing, line); - } - if n == catalog::REFLECT { - self.acc.feature(FeatureId::Reflection, line); - } - return; - } - if member_mark(h0) { - return; - } - if word(h0, "function") { - self.acc.declared(n); - if h0.is_some_and(|f| f.stmt) { - // A function declaration's name is a variable where the function is declared. A - // declaration directly in a function body or in the global code makes it one from the - // body's `{` or from the start of the file ([`Declared`]), and any other declaration - // makes it one from here. - if !self.hoisted(n) { - self.keep_outer(n); - self.unbind(n); - } - } else { - // A function expression's name is a variable inside the function only. - self.expression_name = Some(n.to_string()); - } - return; - } - if n == "catch" { - // Its block's `{` comes after its parameter's parentheses. - self.catch = Some((self.depth(), None)); - } - if n == "function" { - // Its body's `{` comes after its name and parameters. - self.function = Some(self.depth()); - self.expression_name = None; - // `name = function`: the script's own function too. - if is(h0, "=") - && let Some(f) = h1.filter(|s| s.kind == T::Ident) - { - self.acc.declared(f.text); - } - } - if word(h0, "new") && n.starts_with(|c: char| c.is_ascii_uppercase()) && !catalog::CONSTRUCTORS.contains(&n) { - self.acc.type_name(n); - } - // A platform name stands for the platform's object, except where the script declares a - // variable, function or parameter of that name, and where it has a meaning of its own. - // Where a property key or a label may stand, the token after it decides. - if platform_name(n) && !self.declaring() && !self.own_names.contains(n) { - if h0.is_none() || [",", "{", ";", "}"].iter().any(|p| is(h0, p)) { - self.pending = Pending::Name(n, line); - } else { - self.platform(n, line); - } - } - match n { - // E4X: `default xml namespace = …` and `for each (…)`. - "namespace" if word(h0, "xml") && word(h1, "default") => self.acc.feature(FeatureId::E4x, line), - "each" if word(h0, "for") => self.acc.feature(FeatureId::E4x, line), - _ => {} - } - } - - fn punct(&mut self, t: &Sig<'s>, line: Option) { - let [h0, h1, h2, h3] = self.h; - match t.text { - "(" => self.call(line), - // `Name.prototype["+"] = …` defines an operator. - "=" => { - let operator = || h1.filter(|s| s.kind == T::Str).is_some_and(|s| catalog::OPERATORS.contains(&unquote(s.text, 4).as_str())); - if is(h0, "]") && is(h2, "[") && word(h3, "prototype") && operator() { - self.acc.feature(FeatureId::OperatorOverloading, line); - } - } - // E4X: attributes (`x.@id`, `@*`), descendants (`x..item`) and qualified names (`ns::x`). - ".@" | ".." | "::" | "@" => self.acc.feature(FeatureId::E4x, line), - _ => {} - } - } - - /// `(` after a name: a call. - fn call(&mut self, line: Option) { - let [h0, h1, h2, h3] = self.h; - let Some(callee) = h0.filter(|s| s.kind == T::Ident) else { return }; - let n = callee.text; - if is(h1, ".") { - // The receiver is a variable holding a File or Folder object (not a property of the same - // name), or a File or Folder object made in place or held in grouping parentheses - // ([`Sig::file`]). - let file = h2.is_some_and(|r| r.file || (r.kind == T::Ident && !member_mark(h3) && self.files.contains(r.text))); - if n == catalog::MENU_COMMAND { - self.pending = Pending::Menu(line); - } else if n == catalog::OPEN && file { - self.pending = Pending::Open(line); - } else if catalog::DRAWING.contains(&n) { - self.acc.feature(FeatureId::ScriptuiDrawing, line); - } else if catalog::INSTANCE_DIALOGS.contains(&n) { - self.acc.feature(FeatureId::FileDialog, line); - } else if catalog::FOLDER_READS.contains(&n) || (file && catalog::FILE_READS.contains(&n)) { - self.acc.feature(FeatureId::FileRead, line); - } else if file && catalog::FILE_WRITES.contains(&n) { - self.acc.feature(FeatureId::FileWrite, line); - self.acc.outside(OutsideId::FilesWritten, line, None); - } else if n == catalog::EXECUTE { - self.acc.feature(FeatureId::FileExecute, line); - self.acc.outside(OutsideId::FilesOpened, line, None); - } else if catalog::ACTIONS.contains(&n) { - self.acc.feature(FeatureId::Actions, line); - } else if n == catalog::SELECT_TOOL { - self.acc.feature(FeatureId::SelectTool, line); - } - } else if !member_mark(h1) && !word(h1, "function") && !self.own_names.contains(n) { - if catalog::NOTIFY.contains(&n) { - self.acc.feature(FeatureId::Notify, line); - } else if n == catalog::LOCALIZE { - self.acc.feature(FeatureId::Localize, line); - } else if matches!(n, "eval" | "Function") { - self.acc.feature(FeatureId::DynamicCode, line); - self.acc.outside(OutsideId::UnseenCode, line, None); - } - } - } - - fn string(&mut self, t: &Sig<'s>, line: Option) { - let [h0, h1, h2, _] = self.h; - if t.text.contains(catalog::ACTION_TEXT) { - self.acc.feature(FeatureId::Actions, line); - } - let compares = h0.is_some_and(|s| s.kind == T::Punct && matches!(s.text, "==" | "===" | "!=" | "!==")); - if compares && word(h1, "typename") && is(h2, ".") { - let name = unquote(t.text, self.acc.limits.echo.saturating_add(1)); - self.acc.type_name(&name); - } - } - - /// Syntax later than ECMAScript 3: reported once per construct and line. - fn newer(&mut self, t: &Sig<'s>) { - let [h0, h1, ..] = self.h; - let construct: Option<&'s str> = match t.kind { - T::Template => Some("`"), - T::Punct if NEWER.contains(&t.text) => Some(t.text), - T::Number if newer_number(t.text) => Some(t.text), - T::Other if t.text.len() > 1 && t.text.starts_with('#') => Some(t.text), - T::Ident if word(h0, "class") && !member_mark(h1) => Some("class"), - // `let name` declares a variable. ES3 reads `let` as a variable's name in `let = 1`, - // `let.x` and `let in o`. - T::Ident if word(h0, "let") && !t.nl && !member_mark(h1) && !matches!(t.text, "in" | "instanceof") => Some("let"), - T::Ident if t.text == "function" && word(h0, "async") && !t.nl => Some("async"), - _ => None, - }; - if let Some(c) = construct - && self.last_newer != Some((t.line, c)) - { - self.last_newer = Some((t.line, c)); - self.acc.problem(report::NEWER_SYNTAX, Some(t.line), self.file, Some(c)); - } - } - - /// `import` or `export` starting a statement outside any bracket, unless a call, member or - /// property (`import(…)`, `export.x`, `import: 1`) follows: a module. - fn module_check(&mut self, t: &Sig<'s>) { - if std::mem::take(&mut self.module_candidate) && !(t.kind == T::Punct && matches!(t.text, "(" | "." | "=" | ":" | "," | ")")) { - self.module = true; - } - let starts = self.h[0].is_none() || t.nl || is(self.h[0], ";") || is(self.h[0], "}"); - if t.kind == T::Ident && matches!(t.text, "import" | "export") && self.depth() == 0 && !member_mark(self.h[0]) && starts { - self.module_candidate = true; - } - } - // ---------- preprocessor lines ---------- fn directive(&mut self, t: &Token<'s>) { diff --git a/crates/scriptcheck/src/analyze/declared.rs b/crates/scriptcheck/src/analyze/declared.rs new file mode 100644 index 000000000..2f790a347 --- /dev/null +++ b/crates/scriptcheck/src/analyze/declared.rs @@ -0,0 +1,189 @@ +//! The names declared, read before the analysis. + +use super::*; + +/// The names a file's function bodies and its global code declare, read before the analysis. A +/// function's `var` statements and the function declarations directly in its body make the names +/// they declare the function's own before any statement in it runs, and the function declarations +/// directly in the global code do the same for the whole file (ECMA-262 3rd edition, section +/// 10.1.3). A `var` statement in the global code does not change a property the global object +/// already has, such as the host's `alert` (sections 10.1.3, 10.1.5 and 10.2.1). The global code's +/// `var` statements are not read here. A function declaration inside a block, which ECMAScript 3 +/// does not allow (sections 12.1 and 14), makes its name the script's own from where it stands. +#[derive(Default)] +pub(crate) struct Declared<'s> { + /// For each function body, by the offset of its `{` in the text, the names it declares, in the + /// order the bodies start. + pub(super) bodies: Vec<(usize, Vec<&'s str>)>, + /// The names the function declarations directly in the global code declare. + pub(super) global: HashSet<&'s str>, +} + +/// A function body [`Declarations`] is reading: the bracket depth inside it, its entry in +/// [`Declared::bodies`], the names it declares so far, and the bracket depth of the `var` statement +/// the body is in, which goes on when the body closes. +struct Body<'s> { + depth: usize, + index: usize, + names: HashSet<&'s str>, + var_depth: Option, +} + +/// The pass that finds [`Declared`]. It follows brackets, function bodies and `var` statements by +/// the rules [`Analyzer`] follows them by, so that both passes find the same function bodies. +struct Declarations<'s, 'a, 'l> { + acc: &'a mut Acc<'l>, + brackets: Brackets, + /// The two significant tokens before this one, the latest first. + h: [Option>; 2], + /// `function` was read at this bracket depth, and its body's `{` is still to come. + function: Option, + /// The bracket depth of the `var` statement being read. + var_depth: Option, + /// `name =` was read, and the first token of the value is next (`true`: after `new`). As in + /// [`Analyzer::assignment`], that token doesn't start or end a `var` statement. + assign: Option, + /// The function bodies being read, the innermost last, up to [`crate::Limits::nesting`]. + bodies: Vec>, + /// The bracket depth inside the outermost function body past that limit being read. The names + /// declared inside it are not kept. + untracked: Option, + out: Declared<'s>, +} + +/// What `src` declares ([`Declared`]), read in one pass over its tokens. +pub(crate) fn declarations<'s>(src: &'s str, acc: &mut Acc) -> Declared<'s> { + let mut lexer = Lexer::new(src, acc.limits.nesting); + let mut d = Declarations { + acc, + brackets: Brackets::default(), + h: [None; 2], + function: None, + var_depth: None, + assign: None, + bodies: vec![], + untracked: None, + out: Declared::default(), + }; + let mut breaks = LineBreaks::default(); + for t in lexer.by_ref() { + match breaks.read(&t) { + Read::Skip => {} + Read::Directive => d.directive(), + Read::Token(sig) => d.token(sig), + } + } + let out = d.out; + acc.steps = acc.steps.saturating_add(lexer.steps); + out +} + +impl<'s> Declarations<'s, '_, '_> { + fn depth(&self) -> usize { + self.brackets.depth() + } + + fn token(&mut self, t: Sig<'s>) { + let [h0, h1] = self.h; + // As in [`Analyzer::token`], a line break ends a `var` statement when it is outside the + // statement's brackets, and the tokens on either side of it don't go on with the expression. + if t.nl && self.var_depth == Some(self.depth()) && !goes_on_after(h0) && !goes_on_with(&t) { + self.var_depth = None; + } + if t.kind == T::Punct { + self.bracket(&t); + } else if t.kind == T::Ident { + self.declaration(&t); + } + let depth = self.depth(); + if let Some(after_new) = self.assign.take() { + if t.word("new") && !after_new { + self.assign = Some(true); + } + } else { + if t.word("var") { + self.var_depth = Some(depth); + } + if (t.is(";") || for_in(&t, h0, h1)) && self.var_depth == Some(depth) { + self.var_depth = None; + } + if t.is("=") && h0.is_some_and(|s| s.kind == T::Ident) && !member_mark(h1) && !is(h1, "@") { + self.assign = Some(false); + } + } + // `function` that isn't a property's name: its body's `{` comes after its name and + // parameters. + if t.word("function") && !member_mark(h0) && !word(h0, "function") { + self.function = Some(depth); + } + self.h = [Some(t), h0]; + } + + /// An open or close bracket, by the rules of [`Analyzer::bracket`]: the first `{` after + /// `function` at the depth of `function` opens the function's body. + fn bracket(&mut self, t: &Sig<'s>) { + match t.text { + "(" | "[" | "{" => { + let body = t.text == "{" && self.function.take() == Some(self.depth()); + self.brackets.open(t, self.acc.limits.nesting); + if !body { + return; + } + if self.bodies.len() < self.acc.limits.nesting { + let index = self.out.bodies.len(); + self.out.bodies.push((t.start, vec![])); + self.bodies.push(Body { depth: self.depth(), index, names: HashSet::new(), var_depth: self.var_depth.take() }); + } else if self.untracked.is_none() { + self.untracked = Some(self.depth()); + } + } + ")" | "]" | "}" => { + if !self.brackets.close(t) { + return; + } + let depth = self.depth(); + if self.var_depth.is_some_and(|d| depth < d) { + self.var_depth = None; + } + while let Some(body) = self.bodies.pop_if(|b| depth < b.depth) { + self.var_depth = body.var_depth; + } + if self.untracked.is_some_and(|d| depth < d) { + self.untracked = None; + } + } + _ => {} + } + } + + /// The name `t`, when a `var` statement or a function declaration declares it: kept for the + /// innermost function body when the `var` statement is in that body or the declaration is + /// directly in it, and for the global code when the declaration is directly in the global code. + /// Each body keeps up to [`crate::Limits::entries`] names, and so does the global code. + fn declaration(&mut self, t: &Sig<'s>) { + let [h0, _] = self.h; + let depth = self.depth(); + let var = word(h0, "var") || (is(h0, ",") && self.var_depth == Some(depth)); + let function = word(h0, "function") && h0.is_some_and(|f| f.stmt); + if !(var || function) || self.untracked.is_some() { + return; + } + if let Some(body) = self.bodies.last_mut() { + if (var || body.depth == depth) && !body.names.contains(t.text) && self.acc.room(body.names.len()) { + body.names.insert(t.text); + if let Some((_, names)) = self.out.bodies.get_mut(body.index) { + names.push(t.text); + } + } + } else if function && depth == 0 && !self.out.global.contains(t.text) && self.acc.room(self.out.global.len()) { + self.out.global.insert(t.text); + } + } + + /// A preprocessor line ends the statement before it, as in [`Analyzer::directive`]. + fn directive(&mut self) { + self.h = [None; 2]; + self.var_depth = None; + self.assign = None; + } +} diff --git a/crates/scriptcheck/src/analyze/features.rs b/crates/scriptcheck/src/analyze/features.rs new file mode 100644 index 000000000..0a8ba97d8 --- /dev/null +++ b/crates/scriptcheck/src/analyze/features.rs @@ -0,0 +1,339 @@ +//! The platform features, menu commands and later syntax the analysis recognises. + +use super::*; + +/// A global name of the platform the report looks for (`$`, `File`, `Socket`, `alert`, `eval`…). +pub(super) fn platform_name(n: &str) -> bool { + matches!( + n, + catalog::DOLLAR + | catalog::LOCALIZE + | catalog::UNIT_VALUE + | catalog::FILE + | catalog::FOLDER + | catalog::WINDOW + | catalog::BRIDGE_TALK + | catalog::SOCKET + | catalog::EXTERNAL_OBJECT + | "eval" + | "Function" + ) || catalog::NOTIFY.contains(&n) + || catalog::XMP.contains(&n) + || catalog::XML.contains(&n) +} + +/// The feature of a window of type `value` (`"dialog"`, or a resource string that starts with it). +pub(super) fn window_type(value: &str) -> FeatureId { + let word = value.trim_start().split(|c: char| c.is_whitespace() || c == '{').next().unwrap_or_default(); + if word.eq_ignore_ascii_case("dialog") { + FeatureId::ScriptuiDialog + } else if word.eq_ignore_ascii_case("palette") { + FeatureId::ScriptuiPalette + } else { + FeatureId::ScriptuiWindow + } +} + +impl<'s, 'a, 'l> Analyzer<'s, 'a, 'l> { + pub(super) fn features(&mut self, t: &Sig<'s>) { + let line = self.agg(t.line); + let mut pending = std::mem::take(&mut self.pending); + // A platform name that no `:` follows stands for the platform's object. + if let Pending::Name(n, l) = pending { + pending = Pending::None; + if !t.is(":") { + self.platform(n, l); + pending = std::mem::take(&mut self.pending); + } + } + match pending { + Pending::None | Pending::Name(..) => {} + Pending::Window(l) => { + if t.is("(") { + self.pending = Pending::WindowType(l); + } else { + self.acc.feature(FeatureId::ScriptuiWindow, l); + } + } + Pending::WindowType(l) => { + let feature = if t.kind == T::Str { window_type(&unquote(t.text, 64)) } else { FeatureId::ScriptuiWindow }; + self.acc.feature(feature, l); + } + Pending::Menu(l) => { + if t.kind == T::Str { + self.pending = Pending::MenuString(l, t.text); + } else { + self.menu(None, l); + } + } + Pending::MenuString(l, s) => self.menu(t.is(")").then_some(s), l), + Pending::Open(l) => { + if t.kind == T::Str { + self.open_mode(&unquote(t.text, 2), l); + } + } + } + match t.kind { + T::Ident => self.name(t, line), + T::Punct => self.punct(t, line), + T::Str => self.string(t, line), + T::Xml => self.acc.feature(FeatureId::E4x, line), + _ => {} + } + } + + /// A menu command run with the string literal `literal`, or with another value. + fn menu(&mut self, literal: Option<&str>, line: Option) { + match literal { + Some(s) => { + let value = unquote(s, self.acc.limits.echo.saturating_add(1)); + self.acc.menu_string(&value, line); + } + None => self.acc.menu_dynamic(), + } + self.acc.feature(FeatureId::MenuCommand, line); + } + + fn open_mode(&mut self, mode: &str, line: Option) { + if mode.eq_ignore_ascii_case("r") { + self.acc.feature(FeatureId::FileRead, line); + } else if let Some(m) = catalog::WRITE_MODES.iter().copied().find(|m| m.eq_ignore_ascii_case(mode)) { + self.acc.feature(FeatureId::FileWrite, line); + self.acc.outside(OutsideId::FilesWritten, line, Some(m)); + } + } + + /// What a pending feature waits for that never came. + pub(super) fn settle_pending(&mut self) { + let mut pending = std::mem::take(&mut self.pending); + if let Pending::Name(n, l) = pending { + self.platform(n, l); + pending = std::mem::take(&mut self.pending); + } + match pending { + Pending::Window(l) | Pending::WindowType(l) => self.acc.feature(FeatureId::ScriptuiWindow, l), + Pending::Menu(l) | Pending::MenuString(l, _) => self.menu(None, l), + Pending::Open(_) | Pending::Name(..) | Pending::None => {} + } + } + + /// The platform name `n`, used at `line` (a tally's line): the feature it is, and what it does + /// outside the document. + fn platform(&mut self, n: &'s str, line: Option) { + match n { + catalog::UNIT_VALUE => self.acc.feature(FeatureId::UnitValue, line), + catalog::FILE | catalog::FOLDER => self.acc.feature(FeatureId::FileObjects, line), + catalog::BRIDGE_TALK => { + self.acc.feature(FeatureId::BridgeTalk, line); + self.acc.outside(OutsideId::OtherApps, line, None); + } + catalog::SOCKET => { + self.acc.feature(FeatureId::Socket, line); + self.acc.outside(OutsideId::Network, line, None); + } + catalog::EXTERNAL_OBJECT => { + self.acc.feature(FeatureId::ExternalObject, line); + self.acc.outside(OutsideId::NativeCode, line, None); + } + catalog::WINDOW => self.pending = Pending::Window(line), + _ if catalog::XMP.contains(&n) => self.acc.feature(FeatureId::Xmp, line), + _ if catalog::XML.contains(&n) => self.acc.feature(FeatureId::Xml, line), + _ => {} + } + } + + fn name(&mut self, t: &Sig<'s>, line: Option) { + let [h0, h1, h2, _] = self.h; + let n = t.text; + if is(h0, ".") { + // A member: of `$`, `File` or `Folder` when they come first and are the platform's. + let own = &self.own_names; + let of = |name: &str| word(h1, name) && !member_mark(h2) && !own.contains(name); + if of(catalog::DOLLAR) { + if catalog::DOLLAR_ENV.contains(&n) { + self.acc.feature(FeatureId::DollarEnv, line); + self.acc.outside(OutsideId::Environment, line, None); + } else if n == catalog::DOLLAR_EVAL_FILE { + self.acc.feature(FeatureId::DollarEvalFile, line); + self.acc.outside(OutsideId::UnseenCode, line, None); + } else if n == catalog::LOCALIZE { + self.acc.feature(FeatureId::Localize, line); + } else { + self.acc.feature(FeatureId::Dollar, line); + } + } else if (of(catalog::FILE) && catalog::FILE_DIALOGS.contains(&n)) || (of(catalog::FOLDER) && catalog::FOLDER_DIALOGS.contains(&n)) { + self.acc.feature(FeatureId::FileDialog, line); + } else if of(catalog::FOLDER) && catalog::FOLDER_PLACES.contains(&n) { + self.acc.feature(FeatureId::FilePlaces, line); + } + if n == catalog::ON_DRAW { + self.acc.feature(FeatureId::ScriptuiDrawing, line); + } + if n == catalog::REFLECT { + self.acc.feature(FeatureId::Reflection, line); + } + return; + } + if member_mark(h0) { + return; + } + if word(h0, "function") { + self.acc.declared(n); + if h0.is_some_and(|f| f.stmt) { + // A function declaration's name is a variable where the function is declared. A + // declaration directly in a function body or in the global code makes it one from the + // body's `{` or from the start of the file ([`Declared`]), and any other declaration + // makes it one from here. + if !self.hoisted(n) { + self.keep_outer(n); + self.unbind(n); + } + } else { + // A function expression's name is a variable inside the function only. + self.expression_name = Some(n.to_string()); + } + return; + } + if n == "catch" { + // Its block's `{` comes after its parameter's parentheses. + self.catch = Some((self.depth(), None)); + } + if n == "function" { + // Its body's `{` comes after its name and parameters. + self.function = Some(self.depth()); + self.expression_name = None; + // `name = function`: the script's own function too. + if is(h0, "=") + && let Some(f) = h1.filter(|s| s.kind == T::Ident) + { + self.acc.declared(f.text); + } + } + if word(h0, "new") && n.starts_with(|c: char| c.is_ascii_uppercase()) && !catalog::CONSTRUCTORS.contains(&n) { + self.acc.type_name(n); + } + // A platform name stands for the platform's object, except where the script declares a + // variable, function or parameter of that name, and where it has a meaning of its own. + // Where a property key or a label may stand, the token after it decides. + if platform_name(n) && !self.declaring() && !self.own_names.contains(n) { + if h0.is_none() || [",", "{", ";", "}"].iter().any(|p| is(h0, p)) { + self.pending = Pending::Name(n, line); + } else { + self.platform(n, line); + } + } + match n { + // E4X: `default xml namespace = …` and `for each (…)`. + "namespace" if word(h0, "xml") && word(h1, "default") => self.acc.feature(FeatureId::E4x, line), + "each" if word(h0, "for") => self.acc.feature(FeatureId::E4x, line), + _ => {} + } + } + + fn punct(&mut self, t: &Sig<'s>, line: Option) { + let [h0, h1, h2, h3] = self.h; + match t.text { + "(" => self.call(line), + // `Name.prototype["+"] = …` defines an operator. + "=" => { + let operator = || h1.filter(|s| s.kind == T::Str).is_some_and(|s| catalog::OPERATORS.contains(&unquote(s.text, 4).as_str())); + if is(h0, "]") && is(h2, "[") && word(h3, "prototype") && operator() { + self.acc.feature(FeatureId::OperatorOverloading, line); + } + } + // E4X: attributes (`x.@id`, `@*`), descendants (`x..item`) and qualified names (`ns::x`). + ".@" | ".." | "::" | "@" => self.acc.feature(FeatureId::E4x, line), + _ => {} + } + } + + /// `(` after a name: a call. + fn call(&mut self, line: Option) { + let [h0, h1, h2, h3] = self.h; + let Some(callee) = h0.filter(|s| s.kind == T::Ident) else { return }; + let n = callee.text; + if is(h1, ".") { + // The receiver is a variable holding a File or Folder object (not a property of the same + // name), or a File or Folder object made in place or held in grouping parentheses + // ([`Sig::file`]). + let file = h2.is_some_and(|r| r.file || (r.kind == T::Ident && !member_mark(h3) && self.files.contains(r.text))); + if n == catalog::MENU_COMMAND { + self.pending = Pending::Menu(line); + } else if n == catalog::OPEN && file { + self.pending = Pending::Open(line); + } else if catalog::DRAWING.contains(&n) { + self.acc.feature(FeatureId::ScriptuiDrawing, line); + } else if catalog::INSTANCE_DIALOGS.contains(&n) { + self.acc.feature(FeatureId::FileDialog, line); + } else if catalog::FOLDER_READS.contains(&n) || (file && catalog::FILE_READS.contains(&n)) { + self.acc.feature(FeatureId::FileRead, line); + } else if file && catalog::FILE_WRITES.contains(&n) { + self.acc.feature(FeatureId::FileWrite, line); + self.acc.outside(OutsideId::FilesWritten, line, None); + } else if n == catalog::EXECUTE { + self.acc.feature(FeatureId::FileExecute, line); + self.acc.outside(OutsideId::FilesOpened, line, None); + } else if catalog::ACTIONS.contains(&n) { + self.acc.feature(FeatureId::Actions, line); + } else if n == catalog::SELECT_TOOL { + self.acc.feature(FeatureId::SelectTool, line); + } + } else if !member_mark(h1) && !word(h1, "function") && !self.own_names.contains(n) { + if catalog::NOTIFY.contains(&n) { + self.acc.feature(FeatureId::Notify, line); + } else if n == catalog::LOCALIZE { + self.acc.feature(FeatureId::Localize, line); + } else if matches!(n, "eval" | "Function") { + self.acc.feature(FeatureId::DynamicCode, line); + self.acc.outside(OutsideId::UnseenCode, line, None); + } + } + } + + fn string(&mut self, t: &Sig<'s>, line: Option) { + let [h0, h1, h2, _] = self.h; + if t.text.contains(catalog::ACTION_TEXT) { + self.acc.feature(FeatureId::Actions, line); + } + let compares = h0.is_some_and(|s| s.kind == T::Punct && matches!(s.text, "==" | "===" | "!=" | "!==")); + if compares && word(h1, "typename") && is(h2, ".") { + let name = unquote(t.text, self.acc.limits.echo.saturating_add(1)); + self.acc.type_name(&name); + } + } + + /// Syntax later than ECMAScript 3: reported once per construct and line. + pub(super) fn newer(&mut self, t: &Sig<'s>) { + let [h0, h1, ..] = self.h; + let construct: Option<&'s str> = match t.kind { + T::Template => Some("`"), + T::Punct if NEWER.contains(&t.text) => Some(t.text), + T::Number if newer_number(t.text) => Some(t.text), + T::Other if t.text.len() > 1 && t.text.starts_with('#') => Some(t.text), + T::Ident if word(h0, "class") && !member_mark(h1) => Some("class"), + // `let name` declares a variable. ES3 reads `let` as a variable's name in `let = 1`, + // `let.x` and `let in o`. + T::Ident if word(h0, "let") && !t.nl && !member_mark(h1) && !matches!(t.text, "in" | "instanceof") => Some("let"), + T::Ident if t.text == "function" && word(h0, "async") && !t.nl => Some("async"), + _ => None, + }; + if let Some(c) = construct + && self.last_newer != Some((t.line, c)) + { + self.last_newer = Some((t.line, c)); + self.acc.problem(report::NEWER_SYNTAX, Some(t.line), self.file, Some(c)); + } + } + + /// `import` or `export` starting a statement outside any bracket, unless a call, member or + /// property (`import(…)`, `export.x`, `import: 1`) follows: a module. + pub(super) fn module_check(&mut self, t: &Sig<'s>) { + if std::mem::take(&mut self.module_candidate) && !(t.kind == T::Punct && matches!(t.text, "(" | "." | "=" | ":" | "," | ")")) { + self.module = true; + } + let starts = self.h[0].is_none() || t.nl || is(self.h[0], ";") || is(self.h[0], "}"); + if t.kind == T::Ident && matches!(t.text, "import" | "export") && self.depth() == 0 && !member_mark(self.h[0]) && starts { + self.module_candidate = true; + } + } +} diff --git a/crates/scriptcheck/src/analyze/sig.rs b/crates/scriptcheck/src/analyze/sig.rs new file mode 100644 index 000000000..1cde2df0f --- /dev/null +++ b/crates/scriptcheck/src/analyze/sig.rs @@ -0,0 +1,203 @@ +//! The significant tokens the analysis reads, and the checks on them that both passes share. + +use crate::lex::{T, Token}; + +/// A significant token: what it is, its text, its line, and whether a line break comes before it. +#[derive(Clone, Copy, Debug)] +pub(super) struct Sig<'s> { + pub(super) kind: T, + pub(super) text: &'s str, + pub(super) line: usize, + pub(super) nl: bool, + /// The token ends a File or Folder object: the `)` that ends a call of `File` or `Folder` (with + /// or without `new`) or of `File.openDialog`, `File.saveDialog` or `Folder.selectDialog`, the + /// name in `Folder.temp` and the other places of [`catalog::FOLDER_PLACES`], or the `)` of + /// grouping parentheses that hold only such an object or a variable holding one (`(f)`). + pub(super) file: bool, + /// A statement may start at the token ([`crate::lex::Token::stmt`]). + pub(super) stmt: bool, + /// An expression may start at the token ([`crate::lex::Token::expr`]). + pub(super) expr: bool, + /// The byte offset in the text where the token starts. + pub(super) start: usize, +} + +impl<'s> Sig<'s> { + /// The significant token `t`, after a line break when `nl` is set. + pub(super) fn of(t: &Token<'s>, nl: bool) -> Self { + Sig { kind: t.kind, text: t.text, line: t.line, nl, file: false, stmt: t.stmt, expr: t.expr, start: t.start } + } + + pub(super) fn is(&self, punct: &str) -> bool { + self.kind == T::Punct && self.text == punct + } + + pub(super) fn word(&self, word: &str) -> bool { + self.kind == T::Ident && self.text == word + } +} + +/// What a token means to the passes that follow the significant tokens. +pub(super) enum Read<'s> { + /// A blank, or a comment or line break (remembered for the next token). + Skip, + /// A preprocessor line. + Directive, + Token(Sig<'s>), +} + +/// Whether a line break came since the last significant token. +#[derive(Default)] +pub(super) struct LineBreaks(bool); + +impl LineBreaks { + pub(super) fn read<'s>(&mut self, t: &Token<'s>) -> Read<'s> { + match t.kind { + T::Space => Read::Skip, + // A comment with a line break in it ends a line, as a line break does. + T::Comment => { + self.0 |= t.text.contains(['\n', '\r', '\u{2028}', '\u{2029}']); + Read::Skip + } + T::Newline => { + self.0 = true; + Read::Skip + } + T::Directive => { + self.0 = true; + Read::Directive + } + _ => Read::Token(Sig::of(t, std::mem::take(&mut self.0))), + } + } +} + +pub(super) fn is(s: Option, punct: &str) -> bool { + s.is_some_and(|s| s.is(punct)) +} + +pub(super) fn word(s: Option, w: &str) -> bool { + s.is_some_and(|s| s.word(w)) +} + +/// A token after which a name is a member, not a variable: `.`, `.@`, `..` and `::`. +pub(super) fn member_mark(s: Option) -> bool { + s.is_some_and(|s| s.kind == T::Punct && matches!(s.text, "." | ".@" | ".." | "::")) +} + +/// A token after which an expression goes on, also past a line break: a punctuator other than `)`, +/// `]`, `}`, `++`, `--` and `;`, or `in`, `instanceof`, `typeof`, `new`, `delete` or `void`. +pub(super) fn goes_on_after(s: Option) -> bool { + s.is_some_and(|s| match s.kind { + T::Punct => !matches!(s.text, ")" | "]" | "}" | "++" | "--" | ";"), + T::Ident => matches!(s.text, "in" | "instanceof" | "typeof" | "new" | "delete" | "void"), + _ => false, + }) +} + +/// A token that goes on with the expression on the line before when it starts a line: a +/// punctuator other than `{`, `}`, `;`, `++`, `--`, `!` and `~`, or `in` or `instanceof`. +pub(super) fn goes_on_with(t: &Sig) -> bool { + match t.kind { + T::Punct => !matches!(t.text, "{" | "}" | ";" | "++" | "--" | "!" | "~"), + T::Ident => matches!(t.text, "in" | "instanceof"), + _ => false, + } +} + +/// `t` is the `in` of `for (var name in …)`, which ends the `var` statement's declarations: it comes +/// right after a name the statement declares (ECMA-262 3rd edition, section 12.6.4). Outside a `for` +/// statement's parentheses, `in` can't stand there. +pub(super) fn for_in(t: &Sig, h0: Option, h1: Option) -> bool { + t.word("in") && h0.is_some_and(|s| s.kind == T::Ident) && (word(h1, "var") || is(h1, ",")) +} + +/// A token after which the value before it is complete: `;`, `,`, `:` (a conditional expression's +/// colon, ECMA-262 3rd edition, section 11.12), `)`, `]` or `}`, or a token on a new line that +/// doesn't go on with the expression. +pub(super) fn ends_value(t: &Sig) -> bool { + (t.nl && !goes_on_with(t)) || (t.kind == T::Punct && matches!(t.text, ";" | "," | ":" | ")" | "]" | "}")) +} + +/// Punctuators of later syntax. +pub(super) const NEWER: &[&str] = &["=>", "...", "?.", "??", "**", "**=", "??=", "&&=", "||="]; + +/// The compound assignment operators (ECMA-262 3rd edition, section 11.13.2): each gives the name a +/// number or a string worked out from its old value. +pub(super) const COMPOUND: &[&str] = &["*=", "/=", "%=", "+=", "-=", "<<=", ">>=", ">>>=", "&=", "^=", "|="]; + +/// A number in a later form: binary, octal (`0b1`, `0o7`), with separators (`1_000`) or big (`1n`). +pub(super) fn newer_number(text: &str) -> bool { + let b = text.as_bytes(); + text.contains('_') || (b.len() > 1 && b.last() == Some(&b'n')) || matches!((b.first(), b.get(1)), (Some(b'0'), Some(b'b' | b'B' | b'o' | b'O'))) +} + +/// A string literal's value, its quotes removed and its escapes undone (`\n`, `\xHH`, `\uHHHH`, +/// octal escapes, a backslash before a line break), up to `max` characters. +pub(super) fn unquote(text: &str, max: usize) -> String { + let mut chars = text.chars(); + let body = match chars.next() { + Some(q @ ('"' | '\'')) => chars.as_str().strip_suffix(q).unwrap_or(chars.as_str()), + _ => text, + }; + let mut out = String::new(); + let mut n = 0; + let mut it = body.chars().peekable(); + while n < max + && let Some(c) = it.next() + { + let c = if c == '\\' { + match it.next() { + None => break, + Some('n') => '\n', + Some('t') => '\t', + Some('r') => '\r', + Some('b') => '\u{8}', + Some('f') => '\u{c}', + Some('v') => '\u{b}', + // A legacy octal escape (ECMA-262 3rd edition, annex B.1.2): up to three digits + // after `\0` to `\3`, two after `\4` to `\7`. + Some(d @ '0'..='7') => { + let mut v = d.to_digit(8).unwrap_or_default(); + for _ in 0..if v <= 3 { 2 } else { 1 } { + match it.peek().and_then(|o| o.to_digit(8)) { + Some(o) => { + v = v * 8 + o; + it.next(); + } + None => break, + } + } + char::from_u32(v).unwrap_or('\u{fffd}') + } + Some(x @ ('x' | 'u')) => { + let digits = if x == 'x' { 2 } else { 4 }; + let mut v = 0u32; + for _ in 0..digits { + match it.peek().and_then(|d| d.to_digit(16)) { + Some(d) => { + v = v * 16 + d; + it.next(); + } + None => break, + } + } + char::from_u32(v).unwrap_or('\u{fffd}') + } + Some('\r') => { + if it.peek() == Some(&'\n') { + it.next(); + } + continue; + } + Some('\n' | '\u{2028}' | '\u{2029}') => continue, + Some(c) => c, + } + } else { + c + }; + out.push(c); + n += 1; + } + out +}