Skip to main content

sva_core/solve/
vary.rs

1// Concern: one `--vary` variable: bounds, step and scale in one unit, its start off its file, its value as text | Non-concern: searching over it | IO: (text) -> Vary; (file) -> a start
2
3use sva_ast::{LogUnit, Source, TokenKind, VARIABLES, tokenize, variable};
4
5use crate::cli_error::CliError;
6
7const UNITS: [&str; 8] = ["", "hz", "khz", "s", "ms", "db", "ct", "st"];
8
9#[derive(Clone, Debug, PartialEq)]
10pub struct Vary {
11    pub name: String,
12    pub lo: f64,
13    pub hi: f64,
14    /// Linear, the step in `unit`; on a log scale, the ratio each step multiplies by.
15    pub step: Option<f64>,
16    pub log: bool,
17    pub unit: &'static str,
18}
19
20fn bad(code: &'static str, message: String) -> CliError {
21    CliError::usage(code, message)
22}
23
24/// `name=lo..hi[/step][,log]`, as `cutoff=200hz..8khz/1.25,log` or `gain=-12db..0db/1db`.
25pub fn vary(written: &str) -> Result<Vary, CliError> {
26    let shape = || {
27        bad(
28            "solve.bad_vary",
29            format!(
30                "`--vary {written}` is not `<name>=<lo>..<hi>[/<step>][,log]`; write one \
31                 variable's bounds in one unit, as `--vary gain=0..2/0.25` or \
32                 `--vary cutoff=200hz..8000hz/1.25,log`"
33            ),
34        )
35    };
36    let (name, rest) = written.split_once('=').ok_or_else(shape)?;
37    let name = name.trim();
38    if name.is_empty() {
39        return Err(shape());
40    }
41    let (rest, log) = match rest.trim().strip_suffix(",log") {
42        Some(rest) => (rest, true),
43        None => (rest.trim(), false),
44    };
45    let (range, step) = match rest.split_once('/') {
46        Some((range, step)) => (range, Some(step.trim())),
47        None => (rest, None),
48    };
49    let (lo, hi) = range.split_once("..").ok_or_else(shape)?;
50    let (lo, lo_unit) = amount(lo.trim(), written)?;
51    let (hi, hi_unit) = amount(hi.trim(), written)?;
52    let unit = match (lo_unit, hi_unit) {
53        (a, b) if a == b => a,
54        (a, b) => {
55            return Err(bad(
56                "solve.bad_vary",
57                format!(
58                    "`--vary {written}` writes its bounds in `{a}` and `{b}`; write both in one \
59                     unit, as `200hz..8000hz`"
60                ),
61            ));
62        }
63    };
64    if lo >= hi {
65        return Err(bad(
66            "solve.bad_vary",
67            format!("`--vary {written}` has no room: its low bound is not under its high one"),
68        ));
69    }
70    if log && lo <= 0.0 {
71        return Err(bad(
72            "solve.bad_vary",
73            format!(
74                "`--vary {written}` is on a log scale, which needs both bounds above zero; drop \
75                 `,log` or raise the low bound"
76            ),
77        ));
78    }
79    let step = match step {
80        None => None,
81        Some(text) => Some(step_of(text, unit, log, written)?),
82    };
83    Ok(Vary {
84        name: name.to_string(),
85        lo,
86        hi,
87        step,
88        log,
89        unit,
90    })
91}
92
93fn step_of(text: &str, unit: &'static str, log: bool, written: &str) -> Result<f64, CliError> {
94    let (step, step_unit) = amount(text, written)?;
95    match log {
96        true if step_unit.is_empty() && step > 1.0 => Ok(step),
97        true => Err(bad(
98            "solve.bad_vary",
99            format!(
100                "`--vary {written}` is on a log scale, where a step is the ratio one step \
101                 multiplies by, a bare number above 1, as `/1.25`"
102            ),
103        )),
104        false if step > 0.0 && (step_unit == unit || step_unit.is_empty()) => Ok(step),
105        false => Err(bad(
106            "solve.bad_vary",
107            format!(
108                "`--vary {written}` steps by `{text}`; a step is above zero and written in the \
109                 bounds' own unit, or with none"
110            ),
111        )),
112    }
113}
114
115/// A number and the suffix it was written with.
116fn amount(text: &str, written: &str) -> Result<(f64, &'static str), CliError> {
117    let digits = text
118        .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')))
119        .unwrap_or(text.len());
120    let (number, suffix) = text.split_at(digits);
121    let unit = UNITS.iter().find(|u| **u == suffix).copied();
122    match (number.parse::<f64>(), unit) {
123        (Ok(n), Some(unit)) if n.is_finite() => Ok((n, unit)),
124        _ => Err(bad(
125            "solve.bad_vary",
126            format!(
127                "`{text}` in `--vary {written}` is no number with a unit; write a decimal and \
128                 one of `{}` or none, as `900hz` or `-6db`",
129                UNITS[1..].join("`, `")
130            ),
131        )),
132    }
133}
134
135impl Vary {
136    /// `value` as the variable's file would hold it, rounded where its grid runs out of digits.
137    pub fn text(&self, value: f64, precision: Precision) -> String {
138        let number = match precision {
139            Precision::Decimals(d) => trimmed(&format!("{value:.d$}")),
140            Precision::Significant(s) => {
141                let d = (s as i32 - 1 - value.abs().log10().floor() as i32).max(0) as usize;
142                trimmed(&format!("{value:.d$}"))
143            }
144            Precision::Shortest => format!("{value}"),
145        };
146        let number = match number.as_str() {
147            "-0" => "0".to_string(),
148            _ => number,
149        };
150        format!("{number}{}", self.unit)
151    }
152
153    /// The number `text` writes, as the render reads it in this unit.
154    pub fn number(&self, text: &str) -> f64 {
155        text.strip_suffix(self.unit)
156            .and_then(|n| n.parse().ok())
157            .expect("a value this variable wrote")
158    }
159
160    /// The variable's current value off its file, in this variable's unit.
161    pub fn start(&self, source: &dyn Source) -> Result<f64, CliError> {
162        let path = variable(&self.name);
163        let text = source.get(&path).map_err(CliError::Io)?;
164        let Some(text) = text else {
165            let listed = source.paths().map_err(CliError::Io)?;
166            let held: Vec<String> = listed
167                .found
168                .iter()
169                .filter_map(|p| p.strip_prefix(&format!("{VARIABLES}/")))
170                .map(|p| format!("`{p}`"))
171                .collect();
172            return Err(bad(
173                "solve.no_variable",
174                format!(
175                    "`--vary {}` names no file `{path}`; a variable is a file under \
176                     `{VARIABLES}/` holding one number, and here there are {}",
177                    self.name,
178                    match held.is_empty() {
179                        true => "none".to_string(),
180                        false => held.join(", "),
181                    }
182                ),
183            ));
184        };
185        let body = sva_ast::strip_line_comment(&text[sva_ast::body_at(&text)..]).trim();
186        let held = held_number(body).ok_or_else(|| self.no_number(body))?;
187        self.in_unit(held).ok_or_else(|| self.no_number(body))
188    }
189
190    fn no_number(&self, body: &str) -> CliError {
191        bad(
192            "solve.reaches_no_number",
193            format!(
194                "`{}/{}` holds `{body}`, which is no number in `{}`; a varied variable holds one \
195                 number, as `0.5`, `900hz` or `-6db`; set the file to one, or vary another",
196                VARIABLES,
197                self.name,
198                match self.unit {
199                    "" => "plain numbers",
200                    unit => unit,
201                }
202            ),
203        )
204    }
205
206    fn in_unit(&self, held: Held) -> Option<f64> {
207        let ratio = |v: f64| (v > 0.0).then_some(v);
208        Some(match (held, self.unit) {
209            (Held::Plain(v) | Held::Note(v), "" | "hz") => v,
210            (Held::Plain(v) | Held::Note(v), "khz") => v / 1000.0,
211            (Held::Plain(v), "db") => 20.0 * ratio(v)?.log10(),
212            (Held::Plain(v), "ct") => 1200.0 * ratio(v)?.log2(),
213            (Held::Plain(v), "st") => 12.0 * ratio(v)?.log2(),
214            (Held::Log(v, LogUnit::Decibels), "db")
215            | (Held::Log(v, LogUnit::Cents), "ct")
216            | (Held::Log(v, LogUnit::Semitones), "st") => v,
217            (Held::Log(v, unit), "") => unit.resolve(v),
218            (Held::Secs(v), "s") => v,
219            (Held::Secs(v), "ms") => v * 1000.0,
220            _ => return None,
221        })
222    }
223}
224
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226pub enum Precision {
227    Decimals(usize),
228    Significant(usize),
229    Shortest,
230}
231
232#[derive(Clone, Copy)]
233enum Held {
234    Plain(f64),
235    Note(f64),
236    Log(f64, LogUnit),
237    Secs(f64),
238}
239
240/// One literal, a minus before it allowed, or a note name, which is its hertz.
241fn held_number(body: &str) -> Option<Held> {
242    let tokens = tokenize(body).ok()?;
243    let (sign, rest) = match tokens.as_slice() {
244        [first, rest @ ..] if first.kind == TokenKind::Minus => (-1.0, rest),
245        all => (1.0, all),
246    };
247    match rest {
248        [one] => match &one.kind {
249            TokenKind::Num(n) => Some(Held::Plain(sign * n)),
250            TokenKind::Log(n, unit) => Some(Held::Log(sign * n, *unit)),
251            TokenKind::Time(n, sva_ast::SpanUnit::Seconds) => Some(Held::Secs(sign * n)),
252            TokenKind::Ident(name) if sign > 0.0 => sva_ast::note_hz(name).map(Held::Note),
253            _ => None,
254        },
255        _ => None,
256    }
257}
258
259fn trimmed(text: &str) -> String {
260    match text.contains('.') {
261        true => text.trim_end_matches('0').trim_end_matches('.').to_string(),
262        false => text.to_string(),
263    }
264}