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