Skip to main content

sva_cli/args/
mod.rs

1// Concern: parses argv into the subcommands this CLI answers and their options | Non-concern: what a target names (sva-core) | IO: (argv) -> Command or CliError
2
3use std::path::PathBuf;
4
5use sva_core::{Asked, CliError};
6
7mod reading;
8
9pub(crate) use reading::check_frame;
10use reading::{analyze_args, render_args};
11
12pub const USAGE: &str = "usage: sva-cli render '<expression>' --representation <r>[=<path>][,...] \
13     [--until '<condition>'] [--bits <n>] [--rate <hz>] [--flop-budget <n>] [--cache <path|none>] \
14     [--confirm]\n       \
15     sva-cli analyze <file.wav> --representation <r>[=<path>][,...] [--confirm]\n       \
16     sva-cli lint ['<expression>'] [--format <json|text>]\n       \
17     sva-cli trace <node|expression>\n       \
18     sva-cli builtins\n       \
19     sva-cli outline <expression>\n       \
20     sva-cli new <name> [--idempotency-key <key>]\n\
21     a render's target is one expression; `@path` reads a node in the current directory, \
22     `@/abs/path` one anywhere, and its own ref may read an interval: `@piano([0, 2b], f0=C4)`\n\
23     representations: lines atoms spectrum(peaks, frame) envelope(frame) derivative samples \
24     ledger(depth, brief, skim) pitch(peaks, frame) formants(peaks, frame) stereo(frame) bands \
25     crest loudness onsets alias(oversample) bindings(node) arguments flops\n\
26     destinations: a `.wav` path takes `samples` as audio; any other path takes JSON; none \
27     puts the reading under `data.representations`\n\
28     verb aliases: `validate`=lint, `list`=builtins, `create`=new, `show`=trace. `render` \
29     and `analyze` take a reading, which the standard's verb list has no word for, so they \
30     keep their own names.";
31
32/// Only the readings that are a pure function of a buffer; the rest need the graph behind it.
33pub const ANALYZE_REPRESENTATIONS: [&str; 11] = [
34    "samples",
35    "spectrum",
36    "envelope",
37    "derivative",
38    "pitch",
39    "formants",
40    "bands",
41    "loudness",
42    "crest",
43    "stereo",
44    "onsets",
45];
46
47#[derive(Debug, PartialEq)]
48pub struct RenderArgs {
49    /// One expression; its refs name nodes from the current directory or an absolute path.
50    pub target: String,
51    pub until: Option<String>,
52    pub rate: Option<u32>,
53    /// The precision every sample is written to; the profile's own where `None`.
54    pub bits: Option<i32>,
55    /// The operation count the caller acknowledges paying; the profile's own where `None`.
56    pub flop_budget: Option<u128>,
57    pub asked: Vec<Asked>,
58    /// The caller said a destination that already holds a file may be replaced.
59    pub confirm: bool,
60    pub cache: CacheAt,
61}
62
63/// Where a render's persistent store lives.
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub enum CacheAt {
66    #[default]
67    Platform,
68    Path(PathBuf),
69    Off,
70}
71
72#[derive(Debug, PartialEq)]
73pub struct AnalyzeArgs {
74    pub path: PathBuf,
75    pub asked: Vec<Asked>,
76    /// The caller said a destination that already holds a file may be replaced.
77    pub confirm: bool,
78}
79
80#[derive(Debug, PartialEq)]
81pub enum Command {
82    Version,
83    Help,
84    Render(Box<RenderArgs>),
85    Analyze(Box<AnalyzeArgs>),
86    /// One expression, as render's target; `None` lints every file under its own rules.
87    Lint {
88        target: Option<String>,
89        format: Format,
90    },
91    Trace {
92        target: String,
93    },
94    Builtins,
95    /// An expression's own parse tree; it reads no composition.
96    Outline {
97        text: String,
98    },
99    New {
100        name: String,
101        /// Present where the caller says this is a retry, per the `cli` standard's own
102        /// requirement that a create verb be made idempotent.
103        idempotency_key: Option<String>,
104    },
105}
106
107/// How the diagnostics an evaluator answers are rendered. One set of objects, two renderings.
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub enum Format {
110    #[default]
111    Json,
112    Text,
113}
114
115const STANDARD_VERBS: [(&str, &str); 4] = [
116    ("validate", "lint"),
117    ("list", "builtins"),
118    ("create", "new"),
119    ("show", "trace"),
120];
121
122fn canonical(subcommand: &str) -> &str {
123    STANDARD_VERBS
124        .iter()
125        .find(|(alias, _)| *alias == subcommand)
126        .map_or(subcommand, |(_, name)| *name)
127}
128
129/// A free function rather than a closure per parser: a closure would hold its loop's
130/// iterator borrowed.
131pub(crate) fn value<'a>(
132    it: &mut impl Iterator<Item = &'a String>,
133    name: &str,
134) -> Result<String, CliError> {
135    it.next()
136        .cloned()
137        .ok_or_else(|| CliError::Usage(format!("{name} needs a value\n{USAGE}")))
138}
139
140/// `--version`/`-V` and `--help` are global flags, recognized anywhere in argv (cli standard).
141pub fn parse_args(argv: &[String]) -> Result<Command, CliError> {
142    if argv.iter().any(|a| a == "--version" || a == "-V") {
143        return Ok(Command::Version);
144    }
145    if argv.iter().any(|a| a == "--help") {
146        return Ok(Command::Help);
147    }
148
149    let mut it = argv.iter();
150    let subcommand = canonical(
151        it.next()
152            .ok_or_else(|| CliError::Usage(format!("no subcommand given\n{USAGE}")))?,
153    );
154    let rest = it.as_slice();
155    match subcommand {
156        "render" => render_args(rest),
157        "analyze" => analyze_args(rest),
158        "lint" => lint_args(rest),
159        "trace" => trace_args(rest),
160        "builtins" => builtins_args(rest),
161        "outline" => outline_args(rest),
162        "new" => new_args(rest),
163        other => Err(CliError::Usage(format!(
164            "unknown subcommand `{other}`\n{USAGE}"
165        ))),
166    }
167}
168
169fn builtins_args(rest: &[String]) -> Result<Command, CliError> {
170    match rest.first() {
171        None => Ok(Command::Builtins),
172        Some(extra) => Err(CliError::Usage(format!(
173            "builtins takes no arguments, not `{extra}`\n{USAGE}"
174        ))),
175    }
176}
177
178fn outline_args(rest: &[String]) -> Result<Command, CliError> {
179    match rest {
180        [text] => Ok(Command::Outline { text: text.clone() }),
181        _ => Err(CliError::Usage(format!(
182            "outline takes one <expression>, quoted as one argument\n{USAGE}"
183        ))),
184    }
185}
186
187fn new_args(rest: &[String]) -> Result<Command, CliError> {
188    match rest {
189        [name] if !name.starts_with("--") => Ok(Command::New {
190            name: name.clone(),
191            idempotency_key: None,
192        }),
193        [name, flag, key] if !name.starts_with("--") && flag == "--idempotency-key" => {
194            Ok(Command::New {
195                name: name.clone(),
196                idempotency_key: Some(key.clone()),
197            })
198        }
199        [] => Err(CliError::Usage(format!("missing <name>\n{USAGE}"))),
200        _ => Err(CliError::Usage(format!(
201            "new takes one <name> and an optional `--idempotency-key <key>`\n{USAGE}"
202        ))),
203    }
204}
205
206fn lint_args(rest: &[String]) -> Result<Command, CliError> {
207    let mut it = rest.iter().peekable();
208    let target = match it.peek() {
209        Some(a) if !a.starts_with("--") => it.next().cloned(),
210        _ => None,
211    };
212    let mut format = Format::default();
213    while let Some(flag) = it.next() {
214        match flag.as_str() {
215            "--format" => {
216                format = match it.next().map(String::as_str) {
217                    Some("json") => Format::Json,
218                    Some("text") => Format::Text,
219                    other => {
220                        return Err(CliError::Usage(format!(
221                            "`--format` takes json or text, not `{}`\n{USAGE}",
222                            other.unwrap_or("nothing")
223                        )));
224                    }
225                };
226            }
227            extra => {
228                return Err(CliError::Usage(format!(
229                    "lint takes only ['<expression>'] and `--format <json|text>`, not \
230                     `{extra}`\n{USAGE}"
231                )));
232            }
233        }
234    }
235    Ok(Command::Lint { target, format })
236}
237
238/// One positional and nothing else: a trace answers structure, which no option narrows.
239fn trace_args(rest: &[String]) -> Result<Command, CliError> {
240    match rest {
241        [target] if !target.starts_with("--") => Ok(Command::Trace {
242            target: target.clone(),
243        }),
244        [] => Err(CliError::Usage(format!(
245            "missing <node|expression>\n{USAGE}"
246        ))),
247        _ => Err(CliError::Usage(format!(
248            "trace takes one <node|expression> and nothing else\n{USAGE}"
249        ))),
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use std::path::Path;
257
258    fn argv(parts: &[&str]) -> Vec<String> {
259        parts.iter().map(|s| (*s).to_string()).collect()
260    }
261
262    fn rendered(parts: &[&str]) -> RenderArgs {
263        match parse_args(&argv(parts)) {
264            Ok(Command::Render(args)) => *args,
265            other => panic!("expected a render, got {other:?}"),
266        }
267    }
268
269    fn refused(parts: &[&str]) -> String {
270        match parse_args(&argv(parts)) {
271            Err(CliError::Usage(message)) => message,
272            other => panic!("expected a usage refusal, got {other:?}"),
273        }
274    }
275
276    #[test]
277    fn version_and_help_are_recognized_anywhere_in_argv() {
278        assert_eq!(parse_args(&argv(&["--version"])).unwrap(), Command::Version);
279        assert_eq!(parse_args(&argv(&["-V"])).unwrap(), Command::Version);
280        assert_eq!(
281            parse_args(&argv(&["lint", "--help"])).unwrap(),
282            Command::Help
283        );
284    }
285
286    #[test]
287    fn each_standard_verb_alias_reaches_the_subcommand_it_names() {
288        assert_eq!(
289            parse_args(&argv(&["validate"])).unwrap(),
290            Command::Lint {
291                target: None,
292                format: Format::Json,
293            }
294        );
295        assert_eq!(parse_args(&argv(&["list"])).unwrap(), Command::Builtins);
296        assert_eq!(
297            parse_args(&argv(&["show", "kick"])).unwrap(),
298            Command::Trace {
299                target: "kick".to_string(),
300            }
301        );
302        assert_eq!(
303            parse_args(&argv(&["create", "song1"])).unwrap(),
304            Command::New {
305                name: "song1".to_string(),
306                idempotency_key: None,
307            }
308        );
309    }
310
311    /// A render names its target, its readings as calls in one comma list or several, each
312    /// reading's options as its own arguments, and its flags; nothing else.
313    #[test]
314    fn a_render_takes_a_target_readings_with_their_arguments_and_flags() {
315        let args = rendered(&[
316            "render",
317            "@piano([0, 2b], f0=C4)",
318            "--representation",
319            "samples=/tmp/out.wav, spectrum(peaks=8, frame=50ms)",
320            "--representation",
321            "ledger(depth=2, brief=1)=/tmp/ledger.json,bindings(node=@voice)",
322            "--rate",
323            "48000",
324            "--bits",
325            "16",
326            "--flop-budget",
327            "1000",
328            "--until",
329            "t > 1s",
330        ]);
331        assert_eq!(args.target, "@piano([0, 2b], f0=C4)");
332        assert_eq!(args.until.as_deref(), Some("t > 1s"));
333        assert_eq!(args.rate, Some(48_000));
334        assert_eq!(args.bits, Some(16));
335        assert_eq!(args.flop_budget, Some(1000));
336        let names: Vec<&str> = args.asked.iter().map(|a| a.name.as_str()).collect();
337        assert_eq!(names, ["samples", "spectrum", "ledger", "bindings"]);
338        assert_eq!(
339            args.asked[0].dest.as_deref(),
340            Some(Path::new("/tmp/out.wav"))
341        );
342        assert_eq!(
343            args.asked[1].representation,
344            sva_engine::Representation::Spectrum {
345                max_peaks: 8,
346                frame_secs: Some(0.05)
347            }
348        );
349        assert_eq!(
350            args.asked[2].representation,
351            sva_engine::Representation::Ledger { depth: 2 }
352        );
353        assert!(args.asked[2].brief);
354        assert_eq!(
355            args.asked[2].dest.as_deref(),
356            Some(Path::new("/tmp/ledger.json"))
357        );
358        assert_eq!(args.asked[3].node.as_deref(), Some("voice"));
359    }
360
361    #[test]
362    fn a_target_may_open_with_a_minus() {
363        let args = rendered(&["render", "-1*sin(2*pi*220*t)", "--representation", "lines"]);
364        assert_eq!(args.target, "-1*sin(2*pi*220*t)");
365    }
366
367    #[test]
368    fn a_render_with_no_target_or_no_reading_refuses() {
369        refused(&["render"]);
370        refused(&["render", "--representation", "lines"]);
371        refused(&["render", "@a"]);
372    }
373
374    /// Every flag the release before this one read is gone, with no alias left behind.
375    #[test]
376    fn a_retired_flag_refuses_by_name() {
377        for gone in [
378            "-c",
379            "--in",
380            "--from",
381            "--to",
382            "--max",
383            "--sample-rate",
384            "--as",
385            "--node",
386            "--frame",
387            "--depth",
388            "--peaks",
389            "--oversample",
390            "--brief",
391            "--skim",
392            "--pcm16",
393        ] {
394            let message = refused(&["render", "@a", "--representation", "lines", gone, "x"]);
395            assert!(message.contains(gone), "{gone}: {message}");
396        }
397    }
398
399    #[test]
400    fn an_argument_its_reading_does_not_take_or_a_malformed_one_refuses() {
401        for reading in [
402            "lines(depth=2)",
403            "ledger(colour=red)",
404            "ledger(depth)",
405            "ledger(depth=many)",
406            "ledger(brief=yes)",
407            "samples(bits=16)",
408            "spectrum(peaks=8",
409            "bindings(node=voice)",
410        ] {
411            refused(&["render", "@a", "--representation", reading]);
412        }
413    }
414
415    #[test]
416    fn a_representation_that_left_the_language_names_what_replaced_it() {
417        for (gone, write) in sva_core::RETIRED {
418            let message = refused(&["render", "@a", "--representation", gone]);
419            assert!(message.contains(write), "{message}");
420        }
421    }
422
423    #[test]
424    fn a_wav_destination_takes_samples_and_nothing_else() {
425        refused(&["render", "@a", "--representation", "ledger=/tmp/out.wav"]);
426    }
427
428    #[test]
429    fn bindings_needs_a_node() {
430        refused(&["render", "@a", "--representation", "bindings"]);
431    }
432
433    #[test]
434    fn lint_takes_an_optional_target_and_nothing_else() {
435        assert_eq!(
436            parse_args(&argv(&["lint", "@drums/kick"])).unwrap(),
437            Command::Lint {
438                target: Some("@drums/kick".to_string()),
439                format: Format::Json,
440            }
441        );
442        refused(&["lint", "a", "b"]);
443        refused(&["lint", "--in", "x"]);
444    }
445
446    #[test]
447    fn new_takes_a_name_and_an_optional_idempotency_key() {
448        assert_eq!(
449            parse_args(&argv(&["new", "song1", "--idempotency-key", "k"])).unwrap(),
450            Command::New {
451                name: "song1".to_string(),
452                idempotency_key: Some("k".to_string()),
453            }
454        );
455        refused(&["new"]);
456    }
457
458    #[test]
459    fn builtins_takes_no_arguments() {
460        assert_eq!(parse_args(&argv(&["builtins"])).unwrap(), Command::Builtins);
461        refused(&["builtins", "x"]);
462    }
463
464    #[test]
465    fn a_subcommand_this_cli_does_not_answer_refuses_by_name() {
466        for gone in ["bench", "nonlinear-solve"] {
467            assert!(refused(&[gone]).contains(gone));
468        }
469    }
470}