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