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