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, WindowEdge};
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 [<node|expression>] [query options]\n       \
13     sva-cli analyze <file.wav> [--as <representation>[=<destination>]]... [--from <time>] \
14     [--to <time>] [--frame <secs>] [--peaks <n>]\n       \
15     sva-cli lint [<node|expression>] [--in <dir>] [--format <json|text>]\n       \
16     sva-cli trace <node|expression> [--in <dir>]\n       \
17     sva-cli builtins\n       \
18     sva-cli outline <expression>\n       \
19     sva-cli new <name> [--idempotency-key <key>]\n\
20     query options: [--in <dir>] [--node <path>] [--from <time>] [--to <time>] \
21     [--as <representation>[=<destination>]]... [--frame <secs>] [--depth <n>] [--peaks <n>] \
22     [--sample-rate <hz>] [--oversample <n>] [--flop-budget <n>] \
23     [--brief] [--skim] [--pcm16] [--confirm]\n\
24     representations: lines atoms spectrum envelope derivative samples ledger pitch formants \
25     stereo bands crest loudness alias bindings arguments flops\n\
26     analyses (`analyze` only): onsets trajectory masking gain-reduction\n\
27     --against <file.wav> is the second signal `--as masking` is read against\n\
28     --sample-rate <hz> is the observation rate, legal with any --as\n\
29     --node <path> names the instance a reading is taken of; required with `--as bindings`\n\
30     --flop-budget <n> is the operation count the caller means to pay; the profile's own \
31     budget refuses past it, and `--as flops` prints the tree that count came from\n\
32     --brief condenses `ledger` to the nodes that clipped\n\
33     --skim condenses `ledger`'s fields to node/channel/rms/peak/clipped\n\
34     --pcm16 quantizes a `.wav` destination to 16-bit PCM instead of 32-bit float\n\
35     destinations: a `.wav` path takes `samples` as audio; any other path takes JSON; none \
36     prints JSON to stdout\n\
37     --in <dir> names the composition `render`, `lint` and `trace` read; without it they read \
38     the current directory. `new` and `analyze` take none. Each <node|expression> names a node \
39     path or an expression in the same expression grammar a node file's body holds. A file \
40     holds structure besides that body -- `name = <expr>` defaults, a TSV grid, a bare meter \
41     such as `4/4` -- and an argument is a body alone, so `4/4` there is a division.\n\
42     verb aliases: `validate`=lint, `list`=builtins, `create`=new, `show`=trace. `render` \
43     and `analyze` take a reading, which the standard's verb list has no word for, so they \
44     keep their own names.";
45
46/// Only the readings that are a pure function of a buffer; the rest need the graph behind it.
47pub const ANALYZE_REPRESENTATIONS: [&str; 10] = [
48    "samples",
49    "spectrum",
50    "envelope",
51    "derivative",
52    "pitch",
53    "formants",
54    "bands",
55    "loudness",
56    "crest",
57    "stereo",
58];
59
60#[derive(Debug, PartialEq)]
61pub struct RenderArgs {
62    /// A node path or an expression; which one it is, only the graph can say.
63    pub target: Option<String>,
64    /// The instance a reading is taken of, where it is not the target itself.
65    pub node: Option<String>,
66    /// The composition directory `--in` named, where the caller named one.
67    pub dir: Option<String>,
68    pub sample_rate: Option<u32>,
69    pub from: Option<WindowEdge>,
70    pub to: Option<WindowEdge>,
71    /// `--to silent`: the end is where silence is proven, not a time.
72    pub silent: Option<sva_core::Silent>,
73    pub asked: Vec<Asked>,
74    pub brief: bool,
75    pub skim: bool,
76    pub pcm16: bool,
77    /// The caller said a destination that already holds a file may be replaced.
78    pub confirm: bool,
79    pub flop_budget: Option<u128>,
80}
81
82#[derive(Debug, PartialEq)]
83pub struct AnalyzeArgs {
84    pub path: PathBuf,
85    pub from: Option<WindowEdge>,
86    pub to: Option<WindowEdge>,
87    pub asked: Vec<Asked>,
88    /// The readings `sva-analysis` answers, which no `Representation` names.
89    pub analyses: Vec<(String, Option<PathBuf>)>,
90    pub against: Option<PathBuf>,
91    /// The caller said a destination that already holds a file may be replaced.
92    pub confirm: bool,
93}
94
95#[derive(Debug, PartialEq)]
96pub enum Command {
97    Version,
98    Help,
99    Render(Box<RenderArgs>),
100    Analyze(Box<AnalyzeArgs>),
101    /// A node path or an expression, same grammar as render's target; `None` lints the whole
102    /// directory.
103    Lint {
104        target: Option<String>,
105        dir: Option<String>,
106        format: Format,
107    },
108    Trace {
109        target: String,
110        dir: Option<String>,
111    },
112    Builtins,
113    /// An expression's own parse tree; it reads no composition.
114    Outline {
115        text: String,
116    },
117    New {
118        name: String,
119        /// Present where the caller says this is a retry, per the `cli` standard's own
120        /// requirement that a create verb be made idempotent.
121        idempotency_key: Option<String>,
122    },
123}
124
125/// How the diagnostics an evaluator answers are rendered. One set of objects, two renderings.
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub enum Format {
128    #[default]
129    Json,
130    Text,
131}
132
133const STANDARD_VERBS: [(&str, &str); 4] = [
134    ("validate", "lint"),
135    ("list", "builtins"),
136    ("create", "new"),
137    ("show", "trace"),
138];
139
140fn canonical(subcommand: &str) -> &str {
141    STANDARD_VERBS
142        .iter()
143        .find(|(alias, _)| *alias == subcommand)
144        .map_or(subcommand, |(_, name)| *name)
145}
146
147/// A free function rather than a closure per parser: a closure would hold its loop's
148/// iterator borrowed.
149pub(crate) fn value<'a>(
150    it: &mut impl Iterator<Item = &'a String>,
151    name: &str,
152) -> Result<String, CliError> {
153    it.next()
154        .cloned()
155        .ok_or_else(|| CliError::Usage(format!("{name} needs a value\n{USAGE}")))
156}
157
158/// `--version`/`-V` and `--help` are global flags, recognized anywhere in argv (cli standard).
159pub fn parse_args(argv: &[String]) -> Result<Command, CliError> {
160    if argv.iter().any(|a| a == "--version" || a == "-V") {
161        return Ok(Command::Version);
162    }
163    if argv.iter().any(|a| a == "--help") {
164        return Ok(Command::Help);
165    }
166
167    let mut it = argv.iter();
168    let subcommand = canonical(
169        it.next()
170            .ok_or_else(|| CliError::Usage(format!("no subcommand given\n{USAGE}")))?,
171    );
172    let rest = it.as_slice();
173    match subcommand {
174        "render" => render_args(rest),
175        "analyze" => analyze_args(rest),
176        "lint" => lint_args(rest),
177        "trace" => trace_args(rest),
178        "builtins" => builtins_args(rest),
179        "outline" => outline_args(rest),
180        "new" => new_args(rest),
181        other => Err(CliError::Usage(format!(
182            "unknown subcommand `{other}`\n{USAGE}"
183        ))),
184    }
185}
186
187fn builtins_args(rest: &[String]) -> Result<Command, CliError> {
188    match rest.first() {
189        None => Ok(Command::Builtins),
190        Some(extra) => Err(CliError::Usage(format!(
191            "builtins takes no arguments, not `{extra}`\n{USAGE}"
192        ))),
193    }
194}
195
196fn outline_args(rest: &[String]) -> Result<Command, CliError> {
197    match rest {
198        [text] => Ok(Command::Outline { text: text.clone() }),
199        _ => Err(CliError::Usage(format!(
200            "outline takes one <expression>, quoted as one argument\n{USAGE}"
201        ))),
202    }
203}
204
205fn new_args(rest: &[String]) -> Result<Command, CliError> {
206    match rest {
207        [name] if !name.starts_with("--") => Ok(Command::New {
208            name: name.clone(),
209            idempotency_key: None,
210        }),
211        [name, flag, key] if !name.starts_with("--") && flag == "--idempotency-key" => {
212            Ok(Command::New {
213                name: name.clone(),
214                idempotency_key: Some(key.clone()),
215            })
216        }
217        [] => Err(CliError::Usage(format!("missing <name>\n{USAGE}"))),
218        _ => Err(CliError::Usage(format!(
219            "new takes one <name> and an optional `--idempotency-key <key>`\n{USAGE}"
220        ))),
221    }
222}
223
224fn lint_args(rest: &[String]) -> Result<Command, CliError> {
225    let (dir, rest) = composition_flag(rest)?;
226    let mut it = rest.iter().peekable();
227    let target = match it.peek() {
228        Some(a) if !a.starts_with("--") => it.next().cloned(),
229        _ => None,
230    };
231    let mut format = Format::default();
232    while let Some(flag) = it.next() {
233        match flag.as_str() {
234            "--format" => {
235                format = match it.next().map(String::as_str) {
236                    Some("json") => Format::Json,
237                    Some("text") => Format::Text,
238                    other => {
239                        return Err(CliError::Usage(format!(
240                            "`--format` takes json or text, not `{}`\n{USAGE}",
241                            other.unwrap_or("nothing")
242                        )));
243                    }
244                };
245            }
246            extra => {
247                return Err(CliError::Usage(format!(
248                    "lint takes only [<node|expression>], `--in <dir>` and `--format \
249                     <json|text>`, not `{extra}`\n{USAGE}"
250                )));
251            }
252        }
253    }
254    Ok(Command::Lint {
255        target,
256        dir,
257        format,
258    })
259}
260
261/// `--in <dir>` names the composition, wherever in the tail it is written; without it, the
262/// process's own directory is the one a subcommand reads.
263pub(super) fn composition_flag(rest: &[String]) -> Result<(Option<String>, Vec<String>), CliError> {
264    let mut dir = None;
265    let mut kept = Vec::with_capacity(rest.len());
266    let mut it = rest.iter();
267    while let Some(arg) = it.next() {
268        match arg.as_str() {
269            "--in" => {
270                dir = Some(
271                    it.next()
272                        .cloned()
273                        .ok_or_else(|| CliError::Usage(format!("`--in` needs a path\n{USAGE}")))?,
274                );
275            }
276            _ => kept.push(arg.clone()),
277        }
278    }
279    Ok((dir, kept))
280}
281
282/// One positional and nothing else: a trace answers structure, which no option narrows —
283/// the rate is an observation parameter and a trace takes no observation.
284fn trace_args(rest: &[String]) -> Result<Command, CliError> {
285    let (dir, rest) = composition_flag(rest)?;
286    match rest.as_slice() {
287        [target] if !target.starts_with("--") => Ok(Command::Trace {
288            target: target.clone(),
289            dir,
290        }),
291        [] => Err(CliError::Usage(format!(
292            "missing <node|expression>\n{USAGE}"
293        ))),
294        _ => Err(CliError::Usage(format!(
295            "trace takes one <node|expression>, `--in <dir>` and nothing else\n{USAGE}"
296        ))),
297    }
298}
299
300pub(crate) fn number(raw: &str, flag: &str) -> Result<f64, CliError> {
301    match raw.parse::<f64>() {
302        Ok(v) if v.is_finite() => Ok(v),
303        _ => Err(CliError::Usage(format!(
304            "{flag} needs a finite number, got `{raw}`\n{USAGE}"
305        ))),
306    }
307}
308
309pub(crate) fn positive(raw: &str, flag: &str) -> Result<f64, CliError> {
310    match number(raw, flag)? {
311        v if v > 0.0 => Ok(v),
312        _ => Err(CliError::Usage(format!(
313            "{flag} needs a positive number, got `{raw}`\n{USAGE}"
314        ))),
315    }
316}
317
318pub(crate) fn operations(raw: &str, flag: &str) -> Result<u128, CliError> {
319    raw.parse::<u128>().map_err(|_| not_a_count(raw, flag))
320}
321
322pub(crate) fn count(raw: &str, flag: &str) -> Result<usize, CliError> {
323    usize::try_from(operations(raw, flag)?).map_err(|_| not_a_count(raw, flag))
324}
325
326fn not_a_count(raw: &str, flag: &str) -> CliError {
327    CliError::Usage(format!("{flag} needs a whole count, got `{raw}`\n{USAGE}"))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use std::path::Path;
334
335    fn argv(parts: &[&str]) -> Vec<String> {
336        parts.iter().map(|s| (*s).to_string()).collect()
337    }
338
339    fn rendered(parts: &[&str]) -> RenderArgs {
340        match parse_args(&argv(parts)) {
341            Ok(Command::Render(args)) => *args,
342            other => panic!("expected a render, got {other:?}"),
343        }
344    }
345
346    #[test]
347    fn version_and_help_are_recognized_anywhere_in_argv() {
348        assert_eq!(parse_args(&argv(&["--version"])).unwrap(), Command::Version);
349        assert_eq!(parse_args(&argv(&["-V"])).unwrap(), Command::Version);
350        assert_eq!(
351            parse_args(&argv(&["lint", "--help"])).unwrap(),
352            Command::Help
353        );
354    }
355
356    #[test]
357    fn each_standard_verb_alias_reaches_the_subcommand_it_names() {
358        assert_eq!(
359            parse_args(&argv(&["validate"])).unwrap(),
360            Command::Lint {
361                target: None,
362                dir: None,
363                format: Format::Json,
364            }
365        );
366        assert_eq!(parse_args(&argv(&["list"])).unwrap(), Command::Builtins);
367        assert_eq!(
368            parse_args(&argv(&["show", "kick"])).unwrap(),
369            Command::Trace {
370                target: "kick".to_string(),
371                dir: None,
372            }
373        );
374        assert_eq!(
375            parse_args(&argv(&["create", "song1"])).unwrap(),
376            Command::New {
377                name: "song1".to_string(),
378                idempotency_key: None,
379            }
380        );
381    }
382
383    /// The rate is an observation parameter, so it is legal beside any `--as` and nowhere
384    /// near a structural subcommand.
385    #[test]
386    fn the_sample_rate_is_an_observation_flag_render_takes_with_any_reading() {
387        for name in ["lines", "atoms", "samples", "ledger"] {
388            let args = rendered(&["render", "--as", name, "--sample-rate", "48000"]);
389            assert_eq!(args.sample_rate, Some(48_000));
390            assert_eq!(args.asked[0].name, name);
391        }
392        assert!(matches!(
393            parse_args(&argv(&["trace", "kick", "--sample-rate", "48000"])),
394            Err(CliError::Usage(_))
395        ));
396    }
397
398    #[test]
399    fn a_representation_that_left_the_language_names_what_replaced_it() {
400        for (gone, write) in sva_core::RETIRED {
401            let Err(CliError::Usage(message)) = parse_args(&argv(&["render", "--as", gone])) else {
402                panic!("`{gone}` must refuse")
403            };
404            assert!(message.contains(write), "{message}");
405        }
406    }
407
408    #[test]
409    fn a_wav_destination_takes_samples_and_nothing_else() {
410        let args = rendered(&["render", "--as", "samples=/tmp/out.wav", "--pcm16"]);
411        assert!(args.pcm16);
412        assert_eq!(
413            args.asked[0].dest.as_deref(),
414            Some(Path::new("/tmp/out.wav"))
415        );
416        assert!(matches!(
417            parse_args(&argv(&["render", "--as", "ledger=/tmp/out.wav"])),
418            Err(CliError::Usage(_))
419        ));
420    }
421
422    #[test]
423    fn render_needs_a_reading_and_bindings_needs_a_node() {
424        assert!(matches!(
425            parse_args(&argv(&["render"])),
426            Err(CliError::Usage(_))
427        ));
428        assert!(matches!(
429            parse_args(&argv(&["render", "--as", "bindings"])),
430            Err(CliError::Usage(_))
431        ));
432        let args = rendered(&["render", "--as", "bindings", "--node", "kick"]);
433        assert_eq!(args.node.as_deref(), Some("kick"));
434    }
435
436    #[test]
437    fn render_and_analyze_parse_the_same_window_flags() {
438        let args = rendered(&["render", "--as", "samples", "--from", "0.5", "--to", "2"]);
439        let window = (Some(WindowEdge::Secs(0.5)), Some(WindowEdge::Secs(2.0)));
440        assert_eq!((args.from, args.to), window);
441        let Ok(Command::Analyze(args)) = parse_args(&argv(&[
442            "analyze",
443            "/tmp/a.wav",
444            "--as",
445            "spectrum",
446            "--from",
447            "0.5",
448            "--to",
449            "2",
450        ])) else {
451            panic!("an analyze")
452        };
453        assert_eq!((args.from, args.to), window);
454    }
455
456    #[test]
457    fn to_silent_takes_bits_and_a_latest_time_and_nothing_else_does() {
458        let deep = rendered(&["render", "--as", "samples", "--to", "silent"]);
459        let bits = sva_core::DEFAULT_SILENT_BITS;
460        let max_secs = sva_core::DEFAULT_SILENT_MAX_SECS;
461        assert_eq!(deep.silent, Some(sva_core::Silent { bits, max_secs }));
462        assert_eq!(deep.to, None);
463        let timed = rendered(&["render", "--as", "samples", "--to", "silent", "--to", "3"]);
464        assert_eq!(
465            (timed.to, timed.silent),
466            (Some(WindowEdge::Secs(3.0)), None)
467        );
468        let later = rendered(&["render", "--as", "samples", "--to", "3", "--to", "silent"]);
469        assert_eq!((later.to, later.silent.map(|s| s.bits)), (None, Some(bits)));
470        let shallow = rendered(&[
471            "render",
472            "--as",
473            "samples",
474            "--to",
475            "silent:16",
476            "--max",
477            "8",
478        ]);
479        let max_secs = 8.0;
480        assert_eq!(
481            shallow.silent,
482            Some(sva_core::Silent { bits: 16, max_secs })
483        );
484        for refused in [
485            &["render", "--as", "samples", "--max", "8"][..],
486            &["render", "--as", "samples", "--to", "silent:0"],
487            &["render", "--as", "samples", "--to", "silent:54"],
488            &["render", "--as", "samples", "--to", "silently"],
489            &[
490                "analyze",
491                "/tmp/a.wav",
492                "--as",
493                "spectrum",
494                "--to",
495                "silent",
496            ],
497        ] {
498            assert!(
499                matches!(parse_args(&argv(refused)), Err(CliError::Usage(_))),
500                "{refused:?}"
501            );
502        }
503    }
504
505    #[test]
506    fn lint_takes_an_optional_target_and_nothing_else() {
507        assert_eq!(
508            parse_args(&argv(&["lint"])).unwrap(),
509            Command::Lint {
510                target: None,
511                dir: None,
512                format: Format::Json
513            }
514        );
515        assert_eq!(
516            parse_args(&argv(&["lint", "drums/kick"])).unwrap(),
517            Command::Lint {
518                target: Some("drums/kick".to_string()),
519                dir: None,
520                format: Format::Json
521            }
522        );
523        assert!(matches!(
524            parse_args(&argv(&["lint", "a", "b"])),
525            Err(CliError::Usage(_))
526        ));
527    }
528
529    #[test]
530    fn new_takes_a_name_and_an_optional_idempotency_key() {
531        assert_eq!(
532            parse_args(&argv(&["new", "song1", "--idempotency-key", "k"])).unwrap(),
533            Command::New {
534                name: "song1".to_string(),
535                idempotency_key: Some("k".to_string()),
536            }
537        );
538        assert!(matches!(
539            parse_args(&argv(&["new"])),
540            Err(CliError::Usage(_))
541        ));
542    }
543
544    #[test]
545    fn builtins_takes_no_arguments() {
546        assert_eq!(parse_args(&argv(&["builtins"])).unwrap(), Command::Builtins);
547        assert!(matches!(
548            parse_args(&argv(&["builtins", "x"])),
549            Err(CliError::Usage(_))
550        ));
551    }
552
553    #[test]
554    fn a_subcommand_this_cli_does_not_answer_refuses_by_name() {
555        for gone in ["bench", "nonlinear-solve"] {
556            let Err(CliError::Usage(message)) = parse_args(&argv(&[gone])) else {
557                panic!("`{gone}` must refuse")
558            };
559            assert!(message.contains(gone), "{message}");
560        }
561    }
562}