Files
cadcraft/crates/engine/tests/lisp_corpus.rs
T
8166e4a809 AutoLISP: load and run .lsp routines (fixes #758) (#855)
* AutoLISP: load and run .lsp routines (#758)

CADCraft runs AutoLISP routines. `.lsp` files load with APPLOAD, (load)
or by dropping them on the window, and at startup from the support folder
(acad.lsp once per session or per drawing with ACADLSPASDOC, acaddoc.lsp
per drawing, then S::STARTUP; never from a drawing's own folder).
(defun c:NAME …) defines commands, (autoload) defers loading to first
use, and (expression) and !variable work at the command line and at
prompts. docs/autolisp.md lists what is supported.

The interpreter (crates/engine/src/lisp) reads source with its own
stack and evaluates on an explicit-stack machine, so a routine can stop
for input (getpoint, pause in command) and carry on later on one thread,
deep recursion is an error rather than a crash, and long loops run a
slice per frame (Esc stops them). It follows AutoLISP: 32-bit wrapping
integers, dynamic scope, *error*, vl-catch-all-apply, AutoLISP's error
wording. About 200 built-ins cover the language, command (with pause and
commands left open), entity lists (entget/entmake/entmod/entdel/entlast/
entnext/handent, through the DXF reader and writer's groups, exposed by
cadcraft-io and handed to the engine through a hook), ssget with modes
and filters, tables, system variables, get… input with initget, and
units. A routine is one undo step; each drawing has its own symbols.

Also: keywords typed as _KEYWORD match at prompts; TEXT run by a
routine's command takes one line; DIMENSION records carry their
measurement (42); Prompt::raw shows a get… prompt as written; the
interface asks Session::prompting() (a command's or a routine's prompt)
where it asked whether a command runs, so clicks, right-click and Delete
work at a routine's prompts; getcorner and based get… prompts drag a
rubber band.

Tests: the language, errors, startup files, interactive input, hostile
input (lisp/tests.rs); the #648 corpus, 18/18, when CADCRAFT_LISP_CORPUS
points at it (tests/lisp_corpus.rs); drop, frames and canvas clicks in
the app (ui-egui/tests/lisp_files.rs).

* AutoLISP: bound memory, drop nested values iteratively, guard the per-frame tick

- Cons cells drop with a work list over both car and cdr (and function
  bodies), so a list nested a million deep in its car no longer overflows
  the stack when it goes away.
- Memory caps so a runaway routine fails with a LISP error instead of an
  uncatchable out-of-memory abort: at most 5,000,000 live cons cells
  (counted in Value::cons / Drop, checked every step; cells the drawing's
  variables already held at the run's start don't count against it), no
  string a built-in makes longer than 8 MiB (strcat checks before joining,
  printed text is cut), and a 2 GiB per-run budget for new strings,
  printed text and files read.
- Session::lisp_tick runs under catch_unwind, and so does every slice of
  the LISP machine: a panic ends the routine with "internal error", puts
  the drawing back and leaves the session usable.
- Regression tests for each (deep car nesting, nested functions, the cell
  cap, string length and budget, a panicking built-in in a blocking run
  and in a ticked one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:33:57 -04:00

240 lines
9.1 KiB
Rust

//! The AutoLISP conformance corpus (#648): each case's routine runs in a new drawing and must
//! leave exactly the expected entities, layers and printed text. The corpus lives in its own
//! repository; point `CADCRAFT_LISP_CORPUS` at its `cases` directory (or the repository) to run
//! it. Without it the test only says so.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::Entity;
use serde_json::{Value, json};
fn cases_dir() -> Option<PathBuf> {
let root = PathBuf::from(std::env::var_os("CADCRAFT_LISP_CORPUS")?);
let cases = root.join("cases");
Some(if cases.is_dir() { cases } else { root })
}
/// An entity's DXF groups as (code, value) pairs.
fn groups(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> (String, Vec<(i32, Value)>) {
let (kind, tags) = cadcraft_io::entity_groups(d, e);
let v = tags
.into_iter()
.map(|t| {
let v = match t.value {
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => json!(s),
cadcraft_dxf::Value::Real(r) => json!(r),
cadcraft_dxf::Value::Int(i) => json!(i),
cadcraft_dxf::Value::Bool(b) => json!(i32::from(b)),
};
(t.code, v)
})
.collect();
(kind, v)
}
fn num(g: &[(i32, Value)], code: i32) -> Option<f64> {
g.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.as_f64())
}
fn point(g: &[(i32, Value)], code: i32) -> Value {
json!([num(g, code).unwrap_or(0.0), num(g, code + 10).unwrap_or(0.0), num(g, code + 20).unwrap_or(0.0)])
}
/// The fields the corpus checks, read from an entity's groups.
fn actual(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> Value {
let (kind, g) = groups(d, e);
let s = |code: i32| g.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.as_str().map(str::to_string)).unwrap_or_default();
let mut o = json!({ "type": kind, "layer": s(8) });
let m = o.as_object_mut().unwrap();
match kind.as_str() {
"LINE" => {
m.insert("start".into(), point(&g, 10));
m.insert("end".into(), point(&g, 11));
}
"CIRCLE" | "ARC" => {
m.insert("center".into(), point(&g, 10));
m.insert("radius".into(), json!(num(&g, 40).unwrap_or(0.0)));
if kind == "ARC" {
m.insert("start_angle".into(), json!(num(&g, 50).unwrap_or(0.0)));
m.insert("end_angle".into(), json!(num(&g, 51).unwrap_or(0.0)));
}
}
"POINT" => {
m.insert("location".into(), point(&g, 10));
}
"LWPOLYLINE" => {
let xs: Vec<f64> = g.iter().filter(|(c, _)| *c == 10).filter_map(|(_, v)| v.as_f64()).collect();
let ys: Vec<f64> = g.iter().filter(|(c, _)| *c == 20).filter_map(|(_, v)| v.as_f64()).collect();
let verts: Vec<Value> = xs.iter().zip(&ys).map(|(x, y)| json!([x, y])).collect();
m.insert("closed".into(), json!(num(&g, 70).unwrap_or(0.0) as i64 & 1 == 1));
m.insert("vertices".into(), json!(verts));
}
"TEXT" => {
m.insert("insert".into(), point(&g, 10));
m.insert("height".into(), json!(num(&g, 40).unwrap_or(0.0)));
m.insert("rotation".into(), json!(num(&g, 50).unwrap_or(0.0)));
m.insert("text".into(), json!(s(1)));
}
"DIMENSION" => {
let k = num(&g, 70).unwrap_or(0.0) as i64 & 7;
m.insert(
"kind".into(),
json!(if k == 0 {
"linear"
} else if k == 1 {
"aligned"
} else {
"other"
}),
);
m.insert("xline1".into(), point(&g, 13));
m.insert("xline2".into(), point(&g, 14));
m.insert("rotation".into(), json!(num(&g, 50).unwrap_or(0.0)));
m.insert("measurement".into(), json!(num(&g, 42).unwrap_or(0.0)));
}
_ => {}
}
o
}
fn close(a: &Value, b: &Value, tol: f64) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap_or(f64::NAN) - y.as_f64().unwrap_or(f64::NAN)).abs() <= tol,
(Value::Array(x), Value::Array(y)) => {
// A 2D point compares with z = 0.
let pad = |v: &Vec<Value>| -> Vec<Value> {
let mut v = v.clone();
if v.len() == 2 && v.iter().all(Value::is_number) {
v.push(json!(0.0));
}
v
};
let (x, y) = (pad(x), pad(y));
x.len() == y.len() && x.iter().zip(&y).all(|(p, q)| close(p, q, tol))
}
_ => a == b,
}
}
/// Closed polylines match as the same cyclic sequence in either direction.
fn same_ring(have: &[Value], want: &[Value], tol: f64) -> bool {
let n = want.len();
if have.len() != n {
return false;
}
if n == 0 {
return true;
}
for start in 0..n {
for dir in [1isize, -1] {
let ok = (0..n).all(|i| {
let j = (start as isize + dir * i as isize).rem_euclid(n as isize) as usize;
close(&have[j], &want[i], tol)
});
if ok {
return true;
}
}
}
false
}
fn matches(have: &Value, want: &Value, tol: f64) -> bool {
let Some(w) = want.as_object() else { return false };
w.iter().all(|(k, v)| {
let h = have.get(k).unwrap_or(&Value::Null);
if k == "vertices" && have.get("closed") == Some(&json!(true)) {
return match (h, v) {
(Value::Array(h), Value::Array(v)) => same_ring(h, v, tol),
_ => false,
};
}
close(h, v, tol)
})
}
fn run_case(dir: &Path) -> Result<(), String> {
let routine = std::fs::read_to_string(dir.join("routine.lsp")).map_err(|e| e.to_string())?;
let expected: Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("expected.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let mut s = Session::new();
if let Some(vars) = expected["preconditions"]["sysvars"].as_object() {
for (k, v) in vars {
cadcraft_engine::sysvars::set(&mut s, k, v).map_err(|e| format!("setvar {k}: {e}"))?;
}
}
let from = s.log.len();
match s.lisp_load("routine.lsp", &routine, true) {
Ok(Some(Err(e))) => return Err(format!("LISP error: {e}\n{:#?}", &s.log[from..])),
Err(e) => return Err(e.to_string()),
_ => {}
}
let exp = &expected["expect"];
let tol = exp["tolerance"].as_f64().unwrap_or(1e-9);
let d = s.doc().map_err(|e| e.to_string())?;
let mut have: Vec<Value> = d.model.iter().map(|e| actual(d, e)).collect();
let want = exp["entities"].as_array().cloned().unwrap_or_default();
let mut missing = Vec::new();
for w in &want {
match have.iter().position(|h| matches(h, w, tol)) {
Some(i) => {
have.remove(i);
}
None => missing.push(w.clone()),
}
}
if !missing.is_empty() || !have.is_empty() {
return Err(format!(
"missing {}:\n{}\nunexpected {}:\n{}\nlog:\n{}",
missing.len(),
serde_json::to_string_pretty(&missing).unwrap_or_default(),
have.len(),
serde_json::to_string_pretty(&have).unwrap_or_default(),
s.log[from..].join("\n")
));
}
for l in exp["layers"].as_array().cloned().unwrap_or_default() {
let name = l["name"].as_str().unwrap_or("");
let layer = d.layer(name).ok_or_else(|| format!("layer {name} missing"))?;
if let Some(c) = l["color"].as_i64() {
let have = match layer.color {
cadcraft_color::Color::Index(i) => i64::from(i),
_ => -1,
};
if have != c {
return Err(format!("layer {name}: colour {have}, expected {c}"));
}
}
}
if let Some(p) = exp["printed"].as_str() {
let out = s.log[from..].join("\n");
if !out.contains(p.trim_start_matches('\n')) {
return Err(format!("printed {p:?} not found in:\n{out}"));
}
}
Ok(())
}
#[test]
fn autolisp_corpus() {
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
let Some(dir) = cases_dir() else {
eprintln!("CADCRAFT_LISP_CORPUS is not set: the AutoLISP corpus is skipped");
return;
};
let mut cases: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.join("routine.lsp").is_file()).collect();
cases.sort();
assert!(!cases.is_empty(), "no cases in {}", dir.display());
let mut failed = Vec::new();
for c in &cases {
if let Err(e) = run_case(c) {
failed.push(format!("== {}\n{e}", c.file_name().and_then(|n| n.to_str()).unwrap_or("?")));
}
}
assert!(failed.is_empty(), "{} of {} cases failed:\n{}", failed.len(), cases.len(), failed.join("\n\n"));
}