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;
6pub mod encode;
7pub mod json;
8mod lint_code;
9mod outline;
10mod output;
11mod query;
12mod target;
13mod tempo;
14mod until;
15
16pub use answer::{
17    Played, Printed, Report, SAMPLE_LIMIT, answer_json, counters_json, engine_stats_json,
18    label_json, query_data, stats_json, stream_stats_json, value_json, work_json,
19};
20pub use builtins::{Builtins, Callable, Crossing, builtins, builtins_data};
21pub use cli_error::{CliError, LintViolation, lint_diagnostic};
22pub use lint_code::LintCode;
23pub use outline::outline_data;
24pub use output::{Diagnostic, Severity, diagnostics_json, error_envelope, success_envelope};
25pub use query::{
26    Asked, Call, Destination, RETIRED, asked, asked_all, call, calls, is_wav, retired, seconds,
27    wav_path,
28};
29pub use target::{Edge, Target, target};
30pub use tempo::{Tempo, refuse_unresolved_bars, resolved as tempo};
31pub use until::until;
32
33use std::cell::RefCell;
34use std::num::NonZeroUsize;
35use std::path::Path;
36
37use sva_ast::{Dir, Graph, Refusal, Source};
38use sva_engine::{
39    Ask, Change, Changed, DEFAULT_SAMPLE_RATE, EngineError, Kind, PSYCHOACOUSTIC_V1, Phase, Range,
40    RenderConfig, StreamConfig, render, render_called,
41};
42
43pub use sva_engine::{Abandon, Handle, Hear, Never, Out, Placed, Session, Stream, Until};
44
45pub use sva_engine::{Answer, Extent, Label, Output, Representation};
46pub use sva_engine::{
47    Backend, CacheStats, Counters, DEFAULT_CACHE_BYTES, DEFAULT_STORE_BYTES, EngineStats,
48    FETCH_READS, Nothing, Persisted, Store, Tier,
49};
50
51pub const ROOT: &str = "master";
52pub const PROBE: &str = "probe";
53
54pub struct Rendered {
55    pub config: RenderConfig,
56    pub expression: String,
57    pub target: String,
58    pub render: sva_engine::Render,
59    /// So a structural check needs no second parse, and an ad-hoc target is the same `probe`.
60    pub graph: Graph,
61}
62
63impl Rendered {
64    pub fn answer(&self, node: &str, representation: Representation) -> Result<Answer, CliError> {
65        let written = |e| CliError::Engine(as_written(e, &self.expression));
66        let id = self.render.node(node).map_err(written)?;
67        let mut answer = sva_engine::answer(&self.render, id, representation).map_err(written)?;
68        self.attribute(&mut answer)?;
69        Ok(answer)
70    }
71
72    /// An alias score reads beside how many instances the render holds.
73    fn attribute(&self, answer: &mut Answer) -> Result<(), CliError> {
74        let Output::Alias(alias) = &mut answer.value else {
75            return Ok(());
76        };
77        alias.instances = self.render.tys.paths().count();
78        Ok(())
79    }
80
81    pub fn label(&self) -> Option<&sva_engine::Label> {
82        self.render.labels.get(&self.render.root)
83    }
84}
85
86/// One render: `target` an expression over the composition `source` holds, its own ref
87/// read over an interval where it writes one.
88pub struct Job<'a> {
89    pub source: &'a dyn Source,
90    pub target: &'a str,
91    /// `None` ends the render where the interval or the target's support ends.
92    pub until: Option<&'a str>,
93    pub rate: Option<u32>,
94    pub bits: Option<i32>,
95    pub asked: &'a [Asked],
96    pub volatile: &'a [String],
97    pub out: Out,
98    pub abandon: &'a dyn Abandon,
99    pub hear: Option<&'a dyn Hear>,
100    /// The most threads it computes on; `None` takes every core the platform reports.
101    pub threads: Option<NonZeroUsize>,
102}
103
104impl<'a> Job<'a> {
105    pub fn over(source: &'a dyn Source, target: &'a str) -> Job<'a> {
106        Job {
107            source,
108            target,
109            until: None,
110            rate: None,
111            bits: None,
112            asked: &[],
113            volatile: &[],
114            out: Out::Kept,
115            abandon: &Never,
116            hear: None,
117            threads: None,
118        }
119    }
120}
121
122fn settle(job: &Job) -> Result<(Graph, RenderConfig), CliError> {
123    let Target { expr, interval } = target(job.target)?;
124    let roots = roots_of(job.source, &expr)?;
125    let mut graph = settled(sva_ast::load_reaching(
126        job.source,
127        &roots.iter().map(String::as_str).collect::<Vec<_>>(),
128    ))?;
129    let parsed = sva_ast::parse_expr(&expr).map_err(|d| {
130        CliError::BadProbe(format!(
131            "`{}` does not parse as an expression: {}",
132            job.target, d.message
133        ))
134    })?;
135    define_probe(&mut graph, parsed)?;
136    let rate = job.rate.unwrap_or(DEFAULT_SAMPLE_RATE);
137    let per_bar = graph.seconds_per_bar();
138    let range = match interval {
139        None => Range::default(),
140        Some((start, end)) => Range {
141            start: start.sample(rate, per_bar)?,
142            end: end.sample(rate, per_bar)?,
143        },
144    };
145    if let Range {
146        start: Some(start),
147        end: Some(end),
148    } = range
149        && end <= start
150    {
151        return Err(CliError::usage(
152            "render.empty_interval",
153            format!(
154                "`{}` reads an interval from sample {start} to {end}, which holds none",
155                job.target
156            ),
157        ));
158    }
159    let until = match job.until {
160        Some(text) => Some(until(text, rate, per_bar)?),
161        None => None,
162    };
163    let mut config = RenderConfig {
164        range,
165        until,
166        ..RenderConfig::at(rate)
167    };
168    config.profile.precision_bits = precision_bits(job.bits)?;
169    config.volatile = job.volatile.to_vec();
170    config.out = job.out;
171    if let Some(threads) = job.threads {
172        config.threads = threads;
173    }
174    config.asks = job
175        .asked
176        .iter()
177        .map(|asked| Ask {
178            node: asked.node.clone().unwrap_or_else(|| PROBE.to_string()),
179            representation: asked.representation,
180        })
181        .collect();
182    Ok((graph, config))
183}
184
185/// `trace` names one instance of a parameterized file as `<path>(<name>=<value>, ..)`.
186fn instance_call(text: &str) -> Option<(&str, &str)> {
187    let (path, rest) = text.split_once('(')?;
188    let binds = rest.strip_suffix(')')?.trim();
189    (!binds.is_empty() && all_named(binds)).then_some((path, binds))
190}
191
192/// Only commas and equals outside a bind's own parens count: a bind's value may be a call.
193fn all_named(binds: &str) -> bool {
194    let mut depth = 0i32;
195    let mut named = false;
196    for c in binds.chars() {
197        match c {
198            '(' => depth += 1,
199            ')' if depth == 0 => return false,
200            ')' => depth -= 1,
201            '=' if depth == 0 => named = true,
202            ',' if depth == 0 && !std::mem::take(&mut named) => return false,
203            _ => {}
204        }
205    }
206    named && depth == 0
207}
208
209/// The ref an instance name stands for, so `lint` and `trace` answer for the same node.
210fn instance_read(graph: &Graph, text: &str) -> Option<sva_ast::Expr> {
211    let (path, binds) = instance_call(text)?;
212    if !graph.defines(path) {
213        return None;
214    }
215    sva_ast::parse_expr(&format!("@{path}(t, {binds})")).ok()
216}
217
218pub fn execute(job: Job, tier: &Tier) -> Result<Rendered, CliError> {
219    let (graph, config) = settle(&job)?;
220    let render = render(&graph, PROBE, config, tier);
221    rendered(&job, graph, render)
222}
223
224/// `job` over `tier`, typing anew only what `session` typed otherwise; only `persist` commits
225/// what it offers a disk beneath.
226pub async fn execute_over<B: Backend>(
227    job: Job<'_>,
228    tier: &Tier<B>,
229    session: &mut Session,
230) -> Result<Rendered, CliError> {
231    let call = tier.call(Kind::Render, job.target, Phase::Parsing);
232    let (graph, config) = settle(&job)?;
233    let hooks = (job.abandon, job.hear);
234    let render = render_called(session, &graph, PROBE, config, tier, hooks, call).await;
235    rendered(&job, graph, render)
236}
237
238fn rendered(
239    job: &Job,
240    graph: Graph,
241    render: Result<sva_engine::Render, EngineError>,
242) -> Result<Rendered, CliError> {
243    let render = render.map_err(|e| CliError::Engine(as_written(e, job.target)))?;
244    Ok(Rendered {
245        config: render.config.clone(),
246        expression: job.target.to_string(),
247        target: PROBE.to_string(),
248        render,
249        graph,
250    })
251}
252
253pub fn plan(job: &Job) -> Result<sva_engine::Render, CliError> {
254    let (graph, config) = settle(job)?;
255    sva_engine::plan(&graph, PROBE, config).map_err(|e| CliError::Engine(as_written(e, job.target)))
256}
257
258/// Every refusal typing the target reaches, and no render's.
259pub fn types(job: &Job) -> Result<(), CliError> {
260    let (graph, config) = settle(job)?;
261    sva_engine::types_at(&graph, PROBE, config.rate)
262        .map(|_| ())
263        .map_err(|e| CliError::Engine(as_written(e, job.target)))
264}
265
266/// `channels`: what it plays, the target's own where `None`.
267pub async fn stream<B: Backend>(
268    job: &Job<'_>,
269    (block, channels): (usize, Option<usize>),
270    tier: &Tier<B>,
271) -> Result<Stream, CliError> {
272    let call = tier.call(Kind::Stream, job.target, Phase::Parsing);
273    let (graph, config) = settle(job)?;
274    let target = graph
275        .expr(PROBE)
276        .cloned()
277        .expect("the target was defined as the probe");
278    let config = StreamConfig {
279        block,
280        channels,
281        render: config,
282    };
283    Stream::open_called(&graph, &target, config, tier, call)
284        .await
285        .map_err(|e| CliError::Engine(as_written(e, job.target)))
286}
287
288/// `target`, an expression over `source` with no interval of its own, in place of what
289/// `stream` plays. Each edit here is `sva_engine::change`'s: the stream plays on meanwhile.
290pub async fn edit<B: Backend>(
291    stream: &RefCell<Stream>,
292    source: &dyn Source,
293    target: &str,
294    tier: &Tier<B>,
295) -> Result<(), CliError> {
296    let build = |s: &Stream| {
297        let (graph, expr) = streamed(s, source, target)?;
298        Ok(Change::Target(graph, expr))
299    };
300    changed(stream, build, Some(target), tier).await.map(|_| ())
301}
302
303/// `term` summed into the stream's `@notes`, its sample 0 placed `at`.
304pub async fn add<B: Backend>(
305    stream: &RefCell<Stream>,
306    source: &dyn Source,
307    (term, at): (&str, Placed),
308    tier: &Tier<B>,
309) -> Result<Handle, CliError> {
310    let build = |s: &Stream| {
311        let (graph, expr) = streamed(s, source, term)?;
312        Ok(Change::Add(graph, expr, at))
313    };
314    match changed(stream, build, Some(term), tier).await? {
315        Changed::Added(handle) => Ok(handle),
316        _ => unreachable!("an add answers its handle"),
317    }
318}
319
320/// False where the stream no longer holds `handle`.
321pub async fn replace<B: Backend>(
322    stream: &RefCell<Stream>,
323    source: &dyn Source,
324    (handle, term, at): (Handle, &str, Placed),
325    tier: &Tier<B>,
326) -> Result<bool, CliError> {
327    let build = |s: &Stream| {
328        let (graph, expr) = streamed(s, source, term)?;
329        Ok(Change::Replace(handle, graph, expr, at))
330    };
331    Ok(changed(stream, build, Some(term), tier).await? == Changed::Held(true))
332}
333
334/// False where the stream no longer holds `handle`.
335pub async fn remove<B: Backend>(
336    stream: &RefCell<Stream>,
337    handle: Handle,
338    tier: &Tier<B>,
339) -> Result<bool, CliError> {
340    let build = |_: &Stream| Ok(Change::Remove(handle));
341    Ok(changed(stream, build, None, tier).await? == Changed::Held(true))
342}
343
344async fn changed<B: Backend>(
345    stream: &RefCell<Stream>,
346    build: impl FnMut(&Stream) -> Result<Change, CliError>,
347    text: Option<&str>,
348    tier: &Tier<B>,
349) -> Result<Changed, CliError> {
350    let changed = sva_engine::change(stream, build, tier).await;
351    changed.map_err(|e| match (e, text) {
352        (CliError::Engine(e), Some(text)) => CliError::Engine(as_written(e, text)),
353        (e, _) => e,
354    })
355}
356
357pub async fn fetch<B: Backend>(stream: &RefCell<Stream>, tier: &Tier<B>) {
358    sva_engine::fetch(stream, tier).await;
359}
360
361/// `text` with no interval, and each node it reads that `stream` does not hold, or holds at an
362/// earlier edit of `source`, read and parsed off `source`: an edit is a version the stream plays.
363fn streamed(
364    stream: &Stream,
365    source: &dyn Source,
366    text: &str,
367) -> Result<(Graph, sva_ast::Expr), CliError> {
368    let Target { expr, interval } = target(text)?;
369    if interval.is_some() {
370        return Err(CliError::usage(
371            "stream.interval",
372            format!("`{text}` reads an interval, and a stream keeps its own"),
373        ));
374    }
375    let held = stream.graph();
376    let roots = roots_of(source, &expr)?;
377    let roots: Vec<&str> = roots.iter().map(String::as_str).collect();
378    let mut delta = sva_ast::load_beside(source, &roots, held).map_err(CliError::Refusals)?;
379    if let Some(per_bar) = held.per_bar() {
380        delta.resolve_bar_spans(per_bar);
381    }
382    delta
383        .desugar_arrangement_beside(held)
384        .map_err(CliError::Refusals)?;
385    let parsed = sva_ast::parse_expr(&expr).map_err(|d| {
386        CliError::BadProbe(format!(
387            "`{text}` does not parse as an expression: {}",
388            d.message
389        ))
390    })?;
391    let defined = delta
392        .define_arranged_beside(held, PROBE, parsed)
393        .map_err(|r| CliError::Refusals(vec![r]))?;
394    if !defined {
395        return Err(CliError::BadProbe(format!(
396            "this composition already has a node named `{PROBE}`"
397        )));
398    }
399    tempo::refuse_unresolved_bars(&delta)?;
400    let expr = delta
401        .expr(PROBE)
402        .cloned()
403        .expect("the target was defined as the probe");
404    Ok((delta, expr))
405}
406
407/// The precision a render writes every sample to: `bits`, else the profile's own.
408pub fn precision_bits(bits: Option<i32>) -> Result<i32, CliError> {
409    bits.map_or(Ok(PSYCHOACOUSTIC_V1.precision_bits), precision)
410}
411
412/// A double holds no bit past its own mantissa, and one bit writes only zero.
413fn precision(bits: i32) -> Result<i32, CliError> {
414    match (2..=52).contains(&bits) {
415        true => Ok(bits),
416        false => Err(CliError::usage(
417            "cli.bad_value",
418            format!("`--bits` takes a precision from 2 to 52 bits, not {bits}"),
419        )),
420    }
421}
422
423/// The engine knows a target only as the node it was defined as, so a refusal that names
424/// that node names the target as the caller wrote it instead.
425fn as_written(refused: EngineError, target: &str) -> EngineError {
426    match refused {
427        EngineError::Refused(mut d) => {
428            if d.location.node == PROBE {
429                d.location.node = target.to_string();
430            }
431            d.message = d
432                .message
433                .replace(&format!("`{PROBE}`"), &format!("`{target}`"));
434            EngineError::Refused(d)
435        }
436        EngineError::Binding { node, span, fault } if node == PROBE => EngineError::Binding {
437            node: target.to_string(),
438            span,
439            fault,
440        },
441        other => other,
442    }
443}
444
445pub fn probe(dir: &Path, expression: &str) -> Result<Rendered, CliError> {
446    execute(Job::over(&Dir::at(dir), expression), &Tier::default())
447}
448
449pub fn cwd() -> Result<std::path::PathBuf, CliError> {
450    std::env::current_dir()
451        .map_err(|e| CliError::Io(format!("could not read the current directory: {e}")))
452}
453
454pub fn prepared(source: &dyn Source) -> Result<Graph, CliError> {
455    settled(sva_ast::load(source))
456}
457
458/// Argv math: `trace` and `lint` each refuse a target no node answers for here, under one
459/// code and one message.
460pub fn define_probe_for(graph: &mut Graph, text: &str) -> Result<(), CliError> {
461    let expr = match instance_read(graph, text) {
462        Some(read) => read,
463        None if names_a_missing_node(text) => {
464            return Err(CliError::NotFound(format!(
465                "`{text}` is not a node this composition defines"
466            )));
467        }
468        None => sva_ast::parse_expr(text).map_err(|d| {
469            CliError::BadProbe(format!(
470                "`{text}` is not a node in this composition, and does not parse as an \
471                 expression: {}",
472                d.message
473            ))
474        })?,
475    };
476    define_probe(graph, expr)
477}
478
479/// Only a path, or instance syntax, is a node; the rest is math the parser and engine answer
480/// for. `t` and `A4` are spelled like paths and read as values.
481fn names_a_missing_node(text: &str) -> bool {
482    match instance_call(text) {
483        Some((path, _)) => sva_ast::names_a_node(path),
484        None => !text.contains('(') && sva_ast::names_a_node(text) && !reads_as_a_value(text),
485    }
486}
487
488fn reads_as_a_value(text: &str) -> bool {
489    match sva_ast::parse_expr(text) {
490        Ok(sva_ast::Expr::Lit(_)) => true,
491        Ok(sva_ast::Expr::Var(name)) => sva_ast::is_reserved(&name),
492        _ => false,
493    }
494}
495
496/// Argv math settled the way a file is; `concat` is no file-only dialect.
497fn define_probe(graph: &mut Graph, expr: sva_ast::Expr) -> Result<(), CliError> {
498    let defined = graph
499        .define_arranged(PROBE, expr)
500        .map_err(|r| CliError::Refusals(vec![r]))?;
501    if !defined {
502        return Err(CliError::BadProbe(format!(
503            "this composition already has a node named `{PROBE}`"
504        )));
505    }
506    tempo::refuse_unresolved_bars(graph)
507}
508
509pub fn settled(loaded: Result<Graph, Vec<Refusal>>) -> Result<Graph, CliError> {
510    let mut graph = loaded.map_err(CliError::Refusals)?;
511    tempo::resolve(&mut graph)?;
512    graph.desugar_arrangement().map_err(CliError::Refusals)?;
513    Ok(graph)
514}
515
516/// Read by name rather than through a ref.
517pub const RESERVED_VARIABLES: [&str; 3] = ["bpm", "meter", "key"];
518
519/// A node path the source answers for is a node; anything else is math, and its reads are
520/// roots. Only a node path is asked of the source: math is never looked up as a file.
521pub fn roots_of(source: &dyn Source, target: &str) -> Result<Vec<String>, CliError> {
522    let mut roots: Vec<String> = RESERVED_VARIABLES
523        .iter()
524        .flat_map(|n| [(*n).to_string(), format!("{}/{n}", sva_ast::VARIABLES)])
525        .collect();
526    let held = |path: &str| -> Result<bool, CliError> {
527        Ok(sva_ast::whole_ref_path(path) && source.get(path).map_err(CliError::Io)?.is_some())
528    };
529    match target {
530        target if held(target)? => {
531            roots.push(target.to_string());
532        }
533        text => match instance_call(text).map(|(path, _)| path) {
534            Some(path) if held(path)? => {
535                roots.push(path.to_string());
536            }
537            _ => {
538                if let Ok(expr) = sva_ast::parse_expr(text) {
539                    roots.extend(sva_ast::reads_of(PROBE, &expr));
540                }
541            }
542        },
543    }
544    Ok(roots)
545}