1pub 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
32pub const CIRCUITS: &str = include_str!("std/circuits.pic");
35
36pub fn compile(src: &str) -> Result<Drawing, String> {
38 compile_in_dir(src, None)
39}
40
41pub 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
47pub fn render_svg(src: &str) -> Result<String, String> {
49 Ok(to_svg(&compile(src)?))
50}
51
52pub 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
57pub 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 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
120pub 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
162fn 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
174pub 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#[derive(Debug, Clone, Default)]
195pub struct CompileOptions {
196 pub circuits: bool,
198 pub texlabels: bool,
201 pub base: Option<std::path::PathBuf>,
203 pub includes: IncludePolicy,
207 pub max_animation_seconds: Option<f64>,
211 pub max_animation_repeat: Option<i64>,
215 pub max_loop_iterations: Option<u64>,
218 pub max_shapes: Option<usize>,
221}
222
223pub fn compile_with_options(src: &str, opts: &CompileOptions) -> Result<Drawing, String> {
225 compile_with_diagnostics(src, opts).map_err(|e| e.message)
226}
227
228pub 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 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
251pub 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
282pub fn compile_json(src: &str) -> String {
287 compile_json_with_options(src, &CompileOptions::default())
288}
289
290pub 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
302pub 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 let scroll = if d.anim_scroll {
316 ",\"scroll\":true"
317 } else {
318 ""
319 };
320 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
339pub 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
410fn 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\\\">")); assert!(j.contains("\"animations\":[{\"id\":\"s0\",\"effect\":\"fade\""));
437 assert!(j.contains("\"diagnostics\":[]"));
438 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 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 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 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 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 assert!(
521 j.contains("<a href=\\\"https://rpic.dev\\\" class=\\\"rpic-link\\\">"),
522 "{j}"
523 );
524 let circle_entry = &j[j.find("\"id\":\"s1\"").unwrap()..];
526 assert!(
527 !circle_entry[..circle_entry.find('}').unwrap()].contains("link"),
528 "{j}"
529 );
530 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 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 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 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 assert!(j.contains("\"kind\":\"box\",\"bbox\":null"), "{j}");
588 }
589
590 #[test]
591 fn json_object_geometry_names_library_sources() {
592 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 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 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 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 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 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 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 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}"); 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 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 assert!(compile_with_options("copy \"inc.pic\"\nbox", &sandboxed).is_ok());
812 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 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 #[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 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 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 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}"); assert!(j.contains("\"col\":8"), "{j}");
935 }
936
937 #[test]
938 fn json_deferred_parse_errors_keep_their_structure() {
939 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}