Skip to main content

rpic_core/
lib.rs

1//! rpic-core — engine for the `pic` picture-drawing language.
2//!
3//! Pipeline: source → [`lexer`] → [`parser`] → [`ast`] → [`eval`] → placed
4//! primitives ([`ir`]) → render backends ([`svg`], later PNG/PDF). This crate
5//! is pure (no file I/O); the CLI and WASM wrappers drive it.
6//!
7//! Note: [`eval`] is the pic-language *interpreter* — a safe walker over a typed
8//! expression/geometry tree. It executes no arbitrary code.
9
10pub mod ast;
11pub mod color;
12pub mod diagnostic;
13pub mod eval;
14pub mod geom;
15pub mod ir;
16pub mod lexer;
17mod math;
18pub mod parser;
19pub mod svg;
20pub mod token;
21
22pub use ast::{IncludeCtx, IncludePolicy};
23pub use diagnostic::{CompileError, Diagnostic, Span};
24pub use eval::{EvalError, EvalLimits, eval, eval_with_limits};
25pub use ir::Drawing;
26pub use lexer::{LexError, lex};
27pub use math::{MathSpan, set_math_renderer};
28pub use parser::{ParseError, parse, parse_in_dir, parse_with_prelude};
29pub use svg::to_svg;
30pub use token::Token;
31
32/// Bundled native circuit-element library (the `define` dialect). Prepend it to
33/// source to use `resistor`, `capacitor`, … See `std/circuits.pic`.
34pub const CIRCUITS: &str = include_str!("std/circuits.pic");
35
36/// Compile pic source into a placed-primitive [`Drawing`].
37pub fn compile(src: &str) -> Result<Drawing, String> {
38    compile_in_dir(src, None)
39}
40
41/// Compile pic source, resolving `copy "file"` includes relative to `base`.
42pub fn compile_in_dir(src: &str, base: Option<&std::path::Path>) -> Result<Drawing, String> {
43    let picture = parser::parse_in_dir(src, base).map_err(|e| e.to_string())?;
44    eval(&picture).map_err(|e| e.to_string())
45}
46
47/// Compile pic source directly to an SVG string.
48pub fn render_svg(src: &str) -> Result<String, String> {
49    Ok(to_svg(&compile(src)?))
50}
51
52/// Render pic source to SVG, resolving `copy "file"` includes relative to `base`.
53pub fn render_svg_in_dir(src: &str, base: Option<&std::path::Path>) -> Result<String, String> {
54    Ok(to_svg(&compile_in_dir(src, base)?))
55}
56
57/// Build the JSON animation manifest array (`[{id,effect,start,duration},…]`)
58/// for an already-compiled drawing.
59pub fn animations_json(d: &Drawing) -> String {
60    let mut s = String::from("[");
61    for (i, a) in d.anims.iter().enumerate() {
62        if i > 0 {
63            s.push(',');
64        }
65        // Only the always-present keys are emitted for a plain animation; the
66        // optional repeat/yoyo/ease/path/color keys ride along only when set,
67        // so manifests that don't use them stay byte-identical.
68        s.push_str(&format!(
69            "{{\"id\":\"s{}\",\"effect\":\"{}\",\"start\":{},\"duration\":{}",
70            a.shape,
71            json_str(&a.effect),
72            a.start,
73            a.duration
74        ));
75        if a.repeat != 0 {
76            s.push_str(&format!(",\"repeat\":{}", a.repeat));
77        }
78        if a.yoyo {
79            s.push_str(",\"yoyo\":true");
80        }
81        if let Some(ease) = &a.ease {
82            s.push_str(&format!(",\"ease\":\"{}\"", json_str(ease)));
83        }
84        if let Some(path) = a.path {
85            s.push_str(&format!(",\"path\":\"s{path}\""));
86        }
87        if let Some(color) = &a.color {
88            s.push_str(&format!(",\"color\":\"{}\"", json_str(color)));
89        }
90        if a.out {
91            s.push_str(",\"out\":true");
92        }
93        if let Some(from) = &a.from {
94            s.push_str(&format!(",\"from\":\"{}\"", json_str(from)));
95        }
96        if let Some(morph) = a.morph {
97            s.push_str(&format!(",\"morph\":\"s{morph}\""));
98        }
99        if a.type_word {
100            s.push_str(",\"unit\":\"word\"");
101        }
102        if let Some(chars) = &a.scramble_chars {
103            s.push_str(&format!(",\"chars\":\"{}\"", json_str(chars)));
104        }
105        if let Some(wiggles) = a.wiggles {
106            s.push_str(&format!(",\"wiggles\":{wiggles}"));
107        }
108        if let Some(from) = a.draw_from {
109            s.push_str(&format!(",\"drawFrom\":{}", json_num(from)));
110        }
111        if let Some(to) = a.draw_to {
112            s.push_str(&format!(",\"drawTo\":{}", json_num(to)));
113        }
114        s.push('}');
115    }
116    s.push(']');
117    s
118}
119
120/// Build the JSON per-object geometry array: one entry per emitted
121/// `<g id="sN">` group, with the shape kind, its bbox in SVG user units
122/// (`null` for invisible shapes), the object's `link` URL when one was set,
123/// and — when known — the source span of the statement that produced it
124/// (`file` follows the same convention as diagnostics: absent = user input,
125/// `"circuits"` = the `-c` library, else the `copy` include name).
126pub fn objects_json(d: &Drawing) -> String {
127    let geoms = svg::object_geometries(d);
128    let mut s = String::from("[");
129    for (i, g) in geoms.iter().enumerate() {
130        if i > 0 {
131            s.push(',');
132        }
133        s.push_str(&format!("{{\"id\":\"s{}\",\"kind\":\"{}\"", i, g.kind));
134        match g.bbox {
135            Some((x, y, w, h)) => s.push_str(&format!(
136                ",\"bbox\":{{\"x\":{},\"y\":{},\"w\":{},\"h\":{}}}",
137                json_num(x),
138                json_num(y),
139                json_num(w),
140                json_num(h)
141            )),
142            None => s.push_str(",\"bbox\":null"),
143        }
144        if let Some(link) = d.shape_links.get(i).and_then(|l| l.as_deref()) {
145            s.push_str(&format!(",\"link\":\"{}\"", json_str(link)));
146        }
147        if let Some(span) = d.shape_spans.get(i).and_then(|s| s.as_ref()) {
148            s.push_str(&format!(
149                ",\"line\":{},\"col\":{},\"end_col\":{}",
150                span.line, span.col, span.end_col
151            ));
152            if let Some(f) = &span.file {
153                s.push_str(&format!(",\"file\":\"{}\"", json_str(f)));
154            }
155        }
156        s.push('}');
157    }
158    s.push(']');
159    s
160}
161
162/// Format a coordinate for JSON: finite, trimmed like the SVG serializer.
163fn json_num(x: f64) -> String {
164    let v = if x.is_finite() { x } else { 0.0 };
165    let s = format!("{v:.4}");
166    let s = s.trim_end_matches('0').trim_end_matches('.');
167    if s.is_empty() || s == "-" {
168        "0".into()
169    } else {
170        s.into()
171    }
172}
173
174/// Build the JSON diagnostic array emitted by pic `print` statements.
175pub fn diagnostics_json(d: &Drawing) -> String {
176    let mut s = String::from("[");
177    for (i, line) in d.diagnostics.iter().enumerate() {
178        if i > 0 {
179            s.push(',');
180        }
181        s.push('"');
182        s.push_str(&json_str(line));
183        s.push('"');
184    }
185    s.push(']');
186    s
187}
188
189/// Compile options for the `*_with_options` entry points — the library
190/// equivalents of the CLI flags. Preludes (`circuits`, `texlabels`) are lexed
191/// as their own named source units, NOT glued in front of the user's source,
192/// so diagnostic positions always stay relative to the source they belong to
193/// (`Diagnostic::file` names an include/library; `None` is the user's input).
194#[derive(Debug, Clone, Default)]
195pub struct CompileOptions {
196    /// Load the embedded circuit-element library (the `-c` flag).
197    pub circuits: bool,
198    /// Inject `texlabels = 1` as an initializer (the `-t` flag); the source
199    /// can still override it.
200    pub texlabels: bool,
201    /// Resolve `copy "file"` includes relative to this directory.
202    pub base: Option<std::path::PathBuf>,
203    /// Policy for `copy "file"` filesystem includes — leave the default
204    /// (`Unrestricted`, the CLI behavior) for local use; embedders compiling
205    /// untrusted source should pick `SandboxedToBase` or `Deny`.
206    pub includes: IncludePolicy,
207    /// Host ceiling for animation times in seconds. `None` uses the library
208    /// default; the source-level `maxanimseconds` variable may lower this, but
209    /// cannot raise it.
210    pub max_animation_seconds: Option<f64>,
211    /// Host ceiling for finite GSAP repeat counts. `None` uses the library
212    /// default; `repeat -1` remains the explicit infinite-repeat sentinel. The
213    /// source-level `maxanimrepeat` variable may lower this, but cannot raise it.
214    pub max_animation_repeat: Option<i64>,
215    /// Host ceiling for evaluated `for` loop iterations. `None` uses the
216    /// library default. Set this lower for untrusted source.
217    pub max_loop_iterations: Option<u64>,
218    /// Host ceiling for emitted drawing shapes. `None` uses the library
219    /// default. Set this lower for untrusted source.
220    pub max_shapes: Option<usize>,
221}
222
223/// Compile pic source with [`CompileOptions`] into a [`Drawing`].
224pub fn compile_with_options(src: &str, opts: &CompileOptions) -> Result<Drawing, String> {
225    compile_with_diagnostics(src, opts).map_err(|e| e.message)
226}
227
228/// Like [`compile_with_options`], but failures return the structured
229/// [`CompileError`] (flat message + [`Diagnostic`]) instead of a bare string —
230/// for bindings that attach position data to exceptions/conditions.
231pub fn compile_with_diagnostics(src: &str, opts: &CompileOptions) -> Result<Drawing, CompileError> {
232    let picture = parse_options(src, opts).map_err(|e| CompileError {
233        message: e.to_string(),
234        info: Box::new(e.diagnostic()),
235    })?;
236    eval::eval_with_limits(&picture, opts.eval_limits()).map_err(|e| {
237        // Eval errors carry their own diagnostic when the failure site had
238        // one (deferred-parse errors, unknown labels, ordinals — with spans
239        // straight from the failing reference's tokens, includes included).
240        let info = e
241            .info
242            .clone()
243            .unwrap_or_else(|| Box::new(Diagnostic::new("eval", e.msg.clone())));
244        CompileError {
245            message: e.msg,
246            info,
247        }
248    })
249}
250
251/// Render pic source to SVG with [`CompileOptions`].
252pub fn render_svg_with_options(src: &str, opts: &CompileOptions) -> Result<String, String> {
253    Ok(to_svg(&compile_with_options(src, opts)?))
254}
255
256fn parse_options(src: &str, opts: &CompileOptions) -> Result<ast::Picture, ParseError> {
257    parser::parse_with_prelude(
258        src,
259        ast::IncludeCtx::with_policy(opts.base.clone(), opts.includes),
260        opts.circuits,
261        opts.texlabels,
262    )
263}
264
265impl CompileOptions {
266    fn eval_limits(&self) -> EvalLimits {
267        EvalLimits {
268            max_animation_seconds: self
269                .max_animation_seconds
270                .unwrap_or(eval::DEFAULT_MAX_ANIMATION_SECONDS),
271            max_animation_repeat: self
272                .max_animation_repeat
273                .unwrap_or(eval::DEFAULT_MAX_ANIMATION_REPEAT),
274            max_loop_iterations: self
275                .max_loop_iterations
276                .unwrap_or(eval::DEFAULT_MAX_LOOP_ITERATIONS),
277            max_shapes: self.max_shapes.unwrap_or(eval::DEFAULT_MAX_SHAPES),
278        }
279    }
280}
281
282/// Compile to a single JSON object `{ "svg": "...", "animations": [...],
283/// "diagnostics": [...], "warnings": [...] }`, or `{ "error": "...",
284/// "error_info": { ... } }` on failure. The flat `error` string is kept for
285/// backward compatibility with older bindings.
286pub fn compile_json(src: &str) -> String {
287    compile_json_with_options(src, &CompileOptions::default())
288}
289
290/// Compile to a JSON bundle, resolving `copy "file"` includes relative to
291/// `base`.
292pub fn compile_json_in_dir(src: &str, base: Option<&std::path::Path>) -> String {
293    compile_json_with_options(
294        src,
295        &CompileOptions {
296            base: base.map(|p| p.to_path_buf()),
297            ..Default::default()
298        },
299    )
300}
301
302/// Compile to a JSON bundle with [`CompileOptions`]. Diagnostic positions are
303/// relative to the user's `src` (or carry a `file` naming the include/library
304/// they are in) — never to a concatenated stream.
305pub fn compile_json_with_options(src: &str, opts: &CompileOptions) -> String {
306    match compile_with_diagnostics(src, opts) {
307        Ok(d) => drawing_json(&d),
308        Err(e) => error_json(&e.message, &e.info),
309    }
310}
311
312fn drawing_json(d: &Drawing) -> String {
313    // `animate scroll` adds a top-level hint; omitted when unset so plain
314    // bundles stay byte-identical.
315    let scroll = if d.anim_scroll {
316        ",\"scroll\":true"
317    } else {
318        ""
319    };
320    // `draggable` objects ride a top-level `interactions` array; omitted when
321    // there are none so plain bundles stay byte-identical.
322    let interactions = if d.interactions.is_empty() {
323        String::new()
324    } else {
325        format!(",\"interactions\":{}", interactions_json(d))
326    };
327    format!(
328        "{{\"svg\":\"{}\",\"animations\":{},\"diagnostics\":{},\"warnings\":{},\"objects\":{}{}{}}}",
329        json_str(&to_svg(d)),
330        animations_json(d),
331        diagnostics_json(d),
332        diagnostics_json_structured(&d.warnings),
333        objects_json(d),
334        interactions,
335        scroll
336    )
337}
338
339/// Build the JSON `interactions` array (`[{id,kind:"drag",inertia?,bounds?,
340/// axis?},…]`) for the `draggable` directives — the GSAP Draggable targets.
341pub fn interactions_json(d: &Drawing) -> String {
342    let mut s = String::from("[");
343    for (i, x) in d.interactions.iter().enumerate() {
344        if i > 0 {
345            s.push(',');
346        }
347        s.push_str(&format!("{{\"id\":\"s{}\",\"kind\":\"drag\"", x.shape));
348        if x.inertia {
349            s.push_str(",\"inertia\":true");
350        }
351        if let Some(b) = x.bounds {
352            s.push_str(&format!(",\"bounds\":\"s{b}\""));
353        }
354        if let Some(axis) = x.axis {
355            s.push_str(&format!(",\"axis\":\"{axis}\""));
356        }
357        s.push('}');
358    }
359    s.push(']');
360    s
361}
362
363fn error_json(message: &str, diagnostic: &Diagnostic) -> String {
364    format!(
365        "{{\"error\":\"{}\",\"error_info\":{},\"warnings\":[]}}",
366        json_str(message),
367        diagnostic_json(diagnostic)
368    )
369}
370
371fn diagnostics_json_structured(items: &[Diagnostic]) -> String {
372    let mut s = String::from("[");
373    for (i, d) in items.iter().enumerate() {
374        if i > 0 {
375            s.push(',');
376        }
377        s.push_str(&diagnostic_json(d));
378    }
379    s.push(']');
380    s
381}
382
383fn diagnostic_json(d: &Diagnostic) -> String {
384    let mut s = String::from("{");
385    s.push_str(&format!("\"message\":\"{}\"", json_str(&d.message)));
386    s.push_str(&format!(",\"line\":{}", json_opt_u32(d.line)));
387    s.push_str(&format!(",\"col\":{}", json_opt_u32(d.col)));
388    s.push_str(&format!(",\"end_col\":{}", json_opt_u32(d.end_col)));
389    s.push_str(&format!(",\"file\":{}", json_opt_str(d.file.as_deref())));
390    s.push_str(&format!(",\"kind\":\"{}\"", json_str(&d.kind)));
391    s.push_str(&format!(",\"found\":{}", json_opt_str(d.found.as_deref())));
392    s.push_str(&format!(
393        ",\"expected\":{}",
394        json_opt_str(d.expected.as_deref())
395    ));
396    s.push_str(&format!(",\"hint\":{}", json_opt_str(d.hint.as_deref())));
397    s.push('}');
398    s
399}
400
401fn json_opt_u32(v: Option<u32>) -> String {
402    v.map(|n| n.to_string()).unwrap_or_else(|| "null".into())
403}
404
405fn json_opt_str(v: Option<&str>) -> String {
406    v.map(|s| format!("\"{}\"", json_str(s)))
407        .unwrap_or_else(|| "null".into())
408}
409
410/// Escape a string for embedding inside a JSON string literal.
411fn json_str(s: &str) -> String {
412    let mut o = String::with_capacity(s.len() + 8);
413    for c in s.chars() {
414        match c {
415            '"' => o.push_str("\\\""),
416            '\\' => o.push_str("\\\\"),
417            '\n' => o.push_str("\\n"),
418            '\r' => o.push_str("\\r"),
419            '\t' => o.push_str("\\t"),
420            c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
421            c => o.push(c),
422        }
423    }
424    o
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn json_bundles_svg_and_animations() {
433        let j = compile_json("box\nanimate last box with \"fade\"");
434        assert!(j.starts_with("{\"svg\":\"<svg"));
435        assert!(j.contains("<g id=\\\"s0\\\">")); // stable id, JSON-escaped
436        assert!(j.contains("\"animations\":[{\"id\":\"s0\",\"effect\":\"fade\""));
437        assert!(j.contains("\"diagnostics\":[]"));
438        // A plain animation carries none of the optional GSAP keys, so the
439        // object closes right after duration (byte-inert when unused).
440        assert!(j.contains("\"effect\":\"fade\",\"start\":0,\"duration\":0.6}"));
441    }
442
443    #[test]
444    fn json_emits_repeat_yoyo_ease_only_when_set() {
445        let j = compile_json(
446            "box\nanimate last box with \"pop\" for 0.4 repeat -1 yoyo ease \"power2.inOut\"",
447        );
448        assert!(
449            j.contains(
450                "\"effect\":\"pop\",\"start\":0,\"duration\":0.4,\"repeat\":-1,\"yoyo\":true,\"ease\":\"power2.inOut\"}"
451            ),
452            "{j}"
453        );
454    }
455
456    #[test]
457    fn json_emits_draw_range_only_when_set() {
458        // A plain draw carries no range keys (byte-inert).
459        let j = compile_json("line right 2\nanimate last with \"draw\" for 1");
460        assert!(
461            j.contains("\"effect\":\"draw\",\"start\":0,\"duration\":1}"),
462            "{j}"
463        );
464        // A window rides `drawFrom`/`drawTo` as fractions.
465        let j = compile_json("line right 2\nanimate last with \"draw\" from 40% to 60% for 1");
466        assert!(
467            j.contains(
468                "\"effect\":\"draw\",\"start\":0,\"duration\":1,\"drawFrom\":0.4,\"drawTo\":0.6}"
469            ),
470            "{j}"
471        );
472        // `to`-only leaves the start at the beginning.
473        let j = compile_json("line right 2\nanimate last with \"draw\" to 60% for 1");
474        assert!(j.contains("\"duration\":1,\"drawTo\":0.6}"), "{j}");
475    }
476
477    #[test]
478    fn json_emits_move_path_reference() {
479        let j = compile_json("L: line right 3\nD: dot at L.start\nanimate D with \"move\" along L");
480        // The dot (s1) moves along the line (s0); the path id rides the entry.
481        assert!(
482            j.contains(
483                "\"id\":\"s1\",\"effect\":\"move\",\"start\":0,\"duration\":0.6,\"path\":\"s0\"}"
484            ),
485            "{j}"
486        );
487    }
488
489    #[test]
490    fn json_emits_highlight_colour() {
491        let j = compile_json("box\nanimate last box with \"highlight\" to rgb(255,140,0)");
492        assert!(
493            j.contains(
494                "\"effect\":\"highlight\",\"start\":0,\"duration\":0.6,\"color\":\"#ff8c00\"}"
495            ),
496            "{j}"
497        );
498    }
499
500    #[test]
501    fn json_emits_morph_target_reference() {
502        let j = compile_json("A: box\nB: circle at A+(2,0)\nanimate A with \"morph\" into B for 1");
503        assert!(
504            j.contains(
505                "\"id\":\"s0\",\"effect\":\"morph\",\"start\":0,\"duration\":1,\"morph\":\"s1\"}"
506            ),
507            "{j}"
508        );
509    }
510
511    #[test]
512    fn json_objects_carry_link_only_when_set() {
513        let j = compile_json("box link \"https://rpic.dev\"\ncircle");
514        assert!(
515            j.contains("\"id\":\"s0\",\"kind\":\"box\",\"bbox\"")
516                && j.contains("\"link\":\"https://rpic.dev\""),
517            "{j}"
518        );
519        // the SVG side carries the anchor wrapper, JSON-escaped
520        assert!(
521            j.contains("<a href=\\\"https://rpic.dev\\\" class=\\\"rpic-link\\\">"),
522            "{j}"
523        );
524        // the unlinked circle has no link key
525        let circle_entry = &j[j.find("\"id\":\"s1\"").unwrap()..];
526        assert!(
527            !circle_entry[..circle_entry.find('}').unwrap()].contains("link"),
528            "{j}"
529        );
530        // plain bundles stay byte-inert: no link key, no anchor
531        let plain = compile_json("box\ncircle");
532        assert!(!plain.contains("link"), "{plain}");
533        assert!(!plain.contains("<a "), "{plain}");
534    }
535
536    #[test]
537    fn json_emits_scroll_hint_only_when_set() {
538        let on = compile_json("box\nanimate last box with \"fade\"\nanimate scroll");
539        assert!(on.contains(",\"scroll\":true}"), "{on}");
540        let off = compile_json("box\nanimate last box with \"fade\"");
541        assert!(!off.contains("scroll"), "{off}");
542    }
543
544    #[test]
545    fn json_emits_out_and_slide_direction() {
546        let j = compile_json("box\nanimate last box with \"slide\" from left for 0.4 out");
547        assert!(
548            j.contains(
549                "\"effect\":\"slide\",\"start\":0,\"duration\":0.4,\"out\":true,\"from\":\"left\"}"
550            ),
551            "{j}"
552        );
553    }
554
555    #[test]
556    fn json_stagger_expands_to_plain_entries() {
557        // stagger fans into one ordinary entry per child — no new key, so the
558        // manifest schema is unchanged.
559        let j = compile_json("B: [ box; box ]\nanimate B with \"fade\" for 0.2 stagger 0.1");
560        assert!(
561            j.contains(
562                "[{\"id\":\"s0\",\"effect\":\"fade\",\"start\":0,\"duration\":0.2},{\"id\":\"s1\",\"effect\":\"fade\",\"start\":0.1,\"duration\":0.2}]"
563            ),
564            "{j}"
565        );
566    }
567
568    #[test]
569    fn json_exports_object_geometry() {
570        // #227: one entry per <g id="sN">, bbox in the viewBox's units,
571        // span of the producing statement.
572        let j = compile_json("box wid 1 ht 0.5\narrow right 0.5");
573        assert!(
574            j.contains("\"objects\":[{\"id\":\"s0\",\"kind\":\"box\",\"bbox\":{"),
575            "{j}"
576        );
577        // 1in × 0.5in box = 96 × 48 SVG px
578        assert!(j.contains("\"w\":96,\"h\":48"), "{j}");
579        assert!(j.contains("\"kind\":\"path\""), "{j}");
580        assert!(j.contains("\"line\":2,\"col\":1"), "{j}");
581    }
582
583    #[test]
584    fn json_object_geometry_marks_invisible_shapes() {
585        let j = compile_json("box invis \"x\"");
586        // the invisible box still draws its text: box shape null, text real
587        assert!(j.contains("\"kind\":\"box\",\"bbox\":null"), "{j}");
588    }
589
590    #[test]
591    fn json_object_geometry_names_library_sources() {
592        // objects drawn by the circuits library carry file:"circuits";
593        // the user's own statements stay file-less.
594        let opts = CompileOptions {
595            circuits: true,
596            ..Default::default()
597        };
598        let j = compile_json_with_options("A:(0,0); B:(2,0)\nresistor(A,B)", &opts);
599        assert!(j.contains("\"file\":\"circuits\""), "{j}");
600    }
601
602    #[test]
603    fn json_object_geometry_matches_svg_rect() {
604        // the exported bbox must agree with the emitted <rect> attributes
605        let d = compile("box wid 1 ht 0.5").unwrap();
606        let svg = to_svg(&d);
607        let g = svg::object_geometries(&d);
608        let (x, y, w, h) = g[0].bbox.unwrap();
609        let rect = svg.lines().find(|l| l.contains("<rect")).unwrap();
610        for (attr, v) in [("x", x), ("y", y), ("width", w), ("height", h)] {
611            let needle = format!("{attr}=\"");
612            let i = rect.find(&needle).unwrap() + needle.len();
613            let s = &rect[i..rect[i..].find('"').unwrap() + i];
614            let got: f64 = s.parse().unwrap();
615            assert!((got - v).abs() < 1e-3, "{attr}: rect {got} vs bbox {v}");
616        }
617    }
618
619    #[test]
620    fn json_reports_errors() {
621        let j = compile_json("copy \"oops\"");
622        assert!(j.contains("\"error\""));
623        assert!(j.contains("\"error_info\""));
624    }
625
626    #[test]
627    fn json_reports_print_diagnostics() {
628        let j = compile_json("print \"hi\"\nprint 2+3");
629        assert!(j.contains("\"diagnostics\":[\"hi\",\"5\"]"));
630    }
631
632    #[test]
633    fn copy_circuits_loads_the_embedded_library() {
634        // `copy "circuits"` is the in-source spelling of `-c`: byte-identical
635        // output, and it works with no base dir (the wasm/compile_json case,
636        // where file includes are unavailable).
637        let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
638        let via_copy = compile(&format!("copy \"circuits\"\n{body}")).unwrap();
639        let via_flag = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
640        assert_eq!(to_svg(&via_copy), to_svg(&via_flag));
641    }
642
643    #[test]
644    fn copy_circuits_is_idempotent_with_the_flag() {
645        // `-c` plus an explicit `copy "circuits"` must not double-load (the
646        // second load is skipped) — same bytes as the flag alone.
647        let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
648        let both = compile(&format!("{CIRCUITS}\ncopy \"circuits\"\n{body}")).unwrap();
649        let flag = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
650        assert_eq!(to_svg(&both), to_svg(&flag));
651    }
652
653    #[test]
654    fn options_preludes_do_not_shift_user_positions() {
655        // #181 acceptance: with the circuits/texlabels preludes on, an error
656        // on the user's line 1 reports line 1 (not ~1093), file null.
657        let opts = CompileOptions {
658            circuits: true,
659            texlabels: true,
660            ..Default::default()
661        };
662        let j = compile_json_with_options("bxo\n", &opts);
663        assert!(j.contains("\"line\":1"), "{j}");
664        assert!(j.contains("\"col\":1"), "{j}");
665        assert!(j.contains("\"file\":null"), "{j}");
666        assert!(j.contains("\"error\":\"1:1: expected an object"), "{j}");
667    }
668
669    #[test]
670    fn options_output_matches_the_prepending_it_replaces() {
671        // The prelude splice must be behaviorally identical to the old
672        // string-prepending: byte-identical SVG.
673        let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
674        let opts = CompileOptions {
675            circuits: true,
676            ..Default::default()
677        };
678        let via_opts = compile_with_options(body, &opts).unwrap();
679        let via_prepend = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
680        assert_eq!(to_svg(&via_opts), to_svg(&via_prepend));
681    }
682
683    #[test]
684    fn options_texlabels_is_an_initializer_only() {
685        // the source stays sovereign: `texlabels = 0` overrides the prelude
686        let opts = CompileOptions {
687            texlabels: true,
688            ..Default::default()
689        };
690        let j = compile_json_with_options("texlabels = 0\nbox \"$x$\"\n", &opts);
691        assert!(!j.contains("no math renderer"), "{j}");
692    }
693
694    #[test]
695    fn options_cap_animation_limits() {
696        let repeat_opts = CompileOptions {
697            max_animation_repeat: Some(2),
698            ..Default::default()
699        };
700        let err =
701            compile_with_options("box\nanimate last box with \"fade\" repeat 3", &repeat_opts)
702                .unwrap_err();
703        assert!(err.contains("animation repeat must be at most 2"), "{err}");
704
705        let err = compile_with_options(
706            "maxanimrepeat = 3\nbox\nanimate last box with \"fade\" repeat 2",
707            &repeat_opts,
708        )
709        .unwrap_err();
710        assert!(err.contains("maxanimrepeat must be at most 2"), "{err}");
711
712        assert!(
713            compile_with_options(
714                "maxanimrepeat = 1\nbox\nanimate last box with \"fade\" repeat 1",
715                &repeat_opts,
716            )
717            .is_ok()
718        );
719
720        let seconds_opts = CompileOptions {
721            max_animation_seconds: Some(0.5),
722            ..Default::default()
723        };
724        let err =
725            compile_with_options("box\nanimate last box with \"fade\"", &seconds_opts).unwrap_err();
726        assert!(
727            err.contains("animation duration must be at most 0.5"),
728            "{err}"
729        );
730        assert!(
731            compile_with_options(
732                "maxanimseconds = 0.2\nbox\nanimate last box with \"fade\" for 0.2",
733                &seconds_opts,
734            )
735            .is_ok()
736        );
737    }
738
739    #[test]
740    fn options_cap_eval_budgets() {
741        let loop_opts = CompileOptions {
742            max_loop_iterations: Some(2),
743            ..Default::default()
744        };
745        let err = compile_with_options("for i = 1 to 3 do { bxo }", &loop_opts).unwrap_err();
746        assert!(err.contains("for loop exceeded 2 iterations"), "{err}");
747
748        let shape_opts = CompileOptions {
749            max_shapes: Some(1),
750            ..Default::default()
751        };
752        let err = compile_with_options("box\nbox", &shape_opts).unwrap_err();
753        assert!(err.contains("drawing exceeded 1 shapes"), "{err}");
754    }
755
756    #[test]
757    fn options_reject_invalid_animation_limits() {
758        let repeat_opts = CompileOptions {
759            max_animation_repeat: Some(-1),
760            ..Default::default()
761        };
762        let err = compile_with_options("box", &repeat_opts).unwrap_err();
763        assert!(err.contains("max_animation_repeat option must be non-negative"));
764
765        let seconds_opts = CompileOptions {
766            max_animation_seconds: Some(f64::INFINITY),
767            ..Default::default()
768        };
769        let err = compile_with_options("box", &seconds_opts).unwrap_err();
770        assert!(err.contains("max_animation_seconds option must be finite"));
771    }
772
773    #[test]
774    fn diagnostics_inside_an_include_name_the_file() {
775        // #181 acceptance: a warning (or error) inside a `copy`'d file carries
776        // the include's name and include-relative position.
777        let dir = std::env::temp_dir().join(format!("rpic_incl_diag_{}", std::process::id()));
778        std::fs::create_dir_all(&dir).unwrap();
779        std::fs::write(dir.join("warn.pic"), "# comment\nbox \"a\" dashd\n").unwrap();
780        let j = compile_json_in_dir("circle\ncopy \"warn.pic\"", Some(dir.as_path()));
781        assert!(j.contains("\"kind\":\"ignored_attribute\""), "{j}");
782        assert!(j.contains("\"file\":\"warn.pic\""), "{j}");
783        assert!(j.contains("\"line\":2"), "{j}"); // include-relative, not stream
784
785        std::fs::write(dir.join("bad.pic"), "bxo\n").unwrap();
786        let e = compile_json_in_dir("circle\ncopy \"bad.pic\"", Some(dir.as_path()));
787        let _ = std::fs::remove_dir_all(&dir);
788        assert!(
789            e.contains("\"error\":\"bad.pic:1:1: expected an object"),
790            "{e}"
791        );
792        assert!(e.contains("\"file\":\"bad.pic\""), "{e}");
793        assert!(e.contains("\"line\":1"), "{e}");
794    }
795
796    #[test]
797    fn include_policy_sandboxes_and_denies() {
798        // layout: root/outside.pic (outside the fence), root/base/inc.pic (in)
799        let root = std::env::temp_dir().join(format!("rpic_inc_policy_{}", std::process::id()));
800        let base = root.join("base");
801        std::fs::create_dir_all(&base).unwrap();
802        std::fs::write(root.join("outside.pic"), "circle\n").unwrap();
803        std::fs::write(base.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
804
805        let sandboxed = CompileOptions {
806            base: Some(base.clone()),
807            includes: IncludePolicy::SandboxedToBase,
808            ..Default::default()
809        };
810        // in-base include works
811        assert!(compile_with_options("copy \"inc.pic\"\nbox", &sandboxed).is_ok());
812        // `..` escape is denied, with the structured kind and the copy's span
813        let esc = compile_json_with_options("copy \"../outside.pic\"\nbox", &sandboxed);
814        assert!(esc.contains("\"kind\":\"include_denied\""), "{esc}");
815        assert!(esc.contains("outside the include base directory"), "{esc}");
816        assert!(esc.contains("\"line\":1"), "{esc}");
817        // absolute paths are denied outright
818        let abs_src = format!("copy \"{}\"\nbox", root.join("outside.pic").display());
819        let abs = compile_json_with_options(&abs_src, &sandboxed);
820        assert!(abs.contains("\"kind\":\"include_denied\""), "{abs}");
821        assert!(abs.contains("absolute paths are not allowed"), "{abs}");
822        // symlink pointing out of the fence is caught by canonicalization
823        #[cfg(unix)]
824        {
825            let link = base.join("link.pic");
826            let _ = std::fs::remove_file(&link);
827            std::os::unix::fs::symlink(root.join("outside.pic"), &link).unwrap();
828            let sym = compile_json_with_options("copy \"link.pic\"\nbox", &sandboxed);
829            assert!(sym.contains("\"kind\":\"include_denied\""), "{sym}");
830        }
831        // the embedded library is not a filesystem include — always available
832        assert!(
833            compile_with_options(
834                "copy \"circuits\"\nA:(0,0); B:(1,0)\nresistor(A,B)",
835                &sandboxed
836            )
837            .is_ok()
838        );
839
840        let deny = CompileOptions {
841            base: Some(base.clone()),
842            includes: IncludePolicy::Deny,
843            ..Default::default()
844        };
845        let d = compile_json_with_options("copy \"inc.pic\"\nbox", &deny);
846        assert!(d.contains("\"kind\":\"include_denied\""), "{d}");
847        assert!(d.contains("disabled by the include policy"), "{d}");
848        assert!(compile_with_options("copy \"circuits\"\nbox", &deny).is_ok());
849
850        // default stays the CLI behavior: `..` resolution is allowed
851        let open = CompileOptions {
852            base: Some(base.clone()),
853            ..Default::default()
854        };
855        assert!(compile_with_options("copy \"../outside.pic\"\nbox", &open).is_ok());
856
857        let _ = std::fs::remove_dir_all(&root);
858    }
859
860    #[test]
861    fn json_in_dir_resolves_copy_includes() {
862        let dir = std::env::temp_dir().join(format!("rpic_json_copy_{}", std::process::id()));
863        std::fs::create_dir_all(&dir).unwrap();
864        std::fs::write(dir.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
865
866        let j = compile_json_in_dir("copy \"inc.pic\"\ncircle", Some(dir.as_path()));
867        let _ = std::fs::remove_dir_all(&dir);
868
869        assert!(j.starts_with("{\"svg\":\"<svg"), "{j}");
870        assert!(j.contains("<rect"), "{j}");
871        assert!(j.contains("<circle"), "{j}");
872        assert!(j.contains("\"diagnostics\":[]"), "{j}");
873        assert!(!j.contains("\"error\""), "{j}");
874    }
875
876    #[test]
877    fn json_error_info_uses_user_facing_tokens_and_spans() {
878        let j = compile_json("bxo\n");
879
880        assert!(
881            j.contains("\"error\":\"1:1: expected an object, found `bxo`\""),
882            "{j}"
883        );
884        assert!(j.contains("\"kind\":\"expected_token\""), "{j}");
885        assert!(j.contains("\"line\":1"), "{j}");
886        assert!(j.contains("\"col\":1"), "{j}");
887        assert!(j.contains("\"end_col\":4"), "{j}");
888        assert!(j.contains("\"found\":\"`bxo`\""), "{j}");
889        assert!(j.contains("\"expected\":\"an object\""), "{j}");
890        assert!(j.contains("\"hint\":\"did you mean `box`?\""), "{j}");
891    }
892
893    #[test]
894    fn json_unterminated_string_points_at_opening_quote() {
895        let j = compile_json("box wid 1\n  \"oops\n");
896
897        assert!(j.contains("\"kind\":\"unterminated_string\""), "{j}");
898        assert!(j.contains("\"line\":2"), "{j}");
899        assert!(j.contains("\"col\":3"), "{j}");
900    }
901
902    #[test]
903    fn json_eval_errors_get_structured_locations_when_possible() {
904        let label = compile_json("box at A\n");
905        assert!(label.contains("\"kind\":\"unknown_label\""), "{label}");
906        assert!(label.contains("\"line\":1"), "{label}");
907        assert!(label.contains("\"col\":8"), "{label}");
908        assert!(label.contains("\"found\":\"A\""), "{label}");
909
910        let ordinal = compile_json("box\nbox at 3rd box\n");
911        assert!(
912            ordinal.contains("\"kind\":\"ordinal_out_of_range\""),
913            "{ordinal}"
914        );
915        assert!(ordinal.contains("\"line\":2"), "{ordinal}");
916        assert!(ordinal.contains("\"col\":8"), "{ordinal}");
917        assert!(ordinal.contains("\"found\":\"3\""), "{ordinal}");
918        assert!(ordinal.contains("\"expected\":\"1..1\""), "{ordinal}");
919    }
920
921    #[test]
922    fn json_eval_error_inside_include_names_the_file() {
923        // #197: the failing reference's own tokens carry the span (and its
924        // include provenance) — no re-lexing of the user source involved.
925        let dir = std::env::temp_dir().join(format!("rpic_eval_incl_{}", std::process::id()));
926        std::fs::create_dir_all(&dir).unwrap();
927        std::fs::write(dir.join("inc-eval.pic"), "# comment\nbox at Missing\n").unwrap();
928        let j = compile_json_in_dir("circle\ncopy \"inc-eval.pic\"", Some(dir.as_path()));
929        let _ = std::fs::remove_dir_all(&dir);
930        assert!(j.contains("\"error\":\"unknown label `Missing`\""), "{j}");
931        assert!(j.contains("\"kind\":\"unknown_label\""), "{j}");
932        assert!(j.contains("\"file\":\"inc-eval.pic\""), "{j}");
933        assert!(j.contains("\"line\":2"), "{j}"); // include-relative
934        assert!(j.contains("\"col\":8"), "{j}");
935    }
936
937    #[test]
938    fn json_deferred_parse_errors_keep_their_structure() {
939        // #197: a parse error inside a dynamically-executed body used to be
940        // flattened to kind "eval" with null position/found/hint.
941        let j = compile_json("x = 1\nif x then { bxo }\n");
942        assert!(j.contains("\"kind\":\"expected_token\""), "{j}");
943        assert!(j.contains("\"line\":2"), "{j}");
944        assert!(j.contains("\"col\":13"), "{j}");
945        assert!(j.contains("\"found\":\"`bxo`\""), "{j}");
946        assert!(j.contains("\"hint\":\"did you mean `box`?\""), "{j}");
947
948        let f = compile_json("for i = 1 to 2 do { bxo }\n");
949        assert!(f.contains("\"kind\":\"expected_token\""), "{f}");
950        assert!(f.contains("\"hint\":\"did you mean `box`?\""), "{f}");
951    }
952
953    #[test]
954    fn json_success_bundle_reports_warnings() {
955        let attr = compile_json("box \"a\" dashd\n");
956        assert!(attr.contains("\"warnings\":[{"), "{attr}");
957        assert!(attr.contains("\"kind\":\"ignored_attribute\""), "{attr}");
958        assert!(attr.contains("\"found\":\"dashd\""), "{attr}");
959        assert!(
960            attr.contains("\"hint\":\"did you mean `dashed`?\""),
961            "{attr}"
962        );
963
964        let anim = compile_json("box\nanimate 1st box with \"zoom\"\n");
965        assert!(
966            anim.contains("\"kind\":\"unknown_animation_effect\""),
967            "{anim}"
968        );
969        assert!(anim.contains("\"found\":\"zoom\""), "{anim}");
970        assert!(anim.contains("\"line\":2"), "{anim}");
971    }
972
973    #[test]
974    fn circuits_library_compiles_and_draws() {
975        let src = format!(
976            "{}\nA:(0,0); B:(1.6,0); C:(3,0)\nresistor(A,B)\ncapacitor(A,B)\ninductor(A,B)\n\
977             diode(A,B)\nbattery(A,B)\nground(A)\ndot(A)\nwire(A,B)\n\
978             and_gate(C)\nor_gate(C)\nnand_gate(C)\nnor_gate(C)\nxor_gate(C)\n\
979             xnor_gate(C)\nbuffer(C)\nnot_gate(C)\n\
980             opamp(C)\nnpn(C)\npnp(C)\nac_source(A,B)\nswitch(A,B)\n\
981             potentiometer(A,B)\ntransformer(C)\n\
982             nmos(C)\npmos(C)\ncurrent_source(A,B)\nvsource_ctrl(A,B)\n\
983             isource_ctrl(A,B)\nled(A,B)\nphotodiode(A,B)\nzener(A,B)\n\
984             clabel(A,\"x\")\nterminal(A)\nvdd(A)\nantenna(A)\ncurrent(A,B)\n\
985             voltage(A,B)\nlamp(A,B)\nfuse(A,B)\n\
986             voltmeter(A,B)\nammeter(A,B)\nohmmeter(A,B)\nmeter(A,B,\"X\")\n\
987             crystal(A,B)\nspeaker(A,B)\nhop(A,B)\nspdt(A,B,C)\n\
988             iec_resistor(A,B)\nvoltage_source(A,B)\npushbutton(A,B)\nrelay(A,B)\n\
989             thermistor(A,B)\nmotor(A,B)\ngenerator(A,B)\nbell(A,B)\n\
990             ieee_and(C)\nieee_or(C)\nieee_xor(C)\nieee_buf(C)\niecgate(C,\"x\")\n\
991             varistor(A,B)\ntline(A,B)\nbus(A,B)\n\
992             polcap(A,B)\nvarcap(A,B)\nvarind(A,B)\nschottky(A,B)\nldr(A,B)\n\
993             spark_gap(A,B)\nchassis_ground(A)\nsignal_ground(A)\n\
994             njfet(C)\npjfet(C)\nphototransistor(C)\nic_block(C,\"x\")\nmux(C)\n\
995             solar_cell(A,B)\nmicrophone(A,B)\nthermocouple(A,B)",
996            CIRCUITS
997        );
998        let d = compile(&src).expect("circuit library should compile");
999        assert!(!d.shapes.is_empty());
1000    }
1001}