1pub 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
31pub const CIRCUITS: &str = include_str!("std/circuits.pic");
34
35pub fn compile(src: &str) -> Result<Drawing, String> {
37 compile_in_dir(src, None)
38}
39
40pub 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
46pub fn render_svg(src: &str) -> Result<String, String> {
48 Ok(to_svg(&compile(src)?))
49}
50
51pub 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
56pub 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
76pub fn diagnostics_json(d: &Drawing) -> String {
78 let mut s = String::from("[");
79 for (i, line) in d.diagnostics.iter().enumerate() {
80 if i > 0 {
81 s.push(',');
82 }
83 s.push('"');
84 s.push_str(&json_str(line));
85 s.push('"');
86 }
87 s.push(']');
88 s
89}
90
91#[derive(Debug, Clone, Default)]
97pub struct CompileOptions {
98 pub circuits: bool,
100 pub texlabels: bool,
103 pub base: Option<std::path::PathBuf>,
105 pub includes: IncludePolicy,
109}
110
111pub fn compile_with_options(src: &str, opts: &CompileOptions) -> Result<Drawing, String> {
113 compile_with_diagnostics(src, opts).map_err(|e| e.message)
114}
115
116pub fn compile_with_diagnostics(src: &str, opts: &CompileOptions) -> Result<Drawing, CompileError> {
120 let picture = parse_options(src, opts).map_err(|e| CompileError {
121 message: e.to_string(),
122 info: Box::new(e.diagnostic()),
123 })?;
124 eval(&picture).map_err(|e| {
125 let info = e
129 .info
130 .clone()
131 .unwrap_or_else(|| Box::new(Diagnostic::new("eval", e.msg.clone())));
132 CompileError {
133 message: e.msg,
134 info,
135 }
136 })
137}
138
139pub fn render_svg_with_options(src: &str, opts: &CompileOptions) -> Result<String, String> {
141 Ok(to_svg(&compile_with_options(src, opts)?))
142}
143
144fn parse_options(src: &str, opts: &CompileOptions) -> Result<ast::Picture, ParseError> {
145 parser::parse_with_prelude(
146 src,
147 ast::IncludeCtx::with_policy(opts.base.clone(), opts.includes),
148 opts.circuits,
149 opts.texlabels,
150 )
151}
152
153pub fn compile_json(src: &str) -> String {
158 compile_json_with_options(src, &CompileOptions::default())
159}
160
161pub fn compile_json_in_dir(src: &str, base: Option<&std::path::Path>) -> String {
164 compile_json_with_options(
165 src,
166 &CompileOptions {
167 base: base.map(|p| p.to_path_buf()),
168 ..Default::default()
169 },
170 )
171}
172
173pub fn compile_json_with_options(src: &str, opts: &CompileOptions) -> String {
177 match compile_with_diagnostics(src, opts) {
178 Ok(d) => drawing_json(&d),
179 Err(e) => error_json(&e.message, &e.info),
180 }
181}
182
183fn drawing_json(d: &Drawing) -> String {
184 format!(
185 "{{\"svg\":\"{}\",\"animations\":{},\"diagnostics\":{},\"warnings\":{}}}",
186 json_str(&to_svg(d)),
187 animations_json(d),
188 diagnostics_json(d),
189 diagnostics_json_structured(&d.warnings)
190 )
191}
192
193fn error_json(message: &str, diagnostic: &Diagnostic) -> String {
194 format!(
195 "{{\"error\":\"{}\",\"error_info\":{},\"warnings\":[]}}",
196 json_str(message),
197 diagnostic_json(diagnostic)
198 )
199}
200
201fn diagnostics_json_structured(items: &[Diagnostic]) -> String {
202 let mut s = String::from("[");
203 for (i, d) in items.iter().enumerate() {
204 if i > 0 {
205 s.push(',');
206 }
207 s.push_str(&diagnostic_json(d));
208 }
209 s.push(']');
210 s
211}
212
213fn diagnostic_json(d: &Diagnostic) -> String {
214 let mut s = String::from("{");
215 s.push_str(&format!("\"message\":\"{}\"", json_str(&d.message)));
216 s.push_str(&format!(",\"line\":{}", json_opt_u32(d.line)));
217 s.push_str(&format!(",\"col\":{}", json_opt_u32(d.col)));
218 s.push_str(&format!(",\"end_col\":{}", json_opt_u32(d.end_col)));
219 s.push_str(&format!(",\"file\":{}", json_opt_str(d.file.as_deref())));
220 s.push_str(&format!(",\"kind\":\"{}\"", json_str(&d.kind)));
221 s.push_str(&format!(",\"found\":{}", json_opt_str(d.found.as_deref())));
222 s.push_str(&format!(
223 ",\"expected\":{}",
224 json_opt_str(d.expected.as_deref())
225 ));
226 s.push_str(&format!(",\"hint\":{}", json_opt_str(d.hint.as_deref())));
227 s.push('}');
228 s
229}
230
231fn json_opt_u32(v: Option<u32>) -> String {
232 v.map(|n| n.to_string()).unwrap_or_else(|| "null".into())
233}
234
235fn json_opt_str(v: Option<&str>) -> String {
236 v.map(|s| format!("\"{}\"", json_str(s)))
237 .unwrap_or_else(|| "null".into())
238}
239
240fn json_str(s: &str) -> String {
242 let mut o = String::with_capacity(s.len() + 8);
243 for c in s.chars() {
244 match c {
245 '"' => o.push_str("\\\""),
246 '\\' => o.push_str("\\\\"),
247 '\n' => o.push_str("\\n"),
248 '\r' => o.push_str("\\r"),
249 '\t' => o.push_str("\\t"),
250 c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
251 c => o.push(c),
252 }
253 }
254 o
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn json_bundles_svg_and_animations() {
263 let j = compile_json("box\nanimate last box with \"fade\"");
264 assert!(j.starts_with("{\"svg\":\"<svg"));
265 assert!(j.contains("<g id=\\\"s0\\\">")); assert!(j.contains("\"animations\":[{\"id\":\"s0\",\"effect\":\"fade\""));
267 assert!(j.contains("\"diagnostics\":[]"));
268 }
269
270 #[test]
271 fn json_reports_errors() {
272 let j = compile_json("copy \"oops\"");
273 assert!(j.contains("\"error\""));
274 assert!(j.contains("\"error_info\""));
275 }
276
277 #[test]
278 fn json_reports_print_diagnostics() {
279 let j = compile_json("print \"hi\"\nprint 2+3");
280 assert!(j.contains("\"diagnostics\":[\"hi\",\"5\"]"));
281 }
282
283 #[test]
284 fn copy_circuits_loads_the_embedded_library() {
285 let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
289 let via_copy = compile(&format!("copy \"circuits\"\n{body}")).unwrap();
290 let via_flag = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
291 assert_eq!(to_svg(&via_copy), to_svg(&via_flag));
292 }
293
294 #[test]
295 fn copy_circuits_is_idempotent_with_the_flag() {
296 let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
299 let both = compile(&format!("{CIRCUITS}\ncopy \"circuits\"\n{body}")).unwrap();
300 let flag = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
301 assert_eq!(to_svg(&both), to_svg(&flag));
302 }
303
304 #[test]
305 fn options_preludes_do_not_shift_user_positions() {
306 let opts = CompileOptions {
309 circuits: true,
310 texlabels: true,
311 ..Default::default()
312 };
313 let j = compile_json_with_options("bxo\n", &opts);
314 assert!(j.contains("\"line\":1"), "{j}");
315 assert!(j.contains("\"col\":1"), "{j}");
316 assert!(j.contains("\"file\":null"), "{j}");
317 assert!(j.contains("\"error\":\"1:1: expected an object"), "{j}");
318 }
319
320 #[test]
321 fn options_output_matches_the_prepending_it_replaces() {
322 let body = "A:(0,0); B:(2,0)\nresistor(A,B)";
325 let opts = CompileOptions {
326 circuits: true,
327 ..Default::default()
328 };
329 let via_opts = compile_with_options(body, &opts).unwrap();
330 let via_prepend = compile(&format!("{CIRCUITS}\n{body}")).unwrap();
331 assert_eq!(to_svg(&via_opts), to_svg(&via_prepend));
332 }
333
334 #[test]
335 fn options_texlabels_is_an_initializer_only() {
336 let opts = CompileOptions {
338 texlabels: true,
339 ..Default::default()
340 };
341 let j = compile_json_with_options("texlabels = 0\nbox \"$x$\"\n", &opts);
342 assert!(!j.contains("no math renderer"), "{j}");
343 }
344
345 #[test]
346 fn diagnostics_inside_an_include_name_the_file() {
347 let dir = std::env::temp_dir().join(format!("rpic_incl_diag_{}", std::process::id()));
350 std::fs::create_dir_all(&dir).unwrap();
351 std::fs::write(dir.join("warn.pic"), "# comment\nbox \"a\" dashd\n").unwrap();
352 let j = compile_json_in_dir("circle\ncopy \"warn.pic\"", Some(dir.as_path()));
353 assert!(j.contains("\"kind\":\"ignored_attribute\""), "{j}");
354 assert!(j.contains("\"file\":\"warn.pic\""), "{j}");
355 assert!(j.contains("\"line\":2"), "{j}"); std::fs::write(dir.join("bad.pic"), "bxo\n").unwrap();
358 let e = compile_json_in_dir("circle\ncopy \"bad.pic\"", Some(dir.as_path()));
359 let _ = std::fs::remove_dir_all(&dir);
360 assert!(
361 e.contains("\"error\":\"bad.pic:1:1: expected an object"),
362 "{e}"
363 );
364 assert!(e.contains("\"file\":\"bad.pic\""), "{e}");
365 assert!(e.contains("\"line\":1"), "{e}");
366 }
367
368 #[test]
369 fn include_policy_sandboxes_and_denies() {
370 let root = std::env::temp_dir().join(format!("rpic_inc_policy_{}", std::process::id()));
372 let base = root.join("base");
373 std::fs::create_dir_all(&base).unwrap();
374 std::fs::write(root.join("outside.pic"), "circle\n").unwrap();
375 std::fs::write(base.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
376
377 let sandboxed = CompileOptions {
378 base: Some(base.clone()),
379 includes: IncludePolicy::SandboxedToBase,
380 ..Default::default()
381 };
382 assert!(compile_with_options("copy \"inc.pic\"\nbox", &sandboxed).is_ok());
384 let esc = compile_json_with_options("copy \"../outside.pic\"\nbox", &sandboxed);
386 assert!(esc.contains("\"kind\":\"include_denied\""), "{esc}");
387 assert!(esc.contains("outside the include base directory"), "{esc}");
388 assert!(esc.contains("\"line\":1"), "{esc}");
389 let abs_src = format!("copy \"{}\"\nbox", root.join("outside.pic").display());
391 let abs = compile_json_with_options(&abs_src, &sandboxed);
392 assert!(abs.contains("\"kind\":\"include_denied\""), "{abs}");
393 assert!(abs.contains("absolute paths are not allowed"), "{abs}");
394 #[cfg(unix)]
396 {
397 let link = base.join("link.pic");
398 let _ = std::fs::remove_file(&link);
399 std::os::unix::fs::symlink(root.join("outside.pic"), &link).unwrap();
400 let sym = compile_json_with_options("copy \"link.pic\"\nbox", &sandboxed);
401 assert!(sym.contains("\"kind\":\"include_denied\""), "{sym}");
402 }
403 assert!(
405 compile_with_options(
406 "copy \"circuits\"\nA:(0,0); B:(1,0)\nresistor(A,B)",
407 &sandboxed
408 )
409 .is_ok()
410 );
411
412 let deny = CompileOptions {
413 base: Some(base.clone()),
414 includes: IncludePolicy::Deny,
415 ..Default::default()
416 };
417 let d = compile_json_with_options("copy \"inc.pic\"\nbox", &deny);
418 assert!(d.contains("\"kind\":\"include_denied\""), "{d}");
419 assert!(d.contains("disabled by the include policy"), "{d}");
420 assert!(compile_with_options("copy \"circuits\"\nbox", &deny).is_ok());
421
422 let open = CompileOptions {
424 base: Some(base.clone()),
425 ..Default::default()
426 };
427 assert!(compile_with_options("copy \"../outside.pic\"\nbox", &open).is_ok());
428
429 let _ = std::fs::remove_dir_all(&root);
430 }
431
432 #[test]
433 fn json_in_dir_resolves_copy_includes() {
434 let dir = std::env::temp_dir().join(format!("rpic_json_copy_{}", std::process::id()));
435 std::fs::create_dir_all(&dir).unwrap();
436 std::fs::write(dir.join("inc.pic"), "box wid 0.5 ht 0.5\n").unwrap();
437
438 let j = compile_json_in_dir("copy \"inc.pic\"\ncircle", Some(dir.as_path()));
439 let _ = std::fs::remove_dir_all(&dir);
440
441 assert!(j.starts_with("{\"svg\":\"<svg"), "{j}");
442 assert!(j.contains("<rect"), "{j}");
443 assert!(j.contains("<circle"), "{j}");
444 assert!(j.contains("\"diagnostics\":[]"), "{j}");
445 assert!(!j.contains("\"error\""), "{j}");
446 }
447
448 #[test]
449 fn json_error_info_uses_user_facing_tokens_and_spans() {
450 let j = compile_json("bxo\n");
451
452 assert!(
453 j.contains("\"error\":\"1:1: expected an object, found `bxo`\""),
454 "{j}"
455 );
456 assert!(j.contains("\"kind\":\"expected_token\""), "{j}");
457 assert!(j.contains("\"line\":1"), "{j}");
458 assert!(j.contains("\"col\":1"), "{j}");
459 assert!(j.contains("\"end_col\":4"), "{j}");
460 assert!(j.contains("\"found\":\"`bxo`\""), "{j}");
461 assert!(j.contains("\"expected\":\"an object\""), "{j}");
462 assert!(j.contains("\"hint\":\"did you mean `box`?\""), "{j}");
463 }
464
465 #[test]
466 fn json_unterminated_string_points_at_opening_quote() {
467 let j = compile_json("box wid 1\n \"oops\n");
468
469 assert!(j.contains("\"kind\":\"unterminated_string\""), "{j}");
470 assert!(j.contains("\"line\":2"), "{j}");
471 assert!(j.contains("\"col\":3"), "{j}");
472 }
473
474 #[test]
475 fn json_eval_errors_get_structured_locations_when_possible() {
476 let label = compile_json("box at A\n");
477 assert!(label.contains("\"kind\":\"unknown_label\""), "{label}");
478 assert!(label.contains("\"line\":1"), "{label}");
479 assert!(label.contains("\"col\":8"), "{label}");
480 assert!(label.contains("\"found\":\"A\""), "{label}");
481
482 let ordinal = compile_json("box\nbox at 3rd box\n");
483 assert!(
484 ordinal.contains("\"kind\":\"ordinal_out_of_range\""),
485 "{ordinal}"
486 );
487 assert!(ordinal.contains("\"line\":2"), "{ordinal}");
488 assert!(ordinal.contains("\"col\":8"), "{ordinal}");
489 assert!(ordinal.contains("\"found\":\"3\""), "{ordinal}");
490 assert!(ordinal.contains("\"expected\":\"1..1\""), "{ordinal}");
491 }
492
493 #[test]
494 fn json_eval_error_inside_include_names_the_file() {
495 let dir = std::env::temp_dir().join(format!("rpic_eval_incl_{}", std::process::id()));
498 std::fs::create_dir_all(&dir).unwrap();
499 std::fs::write(dir.join("inc-eval.pic"), "# comment\nbox at Missing\n").unwrap();
500 let j = compile_json_in_dir("circle\ncopy \"inc-eval.pic\"", Some(dir.as_path()));
501 let _ = std::fs::remove_dir_all(&dir);
502 assert!(j.contains("\"error\":\"unknown label `Missing`\""), "{j}");
503 assert!(j.contains("\"kind\":\"unknown_label\""), "{j}");
504 assert!(j.contains("\"file\":\"inc-eval.pic\""), "{j}");
505 assert!(j.contains("\"line\":2"), "{j}"); assert!(j.contains("\"col\":8"), "{j}");
507 }
508
509 #[test]
510 fn json_deferred_parse_errors_keep_their_structure() {
511 let j = compile_json("x = 1\nif x then { bxo }\n");
514 assert!(j.contains("\"kind\":\"expected_token\""), "{j}");
515 assert!(j.contains("\"line\":2"), "{j}");
516 assert!(j.contains("\"col\":13"), "{j}");
517 assert!(j.contains("\"found\":\"`bxo`\""), "{j}");
518 assert!(j.contains("\"hint\":\"did you mean `box`?\""), "{j}");
519
520 let f = compile_json("for i = 1 to 2 do { bxo }\n");
521 assert!(f.contains("\"kind\":\"expected_token\""), "{f}");
522 assert!(f.contains("\"hint\":\"did you mean `box`?\""), "{f}");
523 }
524
525 #[test]
526 fn json_success_bundle_reports_warnings() {
527 let attr = compile_json("box \"a\" dashd\n");
528 assert!(attr.contains("\"warnings\":[{"), "{attr}");
529 assert!(attr.contains("\"kind\":\"ignored_attribute\""), "{attr}");
530 assert!(attr.contains("\"found\":\"dashd\""), "{attr}");
531 assert!(
532 attr.contains("\"hint\":\"did you mean `dashed`?\""),
533 "{attr}"
534 );
535
536 let anim = compile_json("box\nanimate 1st box with \"zoom\"\n");
537 assert!(
538 anim.contains("\"kind\":\"unknown_animation_effect\""),
539 "{anim}"
540 );
541 assert!(anim.contains("\"found\":\"zoom\""), "{anim}");
542 assert!(anim.contains("\"line\":2"), "{anim}");
543 }
544
545 #[test]
546 fn circuits_library_compiles_and_draws() {
547 let src = format!(
548 "{}\nA:(0,0); B:(1.6,0); C:(3,0)\nresistor(A,B)\ncapacitor(A,B)\ninductor(A,B)\n\
549 diode(A,B)\nbattery(A,B)\nground(A)\ndot(A)\nwire(A,B)\n\
550 and_gate(C)\nor_gate(C)\nnand_gate(C)\nnor_gate(C)\nxor_gate(C)\n\
551 xnor_gate(C)\nbuffer(C)\nnot_gate(C)\n\
552 opamp(C)\nnpn(C)\npnp(C)\nac_source(A,B)\nswitch(A,B)\n\
553 potentiometer(A,B)\ntransformer(C)\n\
554 nmos(C)\npmos(C)\ncurrent_source(A,B)\nvsource_ctrl(A,B)\n\
555 isource_ctrl(A,B)\nled(A,B)\nphotodiode(A,B)\nzener(A,B)\n\
556 clabel(A,\"x\")\nterminal(A)\nvdd(A)\nantenna(A)\ncurrent(A,B)\n\
557 voltage(A,B)\nlamp(A,B)\nfuse(A,B)\n\
558 voltmeter(A,B)\nammeter(A,B)\nohmmeter(A,B)\nmeter(A,B,\"X\")\n\
559 crystal(A,B)\nspeaker(A,B)\nhop(A,B)\nspdt(A,B,C)\n\
560 iec_resistor(A,B)\nvoltage_source(A,B)\npushbutton(A,B)\nrelay(A,B)\n\
561 thermistor(A,B)\nmotor(A,B)\ngenerator(A,B)\nbell(A,B)\n\
562 ieee_and(C)\nieee_or(C)\nieee_xor(C)\nieee_buf(C)\niecgate(C,\"x\")\n\
563 varistor(A,B)\ntline(A,B)\nbus(A,B)\n\
564 polcap(A,B)\nvarcap(A,B)\nvarind(A,B)\nschottky(A,B)\nldr(A,B)\n\
565 spark_gap(A,B)\nchassis_ground(A)\nsignal_ground(A)\n\
566 njfet(C)\npjfet(C)\nphototransistor(C)\nic_block(C,\"x\")\nmux(C)\n\
567 solar_cell(A,B)\nmicrophone(A,B)\nthermocouple(A,B)",
568 CIRCUITS
569 );
570 let d = compile(&src).expect("circuit library should compile");
571 assert!(!d.shapes.is_empty());
572 }
573}