Files
vectorcraft/crates/js/src/lib.rs
T
dotCooCoo e592e8277b JavaScript: parser for ES5 scripts, with cargo xtask test262
New crate vectorcraft-js reads ECMAScript 5.1 source into a syntax tree
with source positions: a UTF-16 lexer (line and column tracking, automatic
semicolon insertion, the regexp and division goals), preprocessor lines
read as directive tokens, a recursive-descent parser to an arena tree, and
the ES5 early errors, strict mode included. Nesting deeper than
Options::max_depth (200 by default) gives a SyntaxError with its line and
column rather than overflowing the stack. Nothing runs scripts yet.

cargo xtask test262 downloads test262 at a pinned commit, checks its
SHA-256, unpacks it into the gitignored corpus/ folder and runs the ES5
syntax tests. A differential test in the engine compares the lexer with
scriptcheck on line counts, directives and unterminated literals.
2026-11-08 13:17:13 +07:00

150 lines
6.1 KiB
Rust

//! JavaScript for VectorCraft's scripts: an ECMAScript 5.1 lexer and parser
//! (<https://262.ecma-international.org/5.1/>), with the legacy forms of Annex B that older scripts
//! use (octal literals and escapes outside strict mode, function declarations in blocks, the
//! lenient regular expression patterns).
//!
//! [`parse`] reads a script into a [`Program`]: a syntax tree whose expressions, statements and
//! functions live in arenas and carry their source spans. A script that breaks the grammar or one
//! of its early-error rules (section 16: strict mode's restrictions, duplicate parameters in strict
//! code, invalid assignment targets, `break` and `continue` without a target, regular expression
//! literals that are not valid) gives a [`SyntaxError`] with its line and column.
//!
//! With [`Options::preprocessor_lines`], the lexer also reads the preprocessor lines scripts begin
//! with (`#include`, `#includepath`, `#script`, `#strict`, `#target`, `#targetengine`) into
//! [`Directive`]s. They are kept in [`Program::directives`] and never run.
//!
//! The lexer reads the source as UTF-16 code units, as JavaScript does: columns and spans count
//! code units. Nesting is counted: input nested deeper than [`Options::max_depth`] gives a
//! `SyntaxError` ("nesting too deep") instead of exhausting the stack, binary operators, `if` …
//! `else if` chains, labels and prefix operators are read with loops, and the syntax tree is
//! dropped without recursion.
#![forbid(unsafe_code)]
pub mod ast;
mod chars;
mod error;
mod lexer;
mod number;
mod parser;
mod regexp;
mod token;
pub use ast::{
AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property,
PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl,
};
pub use error::{SyntaxError, TOO_DEEP, TOO_LONG};
pub use lexer::{Goal, Lexer};
pub use number::to_string as number_to_string;
pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind};
/// How a script is read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Options {
/// The script is strict mode code from its start, as code passed to a strict `eval` is.
pub strict: bool,
/// Read preprocessor lines (`#target …` and the other directives) into [`Directive`]s. Off,
/// a `#` is an unexpected character, as ECMA-262 has it.
pub preprocessor_lines: bool,
/// How deeply statements, expressions, functions and regular expression groups may nest.
pub max_depth: usize,
/// The most UTF-16 code units a source may hold.
pub max_source_units: usize,
}
impl Default for Options {
fn default() -> Self {
Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 }
}
}
/// The default [`Options::max_depth`]: deeper than scripts nest, and shallow enough for the stack
/// of a wasm build or a secondary thread.
pub const DEFAULT_MAX_DEPTH: usize = 200;
/// Parses `source` as a script.
pub fn parse(source: &str, options: &Options) -> Result<Program, SyntaxError> {
let units = to_utf16(source, options)?;
parse_utf16(&units, options)
}
/// Parses `source`, in UTF-16 code units, as a script.
pub fn parse_utf16(source: &[u16], options: &Options) -> Result<Program, SyntaxError> {
check_length(source.len(), options)?;
parser::Parser::new(source, options, false)?.parse_program().map(|(program, _)| program)
}
/// The tokens of `source`, in order, as the parser reads them: preprocessor lines included, and
/// each `/` read as division or as the start of a regular expression literal as the grammar
/// decides. The source must be a valid script.
pub fn tokenize(source: &str, options: &Options) -> Result<Vec<Token>, SyntaxError> {
let units = to_utf16(source, options)?;
check_length(units.len(), options)?;
parser::Parser::new(&units, options, true)?.parse_program().map(|(_, tokens)| tokens)
}
fn to_utf16(source: &str, options: &Options) -> Result<Vec<u16>, SyntaxError> {
// A source's UTF-16 length is at most its UTF-8 length; count only a source that may be too
// long.
if source.len() > options.max_source_units {
check_length(source.encode_utf16().count(), options)?;
}
Ok(source.encode_utf16().collect())
}
fn check_length(units: usize, options: &Options) -> Result<(), SyntaxError> {
if units > options.max_source_units || u32::try_from(units).is_err() {
return Err(SyntaxError { message: TOO_LONG.into(), line: 1, column: 1, offset: 0 });
}
Ok(())
}
/// Where each line of a source starts, for turning offsets into lines and columns.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct LineTable {
starts: Vec<u32>,
/// The source ends with a line terminator.
ends_with_break: bool,
empty: bool,
}
impl LineTable {
pub(crate) fn new(src: &[u16]) -> Self {
let mut starts = vec![0];
let mut i = 0;
while let Some(&u) = src.get(i) {
i += 1;
let terminator = match u {
0x0D if src.get(i) == Some(&0x0A) => {
i += 1;
true
}
0x0A | 0x0D | 0x2028 | 0x2029 => true,
_ => false,
};
if terminator {
starts.push(u32::try_from(i).unwrap_or(u32::MAX));
}
}
let ends_with_break = src.last().is_some_and(|&u| matches!(u, 0x0A | 0x0D | 0x2028 | 0x2029));
Self { starts, ends_with_break, empty: src.is_empty() }
}
pub(crate) fn line_column(&self, offset: u32) -> (u32, u32) {
let line = self.starts.partition_point(|&s| s <= offset).max(1);
let start = self.starts.get(line - 1).copied().unwrap_or(0);
(u32::try_from(line).unwrap_or(u32::MAX), offset.saturating_sub(start) + 1)
}
pub(crate) fn line_count(&self) -> u32 {
if self.empty {
return 0;
}
let breaks = self.starts.len() - 1;
u32::try_from(breaks + usize::from(!self.ends_with_break)).unwrap_or(u32::MAX)
}
}
#[cfg(test)]
mod tests;