mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-31 14:27:02 -06:00
* 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 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
4.5 KiB
Rust
111 lines
4.5 KiB
Rust
//! AutoLISP commands: APPLOAD loads a `.lsp` file; `lisp` evaluates LISP for programmatic
|
|
//! callers (scripts, the control channel, MCP, the CLI).
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{bad, str_param};
|
|
use crate::lisp::value::{Value as Lv, to_text};
|
|
use crate::{Accept, CommandSpec, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
CommandSpec::new("appload", "Load Application...", run_appload)
|
|
.menu(&["Tools", "Load Application..."])
|
|
.alias(&["ap"])
|
|
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
|
|
.noundo()
|
|
.interactive(|_| Ok(Box::new(ApploadM))),
|
|
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
|
|
.params(
|
|
"{code: LISP source, inputs?: [answers to get… calls: numbers, strings, [x,y] points]} → {value (printed as prin1), output: lines printed, error?}",
|
|
)
|
|
.noundo(),
|
|
]
|
|
}
|
|
|
|
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
|
|
fn source(p: &Value) -> Result<(String, String)> {
|
|
if let Some(text) = str_param(p, "text") {
|
|
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), text.to_string()));
|
|
}
|
|
if let Some(data) = str_param(p, "data") {
|
|
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
|
|
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
|
|
}
|
|
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
|
|
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
|
|
Ok((path.to_string(), text))
|
|
}
|
|
|
|
/// The result of a blocking LISP run as JSON, with the lines it printed.
|
|
fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String>>) -> Value {
|
|
let output: Vec<String> = s.log.get(from_log..).map(|l| l.to_vec()).unwrap_or_default();
|
|
match r {
|
|
Some(Ok(v)) => json!({ "value": to_text(&v, true), "output": output }),
|
|
Some(Err(e)) => json!({ "error": e, "output": output }),
|
|
None => json!({ "output": output }),
|
|
}
|
|
}
|
|
|
|
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (name, text) = source(p)?;
|
|
let from = s.log.len();
|
|
let r = s.lisp_load(&name, &text, true)?;
|
|
Ok(report(s, from, r))
|
|
}
|
|
|
|
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
|
|
fn input_value(v: &Value) -> Lv {
|
|
match v {
|
|
Value::Array(a) if a.len() >= 2 && a.iter().all(Value::is_number) => Lv::point(&a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()),
|
|
other => crate::lisp::drawing_from_json(other),
|
|
}
|
|
}
|
|
|
|
fn run_lisp(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let code = str_param(p, "code").ok_or_else(|| bad("lisp", "`code` is required"))?;
|
|
let inputs = p.get("inputs").and_then(Value::as_array).map(|a| a.iter().map(input_value).collect()).unwrap_or_default();
|
|
let from = s.log.len();
|
|
let r = s.lisp_eval(code, false, true, inputs).map_err(|e| match e {
|
|
EngineError::Other(m) => bad("lisp", m),
|
|
e => e,
|
|
})?;
|
|
Ok(report(s, from, r))
|
|
}
|
|
|
|
/// APPLOAD typed or chosen from the menu: asks for the file by name.
|
|
struct ApploadM;
|
|
|
|
impl Interactive for ApploadM {
|
|
fn name(&self) -> &'static str {
|
|
"APPLOAD"
|
|
}
|
|
fn prompt(&self, _: &Session) -> Prompt {
|
|
Prompt::new("Enter name of LISP file to load", Accept::TEXT)
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
if matches!(i, Input::Cancel | Input::Enter) {
|
|
return Ok(Step::Cancel);
|
|
}
|
|
let Input::Text(t) = i else { return Ok(Step::Continue) };
|
|
let path = t.trim().trim_matches('"').to_string();
|
|
if path.is_empty() {
|
|
return Ok(Step::Cancel);
|
|
}
|
|
match source(&json!({ "path": path })) {
|
|
Ok((name, text)) => {
|
|
// The routine may wait for the user: it carries on after this command ends.
|
|
if let Err(e) = s.lisp_load(&name, &text, false) {
|
|
s.echo(e.to_string());
|
|
}
|
|
Ok(Step::Done)
|
|
}
|
|
Err(EngineError::BadParams { msg, .. }) => {
|
|
s.echo(msg);
|
|
Ok(Step::Done)
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
}
|