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