Skip to main content

treble_lang/parser/
lines.rs

1//! Line-level parser for Treble Live DSL.
2//!
3//! Each source line is parsed independently into a [`SourceLine`].
4
5use super::mini::parse_mini;
6use crate::ast::program::*;
7
8/// Parse a single source line into a [`SourceLine`].
9pub fn parse_line(line: &str) -> Result<SourceLine, String> {
10    let trimmed = line.trim();
11
12    // Blank
13    if trimmed.is_empty() {
14        return Ok(SourceLine::Blank);
15    }
16
17    // Comment
18    if trimmed.starts_with("--") {
19        return Ok(SourceLine::Comment(trimmed.to_string()));
20    }
21
22    // A chain continued on its own line joins the statement above it before
23    // this parser sees it (§4.8); one that reaches here had nothing to join.
24    if trimmed.starts_with('|') {
25        return Err("a line starting with `|` continues the pattern above it, \
26                    but the line above isn't a pattern or a group's closing `}`"
27            .to_string());
28    }
29
30    // Directives
31    if let Some(rest) = strip_keyword(trimmed, "bpm") {
32        return parse_bpm(rest);
33    }
34    if let Some(rest) = strip_keyword(trimmed, "sig") {
35        return parse_sig(rest);
36    }
37    if let Some(rest) = strip_keyword(trimmed, "phrase") {
38        return parse_phrase(rest);
39    }
40    if let Some(rest) = strip_keyword(trimmed, "scale") {
41        return parse_scale(rest);
42    }
43    if let Some(rest) = strip_keyword(trimmed, "load") {
44        return parse_load(rest);
45    }
46    if let Some(rest) = strip_keyword(trimmed, "include") {
47        return parse_include(rest);
48    }
49    if let Some(rest) = strip_keyword(trimmed, "arrange") {
50        return parse_arrange(rest);
51    }
52    if let Some(rest) = strip_keyword(trimmed, "tail") {
53        return parse_tail(rest);
54    }
55    if let Some(rest) = strip_keyword(trimmed, "seed") {
56        return parse_seed(rest);
57    }
58    if let Some(rest) = strip_keyword(trimmed, "meta") {
59        return parse_meta(rest);
60    }
61    // Early builds used `use`; keep it as a parse-compatible alias while all
62    // diagnostics and documentation point to the clearer `include` spelling.
63    if let Some(rest) = strip_keyword(trimmed, "use") {
64        return parse_include(rest);
65    }
66
67    // Group markers. `group` is a reserved word: a pattern cannot be named it.
68    if trimmed == "group" || strip_keyword(trimmed, "group").is_some() {
69        return parse_group_header(trimmed, false);
70    }
71    // Section markers (§8.2). `section` is reserved the same way.
72    if trimmed == "section" || strip_keyword(trimmed, "section").is_some() {
73        return parse_section_header(trimmed, false);
74    }
75    // A bare `}` closes whichever block is open; only a group's may carry a
76    // pipeline, so the line parser reads the general form and
77    // [`parse_program`](super::parse_program) decides which block it ends.
78    if let Some(rest) = trimmed.strip_prefix('}') {
79        return parse_group_footer(rest);
80    }
81
82    // Muted pattern
83    if let Some(rest) = trimmed.strip_prefix(';') {
84        let rest = rest.trim_start();
85        if rest == "group" || strip_keyword(rest, "group").is_some() {
86            return parse_group_header(rest, true);
87        }
88        if rest == "section" || strip_keyword(rest, "section").is_some() {
89            return parse_section_header(rest, true);
90        }
91        return parse_pattern_line(rest, true);
92    }
93
94    // Pattern line
95    parse_pattern_line(trimmed, false)
96}
97
98/// `[;] group <name> {` — the shared filters live on the closing `}` line.
99fn parse_group_header(line: &str, muted: bool) -> Result<SourceLine, String> {
100    let rest = strip_keyword(line, "group")
101        .ok_or_else(|| "group: expected 'group <name> {'".to_string())?;
102    let Some(name) = rest.strip_suffix('{') else {
103        return Err(
104            "group: the header must end with '{' (filters go on the closing '}')".to_string(),
105        );
106    };
107    let name = name.trim();
108    validate_identifier(name).map_err(|error| format!("group: {error}"))?;
109    Ok(SourceLine::GroupStart {
110        muted,
111        name: name.to_string(),
112    })
113}
114
115/// `[;] section <name> <cycles> {` — the length is stated, not inferred (§8.2).
116fn parse_section_header(line: &str, muted: bool) -> Result<SourceLine, String> {
117    let rest = strip_keyword(line, "section")
118        .ok_or_else(|| "section: expected 'section <name> <cycles> {'".to_string())?;
119    let Some(body) = rest.strip_suffix('{') else {
120        return Err("section: the header must end with '{'".to_string());
121    };
122    let mut parts = body.split_whitespace();
123    let name = parts
124        .next()
125        .ok_or_else(|| "section: expected a name before the length".to_string())?;
126    validate_identifier(name).map_err(|error| format!("section: {error}"))?;
127    let Some(cycles) = parts.next() else {
128        return Err(format!(
129            "section: '{name}' needs a length in cycles — 'section {name} 16 {{'"
130        ));
131    };
132    let cycles: u32 = cycles
133        .parse()
134        .map_err(|_| format!("section: expected a length in cycles, got '{cycles}'"))?;
135    if cycles == 0 {
136        return Err("section: a section lasts at least one cycle".to_string());
137    }
138    if let Some(extra) = parts.next() {
139        return Err(format!(
140            "section: unexpected '{extra}' after the length (filters go on the member lines)"
141        ));
142    }
143    Ok(SourceLine::SectionStart {
144        muted,
145        name: name.to_string(),
146        cycles,
147    })
148}
149
150/// `}` closing a group, optionally followed by `| transform | transform`.
151fn parse_group_footer(rest: &str) -> Result<SourceLine, String> {
152    let rest = rest.trim();
153    if rest.is_empty() {
154        return Ok(SourceLine::GroupEnd(Vec::new()));
155    }
156    let Some(pipeline) = rest.strip_prefix('|') else {
157        return Err(format!(
158            "group: expected '| transform' after '}}', got '{rest}'"
159        ));
160    };
161    Ok(SourceLine::GroupEnd(parse_transforms(pipeline.trim())?))
162}
163
164/// Strip a keyword prefix followed by whitespace. Returns the rest.
165fn strip_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
166    if let Some(rest) = input.strip_prefix(keyword)
167        && rest.starts_with(char::is_whitespace)
168    {
169        Some(rest.trim_start())
170    } else {
171        None
172    }
173}
174
175// --- Directive parsers ---
176
177/// `arrange <name> [<name>*N …]` — the order the sections play in (§8.4).
178fn parse_arrange(input: &str) -> Result<SourceLine, String> {
179    let mut items = Vec::new();
180    for token in input.split_whitespace() {
181        let (name, repeat) = match token.split_once('*') {
182            Some((name, count)) => {
183                let repeat: u32 = count.parse().map_err(|_| {
184                    format!("arrange: expected a repeat count after '*', got '{count}'")
185                })?;
186                if repeat == 0 {
187                    return Err(format!(
188                        "arrange: '{name}*0' plays nothing — drop it from the line instead"
189                    ));
190                }
191                (name, repeat)
192            }
193            None => (token, 1),
194        };
195        validate_identifier(name).map_err(|error| format!("arrange: {error}"))?;
196        items.push(ArrangeItem {
197            section: name.to_string(),
198            repeat,
199        });
200    }
201    if items.is_empty() {
202        return Err("arrange: expected at least one section name".to_string());
203    }
204    Ok(SourceLine::Arrange(items))
205}
206
207/// `tail <seconds>` — how long a render rings out past the last cycle (§8.7).
208fn parse_tail(input: &str) -> Result<SourceLine, String> {
209    let raw = input.trim();
210    let seconds: f64 = raw
211        .parse()
212        .map_err(|_| format!("tail: expected a number of seconds, got '{raw}'"))?;
213    if !seconds.is_finite() || seconds < 0.0 {
214        return Err(format!("tail: expected zero or more seconds, got '{raw}'"));
215    }
216    Ok(SourceLine::Tail(seconds))
217}
218
219/// `meta <key> "<value>"` — a free-form tag carried with the piece (§8.10).
220///
221/// The key set is open on purpose: a piece knows things about itself that the
222/// language has no business enumerating, and a renderer can pass them straight
223/// through to the file it writes.
224fn parse_meta(input: &str) -> Result<SourceLine, String> {
225    let input = input.trim();
226    let (key, rest) = match input.split_once(char::is_whitespace) {
227        Some(split) => split,
228        None => return Err(format!("meta: expected a value after '{input}'")),
229    };
230    validate_identifier(key).map_err(|error| format!("meta: {error}"))?;
231    let rest = rest.trim();
232    let value = rest
233        .strip_prefix('"')
234        .and_then(|rest| rest.strip_suffix('"'))
235        .ok_or_else(|| format!("meta: the value must be double-quoted, got '{rest}'"))?;
236    if value.contains('"') {
237        return Err("meta: the value cannot contain a double quote".to_string());
238    }
239    Ok(SourceLine::Meta(
240        key.to_ascii_lowercase(),
241        value.to_string(),
242    ))
243}
244
245/// `seed <integer>` — salts the generative constructs (§8.8).
246fn parse_seed(input: &str) -> Result<SourceLine, String> {
247    let raw = input.trim();
248    let seed: u64 = raw
249        .parse()
250        .map_err(|_| format!("seed: expected a non-negative integer, got '{raw}'"))?;
251    Ok(SourceLine::Seed(seed))
252}
253
254/// `phrase <cycles>` — the unit changes are quantised to.
255///
256/// A drop has to land on the top of a phrase, not merely on the next cycle, so
257/// this is what the consumer aligns its snapshot swaps to.
258fn parse_phrase(input: &str) -> Result<SourceLine, String> {
259    let cycles: u32 = input.trim().parse().map_err(|_| {
260        format!(
261            "phrase: expected a number of cycles, got '{}'",
262            input.trim()
263        )
264    })?;
265    if cycles == 0 {
266        return Err("phrase: a phrase is at least one cycle".to_string());
267    }
268    Ok(SourceLine::Phrase(cycles))
269}
270
271fn parse_bpm(rest: &str) -> Result<SourceLine, String> {
272    let val: u32 = rest
273        .trim()
274        .parse()
275        .map_err(|_| format!("invalid bpm value: '{}'", rest.trim()))?;
276    if !(20..=999).contains(&val) {
277        return Err(format!("bpm must be between 20 and 999, got {}", val));
278    }
279    Ok(SourceLine::Bpm(val))
280}
281
282fn parse_sig(rest: &str) -> Result<SourceLine, String> {
283    let parts: Vec<&str> = rest.trim().split('/').collect();
284    if parts.len() != 2 {
285        return Err(format!(
286            "expected time signature N/D, got '{}'",
287            rest.trim()
288        ));
289    }
290    let num: u8 = parts[0]
291        .trim()
292        .parse()
293        .map_err(|_| format!("invalid numerator: '{}'", parts[0].trim()))?;
294    let den: u8 = parts[1]
295        .trim()
296        .parse()
297        .map_err(|_| format!("invalid denominator: '{}'", parts[1].trim()))?;
298    if num == 0 {
299        return Err("time signature numerator must be > 0".to_string());
300    }
301    if den == 0 {
302        return Err("time signature denominator must be > 0".to_string());
303    }
304    Ok(SourceLine::Sig(num, den))
305}
306
307fn parse_scale(rest: &str) -> Result<SourceLine, String> {
308    let mut tokens = rest.split_whitespace();
309    let root_str = tokens
310        .next()
311        .ok_or_else(|| "expected scale root note".to_string())?;
312    let mode_str = tokens
313        .next()
314        .ok_or_else(|| "expected scale mode".to_string())?;
315
316    let root = parse_pitch_root(root_str)?;
317    let mode = parse_scale_mode(mode_str)?;
318    Ok(SourceLine::Scale(root, mode))
319}
320
321fn parse_load(rest: &str) -> Result<SourceLine, String> {
322    let trimmed = rest.trim();
323    if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
324        let path = &trimmed[1..trimmed.len() - 1];
325        Ok(SourceLine::Load(path.to_string()))
326    } else {
327        Err(format!(
328            "expected quoted path after load, got '{}'",
329            trimmed
330        ))
331    }
332}
333
334fn parse_include(rest: &str) -> Result<SourceLine, String> {
335    let name = rest.trim();
336    validate_identifier(name)?;
337    Ok(SourceLine::Include(name.to_string()))
338}
339
340// --- Pattern line parser ---
341
342fn parse_pattern_line(input: &str, muted: bool) -> Result<SourceLine, String> {
343    let mut tokens = SplitKeepQuotes::new(input);
344
345    let name = tokens
346        .next()
347        .ok_or_else(|| "expected pattern name".to_string())?;
348    validate_identifier(name)?;
349
350    let instrument = tokens
351        .next()
352        .ok_or_else(|| "expected instrument name".to_string())?;
353    validate_identifier(instrument)?;
354
355    let notation_str = tokens
356        .next()
357        .ok_or_else(|| "expected quoted mini-notation".to_string())?;
358
359    if !notation_str.starts_with('"') || !notation_str.ends_with('"') || notation_str.len() < 2 {
360        return Err(format!(
361            "expected double-quoted mini-notation, got '{}'",
362            notation_str
363        ));
364    }
365    let inner = &notation_str[1..notation_str.len() - 1];
366    let notation = parse_mini(inner)?;
367
368    // Everything after the closing quote: an optional `@ span`, then the `|`
369    // pipeline. The span's position is fixed rather than free (§8.3) — it says
370    // when the line exists, not what happens to it, so it stays adjacent to the
371    // notation and leaves the pipeline contiguous.
372    let remainder: String = tokens.collect::<Vec<&str>>().join(" ");
373    let (span, rest) = split_span(remainder.trim())?;
374    let transforms = parse_transforms(rest.trim())?;
375
376    Ok(SourceLine::Pattern(PatternDef {
377        group: None,
378        section: None,
379        span,
380        muted,
381        name: name.to_string(),
382        instrument: instrument.to_string(),
383        notation,
384        transforms,
385    }))
386}
387
388/// Split a leading `@ <span>` off the tail of a pattern line, returning it and
389/// whatever pipeline follows.
390fn split_span(input: &str) -> Result<(Option<Span>, &str), String> {
391    let Some(rest) = input.strip_prefix('@') else {
392        return Ok((None, input));
393    };
394    let rest = rest.trim_start();
395    // The span runs to the first `|`, or to the end of the line.
396    let (body, tail) = match rest.find('|') {
397        Some(index) => rest.split_at(index),
398        None => (rest, ""),
399    };
400    Ok((Some(parse_span(body.trim())?), tail))
401}
402
403/// `3..8`, `5..`, `..4`, `8` — 1-based, both ends inclusive, either omittable.
404fn parse_span(body: &str) -> Result<Span, String> {
405    if body.is_empty() {
406        return Err("span: expected a cycle after '@' — '@ 8' or '@ 3..8'".to_string());
407    }
408    let cycle = |raw: &str| -> Result<u32, String> {
409        raw.parse::<u32>()
410            .map_err(|_| format!("span: expected a cycle number, got '{raw}'"))
411    };
412    let Some((from, to)) = body.split_once("..") else {
413        // A bare `@ n` is the single cycle n.
414        return Ok(Span::at(cycle(body)?));
415    };
416    let (from, to) = (from.trim(), to.trim());
417    if to.contains("..") {
418        return Err(format!("span: '{body}' has more than two ends"));
419    }
420    if from.is_empty() && to.is_empty() {
421        return Err(
422            "span: '@ ..' names no cycles — drop it to sound for the whole section".to_string(),
423        );
424    }
425    Ok(Span {
426        from: if from.is_empty() {
427            None
428        } else {
429            Some(cycle(from)?)
430        },
431        to: if to.is_empty() {
432            None
433        } else {
434            Some(cycle(to)?)
435        },
436    })
437}
438
439fn validate_identifier(s: &str) -> Result<(), String> {
440    if s.is_empty() {
441        return Err("identifier cannot be empty".to_string());
442    }
443    let first = s.chars().next().unwrap();
444    if !first.is_ascii_alphabetic() && first != '_' {
445        return Err(format!(
446            "identifier must start with a letter or underscore, got '{}'",
447            s
448        ));
449    }
450    if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
451        return Err(format!("invalid identifier: '{}'", s));
452    }
453    Ok(())
454}
455
456// --- Transform parser ---
457
458fn parse_transforms(input: &str) -> Result<Vec<Transform>, String> {
459    if input.is_empty() {
460        return Ok(Vec::new());
461    }
462
463    let mut transforms = Vec::new();
464    // Split by `|` (the transform pipe, outside of quotes)
465    let segments: Vec<&str> = input.split('|').collect();
466    for seg in segments {
467        let seg = seg.trim();
468        if seg.is_empty() {
469            continue;
470        }
471        transforms.push(parse_single_transform(seg)?);
472    }
473    Ok(transforms)
474}
475
476fn parse_single_transform(input: &str) -> Result<Transform, String> {
477    let mut parts = input.split_whitespace();
478    let keyword = parts
479        .next()
480        .ok_or_else(|| "expected transform keyword".to_string())?;
481    let transform = parse_transform_keyword(keyword, &mut parts)?;
482    // `every` and the filter calls consume the rest of the segment themselves;
483    // everything else has a fixed arity, and silently ignoring extra words
484    // hides typos.
485    if let Some(extra) = parts.next() {
486        return Err(format!("{}: unexpected argument '{}'", keyword, extra));
487    }
488    Ok(transform)
489}
490
491fn parse_transform_keyword(
492    keyword: &str,
493    parts: &mut std::str::SplitWhitespace,
494) -> Result<Transform, String> {
495    match keyword {
496        "rev" => Ok(Transform::Rev),
497        "fast" => Ok(Transform::Fast(parse_transform_ramp(parts, "fast")?)),
498        "slow" => Ok(Transform::Slow(parse_transform_ramp(parts, "slow")?)),
499        "ramp" => {
500            let text = parts
501                .next()
502                .ok_or_else(|| "ramp: expected a number of cycles".to_string())?;
503            let cycles: u32 = text
504                .parse()
505                .map_err(|_| format!("ramp: invalid number of cycles '{}'", text))?;
506            if cycles == 0 {
507                return Err("ramp: a span needs at least one cycle".to_string());
508            }
509            // Omitting the curve means `lin`, so every buffer written before
510            // curves existed keeps its meaning.
511            let curve = match parts.next() {
512                None => RampCurve::Linear,
513                Some("lin") => RampCurve::Linear,
514                Some("exp") => RampCurve::Exponential,
515                Some("osc") => RampCurve::Oscillate,
516                Some(other) => {
517                    return Err(format!(
518                        "ramp: '{other}' is not a curve; use 'lin' (the default), 'exp' or 'osc'"
519                    ));
520                }
521            };
522            Ok(Transform::RampSpan { cycles, curve })
523        }
524        "every" => {
525            let n_str = parts
526                .next()
527                .ok_or_else(|| "every: expected cycle count".to_string())?;
528            let n: u32 = n_str
529                .parse()
530                .map_err(|_| format!("every: invalid number '{}'", n_str))?;
531            let rest: String = parts.by_ref().collect::<Vec<&str>>().join(" ");
532            let inner = parse_single_transform(rest.trim())?;
533            Ok(Transform::Every(n, Box::new(inner)))
534        }
535        "arp" => {
536            let mode_str = parts
537                .next()
538                .ok_or_else(|| "arp: expected mode (up/down/updown/random)".to_string())?;
539            let mode = match mode_str {
540                "up" => ArpMode::Up,
541                "down" => ArpMode::Down,
542                "updown" => ArpMode::UpDown,
543                "random" => ArpMode::Random,
544                other => return Err(format!("arp: unknown mode '{}'", other)),
545            };
546            Ok(Transform::Arp(mode))
547        }
548        "scale" => {
549            let root_str = parts
550                .next()
551                .ok_or_else(|| "scale: expected root note".to_string())?;
552            let mode_str = parts
553                .next()
554                .ok_or_else(|| "scale: expected mode".to_string())?;
555            let root = parse_pitch_root(root_str)?;
556            let mode = parse_scale_mode(mode_str)?;
557            Ok(Transform::Scale(root, mode))
558        }
559        "oct" => {
560            let text = parts
561                .next()
562                .ok_or_else(|| "oct: expected offset".to_string())?;
563            let parse = |part: &str| {
564                part.parse::<i32>()
565                    .map_err(|_| format!("oct: invalid offset '{}'", part))
566            };
567            Ok(Transform::Oct(ramp_from_text(text, "oct", parse)?))
568        }
569        "vel" => {
570            let vel = parse_transform_ramp(parts, "vel")?;
571            validate_within("vel", &vel, 0.0, 1.0)?;
572            Ok(Transform::Vel(vel))
573        }
574        "gain" => {
575            let gain = parse_transform_ramp(parts, "gain")?;
576            validate_within("gain", &gain, 0.0, 2.0)?;
577            Ok(Transform::Gain(gain))
578        }
579        "pan" => {
580            let first = parts
581                .next()
582                .ok_or_else(|| "pan: expected a position or a waveform".to_string())?;
583            // A number, or a range of them, is a fixed position; anything else
584            // must name a waveform. The first character decides, so `-0.4..0.4`
585            // is read as a position rather than as an unknown waveform.
586            if first.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') {
587                let position = ramp_from_text(first, "pan", |part| {
588                    part.parse::<f64>()
589                        .map_err(|_| format!("pan: invalid position '{}'", part))
590                })?;
591                validate_within("pan", &position, -1.0, 1.0)?;
592                Ok(Transform::Pan(position))
593            } else {
594                Ok(Transform::AutoPan(parse_pan_sweep(first, parts)?))
595            }
596        }
597        "lpf" => {
598            let cutoff = parse_transform_ramp(parts, "lpf")?;
599            validate_at_least("lpf", &cutoff, 0.0)?;
600            Ok(Transform::Lpf(cutoff))
601        }
602        "hpf" => {
603            let cutoff = parse_transform_ramp(parts, "hpf")?;
604            validate_at_least("hpf", &cutoff, 0.0)?;
605            Ok(Transform::Hpf(cutoff))
606        }
607        "delay" => {
608            let time = parse_transform_ramp(parts, "delay time")?;
609            validate_at_least("delay time", &time, 0.0)?;
610            let feedback = parse_transform_ramp(parts, "delay feedback")?;
611            validate_within("delay feedback", &feedback, 0.0, 0.99)?;
612            let mix = match parts.next() {
613                Some(text) => {
614                    let mix = ramp_from_text(text, "delay mix", |part| {
615                        part.parse::<f64>()
616                            .map_err(|_| format!("delay mix: invalid number '{}'", part))
617                    })?;
618                    validate_within("delay mix", &mix, 0.0, 1.0)?;
619                    Some(mix)
620                }
621                None => None,
622            };
623            Ok(Transform::Delay(time, feedback, mix))
624        }
625        "reverb" => {
626            let amount = parse_transform_ramp(parts, "reverb")?;
627            validate_within("reverb", &amount, 0.0, 1.0)?;
628            Ok(Transform::Reverb(amount))
629        }
630        // A raw engine filter, named explicitly.
631        "fx" => {
632            let filter = parts
633                .next()
634                .ok_or_else(|| "fx: expected a filter name".to_string())?;
635            Ok(Transform::Fx(parse_fx_call(filter, parts)?))
636        }
637        // Short aliases for the filters worth reaching for mid-performance.
638        // Only the spelling lives here; what each one maps to is the
639        // consumer's business.
640        keyword if FX_ALIASES.contains(&keyword) => {
641            Ok(Transform::Fx(parse_fx_call(keyword, parts)?))
642        }
643        other => Err(format!("unknown transform: '{}'", other)),
644    }
645}
646
647/// Aliases accepted in place of `fx <filter>`.
648///
649/// Listing them keeps an unknown keyword an unknown keyword, rather than
650/// silently turning a typo into a lookup for a filter that does not exist.
651/// The curated transform spellings (`lpf`, `pan`, …) are included so a `def`
652/// block's effect chain reads the same as a pattern's. In a pattern line the
653/// dedicated `Transform` arms match first, so those keep their own semantics.
654pub const FX_ALIASES: &[&str] = &[
655    "trem", "bpf", "rbpf", "avg", "clip", "comp", "limit", "lpf", "hpf", "delay", "reverb", "pan",
656    "gain",
657];
658
659/// Parse a whole `fx …` or alias line, for reuse inside `def` blocks (§6.7).
660pub(crate) fn parse_fx_line(line: &str) -> Result<FxCall, String> {
661    let mut parts = line.split_whitespace();
662    let keyword = parts
663        .next()
664        .ok_or_else(|| "expected a filter name".to_string())?;
665    match keyword {
666        "fx" => {
667            let filter = parts
668                .next()
669                .ok_or_else(|| "fx: expected a filter name".to_string())?;
670            parse_fx_call(filter, &mut parts)
671        }
672        alias if FX_ALIASES.contains(&alias) => parse_fx_call(alias, &mut parts),
673        other => Err(format!(
674            "'{other}' is not a filter line; use 'fx <filter> …' or a short alias"
675        )),
676    }
677}
678
679fn parse_fx_call(filter: &str, parts: &mut std::str::SplitWhitespace) -> Result<FxCall, String> {
680    if filter.is_empty() {
681        return Err("fx: expected a filter name".to_string());
682    }
683    let mut args = Vec::new();
684    let mut seen_named = false;
685    for token in parts.by_ref() {
686        match token.split_once('=') {
687            Some((name, value)) => {
688                if name.is_empty() {
689                    return Err(format!("{filter}: '{token}' is missing a parameter name"));
690                }
691                seen_named = true;
692                args.push(FxArg::Named(
693                    name.to_string(),
694                    parse_fx_value(filter, name, value)?,
695                ));
696            }
697            None => {
698                // Positional arguments count from the front, so one appearing
699                // after a named argument has no well-defined slot.
700                if seen_named {
701                    return Err(format!(
702                        "{filter}: '{token}' comes after a named argument; give it a name too"
703                    ));
704                }
705                args.push(FxArg::Positional(parse_fx_value(
706                    filter, "argument", token,
707                )?));
708            }
709        }
710    }
711    Ok(FxCall {
712        filter: filter.to_string(),
713        args,
714    })
715}
716
717fn parse_fx_value(filter: &str, label: &str, text: &str) -> Result<FxValue, String> {
718    let lowered = text.to_ascii_lowercase();
719    let (number, hertz) = match lowered.strip_suffix("hz") {
720        Some(number) => (number, true),
721        None => (lowered.as_str(), false),
722    };
723    let name = format!("{filter} {label}");
724    let ramp = ramp_from_text(number, &name, |part| {
725        part.parse::<f64>().map_err(|_| {
726            if hertz {
727                format!("{name}: invalid frequency '{text}'")
728            } else {
729                format!("{name}: invalid number '{text}'")
730            }
731        })
732    })?;
733    // Nothing range-checks these: which interval a filter's parameter accepts
734    // is the consumer's registry to know, not this crate's (§4.5).
735    Ok(if hertz {
736        FxValue::Hertz(ramp)
737    } else {
738        FxValue::Plain(ramp)
739    })
740}
741
742fn parse_pan_sweep(wave: &str, parts: &mut std::str::SplitWhitespace) -> Result<PanSweep, String> {
743    let wave = parse_lfo_wave(wave)?;
744    let rate_text = parts
745        .next()
746        .ok_or_else(|| "pan: expected a rate after the waveform".to_string())?;
747    let rate = parse_lfo_rate(rate_text)?;
748    let depth = match parts.next() {
749        Some(text) => Some(
750            text.parse::<f64>()
751                .map_err(|_| format!("pan depth: invalid number '{}'", text))?,
752        ),
753        None => None,
754    };
755    Ok(PanSweep { wave, rate, depth })
756}
757
758fn parse_lfo_wave(text: &str) -> Result<LfoWave, String> {
759    match text {
760        "sine" | "sin" => Ok(LfoWave::Sine),
761        "tri" | "triangle" => Ok(LfoWave::Triangle),
762        "sq" | "square" => Ok(LfoWave::Square),
763        "saw" => Ok(LfoWave::Saw),
764        "rand" | "random" => Ok(LfoWave::Random),
765        other => Err(format!(
766            "pan: '{}' is not a position or a waveform (sine, tri, sq, saw, rand)",
767            other
768        )),
769    }
770}
771
772/// A bare number is a period in cycles; an `hz` suffix is absolute frequency.
773fn parse_lfo_rate(text: &str) -> Result<LfoRate, String> {
774    let lowered = text.to_ascii_lowercase();
775    match lowered.strip_suffix("hz") {
776        Some(number) => number
777            .parse::<f64>()
778            .map(LfoRate::Hertz)
779            .map_err(|_| format!("pan rate: invalid frequency '{}'", text)),
780        None => text
781            .parse::<f64>()
782            .map(LfoRate::Cycles)
783            .map_err(|_| format!("pan rate: invalid number of cycles '{}'", text)),
784    }
785}
786
787/// `0.4` or `0.4..1.0` — an argument that may travel across the line's `ramp`.
788fn parse_transform_ramp(
789    parts: &mut std::str::SplitWhitespace,
790    name: &str,
791) -> Result<Ramp<f64>, String> {
792    let text = parts
793        .next()
794        .ok_or_else(|| format!("{}: expected number", name))?;
795    ramp_from_text(text, name, |part| {
796        part.parse::<f64>()
797            .map_err(|_| format!("{}: invalid number '{}'", name, part))
798    })
799}
800
801/// `4`, `4..16`, `2>4>8>16` or `r(...)`, from one whitespace-delimited
802/// argument.
803///
804/// The two range spellings mean different things, so mixing them in one
805/// argument is an error rather than a guess.
806pub(crate) fn ramp_from_text<T: Copy>(
807    text: &str,
808    name: &str,
809    parse: impl Fn(&str) -> Result<T, String>,
810) -> Result<Ramp<T>, String> {
811    ramp_from_text_dyn(text, name, &parse)
812}
813
814/// The `dyn` layer: `r(...)` re-enters ramp parsing for its range argument,
815/// and a generic closure parameter would nest a new type each level.
816fn ramp_from_text_dyn<T: Copy>(
817    text: &str,
818    name: &str,
819    parse: &dyn Fn(&str) -> Result<T, String>,
820) -> Result<Ramp<T>, String> {
821    if let Some(inner) = text.strip_prefix("r(") {
822        let Some(inner) = inner.strip_suffix(')') else {
823            return Err(format!(
824                "{name}: '{text}' does not close its parenthesis — r(...) is written \
825                 without spaces, e.g. r(300,9000,4,exp)"
826            ));
827        };
828        return timed_ramp_from_args(inner, name, &parse);
829    }
830    let sweeps = text.contains("..");
831    let steps = text.contains('>');
832    if sweeps && steps {
833        return Err(format!(
834            "{name}: '{text}' mixes '..' and '>'. Use '..' to sweep smoothly, or '>' to hold each value in turn."
835        ));
836    }
837    if sweeps {
838        let mut ends = text.split("..");
839        let from = parse(ends.next().unwrap_or_default())?;
840        let to = parse(ends.next().unwrap_or_default())?;
841        if ends.next().is_some() {
842            return Err(format!(
843                "{name}: '{text}' has more than two ends. Use '>' for a sequence of held values."
844            ));
845        }
846        return Ok(Ramp::Sweep { from, to });
847    }
848    let mut stages = text.split('>');
849    let first = parse(stages.next().unwrap_or_default())?;
850    let rest = stages.map(parse).collect::<Result<Vec<T>, String>>()?;
851    Ok(Ramp::steps(first, rest))
852}
853
854/// The inside of `r(...)`: a range, then optionally a span in time divisions
855/// and a curve. `r(300,9000)` is plain `300..9000`; `r(300,9000,4)` carries
856/// its own four-beat window; `r(2>4>8,6,osc)` cycles its stages.
857fn timed_ramp_from_args<T: Copy>(
858    inner: &str,
859    name: &str,
860    parse: &dyn Fn(&str) -> Result<T, String>,
861) -> Result<Ramp<T>, String> {
862    let args: Vec<&str> = inner.split(',').map(str::trim).collect();
863    if args.iter().any(|arg| arg.is_empty()) {
864        return Err(format!("{name}: r(...) has an empty argument"));
865    }
866
867    // The range: one leading argument spelt `a..b`/`a>b>c`, or two arguments
868    // read as `from,to`.
869    let (ramp, rest) = if args[0].contains("..") || args[0].contains('>') {
870        (ramp_from_text_dyn(args[0], name, parse)?, &args[1..])
871    } else if args.len() >= 2 {
872        let from = parse(args[0])?;
873        let to = parse(args[1])?;
874        (Ramp::Sweep { from, to }, &args[2..])
875    } else {
876        return Err(format!(
877            "{name}: r(...) needs a travel — r(from,to), r(a..b) or r(a>b>c)"
878        ));
879    };
880    if !ramp.travels() {
881        return Err(format!("{name}: r(...) needs a travel, not a single value"));
882    }
883
884    let mut rest = rest.iter();
885    let Some(span_text) = rest.next() else {
886        // No span of its own: plain sugar, the line's `| ramp` governs it.
887        return Ok(ramp);
888    };
889    let span_divisions: f64 = span_text.parse().map_err(|_| {
890        format!("{name}: r(...) span '{span_text}' is not a number of time divisions")
891    })?;
892    if !span_divisions.is_finite() || span_divisions <= 0.0 {
893        return Err(format!(
894            "{name}: r(...) span must be a positive number of time divisions"
895        ));
896    }
897    let curve = match rest.next().copied() {
898        None => RampCurve::Linear,
899        Some("lin") => RampCurve::Linear,
900        Some("exp") => RampCurve::Exponential,
901        Some("osc") => RampCurve::Oscillate,
902        Some(other) => {
903            return Err(format!(
904                "{name}: r(...) style '{other}' is not a curve; use 'lin' (the default), 'exp' or 'osc'"
905            ));
906        }
907    };
908    if let Some(extra) = rest.next() {
909        return Err(format!(
910            "{name}: r(...) has an unexpected argument '{extra}'"
911        ));
912    }
913    Ok(Ramp::Timed {
914        ramp: Box::new(ramp),
915        span_divisions,
916        curve,
917    })
918}
919
920/// Every value a ramp passes through has to be legal, not only the one it
921/// starts on — the whole travel is played, so the whole travel is checked.
922fn validate_within(name: &str, ramp: &Ramp<f64>, low: f64, high: f64) -> Result<(), String> {
923    for value in ramp.values() {
924        if !value.is_finite() || !(low..=high).contains(&value) {
925            // "{low} to {high}" rather than a hyphen: a negative bound would
926            // make `pan: -2 is outside the supported range -1-1`.
927            return Err(format!(
928                "{name}: {value} is outside the supported range {low} to {high}"
929            ));
930        }
931    }
932    Ok(())
933}
934
935/// The half-open case: a cutoff in hertz or a delay time has a floor but no
936/// ceiling this crate is entitled to invent.
937fn validate_at_least(name: &str, ramp: &Ramp<f64>, low: f64) -> Result<(), String> {
938    for value in ramp.values() {
939        if !value.is_finite() || value < low {
940            return Err(format!("{name}: {value} is below the minimum of {low}"));
941        }
942    }
943    Ok(())
944}
945
946// --- Shared helpers ---
947
948fn parse_pitch_root(s: &str) -> Result<PitchRoot, String> {
949    let mut chars = s.chars();
950    let letter_ch = chars.next().ok_or("expected note letter")?;
951    let letter = match letter_ch {
952        'C' | 'c' => NoteLetter::C,
953        'D' | 'd' => NoteLetter::D,
954        'E' | 'e' => NoteLetter::E,
955        'F' | 'f' => NoteLetter::F,
956        'G' | 'g' => NoteLetter::G,
957        'A' | 'a' => NoteLetter::A,
958        'B' | 'b' => NoteLetter::B,
959        other => return Err(format!("invalid note letter: '{}'", other)),
960    };
961    let rest: String = chars.collect();
962    let accidental = match rest.as_str() {
963        "#" => Accidental::Sharp,
964        "##" => Accidental::DoubleSharp,
965        "b" => Accidental::Flat,
966        "bb" => Accidental::DoubleFlat,
967        "" => Accidental::Natural,
968        other => return Err(format!("invalid accidental: '{}'", other)),
969    };
970    Ok(PitchRoot {
971        name: letter,
972        accidental,
973    })
974}
975
976fn parse_scale_mode(s: &str) -> Result<ScaleMode, String> {
977    match s {
978        "major" => Ok(ScaleMode::Major),
979        "minor" => Ok(ScaleMode::Minor),
980        "dorian" => Ok(ScaleMode::Dorian),
981        "phrygian" => Ok(ScaleMode::Phrygian),
982        "lydian" => Ok(ScaleMode::Lydian),
983        "mixolydian" => Ok(ScaleMode::Mixolydian),
984        "aeolian" => Ok(ScaleMode::Aeolian),
985        "locrian" => Ok(ScaleMode::Locrian),
986        "chromatic" => Ok(ScaleMode::Chromatic),
987        "pentatonic" => Ok(ScaleMode::Pentatonic),
988        "blues" => Ok(ScaleMode::Blues),
989        other => Err(format!("unknown scale mode: '{}'", other)),
990    }
991}
992
993/// A simple splitter that keeps quoted strings as single tokens.
994struct SplitKeepQuotes<'a> {
995    rest: &'a str,
996}
997
998impl<'a> SplitKeepQuotes<'a> {
999    fn new(input: &'a str) -> Self {
1000        Self { rest: input }
1001    }
1002}
1003
1004impl<'a> Iterator for SplitKeepQuotes<'a> {
1005    type Item = &'a str;
1006
1007    fn next(&mut self) -> Option<Self::Item> {
1008        self.rest = self.rest.trim_start();
1009        if self.rest.is_empty() {
1010            return None;
1011        }
1012        if self.rest.starts_with('"') {
1013            // Find the closing quote
1014            if let Some(end) = self.rest[1..].find('"') {
1015                let token = &self.rest[..end + 2]; // include both quotes
1016                self.rest = &self.rest[end + 2..];
1017                Some(token)
1018            } else {
1019                // Unclosed quote — return rest
1020                let token = self.rest;
1021                self.rest = "";
1022                Some(token)
1023            }
1024        } else {
1025            // Regular whitespace-delimited token
1026            let end = self
1027                .rest
1028                .find(char::is_whitespace)
1029                .unwrap_or(self.rest.len());
1030            let token = &self.rest[..end];
1031            self.rest = &self.rest[end..];
1032            Some(token)
1033        }
1034    }
1035}
1036
1037// ============================================================
1038// Tests
1039// ============================================================
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use crate::ast::*;
1045
1046    // ---- Comments & blanks ----
1047
1048    #[test]
1049    fn test_blank_line() {
1050        assert_eq!(parse_line("").unwrap(), SourceLine::Blank);
1051        assert_eq!(parse_line("   ").unwrap(), SourceLine::Blank);
1052    }
1053
1054    #[test]
1055    fn test_comment() {
1056        let result = parse_line("-- hello world").unwrap();
1057        assert_eq!(result, SourceLine::Comment("-- hello world".to_string()));
1058    }
1059
1060    // ---- Directives ----
1061
1062    #[test]
1063    fn test_bpm() {
1064        assert_eq!(parse_line("bpm 120").unwrap(), SourceLine::Bpm(120));
1065        assert_eq!(parse_line("bpm 60").unwrap(), SourceLine::Bpm(60));
1066    }
1067
1068    #[test]
1069    fn test_bpm_invalid() {
1070        assert!(parse_line("bpm abc").is_err());
1071        assert!(parse_line("bpm 10").is_err()); // below 20
1072        assert!(parse_line("bpm 1000").is_err()); // above 999
1073    }
1074
1075    #[test]
1076    fn test_sig() {
1077        assert_eq!(parse_line("sig 4/4").unwrap(), SourceLine::Sig(4, 4));
1078        assert_eq!(parse_line("sig 3/4").unwrap(), SourceLine::Sig(3, 4));
1079        assert_eq!(parse_line("sig 7/8").unwrap(), SourceLine::Sig(7, 8));
1080    }
1081
1082    #[test]
1083    fn test_sig_invalid() {
1084        assert!(parse_line("sig 0/4").is_err()); // numerator 0
1085        assert!(parse_line("sig 4/0").is_err()); // denominator 0
1086        assert!(parse_line("sig abc").is_err());
1087    }
1088
1089    #[test]
1090    fn test_sig_accepts_non_binary_denominator() {
1091        assert_eq!(parse_line("sig 4/3").unwrap(), SourceLine::Sig(4, 3));
1092        assert_eq!(parse_line("sig 5/6").unwrap(), SourceLine::Sig(5, 6));
1093    }
1094
1095    #[test]
1096    fn test_explicit_instrument_include() {
1097        assert_eq!(
1098            parse_line("include hihat").unwrap(),
1099            SourceLine::Include("hihat".into())
1100        );
1101        assert_eq!(
1102            parse_line("use hihat").unwrap(),
1103            SourceLine::Include("hihat".into())
1104        );
1105    }
1106
1107    #[test]
1108    fn test_scale() {
1109        let result = parse_line("scale C minor").unwrap();
1110        assert_eq!(
1111            result,
1112            SourceLine::Scale(
1113                PitchRoot {
1114                    name: NoteLetter::C,
1115                    accidental: Accidental::Natural,
1116                },
1117                ScaleMode::Minor
1118            )
1119        );
1120    }
1121
1122    #[test]
1123    fn test_scale_with_accidental() {
1124        let result = parse_line("scale Eb dorian").unwrap();
1125        assert_eq!(
1126            result,
1127            SourceLine::Scale(
1128                PitchRoot {
1129                    name: NoteLetter::E,
1130                    accidental: Accidental::Flat,
1131                },
1132                ScaleMode::Dorian
1133            )
1134        );
1135    }
1136
1137    #[test]
1138    fn test_load() {
1139        let result = parse_line("load \"pads.rt\"").unwrap();
1140        assert_eq!(result, SourceLine::Load("pads.rt".to_string()));
1141    }
1142
1143    #[test]
1144    fn test_load_unquoted() {
1145        assert!(parse_line("load pads.rt").is_err());
1146    }
1147
1148    // ---- Pattern lines ----
1149
1150    #[test]
1151    fn test_simple_pattern() {
1152        let result = parse_line("kick drums \"x ~ x ~\"").unwrap();
1153        if let SourceLine::Pattern(p) = result {
1154            assert_eq!(p.name, "kick");
1155            assert_eq!(p.instrument, "drums");
1156            assert!(!p.muted);
1157            assert!(p.transforms.is_empty());
1158            assert_eq!(p.notation.sequence.steps.len(), 4);
1159        } else {
1160            panic!("expected pattern");
1161        }
1162    }
1163
1164    #[test]
1165    fn test_muted_pattern() {
1166        let result = parse_line("; bass sine \"c2 ~ eb2 ~\"").unwrap();
1167        if let SourceLine::Pattern(p) = result {
1168            assert!(p.muted);
1169            assert_eq!(p.name, "bass");
1170            assert_eq!(p.instrument, "sine");
1171        } else {
1172            panic!("expected muted pattern");
1173        }
1174    }
1175
1176    #[test]
1177    fn test_pattern_with_transforms() {
1178        let result = parse_line("lead saw \"c4 eb4 g4 bb4\" | rev | slow 2").unwrap();
1179        if let SourceLine::Pattern(p) = result {
1180            assert_eq!(p.name, "lead");
1181            assert_eq!(p.transforms.len(), 2);
1182            assert_eq!(p.transforms[0], Transform::Rev);
1183            assert_eq!(p.transforms[1], Transform::Slow(Ramp::fixed(2.0)));
1184        } else {
1185            panic!("expected pattern");
1186        }
1187    }
1188
1189    #[test]
1190    fn test_pattern_with_every_transform() {
1191        let result = parse_line("hats hihat \"x*8\" | every 4 rev").unwrap();
1192        if let SourceLine::Pattern(p) = result {
1193            assert_eq!(
1194                p.transforms[0],
1195                Transform::Every(4, Box::new(Transform::Rev))
1196            );
1197        } else {
1198            panic!("expected pattern");
1199        }
1200    }
1201
1202    #[test]
1203    fn test_pattern_with_arp() {
1204        let result = parse_line("arp piano \"[c3,e3,g3]\" | arp up").unwrap();
1205        if let SourceLine::Pattern(p) = result {
1206            assert_eq!(p.transforms[0], Transform::Arp(ArpMode::Up));
1207        } else {
1208            panic!("expected pattern");
1209        }
1210    }
1211
1212    #[test]
1213    fn test_pattern_with_scale_transform() {
1214        let result = parse_line("mel saw \"0 2 4 6\" | scale C minor").unwrap();
1215        if let SourceLine::Pattern(p) = result {
1216            if let Transform::Scale(root, mode) = &p.transforms[0] {
1217                assert_eq!(root.name, NoteLetter::C);
1218                assert_eq!(*mode, ScaleMode::Minor);
1219            } else {
1220                panic!("expected scale transform");
1221            }
1222        } else {
1223            panic!("expected pattern");
1224        }
1225    }
1226
1227    #[test]
1228    fn test_pattern_with_effects() {
1229        let result = parse_line(
1230            "pad pad \"[c3,eb3,g3]\" | gain 0.5 | lpf 800 | delay 0.25 0.4 | reverb 0.3",
1231        )
1232        .unwrap();
1233        if let SourceLine::Pattern(p) = result {
1234            assert_eq!(p.transforms.len(), 4);
1235            assert_eq!(p.transforms[0], Transform::Gain(Ramp::fixed(0.5)));
1236            assert_eq!(p.transforms[1], Transform::Lpf(Ramp::fixed(800.0)));
1237            assert_eq!(
1238                p.transforms[2],
1239                Transform::Delay(Ramp::fixed(0.25), Ramp::fixed(0.4), None)
1240            );
1241            assert_eq!(p.transforms[3], Transform::Reverb(Ramp::fixed(0.3)));
1242        } else {
1243            panic!("expected pattern");
1244        }
1245    }
1246
1247    #[test]
1248    fn test_pan_and_vel_transforms() {
1249        let result =
1250            parse_line("lead saw \"c4 e4\" | pan -1.0 | vel 0.42 | gain 1.4 | delay 0.2 0.3 0.5")
1251                .unwrap();
1252        if let SourceLine::Pattern(p) = result {
1253            assert_eq!(p.transforms[0], Transform::Pan(Ramp::fixed(-1.0)));
1254            assert_eq!(p.transforms[1], Transform::Vel(Ramp::fixed(0.42)));
1255            assert_eq!(p.transforms[2], Transform::Gain(Ramp::fixed(1.4)));
1256            assert_eq!(
1257                p.transforms[3],
1258                Transform::Delay(Ramp::fixed(0.2), Ramp::fixed(0.3), Some(Ramp::fixed(0.5)))
1259            );
1260        } else {
1261            panic!("expected pattern");
1262        }
1263    }
1264
1265    #[test]
1266    fn test_pan_sweep_variants() {
1267        let sweep = |source: &str| match parse_line(source).unwrap() {
1268            SourceLine::Pattern(p) => match p.transforms[0] {
1269                Transform::AutoPan(sweep) => sweep,
1270                ref other => panic!("expected a swept pan, got {other:?}"),
1271            },
1272            other => panic!("expected a pattern, got {other:?}"),
1273        };
1274
1275        let basic = sweep("hat hihat \"x*8\" | pan sine 4");
1276        assert_eq!(basic.wave, LfoWave::Sine);
1277        assert_eq!(basic.rate, LfoRate::Cycles(4.0));
1278        assert_eq!(basic.depth, None);
1279
1280        let deep = sweep("hat hihat \"x*8\" | pan sq 1 0.6");
1281        assert_eq!(deep.wave, LfoWave::Square);
1282        assert_eq!(deep.rate, LfoRate::Cycles(1.0));
1283        assert_eq!(deep.depth, Some(0.6));
1284
1285        // An `hz` suffix opts out of tempo-relative timing.
1286        let absolute = sweep("hat hihat \"x*8\" | pan tri 0.5hz");
1287        assert_eq!(absolute.wave, LfoWave::Triangle);
1288        assert_eq!(absolute.rate, LfoRate::Hertz(0.5));
1289
1290        assert_eq!(sweep("p pad \"0\" | pan saw 2").wave, LfoWave::Saw);
1291        assert_eq!(sweep("p pad \"0\" | pan rand 1").wave, LfoWave::Random);
1292        // Long spellings and mixed case on the suffix both work.
1293        assert_eq!(
1294            sweep("p pad \"0\" | pan triangle 2HZ").rate,
1295            LfoRate::Hertz(2.0)
1296        );
1297    }
1298
1299    #[test]
1300    fn test_fixed_pan_still_parses_as_a_position() {
1301        let result = parse_line("kick kick \"x ~\" | pan -0.3").unwrap();
1302        if let SourceLine::Pattern(p) = result {
1303            assert_eq!(p.transforms[0], Transform::Pan(Ramp::fixed(-0.3)));
1304        } else {
1305            panic!("expected pattern");
1306        }
1307    }
1308
1309    #[test]
1310    fn test_pan_sweep_errors_are_specific() {
1311        let message = |source: &str| parse_line(source).unwrap_err();
1312        assert!(
1313            message("p pad \"0\" | pan wobble 4").contains("not a position or a waveform"),
1314            "got: {}",
1315            message("p pad \"0\" | pan wobble 4")
1316        );
1317        assert!(message("p pad \"0\" | pan sine").contains("expected a rate"));
1318        assert!(message("p pad \"0\" | pan sine fast").contains("invalid number of cycles"));
1319        assert!(message("p pad \"0\" | pan sine 4 loud").contains("invalid number"));
1320        assert!(message("p pad \"0\" | pan sine 4hz 0.5 extra").contains("unexpected argument"));
1321    }
1322
1323    fn fx_call(source: &str) -> FxCall {
1324        match parse_line(source).unwrap() {
1325            SourceLine::Pattern(p) => match p.transforms[0].clone() {
1326                Transform::Fx(call) => call,
1327                other => panic!("expected a filter call, got {other:?}"),
1328            },
1329            other => panic!("expected a pattern, got {other:?}"),
1330        }
1331    }
1332
1333    #[test]
1334    fn test_fx_call_positional_and_named_arguments() {
1335        let call = fx_call("p pad \"0\" | fx Compressor 0.3 8");
1336        assert_eq!(call.filter, "Compressor");
1337        assert_eq!(
1338            call.args,
1339            vec![
1340                FxArg::Positional(FxValue::Plain(Ramp::fixed(0.3))),
1341                FxArg::Positional(FxValue::Plain(Ramp::fixed(8.0))),
1342            ]
1343        );
1344
1345        let call = fx_call("p pad \"0\" | fx Limiter threshold=0.8 release=0.3");
1346        assert_eq!(
1347            call.args,
1348            vec![
1349                FxArg::Named("threshold".into(), FxValue::Plain(Ramp::fixed(0.8))),
1350                FxArg::Named("release".into(), FxValue::Plain(Ramp::fixed(0.3))),
1351            ]
1352        );
1353
1354        // Positional first, then named, is allowed.
1355        let call = fx_call("p pad \"0\" | fx Compressor 0.3 ratio=8");
1356        assert_eq!(
1357            call.args,
1358            vec![
1359                FxArg::Positional(FxValue::Plain(Ramp::fixed(0.3))),
1360                FxArg::Named("ratio".into(), FxValue::Plain(Ramp::fixed(8.0))),
1361            ]
1362        );
1363
1364        // A filter taking no arguments at all is still a valid call.
1365        assert!(fx_call("p pad \"0\" | fx Limiter").args.is_empty());
1366    }
1367
1368    #[test]
1369    fn test_fx_aliases_parse_as_filter_calls() {
1370        for (source, filter) in [
1371            ("p pad \"0\" | trem 4 0.6", "trem"),
1372            ("p pad \"0\" | bpf 200 1200", "bpf"),
1373            ("p pad \"0\" | rbpf 900 12", "rbpf"),
1374            ("p pad \"0\" | avg 5", "avg"),
1375            ("p pad \"0\" | clip 0.4", "clip"),
1376            ("p pad \"0\" | comp 0.3 8", "comp"),
1377            ("p pad \"0\" | limit 0.9", "limit"),
1378        ] {
1379            assert_eq!(fx_call(source).filter, filter);
1380        }
1381    }
1382
1383    #[test]
1384    fn test_fx_hz_suffix_is_kept_distinct_from_a_bare_number() {
1385        let call = fx_call("p pad \"0\" | trem 0.5hz");
1386        assert_eq!(
1387            call.args,
1388            vec![FxArg::Positional(FxValue::Hertz(Ramp::fixed(0.5)))]
1389        );
1390        let call = fx_call("p pad \"0\" | trem 4");
1391        assert_eq!(
1392            call.args,
1393            vec![FxArg::Positional(FxValue::Plain(Ramp::fixed(4.0)))]
1394        );
1395        // Mixed case on the suffix too.
1396        let call = fx_call("p pad \"0\" | fx Tremolo frequency=2HZ");
1397        assert_eq!(
1398            call.args,
1399            vec![FxArg::Named(
1400                "frequency".into(),
1401                FxValue::Hertz(Ramp::fixed(2.0))
1402            )]
1403        );
1404    }
1405
1406    #[test]
1407    fn test_fx_argument_errors_are_specific() {
1408        let message = |source: &str| parse_line(source).unwrap_err();
1409        assert!(message("p pad \"0\" | fx").contains("expected a filter name"));
1410        assert!(message("p pad \"0\" | trem loud").contains("invalid number"));
1411        assert!(message("p pad \"0\" | trem =4").contains("missing a parameter name"));
1412        assert!(
1413            message("p pad \"0\" | fx Compressor ratio=8 0.3").contains("comes after a named"),
1414            "got: {}",
1415            message("p pad \"0\" | fx Compressor ratio=8 0.3")
1416        );
1417        // An unknown keyword stays an unknown keyword rather than becoming a
1418        // lookup for a filter nobody registered.
1419        assert!(message("p pad \"0\" | revv").contains("unknown transform"));
1420    }
1421
1422    #[test]
1423    fn test_ramp_span_and_ranged_transforms() {
1424        let result = parse_line("sn snare \"x(4..16,4)\" | vel 0.4..1.0 | ramp 16").unwrap();
1425        if let SourceLine::Pattern(p) = result {
1426            assert_eq!(
1427                p.transforms[0],
1428                Transform::Vel(Ramp::Sweep { from: 0.4, to: 1.0 })
1429            );
1430            assert_eq!(
1431                p.transforms[1],
1432                Transform::RampSpan {
1433                    cycles: 16,
1434                    curve: RampCurve::Linear
1435                }
1436            );
1437        } else {
1438            panic!("expected pattern");
1439        }
1440
1441        let result = parse_line("p pad \"0\" | oct 0..-2 | fast 1..4 | slow 2..1").unwrap();
1442        if let SourceLine::Pattern(p) = result {
1443            assert_eq!(
1444                p.transforms[0],
1445                Transform::Oct(Ramp::Sweep { from: 0, to: -2 })
1446            );
1447            assert_eq!(
1448                p.transforms[1],
1449                Transform::Fast(Ramp::Sweep { from: 1.0, to: 4.0 })
1450            );
1451            assert_eq!(
1452                p.transforms[2],
1453                Transform::Slow(Ramp::Sweep { from: 2.0, to: 1.0 })
1454            );
1455        } else {
1456            panic!("expected pattern");
1457        }
1458    }
1459
1460    #[test]
1461    fn test_step_chains_in_transform_arguments() {
1462        let result = parse_line("sn snare \"x(2>4>8>16,4)\" | vel 0.3>0.6>1.0 | ramp 16").unwrap();
1463        if let SourceLine::Pattern(p) = result {
1464            assert_eq!(
1465                p.transforms[0],
1466                Transform::Vel(Ramp::Steps {
1467                    first: 0.3,
1468                    rest: vec![0.6, 1.0]
1469                })
1470            );
1471            assert_eq!(
1472                p.transforms[1],
1473                Transform::RampSpan {
1474                    cycles: 16,
1475                    curve: RampCurve::Linear
1476                }
1477            );
1478        } else {
1479            panic!("expected pattern");
1480        }
1481
1482        // Negative stages read correctly.
1483        let result = parse_line("p pad \"0\" | oct 0>-1>-2").unwrap();
1484        if let SourceLine::Pattern(p) = result {
1485            assert_eq!(
1486                p.transforms[0],
1487                Transform::Oct(Ramp::Steps {
1488                    first: 0,
1489                    rest: vec![-1, -2]
1490                })
1491            );
1492        } else {
1493            panic!("expected pattern");
1494        }
1495    }
1496
1497    #[test]
1498    fn test_mixing_the_two_travel_spellings_is_rejected() {
1499        let error = parse_line("p pad \"0\" | vel 0.2..0.5>1.0").unwrap_err();
1500        assert!(error.contains("mixes"), "{error}");
1501        // And the message says what each one does.
1502        assert!(error.contains("sweep") && error.contains("hold"), "{error}");
1503
1504        let error = parse_line("p pad \"0\" | vel 0.2..0.5..1.0").unwrap_err();
1505        assert!(error.contains("more than two ends"), "{error}");
1506    }
1507
1508    fn transforms(source: &str) -> Vec<Transform> {
1509        match parse_line(source).unwrap() {
1510            SourceLine::Pattern(p) => p.transforms,
1511            other => panic!("expected a pattern, got {other:?}"),
1512        }
1513    }
1514
1515    #[test]
1516    fn test_audio_transforms_take_ranges() {
1517        // The line the engine's sweep mechanism exists for.
1518        assert_eq!(
1519            transforms("p pad \"0\" | lpf 300..9000 | ramp 16"),
1520            vec![
1521                Transform::Lpf(Ramp::Sweep {
1522                    from: 300.0,
1523                    to: 9000.0
1524                }),
1525                Transform::RampSpan {
1526                    cycles: 16,
1527                    curve: RampCurve::Linear
1528                },
1529            ]
1530        );
1531
1532        assert_eq!(
1533            transforms("p pad \"0\" | hpf 40>200>800"),
1534            vec![Transform::Hpf(Ramp::Steps {
1535                first: 40.0,
1536                rest: vec![200.0, 800.0]
1537            })]
1538        );
1539        assert_eq!(
1540            transforms("p pad \"0\" | gain 0.2..1.0"),
1541            vec![Transform::Gain(Ramp::Sweep { from: 0.2, to: 1.0 })]
1542        );
1543        assert_eq!(
1544            transforms("p pad \"0\" | reverb 0.0..0.6"),
1545            vec![Transform::Reverb(Ramp::Sweep { from: 0.0, to: 0.6 })]
1546        );
1547        // A fixed position that travels, still told apart from a waveform.
1548        assert_eq!(
1549            transforms("p pad \"0\" | pan -0.8..0.8"),
1550            vec![Transform::Pan(Ramp::Sweep {
1551                from: -0.8,
1552                to: 0.8
1553            })]
1554        );
1555        // All three of delay's numbers travel independently.
1556        assert_eq!(
1557            transforms("p pad \"0\" | delay 0.1..0.4 0.2>0.6 0.3..0.9"),
1558            vec![Transform::Delay(
1559                Ramp::Sweep { from: 0.1, to: 0.4 },
1560                Ramp::Steps {
1561                    first: 0.2,
1562                    rest: vec![0.6]
1563                },
1564                Some(Ramp::Sweep { from: 0.3, to: 0.9 }),
1565            )]
1566        );
1567    }
1568
1569    #[test]
1570    fn test_fx_arguments_take_ranges() {
1571        // `hz` still marks an absolute frequency, range or not.
1572        let call = fx_call("p pad \"0\" | trem 2..8hz 0.7");
1573        assert_eq!(
1574            call.args,
1575            vec![
1576                FxArg::Positional(FxValue::Hertz(Ramp::Sweep { from: 2.0, to: 8.0 })),
1577                FxArg::Positional(FxValue::Plain(Ramp::fixed(0.7))),
1578            ]
1579        );
1580        let FxArg::Positional(ref rate) = call.args[0] else {
1581            panic!("expected a positional argument");
1582        };
1583        assert!(rate.travels(), "a ranged fx argument needs a ramp span");
1584        assert!(!matches!(&call.args[1], FxArg::Positional(v) if v.travels()));
1585
1586        let call = fx_call("p pad \"0\" | fx Tremolo depth=0.2>0.9");
1587        assert_eq!(
1588            call.args,
1589            vec![FxArg::Named(
1590                "depth".into(),
1591                FxValue::Plain(Ramp::Steps {
1592                    first: 0.2,
1593                    rest: vec![0.9]
1594                })
1595            )]
1596        );
1597    }
1598
1599    #[test]
1600    fn test_travels_reports_on_every_ranged_argument() {
1601        let fixed = transforms("p pad \"0\" | lpf 900");
1602        assert_eq!(fixed, vec![Transform::Lpf(Ramp::fixed(900.0))]);
1603        let Transform::Lpf(ref cutoff) = fixed[0] else {
1604            panic!("expected lpf");
1605        };
1606        assert!(!cutoff.travels(), "a plain value must not ask for a ramp");
1607
1608        let swept = transforms("p pad \"0\" | lpf 300..9000");
1609        let Transform::Lpf(ref cutoff) = swept[0] else {
1610            panic!("expected lpf");
1611        };
1612        assert!(cutoff.travels());
1613        assert_eq!(cutoff.start(), 300.0);
1614        assert_eq!(cutoff.values(), vec![300.0, 9000.0]);
1615    }
1616
1617    #[test]
1618    fn test_every_value_of_an_audio_range_is_validated() {
1619        // The far end of the travel is played too, so it is checked too.
1620        let error = parse_line("p pad \"0\" | reverb 0.5..1.4").unwrap_err();
1621        assert!(error.contains("1.4"), "{error}");
1622        assert!(error.contains("0 to 1"), "{error}");
1623
1624        assert!(
1625            parse_line("p pad \"0\" | gain 0.5>1.0>2.5")
1626                .unwrap_err()
1627                .contains("2.5")
1628        );
1629        assert!(
1630            parse_line("p pad \"0\" | pan -0.5..1.5")
1631                .unwrap_err()
1632                .contains("1.5")
1633        );
1634        assert!(
1635            parse_line("p pad \"0\" | vel 0.4..1.2")
1636                .unwrap_err()
1637                .contains("1.2")
1638        );
1639        assert!(
1640            parse_line("p pad \"0\" | delay 0.2 0.4..1.0")
1641                .unwrap_err()
1642                .contains("delay feedback")
1643        );
1644        assert!(
1645            parse_line("p pad \"0\" | lpf 900..-20")
1646                .unwrap_err()
1647                .contains("below the minimum")
1648        );
1649        // A filter's own limits stay the consumer's business (§4.5), so a wild
1650        // `fx` argument is not rejected here.
1651        assert!(parse_line("p pad \"0\" | trem 0..900").is_ok());
1652    }
1653
1654    #[test]
1655    fn test_ramp_span_must_be_at_least_one_cycle() {
1656        assert!(
1657            parse_line("p pad \"0\" | ramp 0")
1658                .unwrap_err()
1659                .contains("at least one cycle")
1660        );
1661        assert!(
1662            parse_line("p pad \"0\" | ramp")
1663                .unwrap_err()
1664                .contains("expected a number")
1665        );
1666    }
1667
1668    #[test]
1669    fn test_ramp_span_carries_a_curve() {
1670        // Omitted means linear, so old buffers keep their meaning.
1671        assert_eq!(
1672            transforms("p pad \"0\" | lpf 300..9000 | ramp 16"),
1673            transforms("p pad \"0\" | lpf 300..9000 | ramp 16 lin")
1674        );
1675        assert_eq!(
1676            transforms("p pad \"0\" | ramp 8 exp"),
1677            vec![Transform::RampSpan {
1678                cycles: 8,
1679                curve: RampCurve::Exponential
1680            }]
1681        );
1682        assert_eq!(RampCurve::default(), RampCurve::Linear);
1683    }
1684
1685    #[test]
1686    fn test_r_form_without_a_span_is_plain_sugar() {
1687        assert_eq!(
1688            transforms("p pad \"0\" | vel r(0.4,1.0)"),
1689            transforms("p pad \"0\" | vel 0.4..1.0")
1690        );
1691        assert_eq!(
1692            transforms("p pad \"0\" | vel r(0.4>0.6>1.0)"),
1693            transforms("p pad \"0\" | vel 0.4>0.6>1.0")
1694        );
1695    }
1696
1697    #[test]
1698    fn test_r_form_with_a_span_carries_its_own_window() {
1699        let parsed = transforms("p pad \"0\" | vel r(0.4,1.0,4,osc)");
1700        assert_eq!(
1701            parsed,
1702            vec![Transform::Vel(Ramp::Timed {
1703                ramp: Box::new(Ramp::Sweep { from: 0.4, to: 1.0 }),
1704                span_divisions: 4.0,
1705                curve: RampCurve::Oscillate,
1706            })]
1707        );
1708        // The style defaults to lin, and fractions of a division are legal.
1709        assert_eq!(
1710            transforms("p pad \"0\" | vel r(0.4,1.0,0.5)"),
1711            vec![Transform::Vel(Ramp::Timed {
1712                ramp: Box::new(Ramp::Sweep { from: 0.4, to: 1.0 }),
1713                span_divisions: 0.5,
1714                curve: RampCurve::Linear,
1715            })]
1716        );
1717    }
1718
1719    #[test]
1720    fn test_r_form_still_range_checks_its_travel() {
1721        let error = parse_line("p pad \"0\" | vel r(0.4,2.0,4)").unwrap_err();
1722        assert!(error.contains("outside the supported range"), "{error}");
1723    }
1724
1725    #[test]
1726    fn test_r_form_errors_are_specific() {
1727        let error = parse_line("p pad \"0\" | vel r(0.4, 1.0, 4)").unwrap_err();
1728        assert!(error.contains("without spaces"), "{error}");
1729        let error = parse_line("p pad \"0\" | vel r(0.5)").unwrap_err();
1730        assert!(error.contains("needs a travel"), "{error}");
1731        let error = parse_line("p pad \"0\" | vel r(0.4,1.0,4,sine)").unwrap_err();
1732        assert!(error.contains("'sine' is not a curve"), "{error}");
1733        let error = parse_line("p pad \"0\" | vel r(0.4,1.0,exp)").unwrap_err();
1734        assert!(error.contains("time divisions"), "{error}");
1735    }
1736
1737    #[test]
1738    fn test_osc_ramp_curve_parses() {
1739        assert_eq!(
1740            transforms("p pad \"0\" | ramp 8 osc"),
1741            vec![Transform::RampSpan {
1742                cycles: 8,
1743                curve: RampCurve::Oscillate
1744            }]
1745        );
1746    }
1747
1748    #[test]
1749    fn test_ramp_curve_errors_are_specific() {
1750        let error = parse_line("p pad \"0\" | ramp 8 sine").unwrap_err();
1751        assert!(error.contains("'sine' is not a curve"), "{error}");
1752        assert!(
1753            error.contains("lin") && error.contains("exp") && error.contains("osc"),
1754            "{error}"
1755        );
1756        // The arity is still fixed: one curve, not two.
1757        assert!(
1758            parse_line("p pad \"0\" | ramp 8 exp lin")
1759                .unwrap_err()
1760                .contains("unexpected argument")
1761        );
1762    }
1763
1764    #[test]
1765    fn test_trailing_transform_argument_is_rejected() {
1766        let error = parse_line("lead saw \"c4\" | pan -1.0 0.5").unwrap_err();
1767        assert!(error.contains("unexpected argument"), "got: {error}");
1768    }
1769
1770    #[test]
1771    fn test_every_still_accepts_a_nested_transform() {
1772        let result = parse_line("lead saw \"c4\" | every 4 every 2 rev").unwrap();
1773        if let SourceLine::Pattern(p) = result {
1774            assert_eq!(
1775                p.transforms[0],
1776                Transform::Every(4, Box::new(Transform::Every(2, Box::new(Transform::Rev))))
1777            );
1778        } else {
1779            panic!("expected pattern");
1780        }
1781    }
1782
1783    // ---- Error cases ----
1784
1785    #[test]
1786    fn test_unknown_keyword_is_error() {
1787        // "foobar" with no matching pattern syntax
1788        assert!(parse_line("foobar").is_err());
1789    }
1790
1791    #[test]
1792    fn test_pattern_missing_quotes() {
1793        assert!(parse_line("kick drums x ~ x ~").is_err());
1794    }
1795
1796    // ---- SplitKeepQuotes ----
1797
1798    #[test]
1799    fn test_split_keep_quotes() {
1800        let input = r#"kick drums "x ~ x ~" | rev"#;
1801        let tokens: Vec<&str> = SplitKeepQuotes::new(input).collect();
1802        assert_eq!(tokens, vec!["kick", "drums", "\"x ~ x ~\"", "|", "rev"]);
1803    }
1804}