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>] [--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
32pub 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 pub target: String,
51 pub until: Option<String>,
52 pub rate: Option<u32>,
53 pub bits: Option<i32>,
55 pub flop_budget: Option<u128>,
57 pub asked: Vec<Asked>,
58 pub confirm: bool,
60 pub cache: CacheAt,
61}
62
63#[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 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 Lint {
88 target: Option<String>,
89 format: Format,
90 },
91 Trace {
92 target: String,
93 },
94 Builtins,
95 Outline {
97 text: String,
98 },
99 New {
100 name: String,
101 idempotency_key: Option<String>,
104 },
105}
106
107#[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
129pub(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
140pub 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
238fn 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 #[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 #[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}