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, Warmed, 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    Printed, Report, SAMPLE_LIMIT, answer_json, label_json, query_data, stats_json,
18    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, DEFAULT_LEDGER_DEPTH, DEFAULT_MAX_PEAKS, DEFAULT_OVERSAMPLE, REPRESENTATIONS,
27    RETIRED, asked, call, calls, is_wav, retired, seconds, 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::path::Path;
34
35use sva_ast::{Dir, Graph, Refusal, Source};
36use sva_engine::{
37    Ask, DEFAULT_SAMPLE_RATE, EngineError, Range, RenderConfig, StreamConfig, render,
38    render_through,
39};
40
41pub use sva_engine::{Handle, QuietTail, Stream, Until};
42
43pub use sva_engine::{Answer, Extent, Label, Output, Representation};
44pub use sva_engine::{
45    Backend, Cache, CachePolicy, CacheStats, DEFAULT_STORE_BYTES, NoStore, Persisted, PrunePolicy,
46    Store, Through,
47};
48
49pub const ROOT: &str = "master";
50pub const PROBE: &str = "probe";
51
52pub struct Rendered {
53    pub config: RenderConfig,
54    pub expression: String,
55    pub target: String,
56    pub render: sva_engine::Render,
57    /// So a structural check needs no second parse, and an ad-hoc target is the same `probe`.
58    pub graph: Graph,
59}
60
61impl Rendered {
62    pub fn answer(&self, node: &str, representation: Representation) -> Result<Answer, CliError> {
63        let written = |e| CliError::Engine(as_written(e, &self.expression));
64        let id = self.render.node(node).map_err(written)?;
65        let mut answer = sva_engine::answer(&self.render, id, representation).map_err(written)?;
66        self.attribute(&mut answer)?;
67        Ok(answer)
68    }
69
70    /// An alias score reads beside how many instances the render holds.
71    fn attribute(&self, answer: &mut Answer) -> Result<(), CliError> {
72        let Output::Alias(alias) = &mut answer.value else {
73            return Ok(());
74        };
75        alias.instances = self.render.tys.paths().count();
76        Ok(())
77    }
78
79    pub fn label(&self) -> Option<&sva_engine::Label> {
80        self.render.labels.get(&self.render.root)
81    }
82}
83
84/// One render: `target` an expression over the composition `source` holds, its own ref
85/// read over an interval where it writes one.
86pub struct Job<'a> {
87    pub source: &'a dyn Source,
88    pub target: &'a str,
89    /// `None` ends the render where the interval or the target's support ends.
90    pub until: Option<&'a str>,
91    pub rate: Option<u32>,
92    pub bits: Option<i32>,
93    pub cache: Option<&'a Cache>,
94    pub cache_policy: Option<CachePolicy>,
95    pub asked: &'a [Asked],
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}
100
101impl<'a> Job<'a> {
102    pub fn over(source: &'a dyn Source, target: &'a str) -> Job<'a> {
103        Job {
104            source,
105            target,
106            until: None,
107            rate: None,
108            bits: None,
109            cache: None,
110            cache_policy: None,
111            asked: &[],
112            flop_budget: None,
113            volatile: &[],
114        }
115    }
116}
117
118fn settle(job: &Job) -> Result<(Graph, RenderConfig), CliError> {
119    settle_reaching(job, &[])
120}
121
122fn settle_reaching(job: &Job, also: &[String]) -> Result<(Graph, RenderConfig), CliError> {
123    let Target { expr, interval } = target(job.target)?;
124    let mut roots = roots_of(job.source, &expr)?;
125    roots.extend_from_slice(also);
126    let mut graph = settled(sva_ast::load_reaching(
127        job.source,
128        &roots.iter().map(String::as_str).collect::<Vec<_>>(),
129    ))?;
130    let parsed = sva_ast::parse_expr(&expr).map_err(|d| {
131        CliError::BadProbe(format!(
132            "`{}` does not parse as an expression: {}",
133            job.target, d.message
134        ))
135    })?;
136    define_probe(&mut graph, parsed)?;
137    let rate = job.rate.unwrap_or(DEFAULT_SAMPLE_RATE);
138    let per_bar = graph.seconds_per_bar();
139    let range = match interval {
140        None => Range::default(),
141        Some((start, end)) => Range {
142            start: start.sample(rate, per_bar)?,
143            end: end.sample(rate, per_bar)?,
144        },
145    };
146    if let Range {
147        start: Some(start),
148        end: Some(end),
149    } = range
150        && end <= start
151    {
152        return Err(CliError::Usage(format!(
153            "`{}` reads an interval from sample {start} to {end}, which holds none",
154            job.target
155        )));
156    }
157    let until = match job.until {
158        Some(text) => Some(until(text, rate, per_bar)?),
159        None => None,
160    };
161    let mut config = RenderConfig {
162        range,
163        until,
164        ..RenderConfig::at(rate)
165    };
166    if let Some(budget) = job.flop_budget {
167        config.flop_budget = budget;
168    }
169    if let Some(bits) = job.bits {
170        config.profile.precision_bits = precision(bits)?;
171    }
172    config.volatile = job.volatile.to_vec();
173    config.cache_policy = job.cache_policy;
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) -> Result<Rendered, CliError> {
219    let (graph, config) = settle(&job)?;
220    let render = render(&graph, PROBE, config, job.cache);
221    rendered(&job, graph, render)
222}
223
224/// `execute` reading through `store` in place of `job`'s own cache; only `persist` writes it.
225pub async fn execute_through<B: Backend>(
226    job: Job<'_>,
227    store: &Store<B>,
228) -> Result<Rendered, CliError> {
229    let (graph, config) = settle(&job)?;
230    let render = render_through(&graph, PROBE, config, store).await;
231    rendered(&job, graph, render)
232}
233
234pub struct Warmed {
235    pub stats: CacheStats,
236    pub readings: Option<Rendered>,
237}
238
239/// Readings, where asked, are `execute_through`'s, the root computed for them.
240pub async fn warm<B: Backend>(job: Job<'_>, store: &Store<B>) -> Result<Warmed, CliError> {
241    if !job.asked.is_empty() {
242        let mut read = execute_through(job, store).await?;
243        let stats = read.render.cache_stats.take();
244        let stats = stats.expect("a render through a store reports on it");
245        return Ok(Warmed {
246            stats,
247            readings: Some(read),
248        });
249    }
250    let (graph, config) = settle(&job)?;
251    let stats = sva_engine::warm(&graph, PROBE, config, store).await;
252    let stats = stats.map_err(|e| CliError::Engine(as_written(e, job.target)))?;
253    Ok(Warmed {
254        stats,
255        readings: None,
256    })
257}
258
259fn rendered(
260    job: &Job,
261    graph: Graph,
262    render: Result<sva_engine::Render, EngineError>,
263) -> Result<Rendered, CliError> {
264    let render = render.map_err(|e| CliError::Engine(as_written(e, job.target)))?;
265    Ok(Rendered {
266        config: render.config.clone(),
267        expression: job.target.to_string(),
268        target: PROBE.to_string(),
269        render,
270        graph,
271    })
272}
273
274pub fn plan(job: &Job) -> Result<sva_engine::Render, CliError> {
275    let (graph, config) = settle(job)?;
276    sva_engine::plan(&graph, PROBE, config).map_err(|e| CliError::Engine(as_written(e, job.target)))
277}
278
279/// Every refusal typing the target reaches, and no render's.
280pub fn types(job: &Job) -> Result<(), CliError> {
281    let (graph, config) = settle(job)?;
282    sva_engine::types_at(&graph, PROBE, config.rate)
283        .map(|_| ())
284        .map_err(|e| CliError::Engine(as_written(e, job.target)))
285}
286
287/// The target is no file to crop, so it is not among them.
288pub fn quiet_tails(job: &Job) -> Result<Vec<QuietTail>, CliError> {
289    let (graph, config) = settle(job)?;
290    let found = sva_engine::quiet_tails(&graph, PROBE, config)
291        .map_err(|e| CliError::Engine(as_written(e, job.target)))?;
292    Ok(found.into_iter().filter(|t| t.file != PROBE).collect())
293}
294
295pub async fn stream(job: &Job<'_>, block: usize, store: &impl Through) -> Result<Stream, CliError> {
296    let (graph, config) = settle(job)?;
297    let target = graph
298        .expr(PROBE)
299        .cloned()
300        .expect("the target was defined as the probe");
301    let config = StreamConfig {
302        block,
303        render: config,
304    };
305    Stream::open(&graph, &target, config, job.cache, store)
306        .await
307        .map_err(|e| CliError::Engine(as_written(e, job.target)))
308}
309
310/// `target`, an expression over `source` with no interval of its own, in place of what
311/// `stream` plays from its next block on.
312pub async fn edit(
313    stream: &mut Stream,
314    source: &dyn Source,
315    target: &str,
316    store: &impl Through,
317) -> Result<(), CliError> {
318    let (graph, expr) = streamed(stream, source, target)?;
319    stream
320        .edit(&graph, &expr, store)
321        .await
322        .map_err(|e| CliError::Engine(as_written(e, target)))
323}
324
325/// `term` summed into the stream's `@notes` from its next block on.
326pub async fn add(
327    stream: &mut Stream,
328    source: &dyn Source,
329    term: &str,
330    store: &impl Through,
331) -> Result<Handle, CliError> {
332    let (graph, expr) = streamed(stream, source, term)?;
333    stream
334        .add(&graph, &expr, store)
335        .await
336        .map_err(|e| CliError::Engine(as_written(e, term)))
337}
338
339/// False where the stream no longer holds `handle`.
340pub async fn replace(
341    stream: &mut Stream,
342    source: &dyn Source,
343    (handle, term): (Handle, &str),
344    store: &impl Through,
345) -> Result<bool, CliError> {
346    let (graph, expr) = streamed(stream, source, term)?;
347    stream
348        .replace(&graph, (handle, &expr), store)
349        .await
350        .map_err(|e| CliError::Engine(as_written(e, term)))
351}
352
353/// `text` with no interval, and a graph reaching it and all the stream plays.
354fn streamed(
355    stream: &Stream,
356    source: &dyn Source,
357    text: &str,
358) -> Result<(Graph, sva_ast::Expr), CliError> {
359    let playing: Vec<String> = stream
360        .exprs()
361        .flat_map(|e| sva_ast::reads_of(PROBE, e))
362        .collect();
363    let (graph, config) = settle_reaching(&Job::over(source, text), &playing)?;
364    if config.range != Range::default() {
365        return Err(CliError::Usage(format!(
366            "`{text}` reads an interval, and a stream keeps its own"
367        )));
368    }
369    let expr = graph
370        .expr(PROBE)
371        .cloned()
372        .expect("the target was defined as the probe");
373    Ok((graph, expr))
374}
375
376/// A double holds no bit past its own mantissa, and one bit writes only zero.
377fn precision(bits: i32) -> Result<i32, CliError> {
378    match (2..=52).contains(&bits) {
379        true => Ok(bits),
380        false => Err(CliError::Usage(format!(
381            "`--bits` takes a precision from 2 to 52 bits, not {bits}"
382        ))),
383    }
384}
385
386/// The engine knows a target only as the node it was defined as, so a refusal that names
387/// that node names the target as the caller wrote it instead.
388fn as_written(refused: EngineError, target: &str) -> EngineError {
389    match refused {
390        EngineError::Refused(mut d) => {
391            if d.location.node == PROBE {
392                d.location.node = target.to_string();
393            }
394            d.message = d
395                .message
396                .replace(&format!("`{PROBE}`"), &format!("`{target}`"));
397            EngineError::Refused(d)
398        }
399        EngineError::Binding { node, span, fault } if node == PROBE => EngineError::Binding {
400            node: target.to_string(),
401            span,
402            fault,
403        },
404        other => other,
405    }
406}
407
408pub fn probe(dir: &Path, expression: &str) -> Result<Rendered, CliError> {
409    execute(Job::over(&Dir::at(dir), expression))
410}
411
412pub fn cwd() -> Result<std::path::PathBuf, CliError> {
413    std::env::current_dir()
414        .map_err(|e| CliError::Io(format!("could not read the current directory: {e}")))
415}
416
417pub fn prepared(source: &dyn Source) -> Result<Graph, CliError> {
418    settled(sva_ast::load(source))
419}
420
421/// Argv math: `trace` and `lint` each refuse a target no node answers for here, under one
422/// code and one message.
423pub fn define_probe_for(graph: &mut Graph, text: &str) -> Result<(), CliError> {
424    let expr = match instance_read(graph, text) {
425        Some(read) => read,
426        None if names_a_missing_node(text) => {
427            return Err(CliError::NotFound(format!(
428                "`{text}` is not a node this composition defines"
429            )));
430        }
431        None => sva_ast::parse_expr(text).map_err(|d| {
432            CliError::BadProbe(format!(
433                "`{text}` is not a node in this composition, and does not parse as an \
434                 expression: {}",
435                d.message
436            ))
437        })?,
438    };
439    define_probe(graph, expr)
440}
441
442/// Only a path, or instance syntax, is a node; the rest is math the parser and engine answer
443/// for. `t` and `A4` are spelled like paths and read as values.
444fn names_a_missing_node(text: &str) -> bool {
445    match instance_call(text) {
446        Some((path, _)) => sva_ast::names_a_node(path),
447        None => !text.contains('(') && sva_ast::names_a_node(text) && !reads_as_a_value(text),
448    }
449}
450
451fn reads_as_a_value(text: &str) -> bool {
452    match sva_ast::parse_expr(text) {
453        Ok(sva_ast::Expr::Lit(_)) => true,
454        Ok(sva_ast::Expr::Var(name)) => sva_engine::instantiate::is_reserved(&name),
455        _ => false,
456    }
457}
458
459/// Argv math settled the way a file is; `concat` is no file-only dialect.
460fn define_probe(graph: &mut Graph, expr: sva_ast::Expr) -> Result<(), CliError> {
461    let defined = graph
462        .define_arranged(PROBE, expr)
463        .map_err(|r| CliError::Refusals(vec![r]))?;
464    if !defined {
465        return Err(CliError::BadProbe(format!(
466            "this composition already has a node named `{PROBE}`"
467        )));
468    }
469    tempo::refuse_unresolved_bars(graph)
470}
471
472pub fn settled(loaded: Result<Graph, Vec<Refusal>>) -> Result<Graph, CliError> {
473    let mut graph = loaded.map_err(CliError::Refusals)?;
474    tempo::resolve(&mut graph)?;
475    graph.desugar_arrangement().map_err(CliError::Refusals)?;
476    Ok(graph)
477}
478
479/// Read by name rather than through a ref.
480pub const RESERVED_VARIABLES: [&str; 3] = ["bpm", "meter", "key"];
481
482/// A node path the source answers for is a node; anything else is math, and its reads are
483/// roots. Only a node path is asked of the source: math is never looked up as a file.
484pub fn roots_of(source: &dyn Source, target: &str) -> Result<Vec<String>, CliError> {
485    let mut roots: Vec<String> = RESERVED_VARIABLES
486        .iter()
487        .flat_map(|n| [(*n).to_string(), format!("{}/{n}", sva_ast::VARIABLES)])
488        .collect();
489    let held = |path: &str| -> Result<bool, CliError> {
490        Ok(sva_ast::whole_ref_path(path) && source.get(path).map_err(CliError::Io)?.is_some())
491    };
492    match target {
493        target if held(target)? => {
494            roots.push(target.to_string());
495        }
496        text => match instance_call(text).map(|(path, _)| path) {
497            Some(path) if held(path)? => {
498                roots.push(path.to_string());
499            }
500            _ => {
501                if let Ok(expr) = sva_ast::parse_expr(text) {
502                    roots.extend(sva_ast::reads_of(PROBE, &expr));
503                }
504            }
505        },
506    }
507    Ok(roots)
508}