Skip to main content

tatara_lisp_script/stdlib/
cli.rs

1//! Argument parsing — small, script-focused.
2//!
3//! Typical script startup:
4//!
5//!   (define spec
6//!     (list (list :name "template" :short "t" :kind :string :required #t)
7//!           (list :name "verbose"  :short "v" :kind :flag)))
8//!
9//!   (define args (parse-args spec))
10//!   (define tpl  (alist-get args "template"))
11//!   (define verbose? (alist-get args "verbose"))
12//!   (define positional (alist-get args ""))   ; leftover positional args
13//!
14//! Each spec entry is a plist / alist with:
15//!   :name       — long flag name (required)
16//!   :short      — optional single-char alias
17//!   :kind       — :string | :flag | :int
18//!   :required   — bool; default #f
19//!   :default    — default value when not provided
20//!   :help       — one-line description for (print-usage spec)
21//!
22//! `parse-args` returns an alist mapping name → value; "" (empty string)
23//! collects leftover positional args as a list.
24//!
25//! `(print-usage spec progname)` prints a usage banner to stderr.
26
27use std::sync::Arc;
28
29use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
30
31use crate::script_ctx::ScriptCtx;
32use crate::stdlib::env::str_arg;
33
34pub fn install(interp: &mut Interpreter<ScriptCtx>) {
35    interp.register_fn(
36        "parse-args",
37        Arity::Exact(1),
38        |args: &[Value], ctx: &mut ScriptCtx, sp| {
39            let spec = parse_spec(&args[0], sp)?;
40            let argv = ctx.argv.clone();
41            let result =
42                do_parse(&spec, &argv).map_err(|e| EvalError::native_fn("parse-args", e, sp))?;
43            Ok(result)
44        },
45    );
46
47    interp.register_fn(
48        "print-usage",
49        Arity::Range(1, 2),
50        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
51            let spec = parse_spec(&args[0], sp)?;
52            let prog = args
53                .get(1)
54                .map(|v| str_arg(v, "print-usage", sp))
55                .transpose()?
56                .unwrap_or_else(|| Arc::from("tatara-script"));
57            eprintln!("usage: {prog} [flags]");
58            for opt in &spec {
59                let short = opt
60                    .short
61                    .as_ref()
62                    .map(|c| format!("-{c}, "))
63                    .unwrap_or_default();
64                let kind_hint = match opt.kind {
65                    Kind::Flag => "".to_string(),
66                    Kind::String => " <str>".to_string(),
67                    Kind::Int => " <int>".to_string(),
68                };
69                let req = if opt.required { " (required)" } else { "" };
70                let help = opt.help.as_deref().unwrap_or("");
71                eprintln!("  {}--{}{}{}   {}", short, opt.name, kind_hint, req, help);
72            }
73            Ok(Value::Nil)
74        },
75    );
76}
77
78enum Kind {
79    Flag,
80    String,
81    Int,
82}
83
84struct Spec {
85    name: String,
86    short: Option<String>,
87    kind: Kind,
88    required: bool,
89    default: Option<Value>,
90    help: Option<String>,
91}
92
93fn parse_spec(v: &Value, sp: tatara_lisp::Span) -> Result<Vec<Spec>, EvalError> {
94    let Value::List(items) = v else {
95        return Err(EvalError::native_fn(
96            "parse-args",
97            "spec must be a list",
98            sp,
99        ));
100    };
101    items
102        .iter()
103        .map(|entry| parse_spec_entry(entry, sp))
104        .collect()
105}
106
107fn parse_spec_entry(v: &Value, sp: tatara_lisp::Span) -> Result<Spec, EvalError> {
108    let Value::List(pairs) = v else {
109        return Err(EvalError::native_fn(
110            "parse-args",
111            "spec entry must be a plist like (:name \"template\" :kind :string)",
112            sp,
113        ));
114    };
115    let mut spec = Spec {
116        name: String::new(),
117        short: None,
118        kind: Kind::Flag,
119        required: false,
120        default: None,
121        help: None,
122    };
123    let mut i = 0;
124    while i + 1 < pairs.len() {
125        let Value::Keyword(k) = &pairs[i] else {
126            return Err(EvalError::native_fn(
127                "parse-args",
128                "spec entry keys must be keywords (:name etc)",
129                sp,
130            ));
131        };
132        let key: &str = k.as_ref();
133        let val = &pairs[i + 1];
134        match key {
135            "name" => match val {
136                Value::Str(s) => spec.name = s.as_ref().to_owned(),
137                _ => {
138                    return Err(EvalError::native_fn(
139                        "parse-args",
140                        ":name must be string",
141                        sp,
142                    ))
143                }
144            },
145            "short" => match val {
146                Value::Str(s) => spec.short = Some(s.as_ref().to_owned()),
147                _ => {
148                    return Err(EvalError::native_fn(
149                        "parse-args",
150                        ":short must be string",
151                        sp,
152                    ))
153                }
154            },
155            "kind" => match val {
156                Value::Keyword(k) => {
157                    spec.kind = match k.as_ref() {
158                        "flag" => Kind::Flag,
159                        "string" => Kind::String,
160                        "int" => Kind::Int,
161                        other => {
162                            return Err(EvalError::native_fn(
163                                "parse-args",
164                                format!("unknown :kind {other}"),
165                                sp,
166                            ))
167                        }
168                    }
169                }
170                _ => {
171                    return Err(EvalError::native_fn(
172                        "parse-args",
173                        ":kind must be keyword",
174                        sp,
175                    ))
176                }
177            },
178            "required" => {
179                spec.required = matches!(val, Value::Bool(true));
180            }
181            "default" => {
182                spec.default = Some(val.clone());
183            }
184            "help" => match val {
185                Value::Str(s) => spec.help = Some(s.as_ref().to_owned()),
186                _ => {}
187            },
188            other => {
189                return Err(EvalError::native_fn(
190                    "parse-args",
191                    format!("unknown spec key :{other}"),
192                    sp,
193                ))
194            }
195        }
196        i += 2;
197    }
198    if spec.name.is_empty() {
199        return Err(EvalError::native_fn("parse-args", ":name required", sp));
200    }
201    Ok(spec)
202}
203
204fn do_parse(spec: &[Spec], argv: &[String]) -> Result<Value, String> {
205    use std::collections::HashMap;
206    let mut matched: HashMap<String, Value> = HashMap::new();
207    let mut positional: Vec<Value> = Vec::new();
208
209    // Build lookup tables
210    let by_long: HashMap<&str, &Spec> = spec.iter().map(|s| (s.name.as_str(), s)).collect();
211    let by_short: HashMap<&str, &Spec> = spec
212        .iter()
213        .filter_map(|s| s.short.as_deref().map(|k| (k, s)))
214        .collect();
215
216    let mut it = argv.iter();
217    while let Some(arg) = it.next() {
218        if let Some(name) = arg.strip_prefix("--") {
219            let entry = by_long
220                .get(name)
221                .ok_or_else(|| format!("unknown flag --{name}"))?;
222            consume_flag(entry, &mut it, &mut matched)?;
223        } else if let Some(name) = arg.strip_prefix('-') {
224            if name.is_empty() {
225                positional.push(Value::Str(Arc::from(arg.as_str())));
226                continue;
227            }
228            let entry = by_short
229                .get(name)
230                .ok_or_else(|| format!("unknown flag -{name}"))?;
231            consume_flag(entry, &mut it, &mut matched)?;
232        } else {
233            positional.push(Value::Str(Arc::from(arg.as_str())));
234        }
235    }
236
237    // Apply defaults + verify required
238    for entry in spec {
239        if matched.contains_key(&entry.name) {
240            continue;
241        }
242        if let Some(d) = &entry.default {
243            matched.insert(entry.name.clone(), d.clone());
244        } else if entry.required {
245            return Err(format!("missing required flag --{}", entry.name));
246        } else if matches!(entry.kind, Kind::Flag) {
247            matched.insert(entry.name.clone(), Value::Bool(false));
248        }
249    }
250
251    // Format as alist — 2-lists with string keys
252    let mut out: Vec<Value> = matched
253        .into_iter()
254        .map(|(k, v)| Value::list(vec![Value::Str(Arc::from(k.as_str())), v]))
255        .collect();
256    // Always include the "" key for positional args (may be empty list)
257    out.push(Value::list(vec![
258        Value::Str(Arc::from("")),
259        Value::list(positional),
260    ]));
261    Ok(Value::list(out))
262}
263
264fn consume_flag<'a>(
265    entry: &Spec,
266    it: &mut std::slice::Iter<'a, String>,
267    matched: &mut std::collections::HashMap<String, Value>,
268) -> Result<(), String> {
269    let v = match entry.kind {
270        Kind::Flag => Value::Bool(true),
271        Kind::String => {
272            let raw = it
273                .next()
274                .ok_or_else(|| format!("--{} expects a value", entry.name))?;
275            Value::Str(Arc::from(raw.as_str()))
276        }
277        Kind::Int => {
278            let raw = it
279                .next()
280                .ok_or_else(|| format!("--{} expects an integer", entry.name))?;
281            let n: i64 = raw
282                .parse()
283                .map_err(|e| format!("--{} bad int: {e}", entry.name))?;
284            Value::Int(n)
285        }
286    };
287    matched.insert(entry.name.clone(), v);
288    Ok(())
289}