1use 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 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
24pub 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
115fn 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 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 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 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
240fn 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}