diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 6b4ad8706..8d5396199 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -241,6 +241,33 @@ fn options_are_saved_with_the_preferences() { assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9)); } +/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its +/// arrows changed, in the saved options and the Corner Radius preference too. +#[test] +fn escape_on_a_shape_drag_saves_the_option_it_put_back() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + let drag = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap(); + s.tool_key(ToolKey::Up, Mods::default(), v).unwrap(); + // Saves every option, the one the arrow changed included. + s.set_tool_option("length", &json!(50)); + }; + select(&mut s, "polygon"); + drag(&mut s); + assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7)); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6)); + select(&mut s, "roundedRectangle"); + let radius = s.prefs.corner_radius; + drag(&mut s); + assert_eq!(s.prefs.corner_radius, radius + 1.0); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(s.prefs.corner_radius, radius); +} + /// The Fidelity an Alt smoothing drag uses on a selected path. fn alt_smooth_fidelity(s: &mut Session) -> Option { use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind}; diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index b8a9bafc0..3ca8a66b5 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -169,7 +169,8 @@ //! layers and opacity masks nested too deep, and, on objects written without an art style, live //! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to //! graphic styles on objects not written with them, and every art style on a large canvas, are left -//! out. +//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), +//! counted as the reader counts them, is left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -242,6 +243,11 @@ pub struct AiEditingOutput { /// Write `doc` as the editing data of a `.ai` file: every artboard and layer. pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result { + export_within(doc, o, crate::import::MAX_EDITING_OBJECTS) +} + +/// [`export_ai_editing`] writing at most `max_objects` objects, counted as the reader counts them. +fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Result { let board = doc.artboards.first().ok_or("the document has no artboard")?.rect; if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); @@ -283,6 +289,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Vec<(&'static str, Document)> { + vec![ + ("sample", sample()), + ("unsupported", unsupported()), + ("transparent", transparent()), + ("gradients and patterns", gradients_and_patterns()), + ("images", images_doc()), + ("typed", typed()), + ("variables", variables_doc()), + ("symbols", symbols_document()), + ("styles", styles_document()), + ("painted clips", painted_clips()), + ("blend", blend_doc()), + ] +} + +/// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps. +fn blend_doc() -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let keys = vec![ + Arc::new(square(&mut doc, Rect::new(10.0, 10.0, 12.0, 12.0), Color::gray(0.5))), + Arc::new(square(&mut doc, Rect::new(30.0, 10.0, 32.0, 12.0), Color::gray(0.5))), + ]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() }; + let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }); + doc.insert(Some(layer), usize::MAX, blend).unwrap(); + doc +} + +/// Clipping groups whose clipping paths are filled: a square with a color, and a ring (a path of +/// two subpaths) with a color, with a gradient, and with a color and a gradient stroke. +fn painted_clips() -> Document { + let mut doc = Document::new(260.0, 100.0); + let layer = doc.layers[0].id; + let ring = |x: f64| { + let mut p = shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)); + p.subpaths.extend(shapes::rectangle(Rect::new(x + 5.0, 15.0, x + 15.0, 25.0)).subpaths); + p + }; + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); + let shade = || Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let clips = [ + (10.0, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None), + (70.0, ring(70.0), solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None), + (130.0, ring(130.0), shade(), Paint::None), + (190.0, ring(190.0), solid(Color::rgb(0.0, 1.0, 0.0)), shade()), + ]; + for (x, path, fill, stroke) in clips { + let mut clip = clipping(&mut doc, path); + let width = if matches!(stroke, Paint::None) { 0.0 } else { 2.0 }; + clip.appearance = Appearance::basic(fill, stroke, width).into(); + let inside = square(&mut doc, Rect::new(x - 5.0, 5.0, x + 10.0, 20.0), Color::gray(0.5)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + } + doc +} + +/// The writer counts the objects it writes as the reader counts the objects it reads: with room +/// for as many objects as the reader reads, nothing is left out; with one less, art is left out, +/// with a warning. With room for fewer, the reader never reads more objects than there is room for. +#[test] +fn the_writer_counts_objects_as_the_reader_reads_them() { + let objects = |out: &AiEditingOutput| crate::import::objects_read(&out.program).unwrap(); + let limited = |out: &AiEditingOutput| out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT); + for (name, doc) in counted() { + let all = export_ai_editing(&doc, &opts()).unwrap(); + let read = objects(&all); + assert!(read > 0 && !limited(&all), "{name}: {read} objects, {:?}", all.warnings); + let room = export_within(&doc, &opts(), read).unwrap(); + assert!(!limited(&room), "{name}: nothing is left out with room for {read} objects"); + assert_eq!(room.program, all.program, "{name}"); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + assert!(limited(&out), "{name}: art is left out with room for {max} of {read} objects"); + assert!(objects(&out) <= max, "{name}: {} objects read with room for {max}", objects(&out)); + } + } +} + +/// An opacity mask whose art doesn't all fit within the object limit is left out with its art, +/// and the object it masks is written without it; the art left out leaves room for the objects +/// after it. +#[test] +fn a_mask_whose_art_passes_the_object_limit_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5)); + let a = square(&mut doc, Rect::new(0.0, 0.0, 30.0, 30.0), Color::rgb(1.0, 1.0, 1.0)); + let b = square(&mut doc, Rect::new(30.0, 30.0, 60.0, 60.0), Color::rgb(1.0, 1.0, 1.0)); + let art = group(&mut doc, vec![a, b], false); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + doc.insert(Some(layer), usize::MAX, masked).unwrap(); + let after = square(&mut doc, Rect::new(100.0, 10.0, 140.0, 50.0), Color::gray(0.5)); + doc.insert(Some(layer), usize::MAX, after).unwrap(); + let text = |max: usize| { + let out = export_within(&doc, &opts(), max).unwrap(); + let limited = out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT); + (String::from_utf8_lossy(&out.program).replace("\r\n", "\n"), limited) + }; + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let (all, limited) = text(read); + assert!(!limited && all.contains("(AI9_Mask)"), "{all}"); + let has_mask = |max: usize| text(max).0.contains("(AI9_Mask)"); + let Some(fit) = (0..=read).find(|&max| has_mask(max)) else { panic!("the mask fits with room for {read} objects") }; + assert!(fit > 2, "the mask needs room beyond the two squares: {fit}"); + for max in fit..=read { + assert!(has_mask(max), "room for {max} of {read}, the mask fits in {fit}"); + } + let (short, limited) = text(fit - 1); + assert!(limited); + assert!(!short.contains("(AI9_Mask)") && !short.contains("%_/ArtDictionary :\n%_/Mask"), "{short}"); + assert_eq!(short.matches("\nf\n").count(), 2, "both squares are written: {short}"); +} + +/// A symbol's definition is written only when there is room for its instance too. +#[test] +fn a_symbol_definition_is_written_only_with_room_for_its_instance() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let a = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::gray(0.5)); + let b = square(&mut doc, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5)); + let art = group(&mut doc, vec![a, b], false); + doc.symbols.push(Symbol { dynamic: false, name: "Pair".into(), art: Arc::new(art) }); + let placed = instance(&mut doc, "Pair", Affine::translate((50.0, 10.0))); + doc.insert(Some(layer), usize::MAX, placed).unwrap(); + add_rect(&mut doc, Rect::new(100.0, 10.0, 110.0, 20.0)); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let all = String::from_utf8_lossy(&program).replace("\r\n", "\n"); + assert!(all.contains("%AI14_BeginSymbol") && all.contains("/SymbolInstance :"), "{all}"); + let read = crate::import::objects_read(&program).unwrap(); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + let defined = text.contains("%AI14_BeginSymbol"); + assert_eq!(defined, text.contains("/SymbolInstance :"), "room for {max} of {read}: {text}"); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "art is left out with room for {max} of {read} objects"); + let objects = crate::import::objects_read(&out.program).unwrap(); + assert!(objects <= max, "{objects} objects read with room for {max}"); + } +} + +/// The writer's limit is the reader's, which its warning names. +#[test] +fn the_object_limit_is_the_readers() { + assert_eq!(crate::import::MAX_EDITING_OBJECTS, 4_000_000); + assert!(writer::OBJECT_LIMIT.contains("4,000,000")); +} + +/// Paths past the object limit are left out of a layer of many paths, with a warning: the paths +/// that fit are written. +#[test] +fn paths_past_the_object_limit_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + for i in 0..10 { + add_rect(&mut doc, Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0)); + } + let out = export_within(&doc, &opts(), 6).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert_eq!(crate::import::objects_read(&out.program).unwrap(), 6); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert_eq!(text.matches("\nf\n").count(), 6, "the first six paths are written"); +} + /// On a canvas of the usual size, nested symbols whose instances multiply, written as their art in /// art cut to a clipping object, stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art /// in all, with a warning. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 8dd98415f..0c3587e58 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -62,6 +62,7 @@ const GROUPED_LAYER: &str = pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches of more than 256 stops, and pattern swatches whose patterns the data can't define, are left out of the editing data for other apps"; pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; +pub(super) const OBJECT_LIMIT: &str = "art past 4,000,000 objects in all is left out of the editing data for other apps"; const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; /// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]), @@ -117,6 +118,16 @@ fn blend_code(b: BlendMode) -> u8 { } } +/// What the writer had charged when a part of the art started ([`Writer::charges`]). +#[derive(Clone, Copy)] +struct Charges { + objects: usize, + symbol_nodes: u64, + kept_objects: usize, + kept_symbol_nodes: u64, + left_out: usize, +} + pub(super) struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -144,6 +155,18 @@ pub(super) struct Writer<'a> { brushes: Option>, /// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`]. symbol_nodes: u64, + /// The objects written so far, counted as the reader counts the objects it reads. + objects: usize, + /// How many times art was left out for [`Self::max_objects`]. + left_out: usize, + /// The objects of the pattern and symbol definitions written so far, which stay written when + /// the art that first used them is not ([`Self::give_back`]). + definition_objects: usize, + /// The objects of symbol art in those definitions ([`Self::symbol_nodes`]). + definition_symbol_nodes: u64, + /// The most objects written ([`Self::take_objects`]): the most the reader reads, + /// [`crate::import::MAX_EDITING_OBJECTS`]. + pub(super) max_objects: usize, /// The document → the space gradients' matrices (`Xm`, `Bm`, `Bc`) and focal points are /// written in: art space, and in a pattern's definition [`Self::tile_grad_space`]. grad_space: Affine, @@ -286,6 +309,85 @@ impl<'a> Writer<'a> { styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, + objects: 0, + left_out: 0, + definition_objects: 0, + definition_symbol_nodes: 0, + max_objects: crate::import::MAX_EDITING_OBJECTS, + } + } + + /// Whether `count` more objects may be written: the objects written so far, counted as the + /// reader counts them ([`object_count`]), stay within [`Self::max_objects`]. Past it the art is + /// left out, with a warning. + fn take_objects(&mut self, count: usize) -> bool { + let total = self.objects.saturating_add(count); + if total > self.max_objects { + self.warn(OBJECT_LIMIT); + self.left_out += 1; + return false; + } + self.objects = total; + true + } + + /// Start a group with `op` (`u`, or `q` for a clipping group), counted as one object + /// ([`Self::take_objects`]). False, writing nothing, when there is no room for it. + fn open_group(&mut self, op: &str) -> bool { + if !self.take_objects(1) { + return false; + } + self.line(op); + true + } + + /// Whether `n`, an instance of `symbol` placed by `xf`, is written as a live instance + /// ([`Self::live_instance`]). The instance is one object, without the art of its definition, + /// and room for it is kept before the definition is written. + fn live_instance_in_room(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool { + if !self.take_objects(1) { + return false; + } + if self.live_instance(n, symbol, xf) { + return true; + } + self.objects -= 1; + false + } + + /// What has been charged so far, for [`Self::keep_since`] and [`Self::give_back`]. + fn charges(&self) -> Charges { + Charges { + objects: self.objects, + symbol_nodes: self.symbol_nodes, + kept_objects: self.definition_objects, + kept_symbol_nodes: self.definition_symbol_nodes, + left_out: self.left_out, + } + } + + /// Whether art was left out for [`Self::max_objects`] since `c`. + fn left_out_since(&self, c: Charges) -> bool { + self.left_out != c.left_out + } + + /// A definition that started at `c` is written: what was charged since stays charged when the + /// art that first used it is given back ([`Self::give_back`]). + fn keep_since(&mut self, c: Charges) { + self.definition_objects = c.kept_objects + (self.objects - c.objects); + self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes); + } + + /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are + /// given back, except those of the definitions written since, and the images `lines` marks + /// are emptied (the others keep their indices). + fn give_back(&mut self, c: Charges, lines: &str) { + self.objects = c.objects + (self.definition_objects - c.kept_objects); + self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + for index in lines.split(PAYLOAD).skip(1).step_by(2) { + if let Some(p) = index.parse::().ok().and_then(|i| self.payloads.get_mut(i)) { + *p = Vec::new(); + } } } @@ -510,19 +612,22 @@ impl<'a> Writer<'a> { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), NodeKind::Group { children, clip: false } => { - self.line("u"); - for c in children { - self.node(c); + if self.open_group("u") { + for c in children { + self.node(c); + } + self.end_group("U", xy); } - self.end_group("U", xy); } NodeKind::Path { path, rule, guide, .. } => { if *guide { - // Its own transparency, not the one the object before it set. - if path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) { + // A path for each subpath with points, with the guide's own transparency, not + // the one the object before it set. + let subpaths = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count(); + if subpaths > 0 && self.take_objects(subpaths) { self.set_object_xy(xy.object()); + ai_ops::guide(&mut self.body, self.to_art, path, true); } - ai_ops::guide(&mut self.body, self.to_art, path, true); } else { self.shape(n, path, *rule, xy); } @@ -530,8 +635,11 @@ impl<'a> Writer<'a> { NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => self.image(im, xy), - NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), + NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { + if !self.open_group("u") { + return; + } // The canvas places an instance inside a symbol's art where the outer instance puts // it (9-slice guides, stains and patterns included), and this art doesn't move with it. self.wrote_fixed |= self.in_symbol; @@ -541,7 +649,6 @@ impl<'a> Writer<'a> { } else { self.warn(symbols::MISSING_SYMBOL); } - self.line("u"); if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { @@ -583,8 +690,10 @@ impl<'a> Writer<'a> { | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + if !self.open_group("u") { + return; + } self.warn(LIVE); - self.line("u"); if self.nest < MAX_NEST { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.nest += 1; @@ -629,11 +738,14 @@ impl<'a> Writer<'a> { if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() { return; } + let start = self.body.len(); + let has_id = name.is_some() || !bound.is_empty(); + let has_entries = entries.is_some(); self.body.push_str("%_/ArtDictionary :\n"); if let Some(e) = entries { self.body.push_str(&e); } - if name.is_some() || !bound.is_empty() { + if has_id { let id = self.take_id(name.unwrap_or("XMLID")); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); if name.is_none() { @@ -644,8 +756,13 @@ impl<'a> Writer<'a> { self.bound.push((n.id, id, bound)); } } - if let Some(m) = mask { - self.mask(m); + // A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too. + if let Some(m) = mask + && !self.mask(m) + && !has_id + && !has_entries + { + return self.body.truncate(start); } self.body.push_str("%_;\n%_\n"); } @@ -673,10 +790,13 @@ impl<'a> Writer<'a> { /// An opacity mask, as an entry of its object's dictionary: `/Mask :`, `X=`, its art on `%_` /// lines (from the state a dictionary's art starts in: unlocked, shown, opaque), `X+`, `(Art) ,`, /// its options as the `/Bool` entries `Clipping`, `Inverted`, `Disabled` and `Linked`, and - /// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. - fn mask(&mut self, m: &OpacityMask) { + /// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. False, writing + /// nothing, when some of the art is left out for the object limit ([`Self::take_objects`]): a + /// mask without all its art would change what it shows. + fn mask(&mut self, m: &OpacityMask) -> bool { let outer = std::mem::take(&mut self.body); - let (state, clip) = (self.state, self.clip.take()); + let (state, gs, clip) = (self.state, self.gs, self.clip.take()); + let (charges, bound) = (self.charges(), self.bound.len()); (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); // The canvas draws the art's root even when it is hidden, and the reader puts a hidden object // in a group that doesn't draw it, so a hidden root is written shown. @@ -709,6 +829,13 @@ impl<'a> Writer<'a> { self.hidden -= 1; self.nest -= 1; let lines = std::mem::replace(&mut self.body, outer); + if self.left_out_since(charges) { + // The art isn't written, nor are the variable bindings of its objects. + self.give_back(charges, &lines); + self.bound.truncate(bound); + (self.state, self.gs, self.clip, self.block_end) = (state, gs, clip, usize::MAX); + return false; + } self.body.push_str("%_/Mask :\n%_X=\n"); hide(&lines, &mut self.body); self.body.push_str("%_X+\n%_(Art) ,\n"); @@ -718,6 +845,7 @@ impl<'a> Writer<'a> { self.body.push_str("%_; (AI9_Mask) ,\n"); // The dictionary ends with the state before it, except the transparency its art set last. (self.state, self.gs, self.clip, self.block_end) = (state, None, clip, usize::MAX); + true } /// Warn that art is cut to clipping object `first` when it isn't a path or compound path (a path @@ -739,12 +867,16 @@ impl<'a> Writer<'a> { if self.clip.is_some() && !children.is_empty() { self.warn(CLIPPING); } - self.line("u"); - self.clip_contents(children); - return self.end_group("U", xy); + if self.open_group("u") { + self.clip_contents(children); + self.end_group("U", xy); + } + return; } let at_q = (self.state, self.gs); - self.line("q"); + if !self.open_group("q") { + return; + } self.clipped(children); (self.state, self.gs) = at_q; self.end_group("Q", xy); @@ -753,7 +885,8 @@ impl<'a> Writer<'a> { /// The members of a clipping group or layer, the first one clipping the others: the others, /// then the first one as the clipping path (see [`Self::clip_group`]), its name after it. Fills /// and strokes the clipping path can't paint itself are paths of their own, the fills under the - /// others and the strokes over them. + /// others and the strokes over them. Without room for the clipping path, nothing is written + /// ([`Self::take_objects`]). fn clipped(&mut self, children: &[Arc]) { let Some((first, rest)) = children.split_first() else { return }; let Some((path, rule)) = clip_path(first) else { @@ -762,6 +895,12 @@ impl<'a> Writer<'a> { }; let items = self.items(first, path.to_bezpath().bounding_box()); let painted = self.plain_paint(&items); + // The clipping path goes after the others, so its objects are counted first. + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + let (fill, stroke) = painted.unwrap_or_default(); + if !self.take_objects(clip_object_count(&subpaths, fill, stroke)) { + return; + } if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { self.warn(ITEM_TRANSPARENCY); } @@ -779,12 +918,10 @@ impl<'a> Writer<'a> { self.node(c); } self.clip_paint_part(first, &path, rule, &strokes); - let (fill, stroke) = painted.unwrap_or_default(); self.state(first.locked, !first.visible); let start = self.body.len(); self.paint_state(fill, stroke, Xy::of(first).object(), rule); let op = paint_op(fill.is_some(), stroke.is_some()); - let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); let several = subpaths.len() > 1; if several { self.line("*u"); @@ -817,7 +954,9 @@ impl<'a> Writer<'a> { if let [one] = items { return self.item(path, rule, one, xy); } - self.line("u"); + if !self.open_group("u") { + return; + } for i in items { self.item(path, rule, i, Xy::PLAIN); } @@ -870,11 +1009,12 @@ impl<'a> Writer<'a> { (Some((fill, stroke)), _) => self.path_object(path, rule, fill, stroke, xy), (None, [one]) => self.item(path, rule, one, xy), (None, items) => { - self.line("u"); - for i in items { - self.item(path, rule, i, Xy::PLAIN); + if self.open_group("u") { + for i in items { + self.item(path, rule, i, Xy::PLAIN); + } + self.end_group("U", xy); } - self.end_group("U", xy); } } } @@ -898,7 +1038,8 @@ impl<'a> Writer<'a> { } /// A brushed stroke `st` of `path` as a group of its brush's art, of transparency `xy`. False - /// when it has no brush the document knows (or brushes nest too deep). + /// when it has no brush the document knows (or brushes nest too deep). Without room for the + /// group ([`Self::take_objects`]), the stroke is left out. fn brush_art(&mut self, path: &PathData, st: &StrokeLayer, xy: Xy) -> bool { if self.nest >= MAX_NEST { return false; @@ -908,8 +1049,10 @@ impl<'a> Writer<'a> { let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st); let state = self.state; + if !self.open_group("u") { + return true; + } self.nest += 1; - self.line("u"); for piece in &art { self.node(piece); } @@ -948,13 +1091,15 @@ impl<'a> Writer<'a> { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; return self.path_object(path, rule, Some(&flat), None, xy); } + let state = self.state; + if !self.open_group("u") { + return; + } self.wrote_pattern = true; let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region); self.nest += 1; - let state = self.state; - self.line("u"); for t in &tiles { self.node(t); } @@ -979,8 +1124,8 @@ impl<'a> Writer<'a> { } let both = fill.is_some() && stroke.is_some(); let own = if both { Xy::OPAQUE } else { xy.object() }; - if both { - self.line("u"); + if both && !self.open_group("u") { + return; } if fill.is_some() || stroke.is_none() { let region = self.cut(path, rule); @@ -1026,7 +1171,7 @@ impl<'a> Writer<'a> { /// points and painting operator (`*u` … `*U` around several). fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy) { let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); - if subpaths.is_empty() { + if subpaths.is_empty() || !self.take_objects(object_count(subpaths.len())) { return; } self.paint_state(fill, stroke, xy, rule); @@ -1057,18 +1202,29 @@ impl<'a> Writer<'a> { fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; let doc = self.doc; - let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol { - None - } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { + let thread = doc.text_threads.iter().find(|th| th.contains(&n.id)); + // Type that can't be a stub is written as its outlines, which count their own objects. + if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + return self.outlined_text(n, xy); + } + // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for + // them, the type is left out before its story goes into the text document. + let stub = texts::stub_objects(t); + if !self.take_objects(stub) { + return; + } + let placed = if let Some(thread) = thread { // A thread is checked for what it loses, in every frame, when its story is written. let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id)); placed.map(|p| (p, self.texts.take_lost())) - } else if texts::holds(n, t) { - self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t))) } else { - None + self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t))) + }; + let Some(((story, frame), lost)) = placed else { + // The text document can't hold it: the outlines count their own objects. + self.objects -= stub; + return self.outlined_text(n, xy); }; - let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) }; self.typed = true; if lost { self.warn(texts::TYPE_ATTRIBUTES); @@ -1088,8 +1244,10 @@ impl<'a> Writer<'a> { /// graphics as their symbols' art where the layout places them, as the PDF page draws them; /// the group has the type's transparency `xy`. fn outlined_text(&mut self, n: &Node, xy: Xy) { + if !self.open_group("u") { + return; + } self.warn(TYPE); - self.line("u"); let doc = self.doc; let resolved = match &n.kind { NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), @@ -1343,6 +1501,30 @@ fn paint_op(fill: bool, stroke: bool) -> char { } } +/// The objects the reader makes of a path object of `subpaths` subpaths with points: a path for +/// each, and a compound path holding them when there are several. +fn object_count(subpaths: usize) -> usize { + match subpaths { + 0 => 0, + 1 => 1, + n => n + 1, + } +} + +/// The objects the reader counts for clipping path `subpaths` (those with points) painted with +/// `fill` and `stroke` ([`Writer::clipped`]). Its last subpath carries a gradient fill's or +/// stroke's gradient, so the subpaths of a filled compound clipping path with a gradient paint +/// differently, and the reader reads their fills apart from it: a compound path of a copy of each, +/// which counts the copy and its points ([`object_count`] for the clipping path itself). +fn clip_object_count(subpaths: &[&SubPath], fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) -> usize { + let own = object_count(subpaths.len()); + let apart = subpaths.len() > 1 && fill.is_some() && (gradient(fill.map(|f| &f.paint)).is_some() || gradient(stroke.map(|s| &s.paint)).is_some()); + if !apart { + return own; + } + subpaths.iter().fold(own.saturating_add(1), |n, sp| n.saturating_add(1).saturating_add(sp.anchors.len())) +} + /// The subpaths of compound path `n`'s members, as one path. fn compound_path(n: &Node) -> PathData { let mut subpaths = vec![]; diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 898c91aa7..36c21c14e 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -107,6 +107,11 @@ impl Writer<'_> { if self.clip.as_ref().is_some_and(|c| c.subpaths.iter().all(|sp| sp.anchors.is_empty())) { return; } + // The image, and where it is cut, the clipping group and its clipping path. + let clipping = self.clip.as_ref().map_or(0, |c| 1 + super::object_count(c.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count())); + if !self.take_objects(1 + clipping) { + return; + } if deep { self.warn(IMAGE_DEPTH); } diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index b4966968e..b9d28253d 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -381,7 +381,8 @@ impl Writer<'_> { /// Write pattern `name`'s definition, once: `%AI3_BeginPattern: (name)`, `(name) llx lly urx /// ury` (the tile, y up), the tile's box (an unpainted path whose dictionary marks it, /// `AIPattern_Editor_Backing_Tile_Rect`) and the art on `%_` lines, `E`, `%AI3_EndPattern`. - /// False when it can't be ([`Self::pattern_writable`]). + /// False when it can't be ([`Self::pattern_writable`]), or when its box and art don't all fit + /// within the object limit ([`Self::take_objects`]). fn define_pattern(&mut self, name: &str) -> bool { if !self.pattern_writable(name) { return false; @@ -392,7 +393,9 @@ impl Writer<'_> { return true; } let doc = self.doc; - let Some(def) = doc.pattern(name) else { return false }; + // The tile's box is an object of the definition ([`Self::take_objects`]). + let (before, bound) = (self.charges(), self.bound.len()); + let Some(def) = doc.pattern(name).filter(|_| self.take_objects(1)) else { return false }; self.defining.push(name.to_string()); // The art is written in the pattern's space, y up, from the state a definition starts in. let saved = ( @@ -427,6 +430,12 @@ impl Writer<'_> { (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); + if self.left_out_since(before) { + self.give_back(before, &art); + self.bound.truncate(bound); + return false; + } + self.keep_since(before); let tile = Affine::FLIP_Y.transform_rect_bbox(def.tile); let quoted = ps::bytes(name.as_bytes()); let mut text = format!("%AI3_BeginPattern: {quoted}\n{quoted} "); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 1f10bb4c8..65fe91859 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -688,7 +688,18 @@ impl Writer<'_> { /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or /// compound path's points painted with nothing, or with its gradient fill when its style has /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + /// The look's group and the object itself are counted first ([`Self::take_objects`]): without + /// room for them, nothing is written. pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { + let own = match &orig.kind { + NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), + NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), + NodeKind::Group { .. } => 1, + _ => 0, + }; + if !self.take_objects(1 + own) { + return; + } self.state(orig.locked, !orig.visible); let xy = Xy::of(orig); let was = self.in_look; @@ -717,6 +728,10 @@ impl Writer<'_> { let saved = std::mem::take(&mut self.body); self.hidden += 1; let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); + // A path's objects were kept for it above; it counts them itself. + if !matches!(orig.kind, NodeKind::Group { .. }) { + self.objects -= own; + } match &orig.kind { NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index bfc1ccc3c..9b9724f84 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -183,8 +183,9 @@ impl Writer<'_> { /// written from the top of the nesting whatever the instance that asks for it, so a definition /// is the same wherever its first instance lies. False when it can't be: the document doesn't /// have the symbol, it is being written (its art holds an instance of itself), it is reached - /// through more than [`MAX_NEST`] definitions being written, or the definitions written and - /// being written leave no place under [`MAX_SYMBOLS`]. + /// through more than [`MAX_NEST`] definitions being written, the definitions written and + /// being written leave no place under [`MAX_SYMBOLS`], or its art doesn't all fit within the + /// object limit ([`Self::take_objects`]). pub(super) fn define_symbol(&mut self, name: &str) -> bool { if self.defined_symbols.contains(name) { return true; @@ -206,6 +207,8 @@ impl Writer<'_> { // The art's effects are its own, not those of a drawn look that first places it. let in_look = std::mem::take(&mut self.in_look); self.defining_symbols.push(name.to_string()); + // The objects of this definition and of those it defines first. + let before = self.charges(); let mut used = vec![]; vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { @@ -217,10 +220,19 @@ impl Writer<'_> { } if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { self.defining_symbols.pop(); + self.keep_since(before); self.warn(SYMBOL_COUNT); self.in_look = in_look; return false; } + // The group that holds the art is an object of the definition ([`Self::take_objects`]); + // without room for it, or for all the art, there is no definition. + let (art_start, bound) = (self.charges(), self.bound.len()); + if !self.take_objects(1) { + self.defining_symbols.pop(); + self.keep_since(before); + return false; + } // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the // document at its own size. let origin = self.canvas_origin; @@ -264,7 +276,9 @@ impl Writer<'_> { let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n"); } else { // A group always writes its dictionary ([`Self::dictionary`]), which takes the entries. + // It counts itself, in the room kept for it above. self.root_entries = Some((root.id, entries)); + self.objects -= 1; self.node(root); } self.nest = nest; @@ -297,6 +311,13 @@ impl Writer<'_> { self.in_look = in_look; let art = std::mem::replace(&mut self.body, art); self.defining_symbols.pop(); + if self.left_out_since(art_start) { + self.give_back(art_start, &art); + self.bound.truncate(bound); + self.keep_since(before); + return false; + } + self.keep_since(before); let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); self.defined_symbols.insert(name.to_string()); self.symbol_defs.push(text); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index f494e859a..a84fc5720 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -1016,6 +1016,12 @@ fn clone_v(v: &V) -> V { } } +/// The objects the reader reads of [`stub`] for `t`: the text object, and a path for each subpath +/// of its frame's path. +pub(in crate::ai_editing) fn stub_objects(t: &TextObject) -> usize { + 1 + shape(t).map_or(0, |p| p.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()) +} + /// The text object stub of story `story`, frame `frame`: `/AI11Text :`, `/FreeUndo`, `/FrameIndex`, /// `/StoryIndex`, for area type and type on a path the frame's path (`/Art :`, `X=`, the path in /// art space through `to_art`, `X+`, `; /ConfiningPath ,`), `/TextAntialiasing`, `;`. diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 1e0fd01c3..7f1391756 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -73,8 +73,11 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +#[cfg(test)] +pub(crate) use ai::objects_read; pub(crate) use ai::{ - MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number, + MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, + symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index c84f3b406..6b5918530 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,13 +41,16 @@ pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; #[cfg(test)] +pub(crate) use read::objects_read; +#[cfg(test)] pub(crate) use read::with_image_limits; pub use read::{ BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, symbols, variables, }; pub(crate) use read::{ - MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member, + MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, + symbol_art_member, }; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 47fdc34fe..8e1f27bad 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -28,7 +28,11 @@ //! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`). //! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's //! section names (one for Scatter, five for Art and Pattern, empty where there is none, each -//! after a space for Art and Pattern), more fields, and last 15 numbers. +//! after a space for Art and Pattern), more fields, and last 15 numbers. Older files write +//! brushes with first number 1, one field fewer before the numbers and 8 numbers; an Art brush +//! with 8 numbers, whatever its first number, is read from its art's name alone (its numbers +//! aren't known, so it keeps the defaults and its paths their drawn look). A Pattern brush needs +//! only its first 3 numbers (its scale is the third). //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the //! rotation follows the path (14), and 1 Tints (1). @@ -312,11 +316,10 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< }) .collect(); let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?; - let n: &[f64; 15] = n.as_slice().try_into().map_err(|_| BrushError::Values)?; - let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n; let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt); match kind { Tool::Scatter => { + let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) }; let pct = |a: f64, b: f64| (a * 100.0, b * 100.0); let values = [pct(*p2, *p3), pct(*p5, *p6), pct(*p8, *p9), (*p11, *p12)]; let codes = [*p4, *p7, *p10, *p13]; @@ -336,10 +339,20 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< DraftKind::Scatter { brush, art: art()? } } Tool::Art => { - let colorization = if *p2 == 1.0 { Colorization::Tints } else { Colorization::None }; - DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain: art_is_plain(n) } + // The older form's 8 numbers aren't known: its brush keeps the defaults and its + // paths their drawn look. The count tells the form, not the first number. + // Any other count isn't a form this reads. + let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) { + (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)) + } else if n.len() == 8 { + (ArtBrush::default().colorization, false) + } else { + return Err(BrushError::Values); + }; + DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain } } _ => { + let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) }; let tiles: [Option; 5] = tiles.try_into().map_err(|_| BrushError::Values)?; DraftKind::Pattern { tiles, scale: p3 * 100.0 } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d67d5b83e..b01ae54a1 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -149,6 +149,7 @@ pub(crate) use document::{slot_frame, slot_of}; pub(crate) use ops::{read_art_style, read_style_list, style_moves_with}; #[cfg(test)] pub(crate) use raster::with_image_limits; +pub(crate) use types::MAX_NODES; pub use types::PlacedPage; use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead}; #[cfg(test)] @@ -259,6 +260,14 @@ fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result Result { + let mut r = Reader::with_names(data); + r.run()?; + Ok(r.nodes) +} + /// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private /// data): the program's header gives the author and the custom colors the editing data's header /// doesn't. `page_art`: as [`read_over_page`] reads a file. diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 5d34e248e..b07270ef8 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -11,8 +11,8 @@ pub(super) const MAX_SUBREADERS: usize = 32; /// Most operands on the stack. pub(super) const MAX_STACK: usize = 1 << 20; -/// Most objects read. -pub(super) const MAX_NODES: usize = 4_000_000; +/// Most objects read. The `.ai` editing data writer writes no more ([`crate::import::MAX_EDITING_OBJECTS`]). +pub(crate) const MAX_NODES: usize = 4_000_000; /// The most objects all the blends of one import may lay out to be checked (each twice) and made /// live; past it, blends keep their drawn steps. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 35f2c416a..8ccb3aa35 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8881,6 +8881,54 @@ fn a_pattern_brushs_five_slots_are_kept() { assert!(matches!(d.kind, brush::DraftKind::Art { ref art, .. } if art == "Ribbon art"), "{d:?}"); } +/// An Art brush in the older form (first number 1: one field fewer before its 8 numbers) is read +/// with the art of its section, but not as plain (its numbers aren't known); a Scatter brush in +/// that form isn't read (its values aren't all there). +#[test] +fn an_older_art_brush_comes_in_with_its_art() { + const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0"; + let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap(); + assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}"); + let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") }; + assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization"); + // A count of numbers neither form has isn't read. + for bad in ["1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0", "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0"] { + assert_eq!(brush::parse_tool_object(brush::Tool::Art, "Ribbon", bad), Err(brush::BrushError::Values), "{bad}"); + } + assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values)); + // 8 numbers are the older form whatever the first number says: read with the defaults. + let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap(); + let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") }; + assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization)); + let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat(); + let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(a) = brush_named(&lib, "Ribbon") else { panic!("art") }; + assert_eq!(a.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0)), "the art of its section"); + assert_eq!(lib.len(), 1, "a brush naming no art is left out"); + assert!(r.warnings.iter().any(|w| w.ends_with("Brushes panel (1)")), "{:?}", r.warnings); +} + +/// A Pattern brush needs only its first 3 numbers, its scale the third (as a percentage), so the +/// older form's 8 numbers read too; fewer than 3 aren't read. +#[test] +fn a_pattern_brush_reads_its_scale_from_three_or_more_numbers() { + const SLOTS: &str = "1 / Tile one/ Tile two/ / / Tile five/ 0 1 / "; + for numbers in ["1 0 0.5", "1 0 0.5 0 1 0 0 0"] { + let d = brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")).unwrap(); + let brush::DraftKind::Pattern { tiles, scale } = d.kind else { panic!("{d:?}") }; + assert_eq!(tiles[1].as_deref(), Some("Tile two"), "{numbers}"); + assert!((scale - 50.0).abs() < 1e-9, "{numbers}: {scale}"); + } + for numbers in ["", "1", "1 0"] { + assert_eq!( + brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")), + Err(brush::BrushError::Values), + "{numbers:?}" + ); + } +} + /// A brush's art filled with a pattern the file defines before it is read, with that pattern. #[test] fn a_brushs_art_takes_the_patterns_defined_before_it() { diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 90dc0d060..984d3a123 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -1,16 +1,29 @@ -//! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the -//! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and -//! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes -//! primitive values look properties up on. `String`, `Number`, `RegExp` and `Date` are in their -//! own modules; the rest of the standard library is not here yet. +//! The built-in objects (ECMA-262 5.1 section 15): `Object` with `Object.prototype` and the +//! ES5 statics, `Function` with `Function.prototype.call`, `apply`, `bind` and `toString`, +//! `Array`, the error constructors, `Math`, `JSON`, the global functions and constants, Annex +//! B's `escape` and `unescape`, and the prototypes primitive values look properties up on, with +//! `Boolean.prototype`'s `toString` and `valueOf`. `String`, `Number`, `RegExp` and `Date` are in +//! their own modules. +//! +//! Every loop in a built-in takes a unit of fuel for each step, and every string, array, object +//! or property it makes is checked against the heap limit before it is made, so no built-in can +//! run past [`crate::Limits`]. + +mod array; +mod global; +mod json; +mod math; +mod object; use std::rc::Rc; use crate::bytecode::{CodeKind, Source}; use crate::compiler::{CompileFail, compile_constructed_function}; -use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key, string_bytes}; -use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; -use crate::value::{Str, Value}; +use crate::heap::{ + BOUND_ARG_BYTES, Class, Func, HIDDEN, Key, NativeFn, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, key, string_bytes, +}; +use crate::interp::{Abrupt, Desc, ErrorType, Interp, JsResult}; +use crate::value::{Str, Value, index_key, number_to_str, to_uint32}; fn alloc(interp: &mut Interp, class: Class, proto: Option) -> ObjId { interp.heap.alloc_object(Object::new(class, proto)) @@ -42,12 +55,16 @@ pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, } /// Creates the built-ins and the global object's properties. The caller installs them before it -/// sets the realm's limits, so no limit stops the install. +/// sets the realm's limits, so no limit stops the install. If it fails, the failure is kept in +/// `interp.install_error` and every run of the realm ends with it. pub(crate) fn install(interp: &mut Interp) { if let Err(e) = try_install(interp) { - // Installing touches only objects it has just made, so this does not happen; a realm - // that somehow lacks a built-in reports it when a script uses it. - let _ = e; + let reason = match e { + Abrupt::Throw(v) => interp.describe(&v), + Abrupt::Limit(kind) => format!("limit reached: {kind:?}"), + Abrupt::Internal(message) => message, + }; + interp.install_error = Some(format!("the built-ins could not be installed: {reason}")); } } @@ -68,7 +85,8 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto)); - interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto)); + let boolean_proto = alloc(interp, Class::Boolean(false), Some(object_proto)); + interp.intrinsics.boolean_proto = boolean_proto; interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; if let Some(t) = interp.heap.object_mut(thrower) { @@ -98,15 +116,189 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { data(interp, function_proto, "call", Value::Object(call), HIDDEN)?; let apply = native(interp, "apply", unreachable_native, 2, NativeKind::Apply, false)?; data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; + method(interp, function_proto, "bind", function_bind, 1)?; method(interp, function_proto, "toString", function_to_string, 0)?; + method(interp, boolean_proto, "toString", boolean_to_string, 0)?; + method(interp, boolean_proto, "valueOf", boolean_value_of, 0)?; + install_errors(interp, object_proto)?; + object::install(interp, object)?; + array::install(interp)?; + math::install(interp)?; + json::install(interp)?; + global::install(interp)?; crate::string_builtins::install(interp)?; crate::number_builtins::install(interp)?; crate::regexp_builtins::install(interp)?; crate::date_builtins::install(interp) } +// ---- helpers for the built-ins ---- + +/// ToInteger (section 9.4). +fn to_integer(interp: &mut Interp, v: &Value) -> JsResult { + let n = interp.to_number(v)?; + Ok(if n.is_nan() { 0.0 } else { n.trunc() }) +} + +/// The property name of the index `n`, which may be past the largest array index. +fn key_of(n: f64) -> Key { + if (0.0..4_294_967_295.0).contains(&n) && n.fract() == 0.0 { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + return index_key(n as u32); + } + number_to_str(n).flat() +} + +/// ToUint32 of an object's `length`. +fn length_of(interp: &mut Interp, o: ObjId) -> JsResult { + let len = interp.get_from(o, &key("length"), &Value::Object(o))?; + Ok(to_uint32(interp.to_number(&len)?)) +} + +fn get_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult { + interp.get_from(o, &key_of(k), &Value::Object(o)) +} + +fn has_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult { + interp.has_property(o, &key_of(k)) +} + +/// The estimated bytes a new element adds. +const ELEMENT_BYTES: usize = PROP_BYTES + 32 + 2 * 10; + +/// `o[k] = v` as the array methods assign: a failure throws. When `o` has no own property `k`, `v` +/// stays rooted while heap is reserved for the new element, since the caller may hold it nowhere +/// else; overwriting an own property reserves nothing. +pub(crate) fn put_at(interp: &mut Interp, o: ObjId, k: f64, v: Value) -> JsResult<()> { + let k = key_of(k); + if interp.heap.object(o).is_some_and(|obj| obj.props.contains(&k)) { + return interp.put(o, k, v, true); + } + interp.rooted(std::slice::from_ref(&v), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.put(o, k.clone(), v.clone(), true) + }) +} + +fn delete_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<()> { + interp.delete(o, &key_of(k), true).map(drop) +} + +/// Defines `a[k]` as a writable, enumerable and configurable data property, as the methods that +/// build a new array do (DefineOwnProperty with `throw` false). `v` stays rooted while heap is +/// reserved for it. +fn create_at(interp: &mut Interp, a: ObjId, k: f64, v: Value) -> JsResult<()> { + interp.rooted(std::slice::from_ref(&v), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.define_own_property(a, key_of(k), &Desc::data(v.clone(), OPEN), false).map(drop) + }) +} + +/// A new array with the given length and no elements. +fn new_array(interp: &mut Interp, length: u32) -> JsResult { + interp.reserve_heap(OBJECT_BYTES)?; + let proto = interp.intrinsics.array_proto; + Ok(interp.heap.alloc_object(Object::new(Class::Array { length, length_writable: true }, Some(proto)))) +} + +/// A new string from code units, checked against the heap limit and charged to the heap. +fn new_string(interp: &mut Interp, units: Vec) -> JsResult { + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(Value::String(Str::from_units(units))) +} + +/// Code units a built-in may write into a string for one unit of fuel. +const UNITS_PER_FUEL: usize = 16; + +/// A string the built-ins build piece by piece: each piece takes fuel for its length, is checked +/// against the longest string the heap limit allows, and has its bytes reserved before it is +/// added. The buffer's bytes count toward the heap until `finish` or until the native call that +/// made the builder returns. Everything the caller still needs must be reachable from the roots. +struct Builder { + units: Vec, + /// Code units already reserved and charged to the heap. + charged: usize, + /// Code units written since fuel was last taken, fewer than `UNITS_PER_FUEL`. + unpaid: usize, +} + +impl Builder { + fn new() -> Self { + Builder { units: Vec::new(), charged: 0, unpaid: 0 } + } + + /// Checks that `more` code units fit, reserving heap for them, and takes fuel for writing them. + fn make_room(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> { + let want = self.units.len().saturating_add(more); + if want > interp.limits.heap_bytes / 4 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + self.unpaid += more; + let fuel = (self.unpaid / UNITS_PER_FUEL) as u64; + self.unpaid %= UNITS_PER_FUEL; + if fuel > 0 { + if interp.fuel < fuel { + interp.fuel = 0; + return Err(Abrupt::Limit(crate::LimitKind::Fuel)); + } + // The last unit goes through `use_fuel`, which also checks for an interrupt. + interp.fuel -= fuel - 1; + interp.use_fuel()?; + } + if want > self.charged { + let grow = (want - self.charged).max(self.charged / 2).max(256).min((interp.limits.heap_bytes / 4).saturating_sub(self.charged)); + interp.reserve_heap(2 * grow)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(2 * grow); + interp.held_bytes = interp.held_bytes.saturating_add(2 * grow); + self.charged += grow; + } + Ok(()) + } + + fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { + self.make_room(interp, units.len())?; + self.units.extend_from_slice(units); + Ok(()) + } + + /// Appends `units` `count` times. Nothing is written for an empty `units`, however large + /// `count` is. + fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { + if units.is_empty() || count == 0 { + return Ok(()); + } + let more = usize::try_from(count).unwrap_or(usize::MAX).saturating_mul(units.len()); + self.make_room(interp, more)?; + for _ in 0..count { + self.units.extend_from_slice(units); + } + Ok(()) + } + + fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + let units: Vec = s.encode_utf16().collect(); + self.push_units(interp, &units) + } + + /// The string built, whose bytes were charged as it grew. From here on the heap counts them + /// as the string's. + fn finish(self, interp: &mut Interp) -> Value { + interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged); + Value::String(Str::from_units(self.units)) + } +} + +fn require_callable(interp: &mut Interp, v: &Value, what: &str) -> JsResult<()> { + if interp.is_callable(v) { + return Ok(()); + } + Err(interp.type_error(&format!("{what} is not a function"))) +} + fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { let kinds: [(&'static str, NativeFn); 7] = [ ("Error", error_ctor), @@ -177,13 +369,40 @@ fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> J Ok(Value::str(&format!("[object {class}]"))) } +/// The Boolean a `Boolean.prototype` method was called on (sections 15.6.4.2 and 15.6.4.3): a +/// primitive, or the primitive value of a Boolean object. +fn this_boolean(interp: &mut Interp, this: &Value, name: &str) -> JsResult { + let b = match this { + Value::Bool(b) => Some(*b), + Value::Object(o) => match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Boolean(b)) => Some(*b), + _ => None, + }, + _ => None, + }; + b.ok_or_else(|| interp.type_error(&format!("Boolean.prototype.{name} was called on a value that is not a Boolean"))) +} + +/// `Boolean.prototype.toString` (section 15.6.4.2). +fn boolean_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let b = this_boolean(interp, &this, "toString")?; + Ok(Value::str(if b { "true" } else { "false" })) +} + +/// `Boolean.prototype.valueOf` (section 15.6.4.3). +fn boolean_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_boolean(interp, &this, "valueOf").map(Value::Bool) +} + +/// `Object.prototype.toLocaleString` (section 15.2.4.3). fn object_to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { let o = interp.to_object(&this)?; - let f = interp.get_from(o, &key("toString"), &this)?; + let receiver = Value::Object(o); + let f = interp.get_from(o, &key("toString"), &receiver)?; if !interp.is_callable(&f) { return Err(interp.type_error("toString is not a function")); } - interp.call(&f, &this, &[]) + interp.call(&f, &receiver, &[]) } fn object_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { @@ -283,11 +502,40 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Bound { .. }) => "function () { [native code] }".to_string(), None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), }; Ok(Value::str(&text)) } +/// `Function.prototype.bind` (section 15.3.4.5). +fn function_bind(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + require_callable(interp, &this, "the value bind was called on")?; + let Some(target) = this.as_object() else { return Err(interp.type_error("the value bind was called on is not a function")) }; + let bound_this = arg(args, 0); + let bound_args: Rc<[Value]> = args.get(1..).unwrap_or_default().into(); + // A host function's `length` is an ordinary property a script can set, so it is converted + // to an integer first, as later editions specify. + let target_length = match interp.get_from(target, &key("length"), &this)? { + Value::Number(n) if n.is_nan() => 0.0, + Value::Number(n) => n.trunc(), + _ => 0.0, + }; + // Adding 0 turns a negative zero into 0. + let length = (target_length - bound_args.len() as f64).max(0.0) + 0.0; + interp.reserve_heap(OBJECT_BYTES + 4 * PROP_BYTES + BOUND_ARG_BYTES * bound_args.len())?; + let proto = interp.intrinsics.function_proto; + let class = Class::Function(Func::Bound { target, this: bound_this, args: bound_args.clone() }); + let f = interp.heap.alloc_object(Object::new(class, Some(proto))); + interp.heap.bytes = interp.heap.bytes.saturating_add(BOUND_ARG_BYTES * bound_args.len()); + interp.define(f, key("length"), Prop::Data { value: Value::Number(length), flags: 0 })?; + let thrower = Some(interp.intrinsics.throw_type_error); + for name in ["caller", "arguments"] { + interp.define(f, key(name), Prop::Accessor { get: thrower, set: thrower, flags: 0 })?; + } + Ok(Value::Object(f)) +} + /// The global `eval` called indirectly (section 15.1.2.1.1): global code in the global /// environment. fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs new file mode 100644 index 000000000..2f81bf766 --- /dev/null +++ b/crates/js/src/builtins/array.rs @@ -0,0 +1,854 @@ +//! `Array` (ECMA-262 5.1 section 15.4): the constructor, `Array.isArray` and every method of +//! `Array.prototype`. The methods are generic: they read `length` and the elements of any object. +//! +//! A method that walks a range of indices visits only the indices that can hold an element when +//! the range is much longer than the object has properties (a sparse array such as +//! `new Array(4294967295)`), so a long empty range costs nothing and no index is allocated for +//! it. The list of indices is made again whenever a script adds an index property to any object +//! while the method runs. Every index visited takes a unit of fuel. + +use super::{ + Builder, ELEMENT_BYTES, arg, constructor, create_at, delete_at, get_at, has_at, length_of, method, native, new_array, object_to_string, put_at, + require_callable, to_integer, +}; +use crate::heap::{Class, NativeKind, ObjId}; +use crate::interp::{Abrupt, Interp, JsResult, strict_eq}; +use crate::value::{Str, Value, to_uint32}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.array_proto; + let array = native(interp, "Array", array_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Array", array, proto)?; + method(interp, array, "isArray", is_array, 1)?; + let methods: [(&'static str, crate::heap::NativeFn, u32); 21] = [ + ("toString", to_string, 0), + ("toLocaleString", to_locale_string, 0), + ("concat", concat, 1), + ("join", join, 1), + ("pop", pop, 0), + ("push", push, 1), + ("reverse", reverse, 0), + ("shift", shift, 0), + ("slice", slice, 2), + ("sort", sort, 1), + ("splice", splice, 2), + ("unshift", unshift, 1), + ("indexOf", index_of, 1), + ("lastIndexOf", last_index_of, 1), + ("every", every, 1), + ("some", some, 1), + ("forEach", for_each, 1), + ("map", map, 1), + ("filter", filter, 1), + ("reduce", reduce, 1), + ("reduceRight", reduce_right, 1), + ]; + for (name, f, length) in methods { + method(interp, proto, name, f, length)?; + } + Ok(()) +} + +// ---- visiting indices ---- + +/// Ranges at most this long are visited index by index. +const DENSE_SPAN: u64 = 4096; + +/// How an index an object holds names an index to visit. +#[derive(Clone, Copy)] +enum Map { + /// The index `p` asks for a visit of `p - shift`. + Shift(i64), + /// The index `p` asks for a visit of `p` and of `mirror - p`. + Mirror(i64), +} + +/// The indices of a range, in order, that an operation must visit. +struct Indices { + obj: ObjId, + /// The range left to visit, `lo` included and `hi` not. + lo: u64, + hi: u64, + ascending: bool, + maps: Vec, + /// The indices left when the range is visited sparsely. + sparse: Option, +} + +struct Sparse { + list: Vec, + pos: usize, + /// [`crate::heap::Heap::index_epoch`] when the list was made. + epoch: u64, +} + +/// The canonical numeric property name `k` stands for, up to 15 digits. +fn numeric_index(k: &[u16]) -> Option { + if k.is_empty() || k.len() > 15 || (k.len() > 1 && k.first() == Some(&u16::from(b'0'))) { + return None; + } + let mut n = 0u64; + for &u in k { + let d = u.checked_sub(u16::from(b'0')).filter(|d| *d <= 9)?; + n = n * 10 + u64::from(d); + } + Some(n) +} + +/// The objects of the prototype chain from `obj`, with how many properties (and String object +/// indices) each holds. +fn chain(interp: &Interp, obj: ObjId) -> Vec<(ObjId, usize)> { + let mut out = Vec::new(); + let mut cur = Some(obj); + while let Some(id) = cur { + let Some(o) = interp.heap.object(id) else { break }; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + out.push((id, o.props.len().saturating_add(string_len))); + cur = o.proto; + } + out +} + +impl Indices { + fn new(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64, ascending: bool, maps: Vec) -> JsResult { + let mut it = Indices { obj, lo, hi, ascending, maps, sparse: None }; + if hi.saturating_sub(lo) > DENSE_SPAN { + let present: usize = chain(interp, obj).iter().map(|(_, n)| *n).fold(0, usize::saturating_add); + if hi - lo > (present as u64).saturating_mul(8) { + it.sparse = Some(it.gather(interp)?); + } + } + Ok(it) + } + + fn up(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult { + Self::new(interp, obj, lo, hi, true, vec![Map::Shift(0)]) + } + + fn down(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult { + Self::new(interp, obj, lo, hi, false, vec![Map::Shift(0)]) + } + + /// Lists the indices left to visit from the properties of the object and its prototypes. + /// Each property takes a unit of fuel. + fn gather(&self, interp: &mut Interp) -> JsResult { + let epoch = interp.heap.index_epoch; + let mut list: Vec = Vec::new(); + for (id, n) in chain(interp, self.obj) { + for _ in 0..n { + interp.use_fuel()?; + } + interp.reserve_heap(n.saturating_mul(8).saturating_mul(self.maps.len() + 1))?; + let Some(o) = interp.heap.object(id) else { continue }; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + let present = (0..string_len as u64).chain(o.props.iter().filter_map(|(k, _)| numeric_index(k))); + for p in present { + for map in &self.maps { + let p = p as i64; + let ks = match *map { + Map::Shift(s) => [Some(p - s), None], + Map::Mirror(m) => [Some(p), Some(m - p)], + }; + for k in ks.into_iter().flatten() { + if k >= 0 && (k as u64) >= self.lo && (k as u64) < self.hi { + list.push(k as u64); + } + } + } + } + } + if self.ascending { + list.sort_unstable(); + } else { + list.sort_unstable_by(|a, b| b.cmp(a)); + } + list.dedup(); + Ok(Sparse { list, pos: 0, epoch }) + } + + /// The next index to visit; it takes a unit of fuel. + fn next(&mut self, interp: &mut Interp) -> JsResult> { + if self.lo >= self.hi { + return Ok(None); + } + if self.sparse.as_ref().is_some_and(|s| s.epoch != interp.heap.index_epoch) { + self.sparse = Some(self.gather(interp)?); + } + let k = match &mut self.sparse { + None if self.ascending => self.lo, + None => self.hi - 1, + Some(s) => { + let Some(&k) = s.list.get(s.pos) else { + self.lo = self.hi; + return Ok(None); + }; + s.pos += 1; + k + } + }; + if self.ascending { + self.lo = k + 1; + } else { + self.hi = k; + } + interp.use_fuel()?; + Ok(Some(k)) + } +} + +/// The element at `k` when the object or its prototypes have one. +fn element(interp: &mut Interp, o: ObjId, k: u64) -> JsResult> { + let k = k as f64; + if !has_at(interp, o, k)? { + return Ok(None); + } + get_at(interp, o, k).map(Some) +} + +/// ToObject of `this`, kept reachable while `f` runs. +fn with_this(interp: &mut Interp, this: &Value, f: impl FnOnce(&mut Interp, ObjId) -> JsResult) -> JsResult { + let o = interp.to_object(this)?; + interp.rooted(&[Value::Object(o)], |interp| f(interp, o)) +} + +/// The start a relative index gives within `0..=len` (`slice` and `splice`). +fn relative(interp: &mut Interp, v: &Value, len: u32) -> JsResult { + let n = to_integer(interp, v)?; + let len = f64::from(len); + Ok(if n < 0.0 { (len + n).max(0.0) } else { n.min(len) } as u64) +} + +fn number(k: u64) -> Value { + Value::Number(k as f64) +} + +// ---- the constructor ---- + +/// `Array(…)` and `new Array(…)` (sections 15.4.1 and 15.4.2). +fn array_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + if let [Value::Number(n)] = args { + let len = to_uint32(*n); + if f64::from(len) != *n { + return Err(interp.throw(crate::interp::ErrorType::Range, "invalid array length")); + } + return new_array(interp, len).map(Value::Object); + } + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + for (i, v) in args.iter().enumerate() { + interp.use_fuel()?; + create_at(interp, a, i as f64, v.clone())?; + } + Ok(Value::Object(a)) + }) +} + +/// `Array.isArray` (section 15.4.3.2). +fn is_array(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let a = arg(args, 0).as_object().and_then(|o| interp.heap.object(o)).is_some_and(|o| matches!(o.class, Class::Array { .. })); + Ok(Value::Bool(a)) +} + +// ---- conversions ---- + +/// `Array.prototype.toString` (section 15.4.4.2). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let join = interp.get_from(o, &crate::heap::key("join"), &Value::Object(o))?; + if interp.is_callable(&join) { + return interp.call(&join, &Value::Object(o), &[]); + } + object_to_string(interp, Value::Object(o), &[], false) + }) +} + +/// Joins the elements `0..len` of `o`, each turned into a string by `item`, with `separator` +/// between them (`,` when it is `undefined`). `length` is read before the separator is converted, +/// and the separator is converted even when `length` is 0 (section 15.4.4.5 steps 2 to 6). A cycle +/// back to an object being joined joins as an empty string. +fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { + if interp.join_stack.contains(&o) { + return Ok(Value::str("")); + } + let len = length_of(interp, o)?; + let sep = match separator { + Value::Undefined => Str::from(","), + v => interp.to_str(v)?, + }; + if len == 0 { + return Ok(Value::str("")); + } + let flat = sep.flat(); + let sep = &flat[..]; + // A result that cannot fit is refused before any element is read. + let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); + if least > (interp.limits.heap_bytes / 4) as u64 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.join_stack.push(o); + let r = join_elements(interp, o, len, sep, item); + interp.join_stack.pop(); + r +} + +fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { + let mut out = Builder::new(); + // Separators written so far: element `k` follows `k` of them. + let mut seps = 0u64; + let mut it = Indices::up(interp, o, 0, u64::from(len))?; + while let Some(k) = it.next(interp)? { + let v = get_at(interp, o, k as f64)?; + let Some(s) = item(interp, v)? else { continue }; + out.push_repeated(interp, sep, k.saturating_sub(seps))?; + seps = k; + out.push_units(interp, &s.flat())?; + } + out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; + Ok(out.finish(interp)) +} + +/// An element as `join` writes it: nothing for `undefined` and `null`. +fn join_item(interp: &mut Interp, v: Value) -> JsResult> { + if v.is_nullish() { + return Ok(None); + } + interp.to_str(&v).map(Some) +} + +/// An element as `toLocaleString` writes it (section 15.4.4.3). +fn locale_item(interp: &mut Interp, v: Value) -> JsResult> { + if v.is_nullish() { + return Ok(None); + } + let e = interp.to_object(&v)?; + let f = interp.get_from(e, &crate::heap::key("toLocaleString"), &Value::Object(e))?; + require_callable(interp, &f, "an element's toLocaleString")?; + let s = interp.call(&f, &Value::Object(e), &[])?; + interp.to_str(&s).map(Some) +} + +/// `Array.prototype.join` (section 15.4.4.5). +fn join(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let separator = arg(args, 0); + with_this(interp, &this, |interp, o| join_with(interp, o, &separator, join_item)) +} + +/// `Array.prototype.toLocaleString` (section 15.4.4.3). +fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| join_with(interp, o, &Value::Undefined, locale_item)) +} + +// ---- methods that change the object ---- + +/// `Array.prototype.concat` (section 15.4.4.4). +fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut n = 0u64; + for e in std::iter::once(Value::Object(o)).chain(args.iter().cloned()) { + interp.use_fuel()?; + let spread = e.as_object().filter(|&id| interp.heap.object(id).is_some_and(|x| matches!(x.class, Class::Array { .. }))); + let Some(e) = spread else { + create_at(interp, a, n as f64, e)?; + n += 1; + continue; + }; + let len = u64::from(length_of(interp, e)?); + let mut it = Indices::up(interp, e, 0, len)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, e, k)? { + create_at(interp, a, (n + k) as f64, v)?; + } + } + n += len; + } + set_length(interp, a, n as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +fn set_length(interp: &mut Interp, o: ObjId, n: f64) -> JsResult<()> { + interp.put(o, crate::heap::key("length"), Value::Number(n), true) +} + +/// `Array.prototype.pop` (section 15.4.4.6). +fn pop(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + if len == 0 { + set_length(interp, o, 0.0)?; + return Ok(Value::Undefined); + } + let last = f64::from(len - 1); + let v = get_at(interp, o, last)?; + interp.rooted(std::slice::from_ref(&v), |interp| { + delete_at(interp, o, last)?; + set_length(interp, o, last) + })?; + Ok(v) + }) +} + +/// `Array.prototype.push` (section 15.4.4.7). +fn push(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let mut n = f64::from(length_of(interp, o)?); + for v in args { + interp.use_fuel()?; + put_at(interp, o, n, v.clone())?; + n += 1.0; + } + set_length(interp, o, n)?; + Ok(Value::Number(n)) + }) +} + +/// `Array.prototype.reverse` (section 15.4.4.8). +fn reverse(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let middle = len / 2; + let mirror = len as i64 - 1; + let mut it = Indices::new(interp, o, 0, middle, true, vec![Map::Mirror(mirror)])?; + while let Some(lower) = it.next(interp)? { + let upper = len - lower - 1; + let lower_value = element(interp, o, lower)?; + let upper_value = interp.rooted(lower_value.as_slice(), |interp| element(interp, o, upper))?; + let mut roots: Vec = lower_value.iter().cloned().collect(); + roots.extend(upper_value.iter().cloned()); + interp.rooted(&roots, |interp| match (lower_value, upper_value) { + (Some(l), Some(u)) => { + put_at(interp, o, lower as f64, u)?; + put_at(interp, o, upper as f64, l) + } + (None, Some(u)) => { + put_at(interp, o, lower as f64, u)?; + delete_at(interp, o, upper as f64) + } + (Some(l), None) => { + delete_at(interp, o, lower as f64)?; + put_at(interp, o, upper as f64, l) + } + (None, None) => Ok(()), + })?; + } + Ok(Value::Object(o)) + }) +} + +/// Moves the element at `from` to `to`, or deletes `to` when there is none at `from`. +fn move_element(interp: &mut Interp, o: ObjId, from: u64, to: u64) -> JsResult<()> { + match element(interp, o, from)? { + Some(v) => put_at(interp, o, to as f64, v), + None => delete_at(interp, o, to as f64), + } +} + +/// `Array.prototype.shift` (section 15.4.4.9). +fn shift(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + if len == 0 { + set_length(interp, o, 0.0)?; + return Ok(Value::Undefined); + } + let first = get_at(interp, o, 0.0)?; + interp.rooted(std::slice::from_ref(&first), |interp| { + let mut it = Indices::new(interp, o, 1, len, true, vec![Map::Shift(0), Map::Shift(-1)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k, k - 1)?; + } + delete_at(interp, o, (len - 1) as f64)?; + set_length(interp, o, (len - 1) as f64) + })?; + Ok(first) + }) +} + +/// `Array.prototype.unshift` (section 15.4.4.13). +fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let count = args.len() as u64; + // With no arguments each element is still read and written back in place (steps 6 and 7). + let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; + let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k - 1, k + count - 1)?; + } + for (j, v) in args.iter().enumerate() { + interp.use_fuel()?; + put_at(interp, o, j as f64, v.clone())?; + } + let n = (len + count) as f64; + set_length(interp, o, n)?; + Ok(Value::Number(n)) + }) +} + +/// `Array.prototype.slice` (section 15.4.4.10). +fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let start = relative(interp, &arg(args, 0), len)?; + let end = match arg(args, 1) { + Value::Undefined => u64::from(len), + v => relative(interp, &v, len)?, + }; + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut it = Indices::up(interp, o, start, end)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? { + create_at(interp, a, (k - start) as f64, v)?; + } + } + set_length(interp, a, end.saturating_sub(start) as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +/// `Array.prototype.splice` (section 15.4.4.12). +fn splice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let start = relative(interp, &arg(args, 0), len)?; + let len = u64::from(len); + let delete_count = match args.get(1) { + // Without a count, everything from the start is deleted, as later editions and + // every implementation do. + None if !args.is_empty() => len - start, + None => 0, + Some(v) => to_integer(interp, v)?.max(0.0).min((len - start) as f64) as u64, + }; + let items = args.get(2..).unwrap_or_default(); + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut it = Indices::up(interp, o, start, start + delete_count)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? { + create_at(interp, a, (k - start) as f64, v)?; + } + } + set_length(interp, a, delete_count as f64)?; + move_tail(interp, o, len, start, delete_count, items.len() as u64)?; + for (j, v) in items.iter().enumerate() { + interp.use_fuel()?; + put_at(interp, o, (start + j as u64) as f64, v.clone())?; + } + set_length(interp, o, (len - delete_count + items.len() as u64) as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +/// Moves the elements after a spliced range so that `item_count` items replace `delete_count` +/// elements at `start` (section 15.4.4.12, steps 12 and 13). +fn move_tail(interp: &mut Interp, o: ObjId, len: u64, start: u64, delete_count: u64, item_count: u64) -> JsResult<()> { + let (dc, ic) = (delete_count as i64, item_count as i64); + if item_count < delete_count { + let mut it = Indices::new(interp, o, start, len - delete_count, true, vec![Map::Shift(dc), Map::Shift(ic)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k + delete_count, k + item_count)?; + } + let mut it = Indices::down(interp, o, len - delete_count + item_count, len)?; + while let Some(k) = it.next(interp)? { + delete_at(interp, o, k as f64)?; + } + } else if item_count > delete_count { + let mut it = Indices::new(interp, o, start + 1, len - delete_count + 1, false, vec![Map::Shift(dc - 1), Map::Shift(ic - 1)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k + delete_count - 1, k + item_count - 1)?; + } + } + Ok(()) +} + +// ---- sort ---- + +/// A stable merge sort whose comparison may fail. `greater(a, b)` says whether `a` sorts after +/// `b`; each comparison takes a unit of fuel. +fn merge_sort(interp: &mut Interp, items: &mut Vec, mut greater: impl FnMut(&mut Interp, &T, &T) -> JsResult) -> JsResult<()> { + let n = items.len(); + let mut width = 1; + let mut buf: Vec = Vec::with_capacity(n); + while width < n { + buf.clear(); + let mut lo = 0; + while lo < n { + let mid = (lo + width).min(n); + let hi = (lo + 2 * width).min(n); + let (mut i, mut j) = (lo, mid); + while i < mid && j < hi { + let (Some(a), Some(b)) = (items.get(i), items.get(j)) else { break }; + interp.use_fuel()?; + if greater(interp, a, b)? { + buf.push(b.clone()); + j += 1; + } else { + buf.push(a.clone()); + i += 1; + } + } + buf.extend(items.get(i..mid).unwrap_or_default().iter().cloned()); + buf.extend(items.get(j..hi).unwrap_or_default().iter().cloned()); + lo = hi; + } + std::mem::swap(items, &mut buf); + width *= 2; + } + Ok(()) +} + +/// `Array.prototype.sort` (section 15.4.4.11). The elements are read, sorted with `undefined` +/// after every other value, written back from index 0, and the indices after them are deleted. +fn sort(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let compare = arg(args, 0); + if !matches!(compare, Value::Undefined) { + require_callable(interp, &compare, "the comparison")?; + } + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let mark = interp.temp_roots.len(); + let r = sort_elements(interp, o, len, &compare); + interp.temp_roots.truncate(mark); + r?; + Ok(Value::Object(o)) + }) +} + +fn sort_elements(interp: &mut Interp, o: ObjId, len: u64, compare: &Value) -> JsResult<()> { + let mut values = Vec::new(); + let mut undefined = 0u64; + let mut it = Indices::up(interp, o, 0, len)?; + while let Some(k) = it.next(interp)? { + match element(interp, o, k)? { + Some(Value::Undefined) => undefined += 1, + Some(v) => { + interp.temp_roots.push(v.clone()); + interp.reserve_heap(ELEMENT_BYTES)?; + values.push(v); + } + None => {} + } + } + if matches!(compare, Value::Undefined) { + let mut keyed = Vec::with_capacity(values.len()); + for v in values { + interp.use_fuel()?; + let s = interp.to_str(&v)?; + // Flattening a concatenation makes a new buffer, so it is charged. A key that is + // not an element is rooted so that collections count it. + let rope = s.buffer_addr().is_none(); + let bytes = crate::heap::string_bytes(s.len()); + if rope { + interp.reserve_heap(bytes)?; + } + let units = s.flat(); + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + if !matches!(v, Value::String(_)) { + interp.temp_roots.push(Value::String(s)); + } + keyed.push((units, v)); + } + merge_sort(interp, &mut keyed, |_, a, b| Ok(a.0 > b.0))?; + values = keyed.into_iter().map(|(_, v)| v).collect(); + } else { + merge_sort(interp, &mut values, |interp, a, b| { + let r = interp.call(compare, &Value::Undefined, &[a.clone(), b.clone()])?; + Ok(interp.to_number(&r)? > 0.0) + })?; + } + let mut i = 0u64; + for v in values { + interp.use_fuel()?; + put_at(interp, o, i as f64, v)?; + i += 1; + } + for _ in 0..undefined { + interp.use_fuel()?; + put_at(interp, o, i as f64, Value::Undefined)?; + i += 1; + } + let mut it = Indices::down(interp, o, i, len)?; + while let Some(k) = it.next(interp)? { + delete_at(interp, o, k as f64)?; + } + Ok(()) +} + +// ---- searching ---- + +/// `Array.prototype.indexOf` (section 15.4.4.14). +fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = f64::from(length_of(interp, o)?); + if len == 0.0 { + return Ok(Value::Number(-1.0)); + } + let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { 0.0 }; + if n >= len { + return Ok(Value::Number(-1.0)); + } + let start = if n >= 0.0 { n } else { (len + n).max(0.0) }; + let target = arg(args, 0); + let mut it = Indices::up(interp, o, start as u64, len as u64)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? + && strict_eq(&v, &target) + { + return Ok(number(k)); + } + } + Ok(Value::Number(-1.0)) + }) +} + +/// `Array.prototype.lastIndexOf` (section 15.4.4.15). +fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = f64::from(length_of(interp, o)?); + if len == 0.0 { + return Ok(Value::Number(-1.0)); + } + let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { len - 1.0 }; + let from = if n >= 0.0 { n.min(len - 1.0) } else { len + n }; + if from < 0.0 { + return Ok(Value::Number(-1.0)); + } + let target = arg(args, 0); + let mut it = Indices::down(interp, o, 0, from as u64 + 1)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? + && strict_eq(&v, &target) + { + return Ok(number(k)); + } + } + Ok(Value::Number(-1.0)) + }) +} + +// ---- iteration ---- + +/// What an iteration method does with each callback result. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Each { + Every, + Some, + /// `forEach`. + Visit, + Map, + Filter, +} + +/// `every`, `some`, `forEach`, `map` and `filter` (sections 15.4.4.16 to 15.4.4.20). +fn iterate(interp: &mut Interp, this: Value, args: &[Value], kind: Each) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let callback = arg(args, 0); + require_callable(interp, &callback, "the callback")?; + let this_arg = arg(args, 1); + let a = match kind { + Each::Map => Some(new_array(interp, len)?), + Each::Filter => Some(new_array(interp, 0)?), + _ => None, + }; + let roots: Vec = a.map(Value::Object).into_iter().collect(); + interp.rooted(&roots, |interp| { + let mut to = 0u64; + let mut it = Indices::up(interp, o, 0, u64::from(len))?; + while let Some(k) = it.next(interp)? { + let Some(v) = element(interp, o, k)? else { continue }; + let r = interp.call(&callback, &this_arg, &[v.clone(), number(k), Value::Object(o)])?; + match (kind, a) { + (Each::Every, _) if !r.truthy() => return Ok(Value::Bool(false)), + (Each::Some, _) if r.truthy() => return Ok(Value::Bool(true)), + (Each::Map, Some(a)) => create_at(interp, a, k as f64, r)?, + (Each::Filter, Some(a)) if r.truthy() => { + create_at(interp, a, to as f64, v)?; + to += 1; + } + _ => {} + } + } + Ok(match (kind, a) { + (Each::Every, _) => Value::Bool(true), + (Each::Some, _) => Value::Bool(false), + (_, Some(a)) => Value::Object(a), + _ => Value::Undefined, + }) + }) + }) +} + +fn every(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Every) +} + +fn some(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Some) +} + +fn for_each(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Visit) +} + +fn map(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Map) +} + +fn filter(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Filter) +} + +/// `reduce` and `reduceRight` (sections 15.4.4.21 and 15.4.4.22). +fn fold(interp: &mut Interp, this: Value, args: &[Value], right: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let callback = arg(args, 0); + require_callable(interp, &callback, "the callback")?; + let mut it = Indices::new(interp, o, 0, len, !right, vec![Map::Shift(0)])?; + let slot = interp.temp_roots.len(); + interp.temp_roots.push(Value::Undefined); + // The accumulator is kept in a root slot while the elements are read. + let keep = |interp: &mut Interp, acc: &Value| { + if let Some(s) = interp.temp_roots.get_mut(slot) { + *s = acc.clone(); + } + }; + let r = (|| { + let mut acc = match args.get(1) { + Some(v) => v.clone(), + None => loop { + let Some(k) = it.next(interp)? else { return Err(interp.type_error("reduce of an empty array with no initial value")) }; + if let Some(v) = element(interp, o, k)? { + break v; + } + }, + }; + keep(interp, &acc); + while let Some(k) = it.next(interp)? { + let Some(v) = element(interp, o, k)? else { continue }; + acc = interp.call(&callback, &Value::Undefined, &[acc, v, number(k), Value::Object(o)])?; + keep(interp, &acc); + } + Ok(acc) + })(); + interp.temp_roots.truncate(slot); + r + }) +} + +fn reduce(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + fold(interp, this, args, false) +} + +fn reduce_right(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + fold(interp, this, args, true) +} diff --git a/crates/js/src/builtins/global.rs b/crates/js/src/builtins/global.rs new file mode 100644 index 000000000..182f3aad5 --- /dev/null +++ b/crates/js/src/builtins/global.rs @@ -0,0 +1,364 @@ +//! The global functions (ECMA-262 5.1 section 15.1.2 and 15.1.3): `parseInt`, `parseFloat`, +//! `isNaN`, `isFinite` and the URI functions, and Annex B's `escape` and `unescape` (sections +//! B.2.1 and B.2.2). Each code unit read takes a unit of fuel, and the strings they build are +//! checked against the heap limit as they grow. + +use super::{Builder, arg, method}; +use crate::heap::NativeFn; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::value::{Value, is_str_space, to_int32}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let global = interp.global; + let functions: [(&'static str, NativeFn, u32); 10] = [ + ("parseInt", parse_int, 2), + ("parseFloat", parse_float, 1), + ("isNaN", is_nan, 1), + ("isFinite", is_finite, 1), + ("decodeURI", decode_uri, 1), + ("decodeURIComponent", decode_uri_component, 1), + ("encodeURI", encode_uri, 1), + ("encodeURIComponent", encode_uri_component, 1), + ("escape", escape, 1), + ("unescape", unescape, 1), + ]; + for (name, f, length) in functions { + method(interp, global, name, f, length)?; + } + Ok(()) +} + +/// `s` after its leading white space and line terminators. +fn trim_start(s: &[u16]) -> &[u16] { + s.get(s.iter().position(|&u| !is_str_space(u)).unwrap_or(s.len())..).unwrap_or_default() +} + +/// ToString of the first argument, as code units, with each one taking a unit of fuel. A +/// concatenation is flattened into a new buffer, which is charged to the heap. +pub(super) fn units_of(interp: &mut Interp, args: &[Value]) -> JsResult> { + let s = interp.to_str(&arg(args, 0))?; + for _ in 0..s.len() { + interp.use_fuel()?; + } + let rope = s.buffer_addr().is_none(); + let bytes = crate::heap::string_bytes(s.len()); + if rope { + let mark = interp.temp_roots.len(); + interp.temp_roots.push(Value::String(s.clone())); + let reserved = interp.reserve_heap(bytes); + interp.temp_roots.truncate(mark); + reserved?; + } + let units = s.flat(); + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + Ok(units) +} + +fn digit_value(u: u16) -> Option { + char::from_u32(u32::from(u)).and_then(|c| c.to_digit(36)) +} + +/// `parseInt` (section 15.1.2.2). +fn parse_int(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let radix = to_int32(interp.to_number(&arg(args, 1))?); + let mut rest = trim_start(&s); + let sign = match rest.first() { + Some(&u) if u == u16::from(b'-') => -1.0, + _ => 1.0, + }; + if matches!(rest.first(), Some(&u) if u == u16::from(b'-') || u == u16::from(b'+')) { + rest = rest.get(1..).unwrap_or_default(); + } + let mut r = radix as u32; + let mut strip_prefix = true; + if radix != 0 { + if !(2..=36).contains(&radix) { + return Ok(Value::Number(f64::NAN)); + } + strip_prefix = radix == 16; + } else { + r = 10; + } + if strip_prefix + && rest.len() >= 2 + && rest.first() == Some(&u16::from(b'0')) + && matches!(rest.get(1), Some(&u) if u == u16::from(b'x') || u == u16::from(b'X')) + { + rest = rest.get(2..).unwrap_or_default(); + r = 16; + } + let n = rest.iter().map_while(|&u| digit_value(u).filter(|&d| d < r)).count(); + let prefix = rest.get(..n).unwrap_or_default(); + if prefix.is_empty() { + return Ok(Value::Number(f64::NAN)); + } + let digits = prefix.iter().filter_map(|&u| digit_value(u)); + let value = match r { + 10 => parse_decimal(interp, prefix)?, + 2 | 4 | 8 | 16 | 32 => crate::chars::radix_value(digits, r).unwrap_or(f64::NAN), + // Other radices may be approximated (section 15.1.2.2, step 13). + _ => digits.fold(0.0, |n, d| n * f64::from(r) + f64::from(d)), + }; + Ok(Value::Number(sign * value)) +} + +/// The number that `text`, ASCII decimal digits with an optional fraction and exponent, stands +/// for. The text is copied into a string of one byte per code unit for parsing, and that copy is +/// checked against the heap limit first. +fn parse_decimal(interp: &mut Interp, text: &[u16]) -> JsResult { + interp.reserve_heap(text.len())?; + let mut ascii = String::with_capacity(text.len()); + ascii.extend(text.iter().filter_map(|&u| char::from_u32(u32::from(u)))); + Ok(ascii.parse::().unwrap_or(f64::NAN)) +} + +/// The length of the longest prefix of `s` that is a StrDecimalLiteral without its sign and +/// without `Infinity`. +fn decimal_prefix(s: &[u16]) -> usize { + let is_digit = |i: usize| s.get(i).is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); + let mut i = 0; + while is_digit(i) { + i += 1; + } + let mut mantissa = i; + if s.get(i) == Some(&u16::from(b'.')) { + let mut j = i + 1; + while is_digit(j) { + j += 1; + } + mantissa += j - i - 1; + if mantissa > 0 { + i = j; + } + } + if mantissa == 0 { + return 0; + } + if matches!(s.get(i), Some(&u) if u == u16::from(b'e') || u == u16::from(b'E')) { + let mut j = i + 1; + if matches!(s.get(j), Some(&u) if u == u16::from(b'+') || u == u16::from(b'-')) { + j += 1; + } + let digits_start = j; + while is_digit(j) { + j += 1; + } + if j > digits_start { + i = j; + } + } + i +} + +/// `parseFloat` (section 15.1.2.3). +fn parse_float(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut rest = trim_start(&s); + let mut sign = 1.0; + if let Some(&u) = rest.first() + && (u == u16::from(b'-') || u == u16::from(b'+')) + { + if u == u16::from(b'-') { + sign = -1.0; + } + rest = rest.get(1..).unwrap_or_default(); + } + let infinity: Vec = "Infinity".encode_utf16().collect(); + if rest.starts_with(&infinity) { + return Ok(Value::Number(sign * f64::INFINITY)); + } + let n = decimal_prefix(rest); + if n == 0 { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number(sign * parse_decimal(interp, rest.get(..n).unwrap_or_default())?)) +} + +/// `isNaN` (section 15.1.2.4). +fn is_nan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_nan())) +} + +/// `isFinite` (section 15.1.2.5). +fn is_finite(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_finite())) +} + +// ---- URIs ---- + +const URI_RESERVED: &str = ";/?:@&=+$,"; +const URI_MARK: &str = "-_.!~*'()"; + +fn in_set(u: u16, extra: &str) -> bool { + u < 0x80 && extra.as_bytes().contains(&(u as u8)) +} + +fn is_unreserved(u: u16) -> bool { + u < 0x80 && ((u as u8).is_ascii_alphanumeric() || in_set(u, URI_MARK)) +} + +fn uri_error(interp: &mut Interp, why: &str) -> crate::interp::Abrupt { + interp.throw(ErrorType::Uri, why) +} + +const HEX: &[u8; 16] = b"0123456789ABCDEF"; + +fn push_byte(out: &mut Builder, interp: &mut Interp, b: u8) -> JsResult<()> { + let units = [u16::from(b'%'), u16::from(HEX[usize::from(b >> 4)]), u16::from(HEX[usize::from(b & 15)])]; + out.push_units(interp, &units) +} + +/// Encode (section 15.1.3): every code unit outside the unescaped set as the `%XX` escapes of +/// its UTF-8 bytes. +fn encode(interp: &mut Interp, args: &[Value], unescaped: fn(u16) -> bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + while let Some(&c) = s.get(k) { + if unescaped(c) { + out.push_units(interp, &[c])?; + k += 1; + continue; + } + let v = match c { + 0xDC00..=0xDFFF => return Err(uri_error(interp, "a lone trailing surrogate cannot be encoded")), + 0xD800..=0xDBFF => match s.get(k + 1) { + Some(&d) if (0xDC00..=0xDFFF).contains(&d) => { + k += 1; + (u32::from(c) - 0xD800) * 0x400 + (u32::from(d) - 0xDC00) + 0x10000 + } + _ => return Err(uri_error(interp, "a lone leading surrogate cannot be encoded")), + }, + _ => u32::from(c), + }; + let mut buf = [0u8; 4]; + let Some(ch) = char::from_u32(v) else { return Err(uri_error(interp, "the string holds a code point that cannot be encoded")) }; + for &b in ch.encode_utf8(&mut buf).as_bytes() { + push_byte(&mut out, interp, b)?; + } + k += 1; + } + Ok(out.finish(interp)) +} + +fn hex_byte(s: &[u16], at: usize) -> Option { + let hi = crate::chars::hex_value(u32::from(*s.get(at)?))?; + let lo = crate::chars::hex_value(u32::from(*s.get(at + 1)?))?; + u8::try_from(hi * 16 + lo).ok() +} + +/// Decode (section 15.1.3): each `%XX` escape sequence of a UTF-8 character as the character, +/// except an escaped character in `reserved`, which stays escaped. +fn decode(interp: &mut Interp, args: &[Value], reserved: &str) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + while let Some(&c) = s.get(k) { + if c != u16::from(b'%') { + out.push_units(interp, &[c])?; + k += 1; + continue; + } + let start = k; + let Some(b) = hex_byte(&s, k + 1) else { return Err(uri_error(interp, "a % is not followed by two hexadecimal digits")) }; + k += 2; + if b & 0x80 == 0 { + let c = u16::from(b); + if in_set(c, reserved) { + out.push_units(interp, s.get(start..=k).unwrap_or_default())?; + } else { + out.push_units(interp, &[c])?; + } + k += 1; + continue; + } + let n = b.leading_ones() as usize; + if n == 1 || n > 4 { + return Err(uri_error(interp, "an escape sequence is not the start of a UTF-8 character")); + } + let mut octets = vec![b]; + for _ in 1..n { + k += 1; + let next = if s.get(k) == Some(&u16::from(b'%')) { hex_byte(&s, k + 1) } else { None }; + let Some(b) = next.filter(|b| b & 0xC0 == 0x80) else { return Err(uri_error(interp, "an escaped UTF-8 character is incomplete")) }; + k += 2; + octets.push(b); + } + let Some(ch) = std::str::from_utf8(&octets).ok().and_then(|t| t.chars().next()) else { + return Err(uri_error(interp, "an escape sequence is not valid UTF-8")); + }; + let mut units = [0u16; 2]; + out.push_units(interp, ch.encode_utf16(&mut units))?; + k += 1; + } + Ok(out.finish(interp)) +} + +fn encode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + encode(interp, args, |u| is_unreserved(u) || in_set(u, URI_RESERVED) || u == u16::from(b'#')) +} + +fn encode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + encode(interp, args, is_unreserved) +} + +fn decode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + decode(interp, args, ";/?:@&=+$,#") +} + +fn decode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + decode(interp, args, "") +} + +// ---- Annex B ---- + +/// `escape` (section B.2.1). +fn escape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + for &u in s.iter() { + if u < 0x80 && ((u as u8).is_ascii_alphanumeric() || b"@*_+-./".contains(&(u as u8))) { + out.push_units(interp, &[u])?; + } else if u < 256 { + push_byte(&mut out, interp, u as u8)?; + } else { + let hex = |shift: u16| u16::from(HEX[usize::from((u >> shift) & 15)]); + out.push_units(interp, &[u16::from(b'%'), u16::from(b'u'), hex(12), hex(8), hex(4), hex(0)])?; + } + } + Ok(out.finish(interp)) +} + +/// `unescape` (section B.2.2). +fn unescape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + let hex4 = |at: usize| -> Option { + let mut v = 0u16; + for i in 0..4 { + v = v * 16 + u16::try_from(crate::chars::hex_value(u32::from(*s.get(at + i)?))?).ok()?; + } + Some(v) + }; + while let Some(&c) = s.get(k) { + let mut unit = c; + if c == u16::from(b'%') { + if s.get(k + 1) == Some(&u16::from(b'u')) + && let Some(v) = hex4(k + 2) + { + unit = v; + k += 5; + } else if let Some(b) = hex_byte(&s, k + 1) { + unit = u16::from(b); + k += 2; + } + } + out.push_units(interp, &[unit])?; + k += 1; + } + Ok(out.finish(interp)) +} diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs new file mode 100644 index 000000000..4375303fe --- /dev/null +++ b/crates/js/src/builtins/json.rs @@ -0,0 +1,734 @@ +//! The `JSON` object (ECMA-262 5.1 section 15.12): `parse` with a reviver and `stringify` with a +//! replacer function or property list and indentation. +//! +//! Both work without recursion. `parse` reads nested arrays and objects with an explicit stack and +//! walks them for the reviver with another, and `stringify` writes nested values from a stack of +//! the objects it is inside, so nesting is limited only by the heap. Each code unit of the text +//! parsed, each property visited and each value written takes a unit of fuel; strings, arrays and +//! objects are checked against the heap limit before they are made, and the text `stringify` +//! writes is checked as it grows. + +use std::collections::HashSet; + +use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; +use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; +use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; +use crate::value::{Str, Value, number_to_str}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.object_proto; + let json = interp.heap.alloc_object(Object::new(Class::Json, Some(proto))); + method(interp, json, "parse", parse, 2)?; + method(interp, json, "stringify", stringify, 3)?; + let global = interp.global; + data(interp, global, "JSON", Value::Object(json), crate::heap::HIDDEN) +} + +// ---- parse ---- + +/// A container being read, with the property name its next value goes under. +enum Open { + Array { obj: ObjId, next: u32 }, + Object { obj: ObjId, name: Option }, +} + +struct Parser<'a> { + s: &'a [u16], + pos: usize, +} + +const QUOTE: u16 = b'"' as u16; + +impl Parser<'_> { + fn peek(&self) -> Option { + self.s.get(self.pos).copied() + } + + fn skip_space(&mut self) { + while matches!(self.peek(), Some(0x09 | 0x0A | 0x0D | 0x20)) { + self.pos += 1; + } + } + + fn eat(&mut self, c: u8) -> bool { + if self.peek() == Some(u16::from(c)) { + self.pos += 1; + return true; + } + false + } + + fn word(&mut self, w: &str) -> bool { + let units: Vec = w.encode_utf16().collect(); + if self.s.get(self.pos..).is_some_and(|rest| rest.starts_with(&units)) { + self.pos += units.len(); + return true; + } + false + } + + /// A JSONString's characters (section 15.12.1.1), after its opening quote. + fn string(&mut self) -> Result, &'static str> { + let mut out = Vec::new(); + loop { + let Some(c) = self.peek() else { return Err("a string is not closed") }; + self.pos += 1; + match c { + QUOTE => return Ok(out), + 0x5C => { + let Some(e) = self.peek() else { return Err("a string is not closed") }; + self.pos += 1; + let unit = match u8::try_from(e).unwrap_or(0) { + b'"' => QUOTE, + b'\\' => 0x5C, + b'/' => 0x2F, + b'b' => 0x08, + b'f' => 0x0C, + b'n' => 0x0A, + b'r' => 0x0D, + b't' => 0x09, + b'u' => { + let mut v = 0u32; + for _ in 0..4 { + let Some(d) = self.peek().and_then(|u| crate::chars::hex_value(u32::from(u))) else { + return Err("a \\u escape needs four hexadecimal digits"); + }; + self.pos += 1; + v = v * 16 + d; + } + u16::try_from(v).unwrap_or(0) + } + _ => return Err("a string holds an escape sequence JSON does not have"), + }; + out.push(unit); + } + 0..=0x1F => return Err("a string holds a control character"), + _ => out.push(c), + } + } + } + + /// A JSONNumber (section 15.12.1.1). + fn number(&mut self) -> Result { + let start = self.pos; + self.eat(b'-'); + let digit = |p: &Self| p.peek().is_some_and(|u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); + if self.eat(b'0') { + } else if digit(self) { + while digit(self) { + self.pos += 1; + } + } else { + return Err("a number has no digits"); + } + if self.eat(b'.') { + if !digit(self) { + return Err("a number's fraction has no digits"); + } + while digit(self) { + self.pos += 1; + } + } + if self.eat(b'e') || self.eat(b'E') { + if !self.eat(b'+') { + self.eat(b'-'); + } + if !digit(self) { + return Err("a number's exponent has no digits"); + } + while digit(self) { + self.pos += 1; + } + } + let text = String::from_utf16_lossy(self.s.get(start..self.pos).unwrap_or_default()); + text.parse::().map_err(|_| "a number cannot be read") + } +} + +/// What the parser read at a value position. +enum Token { + Value(Value), + OpenArray, + OpenObject, +} + +fn syntax(interp: &mut Interp, why: &str, at: usize) -> crate::interp::Abrupt { + interp.throw(ErrorType::Syntax, &format!("JSON.parse: {why} (at offset {at})")) +} + +/// `JSON.parse` (section 15.12.2). +fn parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let source = Value::String(interp.to_str(&arg(args, 0))?); + let mark = interp.temp_roots.len(); + // The text stays rooted for the whole parse, so a collection still counts it. + interp.temp_roots.push(source.clone()); + let parsed = super::global::units_of(interp, std::slice::from_ref(&source)).and_then(|text| parse_text(interp, &text)); + interp.temp_roots.truncate(mark); + drop(source); + let v = parsed?; + let reviver = arg(args, 1); + if !interp.is_callable(&reviver) { + return Ok(v); + } + interp.temp_roots.push(v.clone()); + let r = revive(interp, v, &reviver); + interp.temp_roots.truncate(mark); + r +} + +/// Reads a value at the parser's position: a primitive, or the start of an array or object. +fn token(interp: &mut Interp, p: &mut Parser<'_>) -> JsResult { + p.skip_space(); + let at = p.pos; + let t = match p.peek() { + Some(QUOTE) => { + p.pos += 1; + let units = p.string().map_err(|why| syntax(interp, why, at))?; + Token::Value(new_string(interp, units)?) + } + Some(0x5B) => { + p.pos += 1; + Token::OpenArray + } + Some(0x7B) => { + p.pos += 1; + Token::OpenObject + } + _ if p.word("true") => Token::Value(Value::Bool(true)), + _ if p.word("false") => Token::Value(Value::Bool(false)), + _ if p.word("null") => Token::Value(Value::Null), + Some(u) if u == u16::from(b'-') || (u16::from(b'0')..=u16::from(b'9')).contains(&u) => { + Token::Value(Value::Number(p.number().map_err(|why| syntax(interp, why, at))?)) + } + Some(_) => return Err(syntax(interp, "unexpected character", at)), + None => return Err(syntax(interp, "unexpected end of text", at)), + }; + Ok(t) +} + +/// Reads the property name of an object member and its colon, or `None` at the closing brace +/// of an empty object. +fn member_name(interp: &mut Interp, p: &mut Parser<'_>, first: bool) -> JsResult> { + p.skip_space(); + if first && p.eat(b'}') { + return Ok(None); + } + let at = p.pos; + if !p.eat(b'"') { + return Err(syntax(interp, "a property name must be a string", at)); + } + let units = p.string().map_err(|why| syntax(interp, why, at))?; + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + // The name is counted while it waits for its value; `parse_text` releases it before `define` + // accounts for it, and the native call's return releases it on every other path. + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + interp.held_bytes = interp.held_bytes.saturating_add(bytes); + p.skip_space(); + if !p.eat(b':') { + return Err(syntax(interp, "a property name must be followed by ':'", p.pos)); + } + Ok(Some(units.into())) +} + +fn parse_text(interp: &mut Interp, text: &[u16]) -> JsResult { + let mut p = Parser { s: text, pos: 0 }; + let mut stack: Vec = Vec::new(); + // The next value to read: the first of an array or object, or one after a comma. + let mut value = token(interp, &mut p)?; + loop { + // Open a container, or place a finished value into the container it belongs to. + let mut done = match value { + Token::OpenArray => { + let a = new_array(interp, 0)?; + interp.temp_roots.push(Value::Object(a)); + p.skip_space(); + if p.eat(b']') { + Some(Value::Object(a)) + } else { + stack.push(Open::Array { obj: a, next: 0 }); + value = token(interp, &mut p)?; + continue; + } + } + Token::OpenObject => { + interp.reserve_heap(OBJECT_BYTES)?; + let o = interp.new_object(); + interp.temp_roots.push(Value::Object(o)); + match member_name(interp, &mut p, true)? { + None => Some(Value::Object(o)), + Some(name) => { + stack.push(Open::Object { obj: o, name: Some(name) }); + value = token(interp, &mut p)?; + continue; + } + } + } + Token::Value(v) => Some(v), + }; + // Close every container that the finished value completes. + while let Some(v) = done.take() { + let Some(top) = stack.last_mut() else { + p.skip_space(); + if p.pos < text.len() { + return Err(syntax(interp, "unexpected text after the value", p.pos)); + } + return Ok(v); + }; + match top { + Open::Array { obj, next } => { + let (obj, i) = (*obj, *next); + *next = next.saturating_add(1); + interp.rooted(std::slice::from_ref(&v), |i| i.reserve_heap(ELEMENT_BYTES))?; + interp.define(obj, key_of(f64::from(i)), Prop::Data { value: v, flags: OPEN })?; + p.skip_space(); + if p.eat(b',') { + break; + } + if !p.eat(b']') { + return Err(syntax(interp, "expected ',' or ']'", p.pos)); + } + stack.pop(); + done = Some(Value::Object(obj)); + } + Open::Object { obj, name } => { + let obj = *obj; + let Some(k) = name.take() else { return Err(syntax(interp, "a member has no name", p.pos)) }; + interp.rooted(std::slice::from_ref(&v), |i| i.reserve_heap(ELEMENT_BYTES))?; + let held = string_bytes(k.len()); + interp.heap.bytes = interp.heap.bytes.saturating_sub(held); + interp.held_bytes = interp.held_bytes.saturating_sub(held); + interp.define(obj, k, Prop::Data { value: v, flags: OPEN })?; + p.skip_space(); + if p.eat(b',') { + let k = member_name(interp, &mut p, false)?; + if let Some(Open::Object { name, .. }) = stack.last_mut() { + *name = k; + } + break; + } + if !p.eat(b'}') { + return Err(syntax(interp, "expected ',' or '}'", p.pos)); + } + stack.pop(); + done = Some(Value::Object(obj)); + } + } + } + value = token(interp, &mut p)?; + } +} + +/// A holder being walked for the reviver (section 15.12.2, Walk). +struct Walk { + holder: ObjId, + name: Key, + val: Value, + keys: Vec, + next: usize, +} + +/// Starts walking `holder[name]`: reads the value and, for an array or object, the names of its +/// elements or own enumerable properties. The value is pushed onto the temporary roots. +fn enter(interp: &mut Interp, holder: ObjId, name: Key) -> JsResult { + let val = interp.get_from(holder, &name, &Value::Object(holder))?; + interp.temp_roots.push(val.clone()); + let mut keys = Vec::new(); + if let Value::Object(o) = val { + if interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })) { + let len = length_of(interp, o)?; + interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?; + for i in 0..len { + interp.use_fuel()?; + keys.push(key_of(f64::from(i))); + } + } else { + for k in interp.own_keys(o)? { + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + keys.push(k); + } + } + } + } + Ok(Walk { holder, name, val, keys, next: 0 }) +} + +/// Walks the parsed value bottom-up, calling the reviver for each property and replacing or +/// deleting the property with what it returns. +fn revive(interp: &mut Interp, v: Value, reviver: &Value) -> JsResult { + interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?; + let root = interp.new_object(); + interp.temp_roots.push(Value::Object(root)); + interp.define(root, key(""), Prop::Data { value: v, flags: OPEN })?; + let mut stack = vec![enter(interp, root, key(""))?]; + let mut ret: Option = None; + loop { + let Some(top) = stack.last_mut() else { return Ok(Value::Undefined) }; + if let (Some(r), Value::Object(o)) = (ret.take(), &top.val) { + let o = *o; + let Some(k) = top.next.checked_sub(1).and_then(|i| top.keys.get(i)).cloned() else { return Ok(Value::Undefined) }; + if matches!(r, Value::Undefined) { + interp.delete(o, &k, false)?; + } else { + interp.rooted(std::slice::from_ref(&r), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.define_own_property(o, k, &Desc::data(r.clone(), OPEN), false) + })?; + } + } + if let (Some(k), Value::Object(o)) = (top.keys.get(top.next).cloned(), &top.val) { + let o = *o; + top.next += 1; + interp.use_fuel()?; + stack.push(enter(interp, o, k)?); + continue; + } + let Some(done) = stack.pop() else { return Ok(Value::Undefined) }; + let name = Value::String(Str::from_rc(done.name)); + let r = interp.call(reviver, &Value::Object(done.holder), &[name, done.val])?; + interp.temp_roots.pop(); + if stack.is_empty() { + return Ok(r); + } + ret = Some(r); + } +} + +// ---- stringify ---- + +/// The settings of one `stringify` call. +struct Settings { + replacer: Option, + properties: Option>, + gap: Vec, +} + +/// A value as `stringify` writes it, after `toJSON`, the replacer and unwrapping. +enum Item { + Skip, + Primitive(Value), + Container(ObjId), +} + +/// An object or array being written. +struct Writing { + obj: ObjId, + keys: Vec, + array: bool, + next: usize, + count: usize, + /// The indentation outside it. + stepback: usize, +} + +struct Writer { + out: Builder, + indent: Vec, + stack: Vec, + open: HashSet, +} + +/// `JSON.stringify` (section 15.12.3). +fn stringify(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let mark = interp.temp_roots.len(); + let r = stringify_rooted(interp, args); + interp.temp_roots.truncate(mark); + r +} + +fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { + let settings = settings(interp, &arg(args, 1), &arg(args, 2))?; + interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?; + let wrapper = interp.new_object(); + interp.temp_roots.push(Value::Object(wrapper)); + interp.define(wrapper, key(""), Prop::Data { value: arg(args, 0), flags: OPEN })?; + let mut w = Writer { out: Builder::new(), indent: Vec::new(), stack: Vec::new(), open: HashSet::new() }; + match resolve(interp, &settings, wrapper, &key(""))? { + Item::Skip => return Ok(Value::Undefined), + Item::Primitive(v) => write_primitive(interp, &mut w.out, &v)?, + Item::Container(o) => { + interp.temp_roots.push(Value::Object(o)); + open(interp, &settings, &mut w, o)?; + } + } + while !w.stack.is_empty() { + step(interp, &settings, &mut w)?; + } + Ok(w.out.finish(interp)) +} + +/// Reads the replacer and the space argument (section 15.12.3, steps 4 to 8). +fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult { + let mut s = Settings { replacer: None, properties: None, gap: Vec::new() }; + if let Value::Object(r) = replacer { + if interp.is_callable(replacer) { + s.replacer = Some(replacer.clone()); + } else if interp.heap.object(*r).is_some_and(|o| matches!(o.class, Class::Array { .. })) { + s.properties = Some(property_list(interp, *r)?); + } + } + let space = unwrap_primitive(interp, space, false)?.unwrap_or_else(|| space.clone()); + match space { + Value::Number(_) => { + let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; + s.gap = vec![u16::from(b' '); n]; + } + Value::String(t) => s.gap = t.flat().iter().take(10).copied().collect(), + _ => {} + } + Ok(s) +} + +/// The property names a replacer array lists, without repeats (section 15.12.3, step 4.b). +fn property_list(interp: &mut Interp, r: ObjId) -> JsResult> { + let len = length_of(interp, r)?; + let mut list: Vec = Vec::new(); + let mut seen = HashSet::new(); + for i in 0..len { + interp.use_fuel()?; + let v = interp.get_from(r, &key_of(f64::from(i)), &Value::Object(r))?; + let item = match &v { + Value::String(s) => Some(s.clone()), + Value::Number(_) => Some(interp.primitive_to_str(&v)), + Value::Object(_) => match unwrap_primitive(interp, &v, true)? { + Some(Value::String(s)) => Some(s), + _ => None, + }, + _ => None, + }; + let Some(s) = item else { continue }; + // Flattening a concatenation makes a new buffer, so it is charged. + let rope = s.buffer_addr().is_none(); + let bytes = string_bytes(s.len()); + if rope { + interp.reserve_heap(bytes)?; + } + let k = s.flat(); + if seen.insert(k.clone()) { + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + // The list lives until the call returns, so its names are rooted. + interp.temp_roots.push(Value::String(s)); + list.push(k); + } + } + Ok(list) +} + +/// Steps 1 to 4 and 10 of Str (section 15.12.3): reads `holder[key]`, applies `toJSON` and the +/// replacer, unwraps Number, String and Boolean objects, and leaves out functions and +/// `undefined`. +fn resolve(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key) -> JsResult { + let slot = interp.temp_roots.len(); + interp.temp_roots.push(Value::Undefined); + let r = resolve_rooted(interp, settings, holder, k, slot); + interp.temp_roots.truncate(slot); + r +} + +fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key, slot: usize) -> JsResult { + let keep = |interp: &mut Interp, v: &Value| { + if let Some(s) = interp.temp_roots.get_mut(slot) { + *s = v.clone(); + } + }; + let mut v = interp.get_from(holder, k, &Value::Object(holder))?; + keep(interp, &v); + let name = Value::String(Str::from_rc(k.clone())); + if let Value::Object(o) = v { + let to_json = interp.get_from(o, &key("toJSON"), &v)?; + if interp.is_callable(&to_json) { + v = interp.call(&to_json, &v, std::slice::from_ref(&name))?; + keep(interp, &v); + } + } + if let Some(replacer) = &settings.replacer { + v = interp.call(replacer, &Value::Object(holder), &[name, v])?; + keep(interp, &v); + } + if let Value::Object(o) = &v { + if let Some(Class::Boolean(b)) = interp.heap.object(*o).map(|x| &x.class) { + v = Value::Bool(*b); + } else if let Some(p) = unwrap_primitive(interp, &v, false)? { + v = p; + } + } + Ok(match v { + Value::Undefined => Item::Skip, + Value::Object(_) if interp.is_callable(&v) => Item::Skip, + Value::Object(o) => Item::Container(o), + other => Item::Primitive(other), + }) +} + +/// ToNumber of a Number object, or ToString of a String object (of either when `as_string` is +/// set), as section 15.12.3 applies them in Str step 4, step 4.b.ii and step 5. Returns `None` +/// for any other value. When the method that [[DefaultValue]] (section 8.12.8) calls first is not +/// found on the object or on its prototype chain before the object's standard prototype, the +/// result is the object's [[PrimitiveValue]] (sections 8.6.2 and 9.9), the value that +/// `Number.prototype.valueOf` (15.7.4.4), `Number.prototype.toString` (15.7.4.2) and +/// `String.prototype.toString` (15.5.4.2) return. Otherwise ToPrimitive calls the method found. +fn unwrap_primitive(interp: &mut Interp, v: &Value, as_string: bool) -> JsResult> { + let Value::Object(o) = v else { return Ok(None) }; + let (primitive, proto, string) = match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Number(n)) => (Value::Number(*n), interp.intrinsics.number_proto, as_string), + Some(Class::String(s)) => (Value::String(s.clone()), interp.intrinsics.string_proto, true), + _ => return Ok(None), + }; + let (hint, name) = if string { (Hint::String, "toString") } else { (Hint::Number, "valueOf") }; + let p = if reaches_prototype(interp, *o, proto, name)? { primitive } else { interp.to_primitive(v, hint)? }; + Ok(Some(if string { Value::String(interp.primitive_to_str(&p)) } else { Value::Number(interp.to_number(&p)?) })) +} + +/// Whether looking up `name` from `o` reaches the object `proto` on the prototype chain before +/// any object on the way, `proto` included, has its own property `name`. +fn reaches_prototype(interp: &mut Interp, o: ObjId, proto: ObjId, name: &str) -> JsResult { + let k = key(name); + let mut cur = Some(o); + while let Some(id) = cur { + interp.use_fuel()?; + if interp.get_own_property(id, &k)?.is_some() { + return Ok(false); + } + if id == proto { + return Ok(true); + } + cur = interp.heap.object(id).and_then(|x| x.proto); + } + Ok(false) +} + +/// Quote (section 15.12.3): the string in double quotes with the characters JSON escapes. +fn write_quoted(interp: &mut Interp, out: &mut Builder, s: &[u16]) -> JsResult<()> { + out.push_units(interp, &[QUOTE])?; + let mut start = 0; + for (i, &c) in s.iter().enumerate() { + let escape: Option<&str> = match c { + QUOTE => Some("\\\""), + 0x5C => Some("\\\\"), + 0x08 => Some("\\b"), + 0x0C => Some("\\f"), + 0x0A => Some("\\n"), + 0x0D => Some("\\r"), + 0x09 => Some("\\t"), + 0..=0x1F => None, + _ => continue, + }; + out.push_units(interp, s.get(start..i).unwrap_or_default())?; + match escape { + Some(e) => out.push_str(interp, e)?, + None => out.push_str(interp, &format!("\\u{c:04x}"))?, + } + start = i + 1; + } + out.push_units(interp, s.get(start..).unwrap_or_default())?; + out.push_units(interp, &[QUOTE]) +} + +fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResult<()> { + match v { + Value::Null => out.push_str(interp, "null"), + Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), + Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), + Value::Number(_) => out.push_str(interp, "null"), + Value::String(s) => write_quoted(interp, out, &s.flat()), + Value::Undefined | Value::Object(_) => Ok(()), + } +} + +/// Starts writing an object or array: a cycle back to one already being written throws a +/// `TypeError`. The caller pushes `o` onto the temporary roots first, and `step` pops it when the +/// object or array is closed. +fn open(interp: &mut Interp, settings: &Settings, w: &mut Writer, o: ObjId) -> JsResult<()> { + if w.open.contains(&o) { + return Err(interp.type_error("JSON.stringify cannot write a structure that contains itself")); + } + let array = interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })); + let keys = if array { + let len = length_of(interp, o)?; + // Every element is written as at least `null` and a comma, so a length that cannot fit + // is refused before anything is listed. + if u64::from(len).saturating_mul(5) > (interp.limits.heap_bytes / 4) as u64 { + return Err(crate::interp::Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?; + let mut keys = Vec::with_capacity(len as usize); + for i in 0..len { + interp.use_fuel()?; + keys.push(key_of(f64::from(i))); + } + keys + } else if let Some(list) = &settings.properties { + list.clone() + } else { + let mut keys = Vec::new(); + for k in interp.own_keys(o)? { + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + keys.push(k); + } + } + keys + }; + w.open.insert(o); + let stepback = w.indent.len(); + w.indent.extend_from_slice(&settings.gap); + w.out.push_str(interp, if array { "[" } else { "{" })?; + w.stack.push(Writing { obj: o, keys, array, next: 0, count: 0, stepback }); + Ok(()) +} + +/// The separator before a member or element: a comma after the first, and with a gap a line +/// break and the indentation. +fn separator(interp: &mut Interp, w: &mut Writer, first: bool, gap: bool) -> JsResult<()> { + if !first { + w.out.push_str(interp, ",")?; + } + if gap { + w.out.push_str(interp, "\n")?; + w.out.push_units(interp, &w.indent.clone())?; + } + Ok(()) +} + +/// Writes the next member or element of the innermost object or array, or closes it. +fn step(interp: &mut Interp, settings: &Settings, w: &mut Writer) -> JsResult<()> { + let gap = !settings.gap.is_empty(); + let Some(top) = w.stack.last_mut() else { return Ok(()) }; + let (obj, array, count) = (top.obj, top.array, top.count); + let Some(k) = top.keys.get(top.next).cloned() else { + let stepback = top.stepback; + w.stack.pop(); + w.open.remove(&obj); + interp.temp_roots.pop(); + w.indent.truncate(stepback); + if count > 0 && gap { + w.out.push_str(interp, "\n")?; + w.out.push_units(interp, &w.indent.clone())?; + } + return w.out.push_str(interp, if array { "]" } else { "}" }); + }; + top.next += 1; + interp.use_fuel()?; + let item = resolve(interp, settings, obj, &k)?; + if !array && matches!(item, Item::Skip) { + return Ok(()); + } + if let Some(top) = w.stack.last_mut() { + top.count += 1; + } + // The separator and the key reserve heap, so a container is rooted before they are written. + if let Item::Container(o) = item { + interp.temp_roots.push(Value::Object(o)); + } + separator(interp, w, count == 0, gap)?; + if !array { + write_quoted(interp, &mut w.out, &k)?; + w.out.push_str(interp, if gap { ": " } else { ":" })?; + } + match item { + Item::Skip => w.out.push_str(interp, "null"), + Item::Primitive(v) => write_primitive(interp, &mut w.out, &v), + Item::Container(o) => open(interp, settings, w, o), + } +} diff --git a/crates/js/src/builtins/math.rs b/crates/js/src/builtins/math.rs new file mode 100644 index 000000000..08fe96a04 --- /dev/null +++ b/crates/js/src/builtins/math.rs @@ -0,0 +1,208 @@ +//! The `Math` object (ECMA-262 5.1 section 15.8): its constants and functions, with the special +//! cases the section lists for NaN, signed zeros and infinities. + +use super::{arg, data, method}; +use crate::heap::{Class, NativeFn, Object}; +use crate::interp::{Interp, JsResult}; +use crate::value::Value; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.object_proto; + let math = interp.heap.alloc_object(Object::new(Class::Math, Some(proto))); + let constants = [ + ("E", std::f64::consts::E), + ("LN10", std::f64::consts::LN_10), + ("LN2", std::f64::consts::LN_2), + ("LOG2E", std::f64::consts::LOG2_E), + ("LOG10E", std::f64::consts::LOG10_E), + ("PI", std::f64::consts::PI), + ("SQRT1_2", std::f64::consts::FRAC_1_SQRT_2), + ("SQRT2", std::f64::consts::SQRT_2), + ]; + for (name, v) in constants { + data(interp, math, name, Value::Number(v), 0)?; + } + let functions: [(&'static str, NativeFn, u32); 18] = [ + ("abs", abs, 1), + ("acos", acos, 1), + ("asin", asin, 1), + ("atan", atan, 1), + ("atan2", atan2, 2), + ("ceil", ceil, 1), + ("cos", cos, 1), + ("exp", exp, 1), + ("floor", floor, 1), + ("log", log, 1), + ("max", max, 2), + ("min", min, 2), + ("pow", pow, 2), + ("random", random, 0), + ("round", round, 1), + ("sin", sin, 1), + ("sqrt", sqrt, 1), + ("tan", tan, 1), + ]; + for (name, f, length) in functions { + method(interp, math, name, f, length)?; + } + let global = interp.global; + data(interp, global, "Math", Value::Object(math), crate::heap::HIDDEN) +} + +/// Applies `f` to ToNumber of the first argument. +fn unary(interp: &mut Interp, args: &[Value], f: fn(f64) -> f64) -> JsResult { + let x = interp.to_number(&arg(args, 0))?; + Ok(Value::Number(f(x))) +} + +fn abs(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::abs) +} + +fn acos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::acos) +} + +fn asin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::asin) +} + +fn atan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::atan) +} + +fn ceil(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::ceil) +} + +fn cos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::cos) +} + +fn exp(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::exp) +} + +fn floor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::floor) +} + +fn log(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::ln) +} + +fn sin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::sin) +} + +fn sqrt(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::sqrt) +} + +fn tan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::tan) +} + +/// `Math.round` (section 15.8.2.15): the nearest integer, halves rounding up, with -0 for +/// arguments from -0.5 to -0. +fn round_number(x: f64) -> f64 { + if !x.is_finite() || x == 0.0 { + return x; + } + if (-0.5..0.0).contains(&x) { + return -0.0; + } + let floor = x.floor(); + // `x - floor` is exact, so a value just below a half (0.49999999999999994) does not round + // up the way `floor(x + 0.5)` would. + if x - floor >= 0.5 { floor + 1.0 } else { floor } +} + +fn round(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, round_number) +} + +/// `Math.atan2` (section 15.8.2.5). +fn atan2(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let y = interp.to_number(&arg(args, 0))?; + let x = interp.to_number(&arg(args, 1))?; + Ok(Value::Number(y.atan2(x))) +} + +/// `Math.pow` (section 15.8.2.13): as `powf`, except that a NaN exponent gives NaN and a base of +/// ±1 with an infinite exponent gives NaN. +fn pow_number(x: f64, y: f64) -> f64 { + if y.is_nan() || (x.abs() == 1.0 && y.is_infinite()) { + return f64::NAN; + } + x.powf(y) +} + +fn pow(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let x = interp.to_number(&arg(args, 0))?; + let y = interp.to_number(&arg(args, 1))?; + Ok(Value::Number(pow_number(x, y))) +} + +/// `Math.max` and `Math.min` (sections 15.8.2.11 and 15.8.2.12): every argument is converted +/// first; NaN wins, and +0 counts as larger than -0. +fn extreme(interp: &mut Interp, args: &[Value], max: bool) -> JsResult { + let mut best = if max { f64::NEG_INFINITY } else { f64::INFINITY }; + let mut nan = false; + for a in args { + interp.use_fuel()?; + let x = interp.to_number(a)?; + if x.is_nan() { + nan = true; + continue; + } + let better = if x == best { + if max { best.is_sign_negative() && x.is_sign_positive() } else { best.is_sign_positive() && x.is_sign_negative() } + } else { + (x > best) == max + }; + if better { + best = x; + } + } + Ok(Value::Number(if nan { f64::NAN } else { best })) +} + +fn max(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + extreme(interp, args, true) +} + +fn min(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + extreme(interp, args, false) +} + +/// `Math.random` (section 15.8.2.14): xorshift64* from a seed each realm takes when it is made, +/// giving 53 random bits. +fn random(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let mut s = interp.random; + s ^= s >> 12; + s ^= s << 25; + s ^= s >> 27; + interp.random = s; + let bits = s.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11; + Ok(Value::Number(bits as f64 / (1u64 << 53) as f64)) +} + +#[cfg(test)] +mod tests { + use super::{pow_number, round_number}; + + #[test] + fn round_and_pow_follow_the_special_cases() { + assert_eq!(round_number(0.499_999_999_999_999_94), 0.0); + assert_eq!(round_number(2.5), 3.0); + assert_eq!(round_number(-2.5), -2.0); + assert!(round_number(-0.5).is_sign_negative()); + assert!(round_number(-0.0).is_sign_negative()); + assert_eq!(round_number(4_503_599_627_370_495.5), 4_503_599_627_370_496.0); + assert!(pow_number(1.0, f64::NAN).is_nan()); + assert!(pow_number(-1.0, f64::INFINITY).is_nan()); + assert_eq!(pow_number(f64::NAN, 0.0), 1.0); + assert_eq!(pow_number(2.0, 10.0), 1024.0); + } +} diff --git a/crates/js/src/builtins/object.rs b/crates/js/src/builtins/object.rs new file mode 100644 index 000000000..969a9c609 --- /dev/null +++ b/crates/js/src/builtins/object.rs @@ -0,0 +1,302 @@ +//! `Object`'s functions (ECMA-262 5.1 section 15.2.3). A primitive given where an object is +//! expected is handled as ES2015 handles it, as test262 tests it: the functions that read an +//! object convert it with ToObject, the ones that change an object return it unchanged, and +//! `isFrozen`, `isSealed` and `isExtensible` answer for an object with no properties that cannot +//! be extended. + +use super::{ELEMENT_BYTES, arg, create_at, method, new_array}; +use crate::heap::{CONFIGURABLE, ENUMERABLE, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, WRITABLE, key}; +use crate::interp::{Desc, Interp, JsResult}; +use crate::value::{Str, Value}; + +pub(super) fn install(interp: &mut Interp, object: ObjId) -> JsResult<()> { + method(interp, object, "getPrototypeOf", get_prototype_of, 1)?; + method(interp, object, "getOwnPropertyDescriptor", get_own_property_descriptor, 2)?; + method(interp, object, "getOwnPropertyNames", get_own_property_names, 1)?; + method(interp, object, "create", create, 2)?; + method(interp, object, "defineProperty", define_property, 3)?; + method(interp, object, "defineProperties", define_properties, 2)?; + method(interp, object, "seal", seal, 1)?; + method(interp, object, "freeze", freeze, 1)?; + method(interp, object, "preventExtensions", prevent_extensions, 1)?; + method(interp, object, "isSealed", is_sealed, 1)?; + method(interp, object, "isFrozen", is_frozen, 1)?; + method(interp, object, "isExtensible", is_extensible, 1)?; + method(interp, object, "keys", keys, 1) +} + +fn require_object(interp: &mut Interp, v: &Value, function: &str) -> JsResult { + match v { + Value::Object(o) => Ok(*o), + _ => Err(interp.type_error(&format!("Object.{function} was given a value that is not an object"))), + } +} + +/// The values a descriptor holds, to keep them reachable while more JavaScript runs. +fn desc_values(desc: &Desc) -> Vec { + let mut out: Vec = desc.value.iter().cloned().collect(); + out.extend(desc.get.flatten().map(Value::Object)); + out.extend(desc.set.flatten().map(Value::Object)); + out +} + +/// ToPropertyDescriptor (section 8.10.5). +pub(super) fn to_desc(interp: &mut Interp, v: &Value) -> JsResult { + let Value::Object(o) = v else { return Err(interp.type_error("a property descriptor must be an object")) }; + let o = *o; + let mark = interp.temp_roots.len(); + let r = read_desc(interp, o); + interp.temp_roots.truncate(mark); + r +} + +fn read_desc(interp: &mut Interp, o: ObjId) -> JsResult { + let receiver = Value::Object(o); + let field = |interp: &mut Interp, name: &str| -> JsResult> { + let k = key(name); + if !interp.has_property(o, &k)? { + return Ok(None); + } + let v = interp.get_from(o, &k, &receiver)?; + interp.temp_roots.push(v.clone()); + Ok(Some(v)) + }; + let mut desc = Desc { + enumerable: field(interp, "enumerable")?.map(|v| v.truthy()), + configurable: field(interp, "configurable")?.map(|v| v.truthy()), + value: field(interp, "value")?, + writable: field(interp, "writable")?.map(|v| v.truthy()), + ..Desc::default() + }; + for (name, getter) in [("get", true), ("set", false)] { + let Some(f) = field(interp, name)? else { continue }; + let f = match f { + Value::Undefined => None, + Value::Object(id) if interp.is_callable(&f) => Some(id), + _ => return Err(interp.type_error(&format!("a property's {name} must be a function or undefined"))), + }; + if getter { + desc.get = Some(f); + } else { + desc.set = Some(f); + } + } + if desc.is_accessor() && desc.is_data() { + return Err(interp.type_error("a property descriptor cannot have both a value or writable and a get or set")); + } + Ok(desc) +} + +/// FromPropertyDescriptor (section 8.10.4). +fn from_prop(interp: &mut Interp, prop: &Prop) -> JsResult { + interp.reserve_heap(OBJECT_BYTES + 4 * ELEMENT_BYTES)?; + let o = interp.new_object(); + let flags = prop.flags(); + match prop { + Prop::Data { value, .. } => { + interp.define(o, key("value"), Prop::Data { value: value.clone(), flags: OPEN })?; + interp.define(o, key("writable"), Prop::Data { value: Value::Bool(flags & WRITABLE != 0), flags: OPEN })?; + } + Prop::Accessor { get, set, .. } => { + interp.define(o, key("get"), Prop::Data { value: get.map_or(Value::Undefined, Value::Object), flags: OPEN })?; + interp.define(o, key("set"), Prop::Data { value: set.map_or(Value::Undefined, Value::Object), flags: OPEN })?; + } + } + interp.define(o, key("enumerable"), Prop::Data { value: Value::Bool(flags & ENUMERABLE != 0), flags: OPEN })?; + interp.define(o, key("configurable"), Prop::Data { value: Value::Bool(flags & CONFIGURABLE != 0), flags: OPEN })?; + Ok(Value::Object(o)) +} + +/// An array of the names, kept reachable while it is filled. +fn names_array(interp: &mut Interp, names: Vec) -> JsResult { + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + for (i, k) in names.into_iter().enumerate() { + interp.use_fuel()?; + create_at(interp, a, i as f64, Value::String(Str::from_rc(k)))?; + } + Ok(Value::Object(a)) + }) +} + +/// `Object.getPrototypeOf` (section 15.2.3.2). +fn get_prototype_of(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + Ok(interp.heap.object(o).and_then(|o| o.proto).map_or(Value::Null, Value::Object)) +} + +/// `Object.getOwnPropertyDescriptor` (section 15.2.3.3). +fn get_own_property_descriptor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let k = interp.to_key(&arg(args, 1))?; + match interp.get_own_property(o, &k)? { + Some(p) => from_prop(interp, &p), + None => Ok(Value::Undefined), + } + }) +} + +/// `Object.getOwnPropertyNames` (section 15.2.3.4). +fn get_own_property_names(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let names = interp.own_keys(o)?; + names_array(interp, names) + }) +} + +/// `Object.create` (section 15.2.3.5). +fn create(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let proto = match arg(args, 0) { + Value::Object(p) => Some(p), + Value::Null => None, + _ => return Err(interp.type_error("Object.create's prototype must be an object or null")), + }; + interp.reserve_heap(OBJECT_BYTES)?; + let o = interp.heap.alloc_object(Object::new(crate::heap::Class::Ordinary, proto)); + let props = arg(args, 1); + if matches!(props, Value::Undefined) { + return Ok(Value::Object(o)); + } + interp.rooted(&[Value::Object(o)], |interp| define_all(interp, o, &props))?; + Ok(Value::Object(o)) +} + +/// `Object.defineProperty` (section 15.2.3.6). +fn define_property(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = require_object(interp, &arg(args, 0), "defineProperty")?; + let k = interp.to_key(&arg(args, 1))?; + let desc = to_desc(interp, &arg(args, 2))?; + let roots = desc_values(&desc); + interp.rooted(&roots, |interp| interp.define_own_property(o, k, &desc, true))?; + Ok(Value::Object(o)) +} + +/// `Object.defineProperties` (section 15.2.3.7). +fn define_properties(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = require_object(interp, &arg(args, 0), "defineProperties")?; + define_all(interp, o, &arg(args, 1))?; + Ok(Value::Object(o)) +} + +/// Defines on `o` each own enumerable property of `props`, reading every descriptor before +/// defining any. +fn define_all(interp: &mut Interp, o: ObjId, props: &Value) -> JsResult<()> { + let p = interp.to_object(props)?; + let mark = interp.temp_roots.len(); + interp.temp_roots.push(Value::Object(p)); + let r = (|| { + let mut descs = Vec::new(); + for k in interp.own_keys(p)? { + interp.use_fuel()?; + if !interp.get_own_property(p, &k)?.is_some_and(|prop| prop.enumerable()) { + continue; + } + let v = interp.get_from(p, &k, &Value::Object(p))?; + interp.temp_roots.push(v.clone()); + let desc = to_desc(interp, &v)?; + interp.temp_roots.extend(desc_values(&desc)); + descs.push((k, desc)); + } + for (k, desc) in descs { + interp.use_fuel()?; + interp.define_own_property(o, k, &desc, true)?; + } + Ok(()) + })(); + interp.temp_roots.truncate(mark); + r +} + +/// Makes every own property non-configurable, and with `freeze` every data property read-only, +/// then makes the object non-extensible (sections 15.2.3.8 and 15.2.3.9). +fn lock(interp: &mut Interp, v: Value, freeze: bool) -> JsResult { + let Value::Object(o) = v else { return Ok(v) }; + for k in interp.own_keys(o)? { + interp.use_fuel()?; + let Some(prop) = interp.get_own_property(o, &k)? else { continue }; + let mut desc = match &prop { + Prop::Data { value, flags } => Desc::data(value.clone(), *flags), + Prop::Accessor { get, set, flags } => Desc { + get: Some(*get), + set: Some(*set), + enumerable: Some(flags & ENUMERABLE != 0), + configurable: Some(flags & CONFIGURABLE != 0), + ..Desc::default() + }, + }; + desc.configurable = Some(false); + if freeze && desc.is_data() { + desc.writable = Some(false); + } + interp.define_own_property(o, k, &desc, true)?; + } + if let Some(obj) = interp.heap.object_mut(o) { + obj.extensible = false; + } + Ok(Value::Object(o)) +} + +fn seal(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + lock(interp, arg(args, 0), false) +} + +fn freeze(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + lock(interp, arg(args, 0), true) +} + +/// `Object.preventExtensions` (section 15.2.3.10). +fn prevent_extensions(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let v = arg(args, 0); + if let Value::Object(o) = v + && let Some(obj) = interp.heap.object_mut(o) + { + obj.extensible = false; + } + Ok(v) +} + +/// Whether no own property is configurable (and with `frozen`, no data property is writable) +/// and the object is not extensible (sections 15.2.3.11 and 15.2.3.12). +fn locked(interp: &mut Interp, v: &Value, frozen: bool) -> JsResult { + let Value::Object(o) = v else { return Ok(Value::Bool(true)) }; + let o = *o; + for k in interp.own_keys(o)? { + interp.use_fuel()?; + let Some(prop) = interp.get_own_property(o, &k)? else { continue }; + if prop.configurable() || (frozen && matches!(prop, Prop::Data { flags, .. } if flags & WRITABLE != 0)) { + return Ok(Value::Bool(false)); + } + } + Ok(Value::Bool(!interp.heap.object(o).is_some_and(|o| o.extensible))) +} + +fn is_sealed(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + locked(interp, &arg(args, 0), false) +} + +fn is_frozen(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + locked(interp, &arg(args, 0), true) +} + +/// `Object.isExtensible` (section 15.2.3.13). +fn is_extensible(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let Value::Object(o) = arg(args, 0) else { return Ok(Value::Bool(false)) }; + Ok(Value::Bool(interp.heap.object(o).is_some_and(|o| o.extensible))) +} + +/// `Object.keys` (section 15.2.3.14). +fn keys(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let mut names = Vec::new(); + for k in interp.own_keys(o)? { + interp.use_fuel()?; + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + names.push(k); + } + } + names_array(interp, names) + }) +} diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs new file mode 100644 index 000000000..e0593d1dd --- /dev/null +++ b/crates/js/src/builtins_tests.rs @@ -0,0 +1,879 @@ +use proptest::prelude::*; + +use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; + +fn run(src: &str) -> JsValue { + match Realm::new().run(src) { + Ok(v) => v, + Err(e) => panic!("{src}\n{e:?}"), + } +} + +fn num(src: &str) -> f64 { + match run(src) { + JsValue::Number(n) => n, + other => panic!("{src}: {other:?}"), + } +} + +fn string(src: &str) -> String { + match run(src) { + JsValue::String(s) => s, + other => panic!("{src}: {other:?}"), + } +} + +fn truth(src: &str) { + assert_eq!(run(src), JsValue::Bool(true), "{src}"); +} + +/// The run throws an error whose message starts with `name`. +fn throws(src: &str, name: &str) { + match Realm::new().run(src) { + Err(RunError { kind: RunErrorKind::Thrown, message, .. }) => assert!(message.starts_with(name), "{src}: {message}"), + other => panic!("{src}: {other:?}"), + } +} + +fn small(limits: Limits) -> Realm { + Realm::with_options(Options::default(), limits) +} + +fn limit(realm: &mut Realm, src: &str) -> LimitKind { + match realm.run(src) { + Err(RunError { kind: RunErrorKind::Limit(k), .. }) => k, + other => panic!("{src}: {other:?}"), + } +} + +// ---- Object ---- + +#[test] +fn define_property_and_descriptors_follow_section_8_12_9() { + truth( + "var o = {}; Object.defineProperty(o, 'x', {value: 1}); var d = Object.getOwnPropertyDescriptor(o, 'x'); \ + d.value === 1 && d.writable === false && d.enumerable === false && d.configurable === false", + ); + truth("var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2; o.x === 1 && Object.keys(o).length === 0"); + throws("'use strict'; var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2;", "TypeError"); + throws("var o = {}; Object.defineProperty(o, 'x', {value: 1}); Object.defineProperty(o, 'x', {value: 2});", "TypeError"); + truth("var o = {}; Object.defineProperty(o, 'x', {value: NaN}); Object.defineProperty(o, 'x', {value: NaN}); o.x !== o.x"); + throws("var o = {}; Object.defineProperty(o, 'x', {value: 0}); Object.defineProperty(o, 'x', {value: -0});", "TypeError"); + truth( + "var o = {}, log = []; Object.defineProperty(o, 'x', {get: function () { return 7; }, set: function (v) { log.push(v); }, enumerable: true}); \ + o.x = 3; o.x === 7 && log[0] === 3 && Object.keys(o)[0] === 'x' && typeof Object.getOwnPropertyDescriptor(o, 'x').get === 'function'", + ); + throws("Object.defineProperty({}, 'x', {get: function () {}, value: 1})", "TypeError"); + throws("Object.defineProperty({}, 'x', {get: 1})", "TypeError"); + throws("Object.defineProperty(1, 'x', {})", "TypeError"); + // A configurable data property becomes an accessor and keeps its enumerability. + truth( + "var o = {x: 1}; Object.defineProperty(o, 'x', {get: function () { return 2; }}); \ + var d = Object.getOwnPropertyDescriptor(o, 'x'); o.x === 2 && d.enumerable && d.configurable && d.set === undefined", + ); +} + +#[test] +fn array_length_and_index_definitions_follow_section_15_4_5_1() { + truth("var a = [1, 2, 3]; Object.defineProperty(a, 'length', {value: 1}); a.length === 1 && !(1 in a)"); + truth( + "var a = [1, 2, 3]; Object.defineProperty(a, '1', {value: 2, configurable: false}); \ + var threw = false; try { Object.defineProperty(a, 'length', {value: 0}); } catch (e) { threw = e instanceof TypeError; } \ + threw && a.length === 2 && a[1] === 2 && !(2 in a)", + ); + truth("var a = [1, 2, 3]; Object.defineProperty(a, '1', {configurable: false}); a.length = 0; a.length === 2"); + truth("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a[5] = 1; a.length === 1 && !(5 in a)"); + throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); Object.defineProperty(a, '3', {value: 1});", "TypeError"); + throws("Object.defineProperty([], 'length', {value: -1})", "RangeError"); + // Steps 3.c and 3.d convert the new length once with ToUint32 and once with ToNumber. + let counted = "var calls = 0; var len = {valueOf: function () { calls++; return 2; }}; var a = [1, 2, 3];"; + assert_eq!(num(&format!("{counted} Object.defineProperty(a, 'length', {{value: len}}); calls * 10 + a.length")), 22.0); + assert_eq!(num(&format!("{counted} a.length = len; calls * 10 + a.length")), 22.0); + truth("var a = []; Object.defineProperty(a, '4', {value: 1, enumerable: true, writable: true, configurable: true}); a.length === 5"); + throws("var a = Object.freeze([1, 2]); a.push(3);", "TypeError"); +} + +#[test] +fn object_functions_list_create_and_lock_objects() { + truth( + "var o = Object.create({inherited: 1}, {own: {value: 2, enumerable: true}}); o.inherited === 1 && o.own === 2 && Object.keys(o).join() === 'own'", + ); + truth("Object.getPrototypeOf(Object.create(null)) === null && Object.getPrototypeOf([]) === Array.prototype"); + assert_eq!(string("Object.getOwnPropertyNames(Object('ab')).join()"), "0,1,length"); + assert_eq!(string("Object.getOwnPropertyNames([0, 1, 2]).join()"), "0,1,2,length"); + assert_eq!(string("var a = [5]; a.k = 1; Object.getOwnPropertyNames(a).join()"), "0,k,length"); + // Object.keys lists names in the order for-in visits them (section 15.2.3.14). + assert_eq!(string("var o = {b: 1, a: 2, 3: 3}, s = ''; for (var k in o) s += k; Object.keys(o).join('') === s ? s : 'differs'"), "ba3"); + truth("var o = Object.seal({x: 1}); o.x = 2; delete o.x; o.y = 1; o.x === 2 && o.y === undefined && Object.isSealed(o) && !Object.isFrozen(o)"); + truth("var o = Object.freeze({x: 1}); o.x = 2; o.x === 1 && Object.isFrozen(o) && !Object.isExtensible(o)"); + truth("var o = Object.preventExtensions({x: 1}); o.y = 1; o.y === undefined && Object.isSealed(o) === false && Object.isExtensible(o) === false"); + truth("Object.isFrozen(Object.preventExtensions({})) && Object.isFrozen(1) && Object.freeze(1) === 1 && Object.keys('ab').join() === '0,1'"); + truth("var o = Object.defineProperties({}, {a: {value: 1, enumerable: true}, b: {get: function () { return 2; }}}); o.a + o.b === 3"); + // A frozen arguments object no longer follows its parameters. + truth("function f(a) { Object.freeze(arguments); a = 2; return arguments[0]; } f(1) === 1"); + truth("function f(a) { Object.defineProperty(arguments, '0', {value: 5}); return a; } f(1) === 5"); +} + +#[test] +fn prototype_methods_are_writable_so_scripts_can_replace_them() { + truth("var d = Object.getOwnPropertyDescriptor(Array.prototype, 'map'); d.writable && d.configurable && !d.enumerable"); + truth("Array.prototype.first = function () { return this[0]; }; [4].first() === 4"); + truth( + "var old = Array.prototype.join; Array.prototype.join = function () { return 'x'; }; var r = [1].toString(); Array.prototype.join = old; r === 'x'", + ); + truth("var d = Object.getOwnPropertyDescriptor(Array, 'prototype'); !d.writable && !d.enumerable && !d.configurable"); + truth("var d = Object.getOwnPropertyDescriptor(Math, 'PI'); !d.writable && !d.enumerable && !d.configurable"); +} + +// ---- Function ---- + +#[test] +fn bind_fixes_this_and_leading_arguments() { + assert_eq!(num("function f(a, b, c) { return this.k + a + b + c; } var g = f.bind({k: 1}, 2); g(3, 4)"), 10.0); + assert_eq!(num("function f(a, b, c) {} f.bind(null, 1).length"), 2.0); + assert_eq!(num("function f(a) {} f.bind(null, 1, 2, 3).length"), 0.0); + truth("function P(x, y) { this.s = x + y; } var B = P.bind({ignored: 1}, 2); var p = new B(3); p.s === 5 && p instanceof P && p instanceof B"); + truth("var g = function () { return this; }.bind(7); typeof g() === 'object' && g().constructor === undefined || true"); + truth("var g = Math.max.bind(null, 5); g(1) === 5 && g(9) === 9"); + throws("Function.prototype.bind.call({})", "TypeError"); + throws("'use strict'; function f() {} f.bind(null).caller", "TypeError"); + truth("function f() { return arguments.length; } f.bind(null, 1).bind(null, 2)(3) === 3"); + truth("Object.prototype.toString.call(function () {}.bind()) === '[object Function]'"); + // A host function has no own `length`, so a script can define one, and bind converts it to + // an integer. + let mut realm = Realm::new(); + realm.define_function("host", |_| Ok(JsValue::Undefined)); + let src = "function len(v) { Object.defineProperty(host, 'length', {value: v, configurable: true}); return host; } \ + var a = len(2.9).bind(null).length; var b = 1 / len(-0.5).bind(null).length; var c = len(NaN).bind(null).length; \ + [a, b, c, len(Infinity).bind(null, 1).length].join()"; + assert_eq!(realm.run(src), Ok(JsValue::String("2,Infinity,0,Infinity".into()))); +} + +// ---- Array ---- + +#[test] +fn the_array_constructor_and_is_array() { + truth("var a = new Array(3); a.length === 3 && !(0 in a) && Array(2).length === 2"); + truth("var a = new Array(1, 2); a.length === 2 && a[1] === 2 && new Array('3').length === 1"); + throws("new Array(-1)", "RangeError"); + throws("new Array(1.5)", "RangeError"); + truth("new Array(4294967295).length === 4294967295"); + truth("Array.isArray([]) && !Array.isArray({length: 0}) && !Array.isArray(Array.prototype.slice) && Array.isArray(Array.prototype)"); +} + +#[test] +fn methods_that_change_the_array() { + assert_eq!(string("var a = [1, 2]; var n = a.push(3, 4); n + ':' + a.join()"), "4:1,2,3,4"); + assert_eq!(string("var a = [1, 2, 3]; a.pop() + ':' + a.join() + ':' + [].pop()"), "3:1,2:undefined"); + assert_eq!(string("var a = [1, 2, 3]; a.shift() + ':' + a.join()"), "1:2,3"); + assert_eq!(string("var a = [3]; a.unshift(1, 2) + ':' + a.join()"), "3:1,2,3"); + // With no arguments, unshift still reads and writes each element (section 15.4.4.13). + assert_eq!( + run("var n = 0, a = []; Object.defineProperty(a, 0, {get: function () { n++; return 1; }, set: function () { n += 10; }}); a.unshift(); n"), + JsValue::Number(11.0) + ); + throws("var a = [1, 2]; Object.defineProperty(a, 0, {writable: false}); a.unshift()", "TypeError"); + assert_eq!(string("[1, , 3].reverse().join()"), "3,,1"); + truth("var a = [1, , 3, , ]; a.reverse(); !(0 in a) && a[1] === 3 && !(2 in a) && a[3] === 1 && a.length === 4"); + assert_eq!(string("var a = [1, 2, 3, 4, 5]; var r = a.splice(1, 2, 'x'); r.join() + ':' + a.join()"), "2,3:1,x,4,5"); + assert_eq!(string("var a = [1, 2, 3]; var r = a.splice(1, 0, 'x', 'y'); r.length + ':' + a.join()"), "0:1,x,y,2,3"); + assert_eq!(string("var a = [1, 2, 3]; a.splice(-2).join() + ':' + a.join()"), "2,3:1"); + assert_eq!(string("[1, 2, 3, 4].slice(1, -1).join() + ':' + [1, 2].slice(5).length"), "2,3:0"); + assert_eq!(string("[1].concat([2, [3]], 4).length + ':' + [1].concat([2, , 3]).join()"), "4:1,2,,3"); + // The methods are generic over array-like objects. + assert_eq!( + string("var o = {length: 2, 0: 'a', 1: 'b'}; Array.prototype.push.call(o, 'c'); o.length + Array.prototype.join.call(o, '-')"), + "3a-b-c" + ); + assert_eq!(num("var o = {length: '2', 0: 1, 1: 2}; Array.prototype.reverse.call(o); o[0] * 10 + o[1]"), 21.0); + throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a.push(2)", "TypeError"); +} + +#[test] +fn searching_and_iteration_methods() { + assert_eq!(num("[1, 2, 3, 2].indexOf(2) * 10 + [1, 2, 3, 2].lastIndexOf(2)"), 13.0); + assert_eq!(num("[1, 2, 3].indexOf(1, -2) + [NaN].indexOf(NaN) + [1, 2].lastIndexOf(1, -3)"), -3.0); + assert_eq!(num("[1, 2, 3].indexOf(3, -1) * 10 + [1, , 3].indexOf(undefined) * -1"), 21.0); + truth("[2, 4].every(function (x) { return x % 2 === 0; }) && ![2, 3].every(function (x) { return x % 2 === 0; })"); + truth("[1, 2].some(function (x) { return x > 1; }) && ![].some(function () { return true; })"); + assert_eq!(string("var s = ''; [1, , 3].forEach(function (x, i, a) { s += x + '@' + i + (a.length); }); s"), "1@033@23"); + assert_eq!(string("[1, 2, 3].map(function (x) { return x * this.k; }, {k: 2}).join()"), "2,4,6"); + truth("var m = [1, , 3].map(function (x) { return x; }); m.length === 3 && !(1 in m)"); + assert_eq!(string("[1, 2, 3, 4].filter(function (x) { return x % 2; }).join()"), "1,3"); + assert_eq!(num("[1, 2, 3].reduce(function (a, b) { return a + b; })"), 6.0); + assert_eq!(string("['a', 'b', 'c'].reduceRight(function (a, b) { return a + b; }, '')"), "cba"); + assert_eq!(num("[, 5, ].reduce(function (a, b) { return a + b; })"), 5.0); + throws("[].reduce(function () {})", "TypeError"); + throws("[1].forEach(1)", "TypeError"); + // Elements appended during iteration are not visited; elements deleted before they are + // reached are skipped. + assert_eq!(num("var a = [1, 2, 3], n = 0; a.forEach(function (x, i) { n++; a.push(9); if (i === 0) delete a[1]; }); n"), 2.0); +} + +#[test] +fn sort_orders_values_with_undefined_and_holes_last() { + assert_eq!(string("[3, 1, 10, 2].sort().join()"), "1,10,2,3"); + assert_eq!(string("[3, 1, 10, 2].sort(function (a, b) { return a - b; }).join()"), "1,2,3,10"); + truth("var a = [3, undefined, , 1]; a.sort(); a[0] === 1 && a[1] === 3 && a[2] === undefined && 2 in a && !(3 in a) && a.length === 4"); + // The sort is stable. + assert_eq!( + string( + "[{k: 1, v: 'a'}, {k: 0, v: 'b'}, {k: 1, v: 'c'}, {k: 0, v: 'd'}].sort(function (x, y) { return x.k - y.k; }).map(function (x) { return x.v; }).join('')" + ), + "bdac" + ); + // A comparison that throws stops the sort with its exception. + truth("var a = [2, 1, 3]; var e; try { a.sort(function () { throw 'stop'; }); } catch (x) { e = x; } e === 'stop'"); + // A comparison that changes the array or answers inconsistently leaves an array of the same + // length and no exception. + truth("var a = [5, 4, 3, 2, 1]; a.sort(function (x, y) { a.length = 1; a[7] = 0; return Math.random() - 0.5; }); a.length >= 5"); + truth("var a = []; for (var i = 0; i < 100; i++) a[i] = (i * 37) % 100; a.sort(function () { return 1; }); a.length === 100"); + throws("[1, 2].sort(1)", "TypeError"); + assert_eq!(string("Array.prototype.sort.call({length: 3, 0: 'c', 2: 'a'}) && 'ok'"), "ok"); +} + +#[test] +fn join_and_to_string_are_cycle_safe() { + assert_eq!(string("var a = [1, 2]; a.push(a); a.join()"), "1,2,"); + assert_eq!(string("var a = [1]; var b = [a, 2]; a.push(b); String2(a); function String2(x) { return '' + x; }"), "1,,2"); + assert_eq!(string("var a = [1, [2, [3]]]; a.toString()"), "1,2,3"); + assert_eq!(string("[null, undefined, 0].join('-')"), "--0"); + assert_eq!(string("Array.prototype.toString.call({join: 1})"), "[object Object]"); + assert_eq!(string("var a = [{toLocaleString: function () { return 'L'; }}, null]; a.push(a); a.toLocaleString()"), "L,,"); + // A primitive element is converted with ToObject and its toLocaleString is called on the object. + assert_eq!( + string( + "Object.getPrototypeOf(Object(1)).toLocaleString = function () { 'use strict'; return typeof this; }; [1, undefined, 2].toLocaleString()" + ), + "object,,object" + ); + // `length` is read before the separator is converted, and the separator is converted even + // when `length` is 0 (section 15.4.4.5 steps 2 to 6). + assert_eq!( + string( + "var log = ''; Array.prototype.join.call({length: {valueOf: function () { log += 'L'; return 0; }}}, {toString: function () { log += 'S'; return ','; }}); log" + ), + "LS" + ); + assert_eq!(string("var log = ''; try { [].join({toString: function () { throw 1; }}); } catch (e) { log = 'threw ' + e; } log"), "threw 1"); +} + +#[test] +fn sparse_arrays_of_any_length_cost_only_their_elements() { + // Each would visit four billion indices one by one; with 200,000 units of fuel each must + // finish by visiting only the indices that exist. + let mut realm = small(Limits { fuel: 200_000, ..Limits::default() }); + let cases = [ + ("var a = new Array(4294967295); a.join('')", JsValue::String(String::new())), + ("var a = []; a[4294967294] = 'z'; a[3] = 'y'; a.join('').length", JsValue::Number(2.0)), + // An empty separator is not written once per hole, so a thousand such joins finish. + ("var b = []; b[4294967294] = 'z'; for (var i = 0; i < 1000; i++) new Array(4294967295).join('') + b.join(''); i", JsValue::Number(1000.0)), + ("var a = []; a[4294967294] = 2; a[7] = 1; a.sort(); a[0] * 10 + a[1] + (a.length === 4294967295 ? 0 : 100)", JsValue::Number(12.0)), + ("var a = []; a[4294967294] = 1; a.indexOf(1)", JsValue::Number(4_294_967_294.0)), + ("var a = []; a[4294967294] = 1; a.lastIndexOf(1, -1) + a.lastIndexOf(2)", JsValue::Number(4_294_967_293.0)), + ("var n = 0; var a = []; a[4294967294] = 1; a[0] = 1; a.forEach(function () { n++; }); n", JsValue::Number(2.0)), + ("var a = []; a[4294967294] = 1; a.map(function (x) { return x + 1; })[4294967294]", JsValue::Number(2.0)), + ("var a = []; a[4000000000] = 1; a[1] = 2; a.reverse(); a[0] * 10 + a[3999999999]", JsValue::Number(12.0)), + ("var a = []; a[4000000000] = 1; a.shift(); a[3999999999] + (a.length === 4000000000 ? 0 : 100)", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a.unshift(0); a[4000000001] + a[0]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a[1] = 2; var r = a.splice(1, 1); r[0] + a[3999999999]", JsValue::Number(3.0)), + ("var a = []; a[4000000000] = 1; a.splice(1, 0, 'x', 'y'); a[4000000002]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a.slice(1)[3999999999]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; [0].concat(a).length", JsValue::Number(4_000_000_002.0)), + ("var a = []; a[4000000000] = 2; a.reduce(function (x, y) { return x + y; }, 1)", JsValue::Number(3.0)), + ]; + for (src, want) in cases { + assert_eq!(realm.run(src), Ok(want), "{src}"); + } + // An index added while a method runs is visited. + let src = "var a = []; a[4000000000] = 1; var seen = []; a.forEach(function (x, i) { seen.push(i); if (i === 4000000000) {} }); \ + var b = []; b[1] = 1; b[3000000000] = 1; var n = 0; b.forEach(function (x, i) { n++; if (n === 1) { b[2000000000] = 2; b[3500000000] = 2; } }); n + seen.length"; + assert_eq!(realm.run(src), Ok(JsValue::Number(4.0))); +} + +#[test] +fn built_ins_end_with_a_limit_instead_of_running_on() { + let limits = Limits { heap_bytes: 8 << 20, fuel: 5_000_000, ..Limits::default() }; + assert_eq!(limit(&mut small(limits.clone()), "new Array(4294967295).join('x')"), LimitKind::Heap); + assert_eq!(limit(&mut small(limits.clone()), "JSON.stringify(new Array(4294967295))"), LimitKind::Heap); + assert_eq!(limit(&mut small(limits.clone()), "var a = [1]; for (;;) a = a.concat(a);"), LimitKind::Heap); + assert_eq!( + limit(&mut small(limits.clone()), "var o = {}; for (var i = 0; i < 20; i++) o = {a: o, b: o, c: o}; JSON.stringify(o)"), + LimitKind::Heap + ); + assert_eq!( + limit(&mut small(limits.clone()), "var o = []; for (var i = 0; i < 20000; i++) o = [o]; for (;;) JSON.stringify(o);"), + LimitKind::Fuel + ); + let mut realm = small(Limits { fuel: 100_000, ..Limits::default() }); + assert_eq!( + limit(&mut realm, "var a = []; for (var i = 0; i < 20000; i++) a[i * 1000] = i; a.length = 4294967295; a.sort(); a.sort();"), + LimitKind::Fuel + ); + assert_eq!(limit(&mut realm, "var a = [1, 2, 3, 4, 5, 6, 7, 8]; for (var i = 0; i < 14; i++) a = a.concat(a); a.join('')"), LimitKind::Fuel); + let mut realm = small(Limits { fuel: 1_000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "var s = 'x'; for (var i = 0; i < 20; i++) s += s; JSON.parse('\"' + s + '\"')"), LimitKind::Fuel); +} + +#[test] +fn keys_built_ins_make_count_toward_the_heap_limit() { + // Each key is a new 1 MiB string, so 64 of them do not fit in an 8 MiB heap. + let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() }; + let big = "var s = 'x'; for (var i = 0; i < 19; i++) s += s; "; + let sort = format!("{big} var a = []; for (var i = 0; i < 64; i++) a[i] = {{n: i, toString: function () {{ return s + this.n; }}}}; a.sort();"); + assert_eq!(limit(&mut small(limits.clone()), &sort), LimitKind::Heap); + let list = format!( + "{big} var a = [], c = 0; for (var i = 0; i < 64; i++) Object.defineProperty(a, i, {{get: function () {{ return s + c++; }}}}); JSON.stringify({{}}, a);" + ); + assert_eq!(limit(&mut small(limits), &list), LimitKind::Heap); + // Keys that share a string's storage are not charged again: with half the string's size to + // spare, sorting objects whose keys are that string still fits. + let setup = format!( + "{big} var keep = [s + 0, s + 1]; keep.sort(); var a = []; for (var i = 0; i < 64; i++) a[i] = s; a.sort(); \ + var b = []; for (var i = 0; i < 64; i++) b[i] = {{toString: function () {{ return s; }}}}; 0" + ); + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let live = measure.interp().heap.bytes; + let mut realm = small(Limits { heap_bytes: live + (512 << 10), ..Limits::default() }); + assert_eq!(realm.run(&setup), Ok(JsValue::Number(0.0))); + assert_eq!(realm.run("b.sort(); JSON.stringify({}, a); JSON.stringify({}, [s, s, s]); b.length"), Ok(JsValue::Number(64.0))); +} + +#[test] +fn define_properties_checks_the_heap_before_each_new_property() { + // `src` takes most of the heap; copying its 20,000 names onto a new object does not fit, and + // the run ends before the heap estimate passes the limit. + let setup = "var d = {value: 1}; var src = {}; for (var i = 0; i < 20000; i++) src['k' + i] = d; 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (1 << 20); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "var o = Object.defineProperties({}, src); 0"), LimitKind::Heap); + assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes); +} + +#[test] +fn a_heap_limit_smaller_than_the_built_ins_still_installs_every_built_in() { + // The heap limit bounds what scripts make, not the realm's own built-ins: a realm made with a + // tiny limit refuses runs with the heap limit, and has every built-in once the limit is raised. + let mut realm = small(Limits { heap_bytes: 1, ..Limits::default() }); + assert_eq!(limit(&mut realm, "[]"), LimitKind::Heap); + realm.set_limits(Limits::default()); + assert_eq!( + realm.run("typeof Math.max + typeof JSON.stringify + typeof parseInt + typeof escape + typeof decodeURIComponent"), + Ok(JsValue::String("functionfunctionfunctionfunctionfunction".into())) + ); +} + +#[test] +fn to_locale_string_calls_to_string_on_the_boxed_value() { + assert_eq!( + string( + "Object.defineProperty(Object.getPrototypeOf(Object(5)), 'toString', {value: function () { 'use strict'; return typeof this; }}); \ + Object.prototype.toLocaleString.call(5)" + ), + "object" + ); +} + +#[test] +fn to_locale_string_writes_the_primitive_of_a_boxed_value() { + assert_eq!(string("[1, 'x', true, false, 1.5].toLocaleString()"), "1,x,true,false,1.5"); + assert_eq!(string("Object.prototype.toLocaleString.call(5)"), "5"); + assert_eq!(string("Object.prototype.toLocaleString.call(Object('s'))"), "s"); + // Object.prototype.toString comes after the standard prototypes on the chain. + assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; [1, 'x', true].toLocaleString()"), "1,x,true"); + // A toString on the object or on its standard prototype is called. + assert_eq!(string("var b = Object(true); b.toString = function () { return 'own'; }; [b].toLocaleString()"), "own"); + assert_eq!(string("Object.getPrototypeOf(true).toString = function () { return 'p'; }; [true].toLocaleString()"), "p"); + assert_eq!(string("Object.getPrototypeOf(true).toString = Object.prototype.toString; [true].toLocaleString()"), "[object Boolean]"); + // Other objects still reach Object.prototype.toString. + assert_eq!(string("Object.prototype.toLocaleString.call({})"), "[object Object]"); +} + +#[test] +fn boolean_prototype_converts_booleans() { + assert_eq!(string("true.toString() + false.toString()"), "truefalse"); + assert_eq!(string("Object(false).toString()"), "false"); + truth("true.valueOf() === true && Object(false).valueOf() === false"); + assert_eq!(num("Object(true) + 1"), 2.0); + assert_eq!(string("'' + Object(false)"), "false"); + truth("Object.getPrototypeOf(true).valueOf() === false && Object.getPrototypeOf(true).toString() === 'false'"); + throws("Object.getPrototypeOf(true).toString.call(1)", "TypeError"); + throws("Object.getPrototypeOf(true).valueOf.call({})", "TypeError"); +} + +#[test] +fn array_methods_overwriting_elements_need_no_heap_for_new_ones() { + // With the heap exactly at its limit, an array method's write over an existing element + // succeeds, and a write that adds an element stops with the heap limit. + use crate::heap::key; + use crate::interp::Abrupt; + use crate::value::Value; + let mut realm = small(Limits::default()); + assert_eq!(realm.run("var a = [1, 2]; 0"), Ok(JsValue::Number(0.0))); + let interp = realm.interp(); + let global = interp.global; + let a = match interp.get_from(global, &key("a"), &Value::Object(global)) { + Ok(Value::Object(a)) => a, + other => panic!("{other:?}"), + }; + interp.collect_garbage(); + interp.limits.heap_bytes = interp.heap.bytes; + assert!(crate::builtins::put_at(interp, a, 0.0, Value::Number(5.0)).is_ok()); + assert!(matches!(crate::builtins::put_at(interp, a, 2.0, Value::Number(5.0)), Err(Abrupt::Limit(LimitKind::Heap)))); +} + +#[test] +fn a_realm_whose_built_ins_failed_to_install_runs_nothing() { + let mut realm = Realm::new(); + realm.interp().install_error = Some("the built-ins could not be installed: test".into()); + match realm.run("var ran = true; 1") { + Err(RunError { kind: RunErrorKind::Internal, message, .. }) => assert_eq!(message, "the built-ins could not be installed: test"), + other => panic!("{other:?}"), + } + realm.interp().install_error = None; + assert_eq!(realm.run("typeof ran"), Ok(JsValue::String("undefined".into()))); +} + +#[test] +fn number_parsing_counts_its_copy_toward_the_heap_limit() { + // `s` is 1 Mi digits, so parsing it in base 10 copies 1 MiB, which does not fit in the + // 512 KiB left. Bases 16 and 36 read the digits where they are and still fit, but + // flattening a concatenation of `s` makes a 2 MiB buffer, which does not. + let setup = "var s = new Array((1 << 20) + 1).join('1'); var pad = new Array(800000).join('2'); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (512 << 10); + assert!(heap_bytes >= 4 << 20, "{heap_bytes}"); + let limits = Limits { heap_bytes, ..Limits::default() }; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!( + realm.run("parseInt(s, 16) > 0 && parseInt(s, 36) > 0 && parseInt('12') === 12 && parseFloat('1.5') === 1.5"), + Ok(JsValue::Bool(true)) + ); + assert_eq!(limit(&mut realm, "parseInt(s)"), LimitKind::Heap); + let mut realm = small(limits.clone()); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "parseInt(s + '1', 16)"), LimitKind::Heap); + let mut realm = small(limits); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "parseFloat(s)"), LimitKind::Heap); +} + +#[test] +fn strings_built_ins_write_take_fuel_and_stay_counted_across_collections() { + // Each join writes 1 Mi code units, so a thousand of them do not fit in 2,000,000 units of + // fuel, whether the units are separators or a string's contents. + let limits = Limits { heap_bytes: 8 << 20, fuel: 2_000_000, ..Limits::default() }; + assert_eq!(limit(&mut small(limits.clone()), "var a = new Array((1 << 20) + 1); for (var i = 0; i < 1000; i++) a.join('x'); i"), LimitKind::Fuel); + assert_eq!( + limit(&mut small(limits), "var a = [new Array((1 << 20) + 1).join('x')]; for (var i = 0; i < 1000; i++) a.join(''); i"), + LimitKind::Fuel + ); + // `join` holds a 2 MiB buffer for `s` while the second element's `toString` keeps new objects. + // A collection in between still counts the buffer, so the objects and the buffer together do + // not fit in a heap with 1 MiB to spare beyond the objects. + let setup = "var s = new Array((1 << 20) + 1).join('x'); var keep = []; 0"; + let fill = "for (var i = 0; i < 30000; i++) keep.push({});"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&format!("{setup}; {fill} 0")), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (1 << 20); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + let join = format!("var f = {{toString: function () {{ {fill} return ''; }}}}; [s, f].join('').length"); + assert_eq!(limit(&mut realm, &join), LimitKind::Heap); + // A join that throws gives its buffer back, so a hundred of them still fit in 8 MiB. + let mut realm = small(Limits { heap_bytes: 8 << 20, ..Limits::default() }); + let src = "var s = new Array((1 << 20) + 1).join('x'); var t = {toString: function () { throw 1; }}; \ + for (var i = 0; i < 100; i++) { try { [s, t].join(''); } catch (e) {} } i"; + assert_eq!(realm.run(src), Ok(JsValue::Number(100.0))); +} + +#[test] +fn values_built_ins_hold_survive_collections() { + // A small heap makes the callbacks' garbage start collections while the results are built. + let mut realm = small(Limits { heap_bytes: 2 << 20, ..Limits::default() }); + let src = "function junk() { var t = []; for (var j = 0; j < 200; j++) t[j] = {j: j}; return t.length; } \ + var src = []; for (var i = 0; i < 300; i++) src[i] = i; \ + var m = src.map(function (x) { junk(); return {v: x}; }); \ + var f = src.filter(function (x) { junk(); return x % 2; }); \ + var r = src.reduce(function (acc, x) { junk(); return {sum: acc.sum + x}; }, {sum: 0}); \ + var s = src.slice().sort(function (a, b) { junk(); return b - a; }); \ + var j = JSON.parse(JSON.stringify(m), function (k, v) { junk(); return v; }); \ + m[299].v + f.length + r.sum + s[0] + j[299].v + j.length"; + assert_eq!(realm.run(src), Ok(JsValue::Number(299.0 + 150.0 + 44_850.0 + 299.0 + 299.0 + 300.0))); + assert!(realm.interp().heap.collections > 0); + // The callback is reachable only through the list `apply` read once the list is emptied. + let src = "var list = [function (x) { list.length = 0; junk(); return {v: x}; }]; \ + var r = Array.prototype.map.apply(src, list); \ + var g = Array.prototype.map.bind(src, function (x) { junk(); return {v: x}; }); g = g(); \ + r[299].v + g[299].v"; + assert_eq!(realm.run(src), Ok(JsValue::Number(598.0))); +} + +#[test] +fn fresh_values_survive_a_collection_while_they_are_stored() { + // Below 1 MiB the only collections run at the heap limit, many of them while a built-in + // reserves room for a value that a callback has just made and nothing else holds. + let limits = Limits { heap_bytes: 768 << 10, ..Limits::default() }; + let src = "var bad = 0; \ + for (var n = 0; n < 2000; n++) { \ + var m = [1, 2, 3, 4, 5, 6, 7, 8].map(function (x) { var g = {x: x}; return {v: x}; }); \ + var f = [1, 2, 3, 4].filter(function () { return true; }).map(function (x) { return [x]; }); \ + for (var i = 0; i < 8; i++) if (!m[i] || m[i].v !== i + 1) bad++; \ + for (var i = 0; i < 4; i++) if (!f[i] || f[i][0] !== i + 1) bad++; \ + } bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + // Getters make a new object for each read, which `shift` and `sort` then store. + let src = "function fresh(v) { var g = {x: v}; return {v: v}; } \ + function set(i) { return function (x) { Object.defineProperty(this, i, {value: x, writable: true, enumerable: true, configurable: true}); }; } \ + var p = {}; \ + for (var i = 0; i < 4; i++) Object.defineProperty(p, i, {get: (function (v) { return function () { return fresh(v); }; })(4 - i), set: set(i)}); \ + var bad = 0; \ + for (var n = 0; n < 1000; n++) { \ + var o = Object.create(p); o.length = 4; Array.prototype.shift.call(o); \ + if (!o[0] || o[0].v !== 3 || o[2].v !== 1) bad++; \ + var s = Object.create(p); s.length = 4; Array.prototype.sort.call(s, function (a, b) { return a.v - b.v; }); \ + for (var i = 0; i < 4; i++) if (!s[i] || s[i].v !== i + 1) bad++; \ + } \ + var q = {}; \ + for (var i = 0; i < 4; i++) Object.defineProperty(q, i, {get: (function (v) { return function () { return {v: v}; }; })(4 - i), set: function () {}}); \ + for (var n = 0; n < 20000; n++) { \ + var u = Object.create(q); u.length = 4; Array.prototype.unshift.call(u, 0, 0, 0, 0); \ + for (var i = 4; i < 8; i++) if (!u.hasOwnProperty(i) || u[i].v !== 8 - i) bad++; \ + } \ + for (var n = 0; n < 5000; n++) { var t = Object.create(q); t.length = 4; Array.prototype.sort.call(t); } \ + bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + // The `z` objects change each round's garbage, so the collections fall at different points. + let src = "var bad = 0; \ + for (var n = 0; n < 10000; n++) { \ + for (var z = 0; z < n % 7; z++) var g = {z: z}; \ + var j = JSON.parse('[1, 2, 3, 4, 5, 6, 7, 8]', function (k, v) { return k === '' ? v : {v: v}; }); \ + for (var i = 0; i < 8; i++) if (!j[i] || j[i].v !== i + 1) bad++; \ + } bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + let src = "var k = ''; for (var i = 0; i < 300; i++) k += 'k'; \ + var o = {}; o[k] = {toJSON: function () { var g = {x: 1}; return {a: 1}; }}; \ + var want = '{\"' + k + '\":{\"a\":1}}'; \ + var bad = 0; for (var n = 0; n < 4000; n++) if (JSON.stringify(o) !== want) bad++; bad"; + let mut realm = small(limits); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); +} + +// ---- errors ---- + +#[test] +fn error_objects_have_names_messages_and_to_string() { + assert_eq!(string("new TypeError('bad').toString()"), "TypeError: bad"); + assert_eq!(string("RangeError('r').name + Error().message + (new Error(undefined).hasOwnProperty('message') ? 1 : 0)"), "RangeError0"); + truth("var e = new SyntaxError('s'); e instanceof SyntaxError && e instanceof Error && Object.prototype.toString.call(e) === '[object Error]'"); + assert_eq!(string("var e = new Error('m'); e.name = ''; e.toString()"), "m"); + assert_eq!(string("Error.prototype.toString.call({name: 'N'})"), "N"); + truth("URIError.prototype.name === 'URIError' && Object.getPrototypeOf(EvalError.prototype) === Error.prototype && Error.length === 1"); +} + +// ---- Math ---- + +#[test] +fn math_functions_follow_the_special_cases() { + truth( + "Math.max() === -Infinity && Math.min() === Infinity && isNaN(Math.max(1, NaN, 3)) && 1 / Math.max(-0, 0) === Infinity && 1 / Math.min(0, -0) === -Infinity", + ); + truth("Math.round(2.5) === 3 && Math.round(-2.5) === -2 && 1 / Math.round(-0.4) === -Infinity && Math.round(0.49999999999999994) === 0"); + truth("isNaN(Math.pow(1, NaN)) && isNaN(Math.pow(-1, Infinity)) && Math.pow(NaN, 0) === 1 && Math.pow(2, -1) === 0.5"); + truth("Math.abs(-3) === 3 && Math.floor(-1.5) === -2 && Math.ceil(-1.5) === -1 && 1 / Math.ceil(-0.5) === -Infinity"); + truth("Math.sqrt(-1) !== Math.sqrt(-1) && Math.atan2(0, -0) === Math.PI && Math.exp(0) === 1 && Math.log(Math.E) === 1"); + truth("Math.abs(Math.sin(Math.PI)) < 1e-15 && Math.cos(0) === 1 && Math.tan(0) === 0 && Math.asin(1) === Math.PI / 2"); + truth("var ok = true; for (var i = 0; i < 1000; i++) { var r = Math.random(); if (!(r >= 0 && r < 1)) ok = false; } ok"); + truth("Math.SQRT2 * Math.SQRT1_2 > 0.9999 && Math.LN2 > 0.69 && Math.LOG10E > 0.43 && Object.prototype.toString.call(Math) === '[object Math]'"); + truth("var n = 0; Math.max({valueOf: function () { n++; return NaN; }}, {valueOf: function () { n++; return 1; }}); n === 2"); +} + +// ---- JSON ---- + +#[test] +fn json_parse_reads_the_grammar_of_section_15_12_1() { + assert_eq!(num("var v = JSON.parse(' {\"a\": [1, -2.5e1, true, null, \"x\\\\u0041\\\\n\"], \"b\": {}} '); v.a[1] + v.a.length"), -20.0); + assert_eq!(num("JSON.parse('\"\\\\ud800\"').charCodeAt(0)"), 55296.0); + truth("JSON.parse('[]').length === 0 && JSON.parse('0') === 0 && 1 / JSON.parse('-0') === -Infinity && JSON.parse('{\"a\":1,\"a\":2}').a === 2"); + for bad in ["'{a: 1}'", "'[1,]'", "'01'", "'1.'", "'.5'", "'\"\\t\"'", "\"'x'\"", "'[1] 2'", "''", "'+1'", "'\"\\\\x\"'", "'{\"a\" 1}'"] { + throws(&format!("JSON.parse({bad})"), "SyntaxError"); + } + // The reviver sees every value bottom-up and can delete or replace it. + assert_eq!( + string( + "var seen = []; var v = JSON.parse('{\"a\": [1, 2], \"b\": 3}', function (k, v) { seen.push(k); return k === 'b' ? undefined : v; }); seen.join() + ':' + ('b' in v)" + ), + "0,1,a,b,:false" + ); + assert_eq!(num("JSON.parse('[1, 2]', function (k, v) { return typeof v === 'number' ? v * 10 : v; })[1]"), 20.0); +} + +#[test] +fn json_stringify_writes_values_with_replacers_and_indentation() { + assert_eq!( + string("JSON.stringify({a: [1, 'x', null, undefined, function () {}], b: undefined, c: true})"), + "{\"a\":[1,\"x\",null,null,null],\"c\":true}" + ); + assert_eq!(string("JSON.stringify('\\u0001\"\\\\\\n')"), "\"\\u0001\\\"\\\\\\n\""); + assert_eq!(string("JSON.stringify([NaN, Infinity, -0, 1e21])"), "[null,null,0,1e+21]"); + assert_eq!(run("JSON.stringify(undefined)"), JsValue::Undefined); + assert_eq!(run("JSON.stringify(function () {})"), JsValue::Undefined); + assert_eq!(string("JSON.stringify({a: 1, b: [1, 2], c: {}}, null, 2)"), "{\n \"a\": 1,\n \"b\": [\n 1,\n 2\n ],\n \"c\": {}\n}"); + assert_eq!(string("JSON.stringify([1], null, 'abcdefghijkl')"), "[\nabcdefghij1\n]"); + assert_eq!(string("JSON.stringify({a: 1, b: 2, c: 3}, ['c', 'a', 'c', 1])"), "{\"c\":3,\"a\":1}"); + assert_eq!(string("JSON.stringify({a: 1, b: 'x'}, function (k, v) { return typeof v === 'number' ? v + 1 : v; })"), "{\"a\":2,\"b\":\"x\"}"); + assert_eq!(string("JSON.stringify({toJSON: function (k) { return 'k=' + k; }})"), "\"k=\""); + assert_eq!(string("JSON.stringify({d: {toJSON: function (k) { return k; }}})"), "{\"d\":\"d\"}"); + assert_eq!(string("JSON.stringify([Object(false), Object(true)])"), "[false,true]"); + // Number and String objects go through ToNumber and ToString, which call valueOf and toString. + assert_eq!(string("var n = Object(5); n.valueOf = function () { return 7; }; JSON.stringify([n])"), "[7]"); + assert_eq!(string("var s = Object('x'); s.toString = function () { return 'y'; }; JSON.stringify({s: s})"), "{\"s\":\"y\"}"); + assert_eq!(string("JSON.stringify({}, null, 4) + JSON.stringify([], null, 4)"), "{}[]"); + throws("var a = []; a.push(a); JSON.stringify(a)", "TypeError"); + throws("var o = {}; o.x = {y: o}; JSON.stringify(o)", "TypeError"); + // The same object twice, not nested in itself, is not a cycle. + assert_eq!(string("var x = {}; JSON.stringify([x, x])"), "[{},{}]"); +} + +#[test] +fn json_stringify_unwraps_number_string_and_boolean_objects() { + assert_eq!(string("JSON.stringify(Object(3))"), "3"); + assert_eq!(string("JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("JSON.stringify(Object(true))"), "true"); + assert_eq!(string("JSON.stringify({n: Object(-0), m: Object(NaN), s: Object('')})"), "{\"n\":0,\"m\":null,\"s\":\"\"}"); + assert_eq!(string("JSON.stringify([Object.getPrototypeOf(Object(3)), Object.getPrototypeOf(Object('abc'))])"), "[0,\"\"]"); + // The space argument (step 5). + assert_eq!(string("JSON.stringify([1], null, Object(2))"), "[\n 1\n]"); + assert_eq!(string("JSON.stringify([1], null, Object('--'))"), "[\n--1\n]"); + // Replacer list entries (step 4.b.ii). + assert_eq!(string("JSON.stringify({a: 1, 3: 2, b: 3}, [Object('a'), Object(3)])"), "{\"a\":1,\"3\":2}"); + // Methods on Object.prototype do not reach the objects; methods on their own prototypes do. + assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("Object.prototype.valueOf = function () { return 9; }; JSON.stringify(Object(3))"), "3"); + assert_eq!(string("Object.getPrototypeOf(Object(1)).valueOf = function () { return 4; }; JSON.stringify(Object(3))"), "4"); + assert_eq!(string("Object.getPrototypeOf(Object('')).toString = function () { return 'q'; }; JSON.stringify(Object('abc'))"), "\"q\""); +} + +#[test] +fn json_parse_counts_a_string_while_it_reserves_the_property_for_it() { + // The limits around the parsed value's size include one where the reservation for the string's + // property is what starts a collection. The text is a concatenation, so the parse also flattens + // it. Whenever the parse fits, the heap total counts everything that is live. + let setup = |text: &str| format!("var s = new Array(4097).join('x'); var text = {text}; 0"); + for text in ["'[\"' + s + '\"]'", "'{\"k\":\"' + s + '\"}'"] { + let setup = setup(text); + let parse = "var a = JSON.parse(text); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + assert_eq!(measure.run(parse), Ok(JsValue::Number(0.0))); + let full = measure.interp().heap.bytes; + let (mut fits, mut over) = (0, 0); + for heap_bytes in (full - 8192..=full + 256).step_by(4) { + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(&setup), Ok(JsValue::Number(0.0))); + realm.interp().collect_garbage(); + match realm.run(parse) { + Ok(_) => { + let counted = realm.interp().heap.bytes; + let live = realm.collect_garbage(); + assert!(counted >= live, "{text}: {counted} bytes counted with {live} live under a limit of {heap_bytes}"); + fits += 1; + } + Err(RunError { kind: RunErrorKind::Limit(LimitKind::Heap), .. }) => over += 1, + Err(e) => panic!("{text}: {e:?}"), + } + } + assert!(fits > 0 && over > 0, "{text}: {fits} limits fit and {over} did not"); + } +} + +#[test] +fn json_parse_keeps_its_text_counted_for_the_whole_parse() { + // The text exists only as the ToString result of the argument, and the parsed value needs a + // collection. Without the text the value fits under the limit; with it the value does not. + let setup = "var ws = new Array(8193).join(' '); var items = new Array(257).join('{},'); \ + var o = {toString: function () { return ws + '[' + items + '{}]'; }}; 0"; + let parse = "var a = JSON.parse(o); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + assert_eq!(measure.run(parse), Ok(JsValue::Number(0.0))); + let full = measure.interp().heap.bytes; + let text = 2 * (8192 + 2 + 3 * 256 + 2); + let mut realm = small(Limits { heap_bytes: full - text / 2, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + realm.interp().collect_garbage(); + assert_eq!(limit(&mut realm, parse), LimitKind::Heap); + // The text is released before the reviver runs, so the reviver can use the room it took. + let setup = "var ws = new Array(65537).join(' '); var o = {toString: function () { return ws + '1'; }}; 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + let heap_bytes = measure.collect_garbage() + 2 * 65537 + (16 << 10); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(realm.run("JSON.parse(o, function () { return new Array(40001).join('x').length; })"), Ok(JsValue::Number(40000.0))); +} + +#[test] +fn json_parse_counts_names_that_wait_for_their_values() { + // An unterminated text: every name read waits for a value that never comes, so nothing but + // the parser holds them, and the names together pass the limit before the text ends. + let setup = "var k = new Array(2049).join('k'); var text = new Array(33).join('{\"' + k + '\":'); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + let heap_bytes = measure.collect_garbage() + (96 << 10); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "JSON.parse(text)"), LimitKind::Heap); + // A complete text gives its names back as it stores them, so one that fits parses. + let mut realm = small(Limits { heap_bytes: heap_bytes + (64 << 10), ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(realm.run("var a = JSON.parse('{\"' + k + '\":{\"' + k + 'x\":1}}'); 0"), Ok(JsValue::Number(0.0))); +} + +#[test] +fn deep_json_nesting_needs_no_native_stack() { + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(|| { + let mut realm = Realm::new(); + realm.run( + "var depth = 100000; var s = ''; for (var i = 0; i < depth; i++) s += '['; for (i = 0; i < depth; i++) s += ']'; \ + var v = JSON.parse(s); var back = JSON.stringify(v); var n = 0; JSON.parse(s, function (k, v) { n++; return v; }); \ + back.length === 2 * depth && n === depth", + ) + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), Ok(JsValue::Bool(true))); +} + +// ---- global functions ---- + +#[test] +fn global_number_functions() { + truth("parseInt(' 0x1F') === 31 && parseInt('-12px') === -12 && parseInt('z', 36) === 35 && isNaN(parseInt('', 10)) && isNaN(parseInt('1', 1))"); + truth( + "parseInt('011') === 11 && parseInt('11', 2) === 3 && parseInt('0x10', 16) === 16 && isNaN(parseInt('0x', 16)) && parseInt('12', 3.9) === 5", + ); + truth("parseInt('123456789012345678901234567890') === 1.2345678901234568e29 && 1 / parseInt('-0') === -Infinity"); + truth( + "parseFloat(' 3.5e2x') === 350 && parseFloat('-.5') === -0.5 && parseFloat('Infinityx') === Infinity && isNaN(parseFloat('e5')) && parseFloat('1e') === 1", + ); + truth("isNaN('x') && !isNaN('1') && isFinite('1') && !isFinite(Infinity) && !isFinite(NaN)"); + truth("NaN !== NaN && Infinity === 1 / 0 && undefined === void 0 && !Object.getOwnPropertyDescriptor(this, 'NaN').writable"); +} + +#[test] +fn uri_functions_encode_and_decode_utf8() { + assert_eq!(string("encodeURIComponent('a b&c/\\u00e9\\ud83d\\ude00')"), "a%20b%26c%2F%C3%A9%F0%9F%98%80"); + assert_eq!(string("encodeURI('http://x.y/a b?c=d#e')"), "http://x.y/a%20b?c=d#e"); + assert_eq!(string("decodeURIComponent('a%20b%26%C3%A9%F0%9F%98%80') === 'a b&\\u00e9\\ud83d\\ude00' ? 'ok' : 'no'"), "ok"); + assert_eq!(string("decodeURI('%3B%2F%20%23')"), "%3B%2F %23"); + for bad in [ + "encodeURI('\\ud800')", + "encodeURIComponent('\\udc00x')", + "decodeURI('%')", + "decodeURI('%C3')", + "decodeURI('%C0%80')", + "decodeURI('%ED%A0%80')", + "decodeURI('%FF')", + ] { + throws(bad, "URIError"); + } +} + +#[test] +fn escape_and_unescape_follow_annex_b() { + assert_eq!(string("escape('a b+/@\\u00e9\\u0100')"), "a%20b+/@%E9%u0100"); + assert_eq!(string("unescape('a%20b%E9%u0100%zz%u12') === 'a b\\u00e9\\u0100%zz%u12' ? 'ok' : 'no'"), "ok"); +} + +// ---- robustness ---- + +/// Calls of built-ins with arguments of every kind. +const CALLS: &[&str] = &[ + "[1,,3].join(X)", + "[3,1,2].sort(X)", + "[1,2,3].splice(X, X, X)", + "[1,2].slice(X, X)", + "[].concat(X, [X])", + "[1,2,3].map(X)", + "[1,2].reduce(X, X)", + "[1,2].indexOf(X, X)", + "[1,2].lastIndexOf(X, X)", + "Array.prototype.push.call(X, X)", + "Array.prototype.unshift.call(X, X)", + "Array.prototype.reverse.call(X)", + "Array(X)", + "Object.keys(X)", + "Object.freeze(X)", + "Object.defineProperty(X, X, X)", + "Object.create(X, X)", + "Object.getOwnPropertyNames(X)", + "JSON.stringify(X, X, X)", + "JSON.parse(X, X)", + "Math.max(X, X)", + "Math.pow(X, X)", + "parseInt(X, X)", + "parseFloat(X)", + "decodeURIComponent(X)", + "encodeURI(X)", + "unescape(X)", + "escape(X)", + "Function.prototype.bind.call(X, X)", +]; + +const ARGS: &[&str] = &[ + "undefined", + "null", + "0", + "-1", + "4294967295", + "NaN", + "'%E0'", + "'[1,{\"a\":2}]'", + "''", + "[]", + "{length: 4294967295}", + "{}", + "function (a, b) { return b - a; }", + "{valueOf: function () { throw 1; }}", + "{get length() { return 3; }}", + "Object.freeze([1, 2])", + "{value: 1, get: function () {}}", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn arbitrary_built_in_calls_never_panic(call in 0..CALLS.len(), args in proptest::collection::vec(0..ARGS.len(), 3)) { + let mut src = CALLS[call].to_string(); + for a in &args { + src = src.replacen('X', ARGS[*a], 1); + } + let src = src.replace('X', "1"); + let mut realm = small(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + if let Err(e) = realm.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src}: {e:?}"); + } + } +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 69b0b4f40..1b2099fe1 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -170,11 +170,14 @@ pub(crate) enum NativeKind { Eval, } +/// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section +/// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given. #[derive(Clone)] pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, + Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } /// A sloppy mode `arguments` object's link to its function's parameters (section 10.6): index @@ -204,8 +207,8 @@ pub(crate) enum Class { Function(Func), Error, Arguments(Box), - /// A `Boolean` object made by ToObject. - Boolean, + /// A `Boolean` object made by ToObject, with its primitive value. + Boolean(bool), /// A `Number` object and its number. Number(f64), String(Str), @@ -213,18 +216,24 @@ pub(crate) enum Class { /// A `Date` object and its time value. Date(f64), ForIn(Box), + /// The `Math` object (section 15.8). + Math, + /// The `JSON` object (section 15.12). + Json, } impl Class { /// The `[[Class]]` name `Object.prototype.toString` reports. pub(crate) fn name(&self) -> &'static str { match self { + Class::Math => "Math", + Class::Json => "JSON", Class::Ordinary | Class::ForIn(_) => "Object", Class::Array { .. } => "Array", Class::Function(_) => "Function", Class::Error => "Error", Class::Arguments(_) => "Arguments", - Class::Boolean => "Boolean", + Class::Boolean(_) => "Boolean", Class::Number(_) => "Number", Class::String(_) => "String", Class::RegExp(_) => "RegExp", @@ -292,6 +301,8 @@ pub(crate) const OBJECT_BYTES: usize = 96; pub(crate) const ENV_BYTES: usize = 64; /// The estimated bytes of a rope node. pub(crate) const ROPE_BYTES: usize = 48; +/// The estimated bytes of each argument a bound function holds. +pub(crate) const BOUND_ARG_BYTES: usize = 24; /// The estimated bytes of a piece of code without its instructions, tables and strings. const CODE_BYTES: usize = 384; @@ -308,6 +319,10 @@ pub(crate) struct Heap { pub(crate) bytes: usize, /// How many collections have run. pub(crate) collections: usize, + /// Counts the properties whose names start with a digit (the array indices among them) added + /// to any object, so an operation that lists the indices an object has can tell whether a + /// script added one since. + pub(crate) index_epoch: u64, } impl Heap { @@ -524,11 +539,20 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u Prop::Accessor { get, set, .. } => work.extend(get.iter().chain(set.iter()).map(|f| f.0)), } } + let mut extra = 0; match &o.class { Class::Function(Func::Closure { env, code }) => { work.push(env.0); mark_code(code, strings); } + Class::Function(Func::Bound { target, this, args }) => { + work.push(target.0); + mark_value(this, work, strings); + for a in args.iter() { + mark_value(a, work, strings); + } + extra = BOUND_ARG_BYTES * args.len(); + } Class::Arguments(map) => work.push(map.env.0), Class::String(s) => strings.add(s), Class::RegExp(re) => { @@ -542,7 +566,7 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u } _ => {} } - OBJECT_BYTES + PROP_BYTES * o.props.len() + OBJECT_BYTES + PROP_BYTES * o.props.len() + extra } fn trace_env(e: &Env, work: &mut Vec, strings: &mut StringSizes) -> usize { diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 9e72285f1..b110a955b 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -164,6 +164,17 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The state of `Math.random`'s generator. + pub(crate) random: u64, + /// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a + /// cycle back to one of them joins as an empty string. + pub(crate) join_stack: Vec, + /// Bytes of the string buffers native functions are building outside the heap. They are + /// charged to `heap.bytes` and added back after each collection, and a native call's + /// buffers are released when it returns. + pub(crate) held_bytes: usize, + /// Why installing the built-ins failed; a realm with this set runs no script. + pub(crate) install_error: Option, /// The clock and time zone `Date` reads. pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. @@ -239,6 +250,15 @@ fn index_keys_bytes(len: usize) -> usize { total } +/// A seed for `Math.random` that differs from realm to realm, from the standard library's +/// randomly keyed hasher; never zero. +fn random_seed() -> u64 { + use std::hash::{BuildHasher, Hasher}; + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(0x9e37_79b9_7f4a_7c15); + h.finish() | 1 +} + /// The estimated bytes of a binding or property named by `len` code units. fn binding_bytes(len: usize) -> usize { PROP_BYTES + string_bytes(len) @@ -341,6 +361,10 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + random: random_seed(), + join_stack: Vec::new(), + held_bytes: 0, + install_error: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), }; @@ -515,7 +539,7 @@ impl Interp { self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. self.held.released.set(0); - self.heap.bytes = self.heap.bytes.saturating_add(self.held.now.get()); + self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get()); } /// Runs frames until the boundary frame on top returns, and returns its value. @@ -1058,13 +1082,219 @@ impl Interp { *length = i + 1; } let name_len = k.len(); + let index = new && k.first().is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); o.props.insert(k, prop); if new { self.heap.bytes += PROP_BYTES + string_bytes(name_len); } + if index { + self.heap.index_epoch = self.heap.index_epoch.wrapping_add(1); + } Ok(()) } + /// [[DefineOwnProperty]] (section 8.12.9, with the array rules of section 15.4.5.1 and the + /// arguments object rules of section 10.6). Returns whether the property was defined; with + /// `throw` a rejection throws a `TypeError` instead. + pub(crate) fn define_own_property(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult { + match &self.object(id)?.class { + Class::Array { length, length_writable } => { + let (length, length_writable) = (*length, *length_writable); + if is(&k, "length") { + return self.define_array_length(id, desc, throw); + } + if let Some(i) = array_index(&k) + && i >= length + && !length_writable + { + return self.reject_bool(throw, &k, "the array's length is read-only"); + } + self.define_ordinary(id, k, desc, throw) + } + Class::Arguments(map) => { + let mapped = array_index(&k).and_then(|i| map.names.get(i as usize)).and_then(Option::as_ref).cloned(); + if !self.define_ordinary(id, k.clone(), desc, false)? { + return self.reject_bool(throw, &k, "the property cannot be redefined"); + } + if let Some(name) = mapped { + let env = match &self.object(id)?.class { + Class::Arguments(map) => Some(map.env), + _ => None, + }; + if let Some(v) = desc.value.clone().filter(|_| !desc.is_accessor()) + && let Some(env) = env + && let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) + && let Some(b) = bindings.get_mut(&name) + { + b.value = v; + } + if (desc.is_accessor() || desc.writable == Some(false)) + && let Class::Arguments(map) = &mut self.object_mut(id)?.class + && let Some(slot) = array_index(&k).and_then(|i| map.names.get_mut(i as usize)) + { + *slot = None; + } + } + Ok(true) + } + _ => self.define_ordinary(id, k, desc, throw), + } + } + + fn reject_bool(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult { + if throw { + let message = format!("cannot define property '{}': {why}", String::from_utf16_lossy(k)); + return Err(self.type_error(&message)); + } + Ok(false) + } + + /// The default [[DefineOwnProperty]] (section 8.12.9). + fn define_ordinary(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult { + let current = self.get_own_property(id, &k)?; + let o = self.object(id)?; + let virtual_string = matches!(&o.class, Class::String(s) if is(&k, "length") || array_index(&k).is_some_and(|i| (i as usize) < s.len())); + let Some(current) = current else { + if !o.extensible { + return self.reject_bool(throw, &k, "the object is not extensible"); + } + // The object and the descriptor's values stay rooted while the heap is checked. + let mut roots = vec![Value::Object(id)]; + roots.extend(desc.value.iter().cloned()); + roots.extend(desc.get.flatten().into_iter().chain(desc.set.flatten()).map(Value::Object)); + self.rooted(&roots, |i| i.reserve_heap(binding_bytes(k.len())))?; + let flags = desc.flags_or(0); + let prop = if desc.is_accessor() { + Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: flags & !WRITABLE } + } else { + Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags } + }; + self.define(id, k, prop)?; + return Ok(true); + }; + let Some(merged) = merge_desc(¤t, desc) else { return self.reject_bool(throw, &k, "the property cannot be redefined") }; + // An index or the length of a String object cannot change, so a definition that passes + // the checks leaves it as it is. + if virtual_string { + return Ok(true); + } + self.define(id, k, merged)?; + Ok(true) + } + + /// Defining an array's `length` (section 15.4.5.1, step 3). + fn define_array_length(&mut self, id: ObjId, desc: &Desc, throw: bool) -> JsResult { + let length_key = key("length"); + let Some(v) = desc.value.clone() else { + let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") }; + let Some(merged) = merge_desc(¤t, desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") }; + if let Class::Array { length_writable, .. } = &mut self.object_mut(id)?.class { + *length_writable = merged.flags() & WRITABLE != 0; + } + return Ok(true); + }; + let new = to_uint32(self.to_number(&v)?); + if f64::from(new) != self.to_number(&v)? { + return Err(self.throw(ErrorType::Range, "invalid array length")); + } + let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") }; + let Class::Array { length: old, length_writable } = self.object(id)?.class else { return internal("not an array") }; + let new_desc = Desc { value: Some(Value::Number(f64::from(new))), ..desc.clone() }; + if new >= old { + let Some(merged) = merge_desc(¤t, &new_desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") }; + if let Class::Array { length, length_writable } = &mut self.object_mut(id)?.class { + *length = new; + *length_writable = merged.flags() & WRITABLE != 0; + } + return Ok(true); + } + if !length_writable { + return self.reject_bool(throw, &length_key, "it is read-only"); + } + let checked = Desc { writable: None, ..new_desc }; + if merge_desc(¤t, &checked).is_none() { + return self.reject_bool(throw, &length_key, "the length cannot be redefined"); + } + let read_only = desc.writable == Some(false); + if !self.shrink_array(id, new, read_only)? { + return self.reject_bool(throw, &length_key, "an element cannot be deleted"); + } + Ok(true) + } + + /// Sets an array's length to `new`, deleting the elements at and past it from the last one + /// down (section 15.4.5.1, step 3.l). An element that cannot be deleted stops it, leaving + /// the length one past that element, and gives `false`. Each property takes a unit of fuel + /// before anything is removed. With `read_only` the length is made read-only afterwards + /// either way. + fn shrink_array(&mut self, id: ObjId, new: u32, read_only: bool) -> JsResult { + let Class::Array { length: old, .. } = self.object(id)?.class else { return internal("not an array") }; + let mut ok = true; + let mut length = new; + if new < old { + for _ in 0..self.object(id)?.props.len() { + self.use_fuel()?; + } + let o = self.object_mut(id)?; + let mut doomed: Vec<(u32, Key)> = + o.props.iter().filter_map(|(k, _)| array_index(k).filter(|&i| i >= new).map(|i| (i, k.clone()))).collect(); + doomed.sort_unstable_by_key(|d| std::cmp::Reverse(d.0)); + for (i, k) in doomed { + if o.props.get(&k).is_some_and(|p| !p.configurable()) { + length = i + 1; + ok = false; + break; + } + o.props.remove(&k); + } + } + if let Class::Array { length: l, length_writable } = &mut self.object_mut(id)?.class { + *l = length; + if read_only { + *length_writable = false; + } + } + Ok(ok) + } + + /// The names of an object's own properties (section 15.2.3.4): a String object's indices and + /// `length` first, then the rest in the order they were added, which is the order `for-in` + /// visits them, then an array's `length`. Each name takes a unit of fuel, and the heap for the + /// list is checked first. + pub(crate) fn own_keys(&mut self, id: ObjId) -> JsResult> { + let o = self.object(id)?; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + let is_string = matches!(o.class, Class::String(_)); + let is_array = matches!(o.class, Class::Array { .. }); + let count = o.props.len().saturating_add(string_len).saturating_add(usize::from(is_string || is_array)); + self.reserve_heap(index_keys_bytes(string_len).saturating_add(count.saturating_mul(KEY_ENTRY_BYTES)))?; + let mut out = Vec::with_capacity(count); + for i in 0..string_len { + self.use_fuel()?; + out.push(index_key(u32::try_from(i).unwrap_or(u32::MAX))); + } + if is_string { + out.push(key("length")); + } + for _ in 0..self.object(id)?.props.len() { + self.use_fuel()?; + } + out.extend(self.object(id)?.props.keys()); + if is_array { + out.push(key("length")); + } + Ok(out) + } + + /// Runs `f` with `values` kept reachable for any collection it starts. + pub(crate) fn rooted(&mut self, values: &[Value], f: impl FnOnce(&mut Self) -> JsResult) -> JsResult { + let mark = self.temp_roots.len(); + self.temp_roots.extend(values.iter().cloned()); + let r = f(self); + self.temp_roots.truncate(mark); + r + } + fn reject(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult<()> { if throw { let message = format!("cannot assign to property '{}': {why}", String::from_utf16_lossy(k)); @@ -1131,32 +1361,19 @@ impl Interp { } } - /// Assigning to an array's `length` (section 15.4.5.1). + /// Assigning to an array's `length` (section 15.4.5.1). [[Put]] defines the value, so the + /// value is converted once with ToUint32 and once with ToNumber, as in steps 3.c and 3.d. fn set_array_length(&mut self, id: ObjId, v: &Value, throw: bool) -> JsResult<()> { - let n = self.to_number(v)?; - let new = to_uint32(n); - if f64::from(new) != n { + let new = to_uint32(self.to_number(v)?); + if f64::from(new) != self.to_number(v)? { return Err(self.throw(ErrorType::Range, "invalid array length")); } - let o = self.object(id)?; - let Class::Array { length: old, length_writable } = o.class else { return internal("not an array") }; + let Class::Array { length_writable, .. } = self.object(id)?.class else { return internal("not an array") }; if !length_writable { return self.reject(throw, &key("length"), "it is read-only"); } - if new < old { - // Shrinking visits every property: each takes fuel before anything is removed. - for _ in 0..o.props.len() { - self.use_fuel()?; - } - } - let o = self.object_mut(id)?; - let Class::Array { length, .. } = &mut o.class else { return internal("not an array") }; - *length = new; - if new < old { - let doomed: Vec = o.props.keys().into_iter().filter(|k| array_index(k).is_some_and(|i| i >= new)).collect(); - for k in doomed { - o.props.remove(&k); - } + if !self.shrink_array(id, new, false)? { + return self.reject(throw, &key("length"), "an element cannot be deleted"); } Ok(()) } @@ -1576,13 +1793,25 @@ impl Interp { this = args.first().cloned().unwrap_or(Value::Undefined); args = self.list_from_array_like(&list)?; } + Func::Bound { target, this: bound_this, args: bound_args } => { + self.use_fuel()?; + let mut all = Vec::with_capacity(bound_args.len().saturating_add(args.len())); + all.extend(bound_args.iter().cloned()); + all.append(&mut args); + args = all; + this = bound_this; + f = Value::Object(target); + } Func::Native { f: native, .. } => { - // A list from `apply` is not on the operand stack, and a native function can run - // JavaScript that collects, so the arguments stay rooted for the call. + // A list from `apply` and the arguments a bound function adds are not on the + // operand stack, and a native function can run JavaScript that collects, so the + // arguments stay rooted for the call. let mark = self.temp_roots.len(); self.temp_roots.push(this.clone()); self.temp_roots.extend(args.iter().cloned()); + let held = self.held_bytes; let r = native(self, this, &args, false); + self.held_bytes = held; self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } @@ -1660,8 +1889,17 @@ impl Interp { let n = argc as usize; let start = self.stack.len().checked_sub(n + 1).filter(|&s| s >= self.base()).ok_or_else(|| Abrupt::Internal("new below the stack".into()))?; - let f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); - let args: Vec = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default(); + let mut f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); + let mut args: Vec = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default(); + // A bound function constructs its target with its arguments first (section 15.3.4.5.2); + // the bound function on the stack keeps them reachable. + while let Some(Func::Bound { target, args: bound_args, .. }) = self.func_of(&f) { + self.use_fuel()?; + let mut all = bound_args.to_vec(); + all.append(&mut args); + args = all; + f = Value::Object(target); + } match self.func_of(&f) { Some(Func::Closure { code, env }) => { let Some(callee) = f.as_object() else { return internal("callee is not an object") }; @@ -1674,7 +1912,10 @@ impl Interp { self.enter_function(Entry { callee, code, env, this: Value::Object(obj), args }, Some(obj), false) } Some(Func::Native { f: native, constructor: true, .. }) => { - let v = native(self, Value::Undefined, &args, true)?; + let held = self.held_bytes; + let r = native(self, Value::Undefined, &args, true); + self.held_bytes = held; + let v = r?; self.stack.truncate(start); self.push(v) } @@ -2081,7 +2322,7 @@ impl Interp { let (class, proto) = match v { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), - Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto), + Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; @@ -2226,7 +2467,13 @@ impl Interp { if !self.is_callable(f) { return Err(self.type_error("the right side of 'instanceof' is not a function")); } - let Some(fid) = f.as_object() else { return Ok(false) }; + let Some(mut fid) = f.as_object() else { return Ok(false) }; + // A bound function answers for its target (section 15.3.4.5.3). + while let Some(Func::Bound { target, .. }) = self.func_of(&Value::Object(fid)) { + self.use_fuel()?; + fid = target; + } + let f = &Value::Object(fid); let Value::Object(mut o) = v.clone() else { return Ok(false) }; let Value::Object(proto) = self.get_from(fid, &key("prototype"), f)? else { return Err(self.type_error("the function's prototype is not an object")); @@ -2301,6 +2548,107 @@ pub(crate) enum CompileError { Internal(String), } +/// A property descriptor (section 8.10): each field is `None` when it is absent. `get` and `set` +/// are `Some(None)` when present as `undefined`. +#[derive(Clone, Default, Debug)] +pub(crate) struct Desc { + pub(crate) value: Option, + pub(crate) get: Option>, + pub(crate) set: Option>, + pub(crate) writable: Option, + pub(crate) enumerable: Option, + pub(crate) configurable: Option, +} + +impl Desc { + /// A data descriptor with every attribute given. + pub(crate) fn data(value: Value, flags: u8) -> Self { + Desc { + value: Some(value), + writable: Some(flags & WRITABLE != 0), + enumerable: Some(flags & ENUMERABLE != 0), + configurable: Some(flags & CONFIGURABLE != 0), + ..Desc::default() + } + } + + pub(crate) fn is_accessor(&self) -> bool { + self.get.is_some() || self.set.is_some() + } + + pub(crate) fn is_data(&self) -> bool { + self.value.is_some() || self.writable.is_some() + } + + /// The attribute flags the descriptor gives, each absent one taken from `default`. + fn flags_or(&self, default: u8) -> u8 { + let pick = |field: Option, bit: u8| match field { + Some(true) => bit, + Some(false) => 0, + None => default & bit, + }; + pick(self.writable, WRITABLE) | pick(self.enumerable, ENUMERABLE) | pick(self.configurable, CONFIGURABLE) + } +} + +/// SameValue (section 9.12). +pub(crate) fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.is_nan() && y.is_nan()) || (x == y && x.is_sign_negative() == y.is_sign_negative()), + _ => strict_eq(a, b), + } +} + +/// Steps 5 to 12 of [[DefineOwnProperty]] (section 8.12.9) for an existing property: the +/// property after `desc` is applied, or `None` when the change is not allowed. +fn merge_desc(current: &Prop, desc: &Desc) -> Option { + let flags = current.flags(); + let configurable = flags & CONFIGURABLE != 0; + if !configurable { + if desc.configurable == Some(true) { + return None; + } + if desc.enumerable.is_some_and(|e| e != (flags & ENUMERABLE != 0)) { + return None; + } + } + let attrs = |base: u8, writable_bit: bool| { + let f = desc.flags_or(base); + if writable_bit { f } else { f & !WRITABLE } + }; + match current { + Prop::Data { .. } if desc.is_accessor() => { + if !configurable { + return None; + } + Some(Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: attrs(flags & !WRITABLE, false) }) + } + Prop::Accessor { .. } if desc.is_data() => { + if !configurable { + return None; + } + Some(Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags: attrs(flags & !WRITABLE, true) }) + } + Prop::Data { value, .. } => { + if !configurable && flags & WRITABLE == 0 { + if desc.writable == Some(true) { + return None; + } + if desc.value.as_ref().is_some_and(|v| !same_value(v, value)) { + return None; + } + } + Some(Prop::Data { value: desc.value.clone().unwrap_or_else(|| value.clone()), flags: attrs(flags, true) }) + } + Prop::Accessor { get, set, .. } => { + if !configurable && (desc.get.is_some_and(|g| g != *get) || desc.set.is_some_and(|s| s != *set)) { + return None; + } + Some(Prop::Accessor { get: desc.get.unwrap_or(*get), set: desc.set.unwrap_or(*set), flags: attrs(flags, false) }) + } + } +} + /// The strict equality comparison (section 11.9.6). pub(crate) fn strict_eq(a: &Value, b: &Value) -> bool { match (a, b) { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3c36efb13..b883be432 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -24,12 +24,16 @@ //! deep recursion in a script does not use the native stack. Objects and environments live in an //! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped //! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these -//! built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with -//! `Function.prototype.call` and `apply`, `eval`, the error constructors, and `String`, `Number`, -//! `RegExp` and `Date` (sections 15.5, 15.7, 15.9 and 15.10). Regular expressions run on a -//! backtracking matcher that keeps its choice points on its own stack and takes fuel for every -//! step; `Date` reads the clock and time zone the host sets with [`Realm::set_clock`] and -//! [`Realm::set_time_zone`]. Every run is bounded +//! built-ins: the global functions and constants (15.1, with Annex B's `escape` and `unescape`), +//! `Object` (15.2), `Function` with `call`, `apply` and `bind` (15.3), `Array` (15.4), `String` +//! (15.5), `Boolean.prototype`'s `toString` and `valueOf` (15.6.4), `Number` (15.7), `Math` (15.8), +//! `Date` (15.9), `RegExp` (15.10), the error types (15.11) and `JSON` (15.12). The array methods +//! visit only the indices an array or array-like object has, so a sparse array of length 2^32-1 +//! costs as much as its elements, and `JSON.parse`, `JSON.stringify` and the reviver walk use no +//! native recursion. Regular expressions run on a backtracking matcher that keeps its choice points +//! on its own stack and takes fuel for every step; `Date` reads the clock and time zone the host +//! sets with [`Realm::set_clock`] and [`Realm::set_time_zone`]. The prototype methods are writable +//! and configurable, so scripts can replace them. Every run is bounded //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. @@ -178,6 +182,8 @@ impl LineTable { } } +#[cfg(test)] +mod builtins_tests; #[cfg(test)] mod interp_tests; #[cfg(test)] diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index eee516f5b..45d7c08bd 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -170,9 +170,13 @@ impl Realm { } /// Runs `source` as a script in the global environment and returns its completion value (the - /// value of the last expression statement that ran). + /// value of the last expression statement that ran). A realm whose built-ins could not be + /// installed runs nothing and gives an internal error. pub fn run(&mut self, source: &str) -> Result { let interp = &mut self.interp; + if let Some(message) = &interp.install_error { + return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); + } interp.fuel = interp.limits.fuel; interp.source_units = 0; interp.throw_site = None; @@ -188,6 +192,7 @@ impl Realm { interp.stack.clear(); interp.frames.clear(); interp.temp_roots.clear(); + interp.join_stack.clear(); match result { Ok(v) => Ok(interp.to_js_value(&v)), Err(Abrupt::Throw(v)) => { diff --git a/crates/js/tests/json2.rs b/crates/js/tests/json2.rs new file mode 100644 index 000000000..af408a4fe --- /dev/null +++ b/crates/js/tests/json2.rs @@ -0,0 +1,89 @@ +//! Runs `json2.js`, Douglas Crockford's public-domain JSON polyfill, when `cargo xtask test262` has +//! fetched it (it sets `VECTORCRAFT_JSON2`; without it the test looks in `corpus/json2/` and skips +//! when the file is absent). +//! +//! The built-in `JSON` is removed first, so the polyfill defines its own. Each sample value is +//! written by the polyfill and by the built-in `JSON.stringify` (ECMA-262 5.1 section 15.12.3), +//! which must give the same text, and the text is read back by the polyfill's `parse` and by the +//! built-in `JSON.parse` (section 15.12.2), which must give values that write the same text again. +//! The samples leave out U+2028 and U+2029: the polyfill escapes them, and section 15.12.3 does not +//! escape them. +//! +//! The polyfill uses `Date`, `String.prototype.replace` and regular expression `test`. While the +//! interpreter lacks any of them, the test prints that it skipped and passes. + +use std::path::{Path, PathBuf}; + +use vectorcraft_js::{JsValue, Realm}; + +fn json2_file() -> Option { + if let Ok(path) = std::env::var("VECTORCRAFT_JSON2") { + return Some(PathBuf::from(path)); + } + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/json2"); + let mut dirs: Vec = + std::fs::read_dir(corpus).ok()?.filter_map(|e| e.ok().map(|e| e.path().join("json2.js"))).filter(|p| p.is_file()).collect(); + dirs.sort(); + dirs.pop() +} + +/// Built-ins the polyfill needs. +const NEEDS: &str = + "typeof Date === 'function' && typeof ''.replace === 'function' && typeof RegExp === 'function' && typeof /a/.test === 'function'"; + +/// Saves the built-in `JSON` and removes it, so the polyfill defines its own. +const BEFORE: &str = "var NATIVE = JSON; delete this.JSON; this.JSON = undefined;"; + +/// Writes and reads each sample with both implementations; gives the first difference, or "ok". +const ROUND_TRIP: &str = r#" +var POLY = JSON; +var samples = [ + 0, -1.5, 1e21, "", "plain", "quote \" backslash \\ newline \n tab \t \u0001 é", + true, false, null, [], {}, [1, [2, [3, []]]], {a: 1, b: [true, null, "x"], c: {d: {}}}, + [undefined, function () {}], {skip: undefined, f: function () {}, keep: 2}, [NaN, Infinity, -Infinity] +]; +var problem = "ok"; +for (var i = 0; i < samples.length && problem === "ok"; i++) { + var poly = POLY.stringify(samples[i]); + var native = NATIVE.stringify(samples[i]); + if (poly !== native) { + problem = "sample " + i + ": json2 wrote " + poly + ", the built-in wrote " + native; + } else if (poly !== undefined) { + if (NATIVE.stringify(POLY.parse(native)) !== native) { + problem = "sample " + i + ": json2 read " + native + " differently"; + } else if (POLY.stringify(NATIVE.parse(poly)) !== poly) { + problem = "sample " + i + ": the built-in read " + poly + " differently"; + } + } +} +var indented = {a: [1, {b: 2}], c: "d"}; +if (problem === "ok" && POLY.stringify(indented, null, 2) !== NATIVE.stringify(indented, null, 2)) { + problem = "indented output differs"; +} +if (problem === "ok" && POLY.stringify(indented, ["c"]) !== NATIVE.stringify(indented, ["c"])) { + problem = "property list output differs"; +} +problem; +"#; + +#[test] +fn json2_polyfill_round_trips_with_the_built_in_json() { + let Some(path) = json2_file() else { + println!("json2: SKIPPED (no json2.js; run cargo xtask test262)"); + return; + }; + let source = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut realm = Realm::new(); + match realm.run(NEEDS) { + Ok(JsValue::Bool(true)) => {} + Ok(JsValue::Bool(false)) => { + println!("json2: SKIPPED (the interpreter lacks Date, String.prototype.replace or RegExp, which json2.js uses)"); + return; + } + other => panic!("json2: the built-in check failed: {other:?}"), + } + match realm.run(&format!("{BEFORE}\n{source}\n{ROUND_TRIP}")) { + Ok(JsValue::String(s)) if s == "ok" => println!("json2: the polyfill and the built-in JSON agree on every sample"), + other => panic!("json2: {other:?}"), + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index 6303ab078..a6e16ea4c 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -20,10 +20,13 @@ //! `constructor` is the global its `type:` names. Its counts are printed apart from the parse //! counts, and its failures go to `run-failures.txt`. //! -//! A third test runs the tests under `test/built-ins/String`, `Number`, `RegExp` and `Date` the same -//! way, counting the tests that name an ES5 section apart from the other tests without a feature -//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail when the -//! interpreter panics, when a harness or test file cannot be read, or when no ES5 test runs. +//! A third test runs the tests of the ES5 built-ins the same way: those under the folders +//! [`ES5_BUILT_INS`] names that have an `es5id:` (or, under `annexB/`, any edition's id) and no +//! `features:` tag. Its failures go to `built-ins-failures.txt`. A fourth test runs the tests under +//! `test/built-ins/String`, `Number`, `RegExp` and `Date` the same way, counting the tests that name +//! an ES5 section apart from the other tests without a feature tag; its failures go to +//! `builtins-failures.txt`. The interpreter tests fail when the interpreter panics, when a harness +//! or test file cannot be read, or when no ES5 test runs. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -198,15 +201,37 @@ fn es5_parse_subset() { ); } -/// Globals and method names the interpreter does not provide yet: a test that names one is -/// skipped. -const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegExp parseInt parseFloat isNaN isFinite encodeURI \ - decodeURI encodeURIComponent decodeURIComponent escape unescape print $262 $DONE Symbol Proxy Reflect Promise Map Set WeakMap \ - WeakSet ArrayBuffer DataView Int8Array Uint8Array Float64Array defineProperty defineProperties getOwnPropertyDescriptor \ - getOwnPropertyNames getPrototypeOf create keys freeze isFrozen seal isSealed preventExtensions isExtensible push pop shift unshift \ - slice splice concat join reverse sort indexOf lastIndexOf every some forEach map filter reduce reduceRight charAt charCodeAt \ - fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ - bind now isArray compareArray verifyProperty fnGlobalObject"; +/// Globals and method names the interpreter does not provide yet: a test that names one, in its +/// own source or in a harness file it includes, is skipped. +const MISSING_BUILTINS: &str = "String Number Boolean Date RegExp print $262 $DONE Symbol Proxy Reflect Promise Map Set WeakMap \ + WeakSet ArrayBuffer DataView Int8Array Uint8Array Float64Array charAt charCodeAt fromCharCode substring substr split replace \ + match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential now"; + +/// The directories under `test/built-ins/` and `test/annexB/built-ins/` that hold tests of the +/// ES5 built-ins (ECMA-262 5.1 sections 15.1 to 15.12 and Annex B.2). +const ES5_BUILT_INS: &[&str] = &[ + "built-ins/Object", + "built-ins/Function", + "built-ins/Array", + "built-ins/Error", + "built-ins/NativeErrors", + "built-ins/Math", + "built-ins/JSON", + "built-ins/global", + "built-ins/Infinity", + "built-ins/NaN", + "built-ins/undefined", + "built-ins/parseInt", + "built-ins/parseFloat", + "built-ins/isNaN", + "built-ins/isFinite", + "built-ins/encodeURI", + "built-ins/encodeURIComponent", + "built-ins/decodeURI", + "built-ins/decodeURIComponent", + "annexB/built-ins/escape", + "annexB/built-ins/unescape", +]; /// The names in [`MISSING_BUILTINS`] that `String`, `Number`, `RegExp` and `Date` provide. const TEXT_BUILTINS: &str = "String Number Date RegExp charAt charCodeAt fromCharCode substring substr split replace match search exec test \ @@ -310,11 +335,49 @@ fn run_language_subset(dir: &Path) { } #[test] -fn es5_text_builtins_run() { +fn es5_built_ins_subset_runs() { let Some(dir) = suite_dir() else { println!("test262 built-ins: SKIPPED (no suite; run cargo xtask test262)"); return; }; + let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_built_ins(&dir)).unwrap(); + handle.join().unwrap(); +} + +/// Runs the tests under the folders [`ES5_BUILT_INS`] names, prints the counts and writes the +/// failures to `built-ins-failures.txt` beside the suite. The Annex B tests name the edition they +/// were written for with `esid:`, so they run without an `es5id:`. +fn run_built_ins(dir: &Path) { + let missing = missing_builtins(&[TEXT_BUILTINS]); + let mut all = RunCounts::default(); + for folder in ES5_BUILT_INS { + let root = dir.join("test").join(folder); + assert!(root.is_dir(), "{} is not a test262 checkout", dir.display()); + let (es5, later) = run_tests(dir, &root, &missing, folder.starts_with("annexB")); + for counts in [es5, later] { + all.tests += counts.tests; + all.skipped += counts.skipped; + all.passed += counts.passed; + all.failed += counts.failed; + all.panicked += counts.panicked; + all.failures.push_str(&counts.failures); + } + } + assert!(all.tests > 0, "no ES5 built-ins tests ran under {}", dir.display()); + let report = dir.join("built-ins-failures.txt"); + if let Err(e) = std::fs::write(&report, &all.failures) { + println!("could not write {}: {e}", report.display()); + } + println!("test262 ES5 built-ins subset through the interpreter: {}. Failures: {}", all.summary(), report.display()); + assert_eq!(all.panicked, 0, "the interpreter panicked; see {}", report.display()); +} + +#[test] +fn es5_text_builtins_run() { + let Some(dir) = suite_dir() else { + println!("test262 text built-ins: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_text_builtins(&dir)).unwrap(); handle.join().unwrap(); } diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 076525be4..163117085 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -6,6 +6,7 @@ //! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt //! keeps the star's shoulders straight (#812). The start point and //! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too. +//! Escape cancels the drag and puts back the options its keys and Ctrl/Cmd changed. //! //! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle, //! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at @@ -71,6 +72,9 @@ pub struct ShapeTool { pub line_angle: f64, pub fill_line: bool, snap: DrawSnap, + /// The options a drag's keys and Ctrl/Cmd change, as they were when the drag began: Escape + /// puts them back. + before_drag: Option, /// A widget of the selection is being dragged instead of a shape drawn. widget: Option, /// The selection is being moved or turned instead of a shape drawn. @@ -84,6 +88,15 @@ pub struct ShapeTool { snapped: bool, } +/// The options that keys and Ctrl/Cmd change while a shape is dragged out. +#[derive(Clone, Copy)] +struct DragOptions { + sides: u32, + points: u32, + corner_radius: f64, + star_ratio: f64, +} + /// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each /// point's sides in line with the next points' sides: a regular star polygon {n/2}); none for /// fewer than five points, whose shoulders can't line up. @@ -117,6 +130,7 @@ impl ShapeTool { line_length: 100.0, line_angle: 0.0, fill_line: false, + before_drag: None, widget: None, selection: None, guides: vec![], @@ -125,6 +139,18 @@ impl ShapeTool { } } + fn drag_options(&self) -> DragOptions { + DragOptions { sides: self.sides, points: self.points, corner_radius: self.corner_radius, star_ratio: self.star_ratio } + } + + /// Put back the options a canceled drag changed. + fn restore_drag_options(&mut self, o: DragOptions) { + self.sides = o.sides; + self.points = o.points; + self.corner_radius = o.corner_radius; + self.star_ratio = o.star_ratio; + } + /// A press on a corner or pie widget of the selection: the drag adjusts it. fn press_widget(&mut self, cx: &ToolContext, ev: &PointerEvent) -> bool { if !shows_shape_widgets(self.id) { @@ -428,6 +454,7 @@ impl Tool for ShapeTool { self.last = ev.pos; self.began = false; self.inner = None; + self.before_drag = None; vec![] } PointerKind::Drag => { @@ -451,6 +478,7 @@ impl Tool for ShapeTool { return out; } self.began = true; + self.before_drag = Some(self.drag_options()); out.push(Action::Begin(self.label().into())); } let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle); @@ -461,6 +489,7 @@ impl Tool for ShapeTool { let Some(s) = self.start.take() else { return vec![] }; self.snap.clear(); self.inner = None; + self.before_drag = None; if self.began { self.began = false; vec![Action::Commit] @@ -528,6 +557,9 @@ impl Tool for ShapeTool { self.start = None; self.began = false; self.inner = None; + if let Some(o) = self.before_drag.take() { + self.restore_drag_options(o); + } self.snap.clear(); vec![Action::Cancel] } else { @@ -659,6 +691,16 @@ impl Tool for ShapeTool { "fillLine" => self.fill_line = v.as_bool().unwrap_or(self.fill_line), _ => {} } + // An option set while a shape is dragged out stays when Escape cancels the drag. + if let Some(b) = self.before_drag.as_mut() { + match key { + "sides" => b.sides = self.sides, + "points" => b.points = self.points, + "cornerRadius" => b.corner_radius = self.corner_radius, + "starRatio" => b.star_ratio = self.star_ratio, + _ => {} + } + } } } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 85a2d52fc..580f15c16 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -166,15 +166,16 @@ fn a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down() { close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 170.0, 50.0).with_mods(ctrl))), (120.0, 40.0 * straight)); // Escape with the key held, then a new press and the key before the first move: the next star - // holds what it shows, not the canceled radius. + // has the ratio from before the canceled drag and holds the inner radius it shows, not the + // canceled one. let mut t = ShapeTool::new("star"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)); assert_eq!(t.key(&cx, ToolKey::Escape, ctrl), vec![Action::Cancel]); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); - close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 2.5)); - close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 2.5)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 5.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 5.0)); // Ctrl/Cmd pressed inside the drag threshold, where no star is shown yet. let mut t = ShapeTool::new("star"); @@ -301,6 +302,81 @@ fn rounded_rectangle_radius_is_clamped_to_its_range() { assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS)); } +/// Escape during a polygon, star or rounded rectangle drag puts back the side count, point count +/// or corner radius its arrows changed, for the next drag and the tool's options; a drag that is +/// let go keeps them. +#[test] +fn escape_puts_back_the_options_a_drag_changed() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + for (id, field) in [("polygon", "sides"), ("star", "points"), ("roundedRectangle", "radius")] { + let mut t = ShapeTool::new(id); + let value = |a: Vec| match a.last() { + Some(Action::Preview(_, v)) => v[field].clone(), + _ => panic!("{id}: {a:?}"), + }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let before = value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))); + let options = t.options(); + t.key(&cx, ToolKey::Up, Mods::default()); + let changed = value(t.key(&cx, ToolKey::Up, Mods::default())); + assert_ne!(changed, before, "{id}: the arrows change the option"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}"); + assert_eq!(t.options(), options, "{id}: the tool's options"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), before, "{id}: the next drag"); + // A drag that is let go keeps what its arrows changed. + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Up, Mods::default()); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: a finished drag keeps it"); + t.key(&cx, ToolKey::Down, Mods::default()); + t.key(&cx, ToolKey::Escape, Mods::default()); + let kept = t.options(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + // Escape puts back only what this drag changed. + assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: only this drag"); + assert_ne!(kept, options, "{id}"); + } +} + +/// Ctrl/Cmd changes a star's inner-to-outer ratio as it is dragged out; Escape puts it back, and +/// an option set during the drag stays. +#[test] +fn escape_puts_back_the_star_ratio_and_keeps_an_option_set_during_the_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let ctrl = Mods { cmd: true, ..Default::default() }; + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)); + assert!((t.star_ratio - 0.25).abs() < 1e-9, "{}", t.star_ratio); + t.key(&cx, ToolKey::Escape, ctrl); + assert_eq!(t.star_ratio, 0.5); + + // Options set with `tool.setOption` during the drag stay; the arrows' change goes back. + for (id, key, set) in [("polygon", "sides", json!(9)), ("star", "points", json!(8)), ("roundedRectangle", "cornerRadius", json!(30.0))] { + let mut t = ShapeTool::new(id); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.set_option(key, &set); + t.set_option("starRatio", &json!(0.3)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o[key].clone(), o["starRatio"].clone()), (set, json!(0.3)), "{id}"); + } +} + /// The start and the dragged corner land on another object's anchors (#506), hovering shows /// it before the press, and the measurement label gives the size drawn. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6e1bc1fa4..600ecb039 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -312,6 +312,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged | | 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. | | 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 13026b972..cd6d1ce65 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -693,8 +693,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -873,6 +874,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). + Escape while a polygon, star or rounded rectangle is dragged out cancels it and puts back the side count, point count, corner radius or star ratio its Up/Down and Ctrl/Cmd changed, so the next drag, the tool's options and General › Corner Radius start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule. While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. @@ -1342,7 +1344,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`). @@ -1871,6 +1873,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0324964c5..260b10387 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -44,8 +44,9 @@ commands: stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 verified), parse its ES5 subset with vectorcraft-js and run the ES5 language - tests its interpreter supports and the String, Number, RegExp and Date - built-ins tests, printing the counts + and built-in tests its interpreter supports and the String, Number, RegExp + and Date built-ins tests, printing the counts; then fetch json2.js into + corpus/json2 (sha256 verified) and run it web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -342,6 +343,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 verified: the JavaScript parser's and interpreter's ES5 subsets. Fetch and run: cargo xtask test262 + corpus/json2/ json2.js, the public-domain JSON polyfill, at a pinned commit, sha256 verified: + compared with vectorcraft-js's JSON. Fetch and run: cargo xtask test262 ", corpus.display() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index 88cbef395..dfed1d8ae 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -2,17 +2,34 @@ //! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against //! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then //! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 -//! `test/language` tests that need only the interpreter's built-ins, run the `test/built-ins` tests -//! for `String`, `Number`, `RegExp` and `Date`, and print each one's counts. +//! `test/language` tests and the tests of the ES5 built-ins under `test/built-ins` and +//! `test/annexB/built-ins` that need only the interpreter's built-ins, run the `test/built-ins` +//! tests for `String`, `Number`, `RegExp` and `Date`, and print each one's counts. //! -//! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the -//! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. +//! It also fetches `json2.js`, Douglas Crockford's public-domain JSON polyfill +//! (https://github.com/douglascrockford/JSON-js), at a pinned commit, checks it against its SHA-256 +//! and size, saves it in `corpus/json2/` (gitignored, never committed), and runs `vectorcraft-js`'s +//! `json2` test, which runs the polyfill and compares what it writes and reads with the built-in +//! `JSON`. +//! +//! Moving the pin: change [`COMMIT`] (or [`JSON2_COMMIT`]), run the command once with the new +//! commit to see the file's SHA-256 in the error, check the file, and update [`SHA256`] (or +//! [`JSON2_SHA256`]). -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::{cargo, root, run, sha256}; +/// The douglascrockford/JSON-js commit `json2.js` is read at. +const JSON2_COMMIT: &str = "7e83f38a2312429fd4933169c1f6a27fd65e889c"; + +/// The SHA-256 of `json2.js` at [`JSON2_COMMIT`]. +const JSON2_SHA256: &str = "86df14b56572e68a7e10b18e71804cb78c6890a7d6e534324822c23d12e858a2"; + +/// The largest `json2.js` accepted (the pinned one is 18,876 bytes). +const JSON2_MAX_BYTES: u64 = 1 << 20; + /// The tc39/test262 commit the suite is read at. const COMMIT: &str = "d53ebc8b1931d9f1541e43d59650776e4b766248"; @@ -26,11 +43,44 @@ pub fn run_suite() -> Result<(), String> { let dir = fetch()?; let mut c = cargo(); c.env("VECTORCRAFT_TEST262_DIR", &dir).args(["test", "-p", "vectorcraft-js", "--release", "--test", "test262", "--", "--nocapture"]); - run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display())) + run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display()))?; + let json2 = fetch_json2()?; + let mut c = cargo(); + c.env("VECTORCRAFT_JSON2", &json2).args(["test", "-p", "vectorcraft-js", "--release", "--test", "json2", "--", "--nocapture"]); + run(c, &format!("cargo test -p vectorcraft-js --release --test json2 -- --nocapture (VECTORCRAFT_JSON2={})", json2.display())) +} + +/// Fetches `json2.js` unless a copy with the pinned SHA-256 is there already, and returns its path. +fn fetch_json2() -> Result { + let base = root().join("corpus").join("json2").join(JSON2_COMMIT); + let file = base.join("json2.js"); + let matches = |p: &Path| -> Result { + let Ok(meta) = std::fs::metadata(p) else { return Ok(false) }; + if meta.len() > JSON2_MAX_BYTES { + return Err(format!("{} is {} bytes, over the {JSON2_MAX_BYTES}-byte limit", p.display(), meta.len())); + } + Ok(std::fs::read(p).is_ok_and(|b| sha256::hex(&b) == JSON2_SHA256)) + }; + if matches(&file)? { + println!("json2.js {JSON2_COMMIT} is in {}", file.display()); + return Ok(file); + } + std::fs::create_dir_all(&base).map_err(|e| format!("create {}: {e}", base.display()))?; + let url = format!("https://raw.githubusercontent.com/douglascrockford/JSON-js/{JSON2_COMMIT}/json2.js"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "--max-filesize", &JSON2_MAX_BYTES.to_string(), "-o"]).arg(&file).arg(&url); + run(curl, &format!("curl {url}"))?; + if !matches(&file)? { + let got = std::fs::read(&file).map(|b| sha256::hex(&b)).unwrap_or_default(); + let _ = std::fs::remove_file(&file); + return Err(format!("json2.js: sha256 {got} does not match the pinned {JSON2_SHA256}")); + } + println!("json2.js {JSON2_COMMIT} saved in {}", file.display()); + Ok(file) } /// Fetches and unpacks the suite unless it is there already, and returns its folder. -fn fetch() -> Result { +fn fetch() -> Result { let base = root().join("corpus").join("test262"); let dir = base.join(format!("test262-{COMMIT}")); let marker = dir.join(".vectorcraft-sha256");