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