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 diagnostic;
12pub mod eval;
13pub mod geom;
14pub mod ir;
15pub mod lexer;
16mod math;
17pub mod parser;
18pub mod svg;
19pub mod token;
20
21pub use ast::{IncludeCtx, IncludePolicy};
22pub use diagnostic::{CompileError, Diagnostic, Span};
23pub use eval::{EvalError, eval};
24pub use ir::Drawing;
25pub use lexer::{LexError, lex};
26pub use math::{MathSpan, set_math_renderer};
27pub use parser::{ParseError, parse, parse_in_dir, parse_with_prelude};
28pub use svg::to_svg;
29pub use token::Token;
30
31/// Bundled native circuit-element library (the `define` dialect). Prepend it to
32/// source to use `resistor`, `capacitor`, … See `std/circuits.pic`.
33pub const CIRCUITS: &str = include_str!("std/circuits.pic");
34
35/// Compile pic source into a placed-primitive [`Drawing`].
36pub fn compile(src: &str) -> Result<Drawing, String> {
37    compile_in_dir(src, None)
38}
39
40/// Compile pic source, resolving `copy "file"` includes relative to `base`.
41pub fn compile_in_dir(src: &str, base: Option<&std::path::Path>) -> Result<Drawing, String> {
42    let picture = parser::parse_in_dir(src, base).map_err(|e| e.to_string())?;
43    eval(&picture).map_err(|e| e.to_string())
44}
45
46/// Compile pic source directly to an SVG string.
47pub fn render_svg(src: &str) -> Result<String, String> {
48    Ok(to_svg(&compile(src)?))
49}
50
51/// Render pic source to SVG, resolving `copy "file"` includes relative to `base`.
52pub fn render_svg_in_dir(src: &str, base: Option<&std::path::Path>) -> Result<String, String> {
53    Ok(to_svg(&compile_in_dir(src, base)?))
54}
55
56/// Build the JSON animation manifest array (`[{id,effect,start,duration},…]`)
57/// for an already-compiled drawing.
58pub fn animations_json(d: &Drawing) -> String {
59    let mut s = String::from("[");
60    for (i, a) in d.anims.iter().enumerate() {
61        if i > 0 {
62            s.push(',');
63        }
64        s.push_str(&format!(
65            "{{\"id\":\"s{}\",\"effect\":\"{}\",\"start\":{},\"duration\":{}}}",
66            a.shape,
67            json_str(&a.effect),
68            a.start,
69            a.duration
70        ));
71    }
72    s.push(']');
73    s
74}
75
76/// Build the JSON per-object geometry array: one entry per emitted
77/// `<g id="sN">` group, with the shape kind, its bbox in SVG user units
78/// (`null` for invisible shapes), and — when known — the source span of the
79/// statement that produced it (`file` follows the same convention as
80/// diagnostics: absent = user input, `"circuits"` = the `-c` library, else
81/// the `copy` include name).
82pub fn objects_json(d: &Drawing) -> String {
83    let geoms = svg::object_geometries(d);
84    let mut s = String::from("[");
85    for (i, g) in geoms.iter().enumerate() {
86        if i > 0 {
87            s.push(',');
88        }
89        s.push_str(&format!("{{\"id\":\"s{}\",\"kind\":\"{}\"", i, g.kind));
90        match g.bbox {
91            Some((x, y, w, h)) => s.push_str(&format!(
92                ",\"bbox\":{{\"x\":{},\"y\":{},\"w\":{},\"h\":{}}}",
93                json_num(x),
94                json_num(y),
95                json_num(w),
96                json_num(h)
97            )),
98            None => s.push_str(",\"bbox\":null"),
99        }
100        if let Some(span) = d.shape_spans.get(i).and_then(|s| s.as_ref()) {
101            s.push_str(&format!(
102                ",\"line\":{},\"col\":{},\"end_col\":{}",
103                span.line, span.col, span.end_col
104            ));
105            if let Some(f) = &span.file {
106                s.push_str(&format!(",\"file\":\"{}\"", json_str(f)));
107            }
108        }
109        s.push('}');
110    }
111    s.push(']');
112    s
113}
114
115/// Format a coordinate for JSON: finite, trimmed like the SVG serializer.
116fn json_num(x: f64) -> String {
117    let v = if x.is_finite() { x } else { 0.0 };
118    let s = format!("{v:.4}");
119    let s = s.trim_end_matches('0').trim_end_matches('.');
120    if s.is_empty() || s == "-" {
121        "0".into()
122    } else {
123        s.into()
124    }
125}
126
127/// Build the JSON diagnostic array emitted by pic `print` statements.
128pub fn diagnostics_json(d: &Drawing) -> String {
129    let mut s = String::from("[");
130    for (i, line) in d.diagnostics.iter().enumerate() {
131        if i > 0 {
132            s.push(',');
133        }
134        s.push('"');
135        s.push_str(&json_str(line));
136        s.push('"');
137    }
138    s.push(']');
139    s
140}
141
142/// Compile options for the `*_with_options` entry points — the library
143/// equivalents of the CLI flags. Preludes (`circuits`, `texlabels`) are lexed
144/// as their own named source units, NOT glued in front of the user's source,
145/// so diagnostic positions always stay relative to the source they belong to
146/// (`Diagnostic::file` names an include/library; `None` is the user's input).
147#[derive(Debug, Clone, Default)]
148pub struct CompileOptions {
149    /// Load the embedded circuit-element library (the `-c` flag).
150    pub circuits: bool,
151    /// Inject `texlabels = 1` as an initializer (the `-t` flag); the source
152    /// can still override it.
153    pub texlabels: bool,
154    /// Resolve `copy "file"` includes relative to this directory.
155    pub base: Option<std::path::PathBuf>,
156    /// Policy for `copy "file"` filesystem includes — leave the default
157    /// (`Unrestricted`, the CLI behavior) for local use; embedders compiling
158    /// untrusted source should pick `SandboxedToBase` or `Deny`.
159    pub includes: IncludePolicy,
160}
161
162/// Compile pic source with [`CompileOptions`] into a [`Drawing`].
163pub fn compile_with_options(src: &str, opts: &CompileOptions) -> Result<Drawing, String> {
164    compile_with_diagnostics(src, opts).map_err(|e| e.message)
165}
166
167/// Like [`compile_with_options`], but failures return the structured
168/// [`CompileError`] (flat message + [`Diagnostic`]) instead of a bare string —
169/// for bindings that attach position data to exceptions/conditions.
170pub fn compile_with_diagnostics(src: &str, opts: &CompileOptions) -> Result<Drawing, CompileError> {
171    let picture = parse_options(src, opts).map_err(|e| CompileError {
172        message: e.to_string(),
173        info: Box::new(e.diagnostic()),
174    })?;
175    eval(&picture).map_err(|e| {
176        // Eval errors carry their own diagnostic when the failure site had
177        // one (deferred-parse errors, unknown labels, ordinals — with spans
178        // straight from the failing reference's tokens, includes included).
179        let info = e
180            .info
181            .clone()
182            .unwrap_or_else(|| Box::new(Diagnostic::new("eval", e.msg.clone())));
183        CompileError {
184            message: e.msg,
185            info,
186        }
187    })
188}
189
190/// Render pic source to SVG with [`CompileOptions`].
191pub fn render_svg_with_options(src: &str, opts: &CompileOptions) -> Result<String, String> {
192    Ok(to_svg(&compile_with_options(src, opts)?))
193}
194
195fn parse_options(src: &str, opts: &CompileOptions) -> Result<ast::Picture, ParseError> {
196    parser::parse_with_prelude(
197        src,
198        ast::IncludeCtx::with_policy(opts.base.clone(), opts.includes),
199        opts.circuits,
200        opts.texlabels,
201    )
202}
203
204/// Compile to a single JSON object `{ "svg": "...", "animations": [...],
205/// "diagnostics": [...], "warnings": [...] }`, or `{ "error": "...",
206/// "error_info": { ... } }` on failure. The flat `error` string is kept for
207/// backward compatibility with older bindings.
208pub fn compile_json(src: &str) -> String {
209    compile_json_with_options(src, &CompileOptions::default())
210}
211
212/// Compile to a JSON bundle, resolving `copy "file"` includes relative to
213/// `base`.
214pub fn compile_json_in_dir(src: &str, base: Option<&std::path::Path>) -> String {
215    compile_json_with_options(
216        src,
217        &CompileOptions {
218            base: base.map(|p| p.to_path_buf()),
219            ..Default::default()
220        },
221    )
222}
223
224/// Compile to a JSON bundle with [`CompileOptions`]. Diagnostic positions are
225/// relative to the user's `src` (or carry a `file` naming the include/library
226/// they are in) — never to a concatenated stream.
227pub fn compile_json_with_options(src: &str, opts: &CompileOptions) -> String {
228    match compile_with_diagnostics(src, opts) {
229        Ok(d) => drawing_json(&d),
230        Err(e) => error_json(&e.message, &e.info),
231    }
232}
233
234fn drawing_json(d: &Drawing) -> String {
235    format!(
236        "{{\"svg\":\"{}\",\"animations\":{},\"diagnostics\":{},\"warnings\":{},\"objects\":{}}}",
237        json_str(&to_svg(d)),
238        animations_json(d),
239        diagnostics_json(d),
240        diagnostics_json_structured(&d.warnings),
241        objects_json(d)
242    )
243}
244
245fn error_json(message: &str, diagnostic: &Diagnostic) -> String {
246    format!(
247        "{{\"error\":\"{}\",\"error_info\":{},\"warnings\":[]}}",
248        json_str(message),
249        diagnostic_json(diagnostic)
250    )
251}
252
253fn diagnostics_json_structured(items: &[Diagnostic]) -> String {
254    let mut s = String::from("[");
255    for (i, d) in items.iter().enumerate() {
256        if i > 0 {
257            s.push(',');
258        }
259        s.push_str(&diagnostic_json(d));
260    }
261    s.push(']');
262    s
263}
264
265fn diagnostic_json(d: &Diagnostic) -> String {
266    let mut s = String::from("{");
267    s.push_str(&format!("\"message\":\"{}\"", json_str(&d.message)));
268    s.push_str(&format!(",\"line\":{}", json_opt_u32(d.line)));
269    s.push_str(&format!(",\"col\":{}", json_opt_u32(d.col)));
270    s.push_str(&format!(",\"end_col\":{}", json_opt_u32(d.end_col)));
271    s.push_str(&format!(",\"file\":{}", json_opt_str(d.file.as_deref())));
272    s.push_str(&format!(",\"kind\":\"{}\"", json_str(&d.kind)));
273    s.push_str(&format!(",\"found\":{}", json_opt_str(d.found.as_deref())));
274    s.push_str(&format!(
275        ",\"expected\":{}",
276        json_opt_str(d.expected.as_deref())
277    ));
278    s.push_str(&format!(",\"hint\":{}", json_opt_str(d.hint.as_deref())));
279    s.push('}');
280    s
281}
282
283fn json_opt_u32(v: Option<u32>) -> String {
284    v.map(|n| n.to_string()).unwrap_or_else(|| "null".into())
285}
286
287fn json_opt_str(v: Option<&str>) -> String {
288    v.map(|s| format!("\"{}\"", json_str(s)))
289        .unwrap_or_else(|| "null".into())
290}
291
292/// Escape a string for embedding inside a JSON string literal.
293fn json_str(s: &str) -> String {
294    let mut o = String::with_capacity(s.len() + 8);
295    for c in s.chars() {
296        match c {
297            '"' => o.push_str("\\\""),
298            '\\' => o.push_str("\\\\"),
299            '\n' => o.push_str("\\n"),
300            '\r' => o.push_str("\\r"),
301            '\t' => o.push_str("\\t"),
302            c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
303            c => o.push(c),
304        }
305    }
306    o
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn json_bundles_svg_and_animations() {
315        let j = compile_json("box\nanimate last box with \"fade\"");
316        assert!(j.starts_with("{\"svg\":\"<svg"));
317        assert!(j.contains("<g id=\\\"s0\\\">")); // stable id, JSON-escaped
318        assert!(j.contains("\"animations\":[{\"id\":\"s0\",\"effect\":\"fade\""));
319        assert!(j.contains("\"diagnostics\":[]"));
320    }
321
322    #[test]
323    fn json_exports_object_geometry() {
324        // #227: one entry per <g id="sN">, bbox in the viewBox's units,
325        // span of the producing statement.
326        let j = compile_json("box wid 1 ht 0.5\narrow right 0.5");
327        assert!(
328            j.contains("\"objects\":[{\"id\":\"s0\",\"kind\":\"box\",\"bbox\":{"),
329            "{j}"
330        );
331        // 1in × 0.5in box = 96 × 48 SVG px
332        assert!(j.contains("\"w\":96,\"h\":48"), "{j}");
333        assert!(j.contains("\"kind\":\"path\""), "{j}");
334        assert!(j.contains("\"line\":2,\"col\":1"), "{j}");
335    }
336
337    #[test]
338    fn json_object_geometry_marks_invisible_shapes() {
339        let j = compile_json("box invis \"x\"");
340        // the invisible box still draws its text: box shape null, text real
341        assert!(j.contains("\"kind\":\"box\",\"bbox\":null"), "{j}");
342    }
343
344    #[test]
345    fn json_object_geometry_names_library_sources() {
346        // objects drawn by the circuits library carry file:"circuits";
347        // the user's own statements stay file-less.
348        let opts = CompileOptions {
349            circuits: true,
350            ..Default::default()
351        };
352        let j = compile_json_with_options("A:(0,0); B:(2,0)\nresistor(A,B)", &opts);
353        assert!(j.contains("\"file\":\"circuits\""), "{j}");
354    }
355
356    #[test]
357    fn json_object_geometry_matches_svg_rect() {
358        // the exported bbox must agree with the emitted <rect> attributes
359        let d = compile("box wid 1 ht 0.5").unwrap();
360        let svg = to_svg(&d);
361        let g = svg::object_geometries(&d);
362        let (x, y, w, h) = g[0].bbox.unwrap();
363        let rect = svg.lines().find(|l| l.contains("<rect")).unwrap();
364        for (attr, v) in [("x", x), ("y", y), ("width", w), ("height", h)] {
365            let needle = format!("{attr}=\"");
366            let i = rect.find(&needle).unwrap() + needle.len();
367            let s = &rect[i..rect[i..].find('"').unwrap() + i];
368            let got: f64 = s.parse().unwrap();
369            assert!((got - v).abs() < 1e-3, "{attr}: rect {got} vs bbox {v}");
370        }
371    }
372
373    #[test]
374    fn json_reports_errors() {
375        let j = compile_json("copy \"oops\"");
376        assert!(j.contains("\"error\""));
377        assert!(j.contains("\"error_info\""));
378    }
379
380    #[test]
381    fn json_reports_print_diagnostics() {
382        let j = compile_json("print \"hi\"\nprint 2+3");
383        assert!(j.contains("\"diagnostics\":[\"hi\",\"5\"]"));
384    }
385
386    #[test]
387    fn copy_circuits_loads_the_embedded_library() {
388        // `copy "circuits"` is the in-source spelling of `-c`: byte-identical
389        // output, and it works with no base dir (the wasm/compile_json case,
390        // where file includes are unavailable).
391        let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
392        let via_copy = compile(&format!("copy \"circuits\"\n{body}")).unwrap();
393        let via_flag = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
394        assert_eq!(to_svg(&via_copy), to_svg(&via_flag));
395    }
396
397    #[test]
398    fn copy_circuits_is_idempotent_with_the_flag() {
399        // `-c` plus an explicit `copy "circuits"` must not double-load (the
400        // second load is skipped) — same bytes as the flag alone.
401        let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
402        let both = compile(&format!("{CIRCUITS}\ncopy \"circuits\"\n{body}")).unwrap();
403        let flag = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
404        assert_eq!(to_svg(&both), to_svg(&flag));
405    }
406
407    #[test]
408    fn options_preludes_do_not_shift_user_positions() {
409        // #181 acceptance: with the circuits/texlabels preludes on, an error
410        // on the user's line 1 reports line 1 (not ~1093), file null.
411        let opts = CompileOptions {
412            circuits: true,
413            texlabels: true,
414            ..Default::default()
415        };
416        let j = compile_json_with_options("bxo\n", &opts);
417        assert!(j.contains("\"line\":1"), "{j}");
418        assert!(j.contains("\"col\":1"), "{j}");
419        assert!(j.contains("\"file\":null"), "{j}");
420        assert!(j.contains("\"error\":\"1:1: expected an object"), "{j}");
421    }
422
423    #[test]
424    fn options_output_matches_the_prepending_it_replaces() {
425        // The prelude splice must be behaviorally identical to the old
426        // string-prepending: byte-identical SVG.
427        let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
428        let opts = CompileOptions {
429            circuits: true,
430            ..Default::default()
431        };
432        let via_opts = compile_with_options(body, &opts).unwrap();
433        let via_prepend = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
434        assert_eq!(to_svg(&via_opts), to_svg(&via_prepend));
435    }
436
437    #[test]
438    fn options_texlabels_is_an_initializer_only() {
439        // the source stays sovereign: `texlabels = 0` overrides the prelude
440        let opts = CompileOptions {
441            texlabels: true,
442            ..Default::default()
443        };
444        let j = compile_json_with_options("texlabels = 0\nbox \"$x$\"\n", &opts);
445        assert!(!j.contains("no math renderer"), "{j}");
446    }
447
448    #[test]
449    fn diagnostics_inside_an_include_name_the_file() {
450        // #181 acceptance: a warning (or error) inside a `copy`'d file carries
451        // the include's name and include-relative position.
452        let dir = std::env::temp_dir().join(format!("rpic_incl_diag_{}", std::process::id()));
453        std::fs::create_dir_all(&dir).unwrap();
454        std::fs::write(dir.join("warn.pic"), "# comment\nbox \"a\" dashd\n").unwrap();
455        let j = compile_json_in_dir("circle\ncopy \"warn.pic\"", Some(dir.as_path()));
456        assert!(j.contains("\"kind\":\"ignored_attribute\""), "{j}");
457        assert!(j.contains("\"file\":\"warn.pic\""), "{j}");
458        assert!(j.contains("\"line\":2"), "{j}"); // include-relative, not stream
459
460        std::fs::write(dir.join("bad.pic"), "bxo\n").unwrap();
461        let e = compile_json_in_dir("circle\ncopy \"bad.pic\"", Some(dir.as_path()));
462        let _ = std::fs::remove_dir_all(&dir);
463        assert!(
464            e.contains("\"error\":\"bad.pic:1:1: expected an object"),
465            "{e}"
466        );
467        assert!(e.contains("\"file\":\"bad.pic\""), "{e}");
468        assert!(e.contains("\"line\":1"), "{e}");
469    }
470
471    #[test]
472    fn include_policy_sandboxes_and_denies() {
473        // layout: root/outside.pic (outside the fence), root/base/inc.pic (in)
474        let root = std::env::temp_dir().join(format!("rpic_inc_policy_{}", std::process::id()));
475        let base = root.join("base");
476        std::fs::create_dir_all(&base).unwrap();
477        std::fs::write(root.join("outside.pic"), "circle\n").unwrap();
478        std::fs::write(base.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
479
480        let sandboxed = CompileOptions {
481            base: Some(base.clone()),
482            includes: IncludePolicy::SandboxedToBase,
483            ..Default::default()
484        };
485        // in-base include works
486        assert!(compile_with_options("copy \"inc.pic\"\nbox", &sandboxed).is_ok());
487        // `..` escape is denied, with the structured kind and the copy's span
488        let esc = compile_json_with_options("copy \"../outside.pic\"\nbox", &sandboxed);
489        assert!(esc.contains("\"kind\":\"include_denied\""), "{esc}");
490        assert!(esc.contains("outside the include base directory"), "{esc}");
491        assert!(esc.contains("\"line\":1"), "{esc}");
492        // absolute paths are denied outright
493        let abs_src = format!("copy \"{}\"\nbox", root.join("outside.pic").display());
494        let abs = compile_json_with_options(&abs_src, &sandboxed);
495        assert!(abs.contains("\"kind\":\"include_denied\""), "{abs}");
496        assert!(abs.contains("absolute paths are not allowed"), "{abs}");
497        // symlink pointing out of the fence is caught by canonicalization
498        #[cfg(unix)]
499        {
500            let link = base.join("link.pic");
501            let _ = std::fs::remove_file(&link);
502            std::os::unix::fs::symlink(root.join("outside.pic"), &link).unwrap();
503            let sym = compile_json_with_options("copy \"link.pic\"\nbox", &sandboxed);
504            assert!(sym.contains("\"kind\":\"include_denied\""), "{sym}");
505        }
506        // the embedded library is not a filesystem include — always available
507        assert!(
508            compile_with_options(
509                "copy \"circuits\"\nA:(0,0); B:(1,0)\nresistor(A,B)",
510                &sandboxed
511            )
512            .is_ok()
513        );
514
515        let deny = CompileOptions {
516            base: Some(base.clone()),
517            includes: IncludePolicy::Deny,
518            ..Default::default()
519        };
520        let d = compile_json_with_options("copy \"inc.pic\"\nbox", &deny);
521        assert!(d.contains("\"kind\":\"include_denied\""), "{d}");
522        assert!(d.contains("disabled by the include policy"), "{d}");
523        assert!(compile_with_options("copy \"circuits\"\nbox", &deny).is_ok());
524
525        // default stays the CLI behavior: `..` resolution is allowed
526        let open = CompileOptions {
527            base: Some(base.clone()),
528            ..Default::default()
529        };
530        assert!(compile_with_options("copy \"../outside.pic\"\nbox", &open).is_ok());
531
532        let _ = std::fs::remove_dir_all(&root);
533    }
534
535    #[test]
536    fn json_in_dir_resolves_copy_includes() {
537        let dir = std::env::temp_dir().join(format!("rpic_json_copy_{}", std::process::id()));
538        std::fs::create_dir_all(&dir).unwrap();
539        std::fs::write(dir.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
540
541        let j = compile_json_in_dir("copy \"inc.pic\"\ncircle", Some(dir.as_path()));
542        let _ = std::fs::remove_dir_all(&dir);
543
544        assert!(j.starts_with("{\"svg\":\"<svg"), "{j}");
545        assert!(j.contains("<rect"), "{j}");
546        assert!(j.contains("<circle"), "{j}");
547        assert!(j.contains("\"diagnostics\":[]"), "{j}");
548        assert!(!j.contains("\"error\""), "{j}");
549    }
550
551    #[test]
552    fn json_error_info_uses_user_facing_tokens_and_spans() {
553        let j = compile_json("bxo\n");
554
555        assert!(
556            j.contains("\"error\":\"1:1: expected an object, found `bxo`\""),
557            "{j}"
558        );
559        assert!(j.contains("\"kind\":\"expected_token\""), "{j}");
560        assert!(j.contains("\"line\":1"), "{j}");
561        assert!(j.contains("\"col\":1"), "{j}");
562        assert!(j.contains("\"end_col\":4"), "{j}");
563        assert!(j.contains("\"found\":\"`bxo`\""), "{j}");
564        assert!(j.contains("\"expected\":\"an object\""), "{j}");
565        assert!(j.contains("\"hint\":\"did you mean `box`?\""), "{j}");
566    }
567
568    #[test]
569    fn json_unterminated_string_points_at_opening_quote() {
570        let j = compile_json("box wid 1\n  \"oops\n");
571
572        assert!(j.contains("\"kind\":\"unterminated_string\""), "{j}");
573        assert!(j.contains("\"line\":2"), "{j}");
574        assert!(j.contains("\"col\":3"), "{j}");
575    }
576
577    #[test]
578    fn json_eval_errors_get_structured_locations_when_possible() {
579        let label = compile_json("box at A\n");
580        assert!(label.contains("\"kind\":\"unknown_label\""), "{label}");
581        assert!(label.contains("\"line\":1"), "{label}");
582        assert!(label.contains("\"col\":8"), "{label}");
583        assert!(label.contains("\"found\":\"A\""), "{label}");
584
585        let ordinal = compile_json("box\nbox at 3rd box\n");
586        assert!(
587            ordinal.contains("\"kind\":\"ordinal_out_of_range\""),
588            "{ordinal}"
589        );
590        assert!(ordinal.contains("\"line\":2"), "{ordinal}");
591        assert!(ordinal.contains("\"col\":8"), "{ordinal}");
592        assert!(ordinal.contains("\"found\":\"3\""), "{ordinal}");
593        assert!(ordinal.contains("\"expected\":\"1..1\""), "{ordinal}");
594    }
595
596    #[test]
597    fn json_eval_error_inside_include_names_the_file() {
598        // #197: the failing reference's own tokens carry the span (and its
599        // include provenance) — no re-lexing of the user source involved.
600        let dir = std::env::temp_dir().join(format!("rpic_eval_incl_{}", std::process::id()));
601        std::fs::create_dir_all(&dir).unwrap();
602        std::fs::write(dir.join("inc-eval.pic"), "# comment\nbox at Missing\n").unwrap();
603        let j = compile_json_in_dir("circle\ncopy \"inc-eval.pic\"", Some(dir.as_path()));
604        let _ = std::fs::remove_dir_all(&dir);
605        assert!(j.contains("\"error\":\"unknown label `Missing`\""), "{j}");
606        assert!(j.contains("\"kind\":\"unknown_label\""), "{j}");
607        assert!(j.contains("\"file\":\"inc-eval.pic\""), "{j}");
608        assert!(j.contains("\"line\":2"), "{j}"); // include-relative
609        assert!(j.contains("\"col\":8"), "{j}");
610    }
611
612    #[test]
613    fn json_deferred_parse_errors_keep_their_structure() {
614        // #197: a parse error inside a dynamically-executed body used to be
615        // flattened to kind "eval" with null position/found/hint.
616        let j = compile_json("x = 1\nif x then { bxo }\n");
617        assert!(j.contains("\"kind\":\"expected_token\""), "{j}");
618        assert!(j.contains("\"line\":2"), "{j}");
619        assert!(j.contains("\"col\":13"), "{j}");
620        assert!(j.contains("\"found\":\"`bxo`\""), "{j}");
621        assert!(j.contains("\"hint\":\"did you mean `box`?\""), "{j}");
622
623        let f = compile_json("for i = 1 to 2 do { bxo }\n");
624        assert!(f.contains("\"kind\":\"expected_token\""), "{f}");
625        assert!(f.contains("\"hint\":\"did you mean `box`?\""), "{f}");
626    }
627
628    #[test]
629    fn json_success_bundle_reports_warnings() {
630        let attr = compile_json("box \"a\" dashd\n");
631        assert!(attr.contains("\"warnings\":[{"), "{attr}");
632        assert!(attr.contains("\"kind\":\"ignored_attribute\""), "{attr}");
633        assert!(attr.contains("\"found\":\"dashd\""), "{attr}");
634        assert!(
635            attr.contains("\"hint\":\"did you mean `dashed`?\""),
636            "{attr}"
637        );
638
639        let anim = compile_json("box\nanimate 1st box with \"zoom\"\n");
640        assert!(
641            anim.contains("\"kind\":\"unknown_animation_effect\""),
642            "{anim}"
643        );
644        assert!(anim.contains("\"found\":\"zoom\""), "{anim}");
645        assert!(anim.contains("\"line\":2"), "{anim}");
646    }
647
648    #[test]
649    fn circuits_library_compiles_and_draws() {
650        let src = format!(
651            "{}\nA:(0,0); B:(1.6,0); C:(3,0)\nresistor(A,B)\ncapacitor(A,B)\ninductor(A,B)\n\
652             diode(A,B)\nbattery(A,B)\nground(A)\ndot(A)\nwire(A,B)\n\
653             and_gate(C)\nor_gate(C)\nnand_gate(C)\nnor_gate(C)\nxor_gate(C)\n\
654             xnor_gate(C)\nbuffer(C)\nnot_gate(C)\n\
655             opamp(C)\nnpn(C)\npnp(C)\nac_source(A,B)\nswitch(A,B)\n\
656             potentiometer(A,B)\ntransformer(C)\n\
657             nmos(C)\npmos(C)\ncurrent_source(A,B)\nvsource_ctrl(A,B)\n\
658             isource_ctrl(A,B)\nled(A,B)\nphotodiode(A,B)\nzener(A,B)\n\
659             clabel(A,\"x\")\nterminal(A)\nvdd(A)\nantenna(A)\ncurrent(A,B)\n\
660             voltage(A,B)\nlamp(A,B)\nfuse(A,B)\n\
661             voltmeter(A,B)\nammeter(A,B)\nohmmeter(A,B)\nmeter(A,B,\"X\")\n\
662             crystal(A,B)\nspeaker(A,B)\nhop(A,B)\nspdt(A,B,C)\n\
663             iec_resistor(A,B)\nvoltage_source(A,B)\npushbutton(A,B)\nrelay(A,B)\n\
664             thermistor(A,B)\nmotor(A,B)\ngenerator(A,B)\nbell(A,B)\n\
665             ieee_and(C)\nieee_or(C)\nieee_xor(C)\nieee_buf(C)\niecgate(C,\"x\")\n\
666             varistor(A,B)\ntline(A,B)\nbus(A,B)\n\
667             polcap(A,B)\nvarcap(A,B)\nvarind(A,B)\nschottky(A,B)\nldr(A,B)\n\
668             spark_gap(A,B)\nchassis_ground(A)\nsignal_ground(A)\n\
669             njfet(C)\npjfet(C)\nphototransistor(C)\nic_block(C,\"x\")\nmux(C)\n\
670             solar_cell(A,B)\nmicrophone(A,B)\nthermocouple(A,B)",
671            CIRCUITS
672        );
673        let d = compile(&src).expect("circuit library should compile");
674        assert!(!d.shapes.is_empty());
675    }
676}