Skip to main content

sva_core/
lib.rs

1// Concern: the parse->tempo->render pipeline shared by both front ends | Non-concern: argv (sva-cli), JS bindings (sva-wasm) | IO: (a Source, a target) -> Rendered, a Stream or CliError
2
3mod answer;
4mod builtins;
5mod cli_error;
6mod duration;
7pub mod json;
8mod lint_code;
9mod outline;
10mod output;
11mod query;
12mod tempo;
13
14pub use answer::{
15    Report, SAMPLE_LIMIT, answer_json, label_json, query_data, stats_json, value_json, work_json,
16};
17pub use builtins::{Builtins, Callable, Crossing, builtins, builtins_data};
18pub use cli_error::{CliError, LintViolation, lint_diagnostic};
19pub use lint_code::LintCode;
20pub use outline::outline_data;
21pub use output::{Diagnostic, Severity, diagnostics_json, error_envelope, success_envelope};
22pub use query::{
23    Asked, DEFAULT_LEDGER_DEPTH, DEFAULT_MAX_PEAKS, DEFAULT_OVERSAMPLE, DEFAULT_SILENT_BITS,
24    DEFAULT_SILENT_MAX_SECS, REPRESENTATIONS, RETIRED, Shaping, WindowEdge, is_wav,
25    representation_for, retired, silence, silent_edge, window_edge, window_for,
26};
27pub use tempo::refuse_unresolved_bars;
28
29use std::path::Path;
30
31use sva_ast::{Dir, Graph, Refusal, Source, SpanUnit};
32use sva_engine::{
33    Ask, BindingFault, DEFAULT_SAMPLE_RATE, EngineError, PSYCHOACOUSTIC_V1, Render, RenderConfig,
34    StreamConfig, render, render_until_silent,
35};
36
37pub use sva_engine::{Checkpoint, Silent, Stream};
38
39pub use sva_engine::{Answer, Horizon, Label, Output, Representation};
40pub use sva_engine::{Cache, CachePolicy, PrunePolicy};
41
42pub const ROOT: &str = "master";
43pub const PROBE: &str = "probe";
44
45pub const DEFAULT_SECONDS: f64 = 1.0;
46
47pub struct Rendered {
48    pub config: RenderConfig,
49    pub target: String,
50    pub render: Render,
51    /// So a structural check needs no second parse, and an ad-hoc target is the same `probe`.
52    pub graph: Graph,
53}
54
55impl Rendered {
56    pub fn answer(&self, node: &str, representation: Representation) -> Result<Answer, CliError> {
57        let id = self.render.node(node).map_err(CliError::Engine)?;
58        let mut answer =
59            sva_engine::answer(&self.render, id, representation).map_err(CliError::Engine)?;
60        self.attribute(&mut answer)?;
61        Ok(answer)
62    }
63
64    /// An alias score is only readable beside what else moves with the rate: a sampled loop
65    /// is a different signal at the oversampled rate, and the engine cannot see the graph.
66    fn attribute(&self, answer: &mut Answer) -> Result<(), CliError> {
67        let Output::Alias(alias) = &mut answer.value else {
68            return Ok(());
69        };
70        alias.rate_dependent = sva_engine::rate_dependent(&self.graph, &self.target)
71            .map_err(CliError::Engine)?
72            .len();
73        alias.instances = self.render.tys.paths().count();
74        Ok(())
75    }
76
77    pub fn label(&self) -> Option<&sva_engine::Label> {
78        self.render.labels.get(&self.render.root)
79    }
80}
81
82/// A `target` the composition holds is that node; anything else is argv math.
83pub struct Job<'a> {
84    pub source: &'a dyn Source,
85    pub target: Option<&'a str>,
86    pub from: Option<WindowEdge>,
87    pub until: Option<WindowEdge>,
88    pub sample_rate: Option<u32>,
89    pub cache: Option<&'a Cache>,
90    pub cache_policy: Option<CachePolicy>,
91    /// Only what the target reaches, so a node nothing reaches is never read or refused.
92    pub reaching: bool,
93    /// The instance every reading is taken of; the target itself where this is `None`.
94    pub reading: Option<&'a str>,
95    pub representations: Vec<Representation>,
96    /// The operation count the caller acknowledges paying; the profile's own where `None`.
97    pub flop_budget: Option<u128>,
98    pub volatile: &'a [String],
99    /// Render until silence is proven, in place of `until`.
100    pub silent: Option<Silent>,
101}
102
103impl<'a> Job<'a> {
104    pub fn over(source: &'a dyn Source) -> Job<'a> {
105        Job {
106            source,
107            target: None,
108            from: None,
109            until: None,
110            sample_rate: None,
111            cache: None,
112            cache_policy: None,
113            reaching: false,
114            reading: None,
115            representations: Vec::new(),
116            flop_budget: None,
117            volatile: &[],
118            silent: None,
119        }
120    }
121}
122
123fn settle(job: &Job, asked: Option<&str>) -> Result<(Graph, String, RenderConfig), CliError> {
124    let mut graph = match job.reaching {
125        false => prepared(job.source)?,
126        true => settled(sva_ast::load_reaching(
127            job.source,
128            &roots_of(job.source, asked)?
129                .iter()
130                .map(String::as_str)
131                .collect::<Vec<_>>(),
132        ))?,
133    };
134    let target = match asked {
135        None => ROOT.to_string(),
136        Some(name) if graph.defines(name) => name.to_string(),
137        Some(text) => {
138            define_probe_for(&mut graph, text)?;
139            PROBE.to_string()
140        }
141    };
142    let until = match job.silent {
143        Some(silent) => Some(WindowEdge::Secs(silent.max_secs)),
144        None => job.until,
145    };
146    let mut config = config_for(&graph, &target, job.from, until, job.sample_rate)?;
147    if let Some(budget) = job.flop_budget {
148        config.flop_budget = budget;
149    }
150    config.volatile = job.volatile.to_vec();
151    config.cache_policy = job.cache_policy;
152    let node = job.reading.unwrap_or(&target);
153    config.asks = job
154        .representations
155        .iter()
156        .map(|representation| Ask {
157            node: node.to_string(),
158            representation: *representation,
159        })
160        .collect();
161    Ok((graph, target, config))
162}
163
164/// `trace` names one instance of a parameterized file as `<path>(<name>=<value>, ..)`.
165fn instance_call(text: &str) -> Option<(&str, &str)> {
166    let (path, rest) = text.split_once('(')?;
167    let binds = rest.strip_suffix(')')?.trim();
168    (!binds.is_empty() && all_named(binds)).then_some((path, binds))
169}
170
171/// Only commas and equals outside a bind's own parens count: a bind's value may be a call.
172fn all_named(binds: &str) -> bool {
173    let mut depth = 0i32;
174    let mut named = false;
175    for c in binds.chars() {
176        match c {
177            '(' => depth += 1,
178            ')' if depth == 0 => return false,
179            ')' => depth -= 1,
180            '=' if depth == 0 => named = true,
181            ',' if depth == 0 && !std::mem::take(&mut named) => return false,
182            _ => {}
183        }
184    }
185    named && depth == 0
186}
187
188/// The ref an instance name stands for, so `render` and `trace` answer for the same node.
189fn instance_read(graph: &Graph, text: &str) -> Option<sva_ast::Expr> {
190    let (path, binds) = instance_call(text)?;
191    if !graph.defines(path) {
192        return None;
193    }
194    sva_ast::parse_expr(&format!("@{path}(t, {binds})")).ok()
195}
196
197pub fn execute(job: Job) -> Result<Rendered, CliError> {
198    let (graph, target, config) = settle(&job, job.target)?;
199    let refused = match rendering(&job, &graph, &target, config) {
200        Ok(render) => {
201            return Ok(Rendered {
202                config: render.config.clone(),
203                render,
204                target,
205                graph,
206            });
207        }
208        Err(refused) => refused,
209    };
210    match instances_behind(job.source, &target, &refused) {
211        Some(held) if held.len() == 1 => at_instance(&job, &held[0]),
212        Some(held) => Err(CliError::Engine(EngineError::AmbiguousNode(target, held))),
213        None => Err(CliError::Engine(refused)),
214    }
215}
216
217pub fn instances_behind(
218    source: &dyn Source,
219    target: &str,
220    refused: &EngineError,
221) -> Option<Vec<String>> {
222    unbound(refused)
223        .then(|| instances_of(source, target))
224        .flatten()
225}
226
227fn unbound(refused: &EngineError) -> bool {
228    matches!(
229        refused,
230        EngineError::Binding {
231            fault: BindingFault::Unbound(..),
232            ..
233        }
234    )
235}
236
237/// The instances a whole composition expanded a file into; a render reaches none of them.
238fn instances_of(source: &dyn Source, target: &str) -> Option<Vec<String>> {
239    let graph = prepared(source).ok()?;
240    let (instances, _) = sva_engine::instantiate::from_roots(&graph, &[ROOT.to_string()]).ok()?;
241    let held: Vec<String> = instances.instances_of(target).collect();
242    (!held.is_empty()).then_some(held)
243}
244
245/// One instance is the node the caller meant, read as if they had named it themselves.
246fn at_instance(job: &Job, instance: &str) -> Result<Rendered, CliError> {
247    let (graph, target, config) = settle(job, Some(instance))?;
248    let render = rendering(job, &graph, &target, config).map_err(CliError::Engine)?;
249    Ok(Rendered {
250        config: render.config.clone(),
251        render,
252        target,
253        graph,
254    })
255}
256
257/// The horizon the caller named, or the one silence ends.
258fn rendering(
259    job: &Job,
260    graph: &Graph,
261    target: &str,
262    config: RenderConfig,
263) -> Result<Render, EngineError> {
264    match job.silent {
265        Some(silent) => render_until_silent(graph, target, config, silent, job.cache),
266        None => render(graph, target, config, job.cache),
267    }
268}
269
270/// `job`'s target settled as a render of it is, each of `bindings` a named argument on it.
271pub fn stream(job: &Job, block: usize, bindings: &[(String, f64)]) -> Result<Stream, CliError> {
272    let (graph, target, config) = settle(job, job.target)?;
273    let config = StreamConfig {
274        rate: config.rate,
275        block,
276        silent: job.silent,
277    };
278    Stream::open(&graph, &target, bindings, config).map_err(CliError::Engine)
279}
280
281pub fn run(dir: &Path) -> Result<Rendered, CliError> {
282    execute(Job::over(&Dir::at(dir)))
283}
284
285pub fn probe(dir: &Path, expression: &str) -> Result<Rendered, CliError> {
286    execute(Job {
287        target: Some(expression),
288        ..Job::over(&Dir::at(dir))
289    })
290}
291
292pub fn cwd() -> Result<std::path::PathBuf, CliError> {
293    std::env::current_dir()
294        .map_err(|e| CliError::Io(format!("could not read the current directory: {e}")))
295}
296
297pub fn prepared(source: &dyn Source) -> Result<Graph, CliError> {
298    settled(sva_ast::load(source))
299}
300
301/// Argv math: `render`, `trace` and `lint` each refuse a target no node answers for here,
302/// under one code and one message.
303pub fn define_probe_for(graph: &mut Graph, text: &str) -> Result<(), CliError> {
304    let expr = match instance_read(graph, text) {
305        Some(read) => read,
306        None if names_a_missing_node(text) => {
307            return Err(CliError::NotFound(format!(
308                "`{text}` is not a node this composition defines"
309            )));
310        }
311        None => sva_ast::parse_expr(text).map_err(|d| {
312            CliError::BadProbe(format!(
313                "`{text}` is not a node in this composition, and does not parse as an \
314                 expression: {}",
315                d.message
316            ))
317        })?,
318    };
319    define_probe(graph, expr)
320}
321
322/// Only a path, or instance syntax, is a node; the rest is math the parser and engine answer
323/// for. `t` and `A4` are spelled like paths and read as values.
324fn names_a_missing_node(text: &str) -> bool {
325    match instance_call(text) {
326        Some((path, _)) => sva_ast::names_a_node(path),
327        None => !text.contains('(') && sva_ast::names_a_node(text) && !reads_as_a_value(text),
328    }
329}
330
331fn reads_as_a_value(text: &str) -> bool {
332    match sva_ast::parse_expr(text) {
333        Ok(sva_ast::Expr::Lit(_)) => true,
334        Ok(sva_ast::Expr::Var(name)) => sva_engine::instantiate::is_reserved(&name),
335        _ => false,
336    }
337}
338
339/// Argv math settled the way a file is; `concat` is no file-only dialect.
340fn define_probe(graph: &mut Graph, expr: sva_ast::Expr) -> Result<(), CliError> {
341    let defined = graph
342        .define_arranged(PROBE, expr)
343        .map_err(|r| CliError::Refusals(vec![r]))?;
344    if !defined {
345        return Err(CliError::BadProbe(format!(
346            "this composition already has a node named `{PROBE}`"
347        )));
348    }
349    tempo::refuse_unresolved_bars(graph)
350}
351
352pub fn settled(loaded: Result<Graph, Vec<Refusal>>) -> Result<Graph, CliError> {
353    let mut graph = loaded.map_err(CliError::Refusals)?;
354    tempo::resolve(&mut graph)?;
355    graph.desugar_arrangement().map_err(CliError::Refusals)?;
356    Ok(graph)
357}
358
359/// Read by name rather than through a ref.
360pub const RESERVED_VARIABLES: [&str; 3] = ["bpm", "meter", "key"];
361
362/// A path the source answers for is a node; anything else is math, and its reads are roots.
363pub fn roots_of(source: &dyn Source, target: Option<&str>) -> Result<Vec<String>, CliError> {
364    let mut roots: Vec<String> = RESERVED_VARIABLES
365        .iter()
366        .flat_map(|n| [(*n).to_string(), format!("{}/{n}", sva_ast::VARIABLES)])
367        .collect();
368    match target {
369        None => roots.push(ROOT.to_string()),
370        Some(target) if source.get(target).map_err(CliError::Io)?.is_some() => {
371            roots.push(target.to_string());
372        }
373        Some(text) => match instance_call(text).map(|(path, _)| path) {
374            Some(path) if source.get(path).map_err(CliError::Io)?.is_some() => {
375                roots.push(path.to_string());
376            }
377            _ => {
378                if let Ok(expr) = sva_ast::parse_expr(text) {
379                    roots.extend(sva_ast::reads_of(PROBE, &expr));
380                }
381            }
382        },
383    }
384    Ok(roots)
385}
386
387/// The horizon a reading runs against: the window the caller named, else the node's own
388/// stated extent, reaching back to whatever a `crop` declares before zero.
389pub fn config_for(
390    graph: &Graph,
391    root: &str,
392    from: Option<WindowEdge>,
393    until: Option<WindowEdge>,
394    sample_rate: Option<u32>,
395) -> Result<RenderConfig, CliError> {
396    let declared = duration::widest_crop(graph, root);
397    let length = match graph.span(root) {
398        Some(span) if span.unit == SpanUnit::Seconds => Some(span.amount),
399        Some(span) => {
400            return Err(CliError::BadTempo(format!(
401                "`{root}`'s span is still {} bars, unresolved; give bpm/meter, or a seconds span",
402                span.amount
403            )));
404        }
405        None => declared.end,
406    };
407    let asked = window_for(from, until, graph.seconds_per_bar())?;
408    let end = match asked.end_secs.is_finite() {
409        true => asked.end_secs,
410        false => length.unwrap_or(DEFAULT_SECONDS),
411    };
412    let stated = !matches!(from, None | Some(WindowEdge::End));
413    let start = match stated {
414        true => asked.start_secs,
415        false => declared.start.unwrap_or(0.0).min(0.0),
416    };
417    if end <= start {
418        return Err(CliError::Usage(format!(
419            "a reading runs from {start}s to {end}s, which is no window; give --to past --from"
420        )));
421    }
422    Ok(RenderConfig {
423        rate: sample_rate.unwrap_or(DEFAULT_SAMPLE_RATE),
424        horizon: Horizon::secs(start, end),
425        profile: PSYCHOACOUSTIC_V1,
426        asks: Vec::new(),
427        flop_budget: PSYCHOACOUSTIC_V1.flop_budget,
428        volatile: Vec::new(),
429        cache_policy: None,
430    })
431}