1use 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
31pub 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 pub target: String,
50 pub until: Option<String>,
51 pub rate: Option<u32>,
52 pub bits: Option<i32>,
54 pub threads: Option<std::num::NonZeroUsize>,
56 pub asked: Vec<Asked>,
57 pub confirm: bool,
59 pub cache: CacheAt,
60}
61
62#[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 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 Lint {
87 target: Option<String>,
88 format: Format,
89 },
90 Trace {
91 target: String,
92 },
93 Builtins,
94 Outline {
96 text: String,
97 },
98 New {
99 name: String,
100 idempotency_key: Option<String>,
103 },
104}
105
106#[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
128pub(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
139pub 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
237fn 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 #[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 #[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}