Skip to main content

treble_lang/parser/
mod.rs

1//! Parser for Treble Live DSL.
2//!
3//! The parser is line-oriented: each source line is parsed independently.
4//! `def` blocks are the sole exception — [`parse_program`] gathers a block's
5//! lines and parses them as a unit. Mini-notation (inside double quotes) is
6//! parsed with nom combinators.
7
8mod instrument;
9mod lines;
10mod mini;
11
12pub use instrument::parse_instrument_def;
13pub use lines::parse_line;
14pub use mini::parse_mini;
15
16use instrument::count_braces;
17
18use crate::ast::{Program, SourceLine};
19use crate::error::{CompileError, CompileErrorKind, SourceLocation};
20
21/// Parse an entire source string into a [`Program`].
22///
23/// Each line is parsed independently. Lines that fail to parse produce a
24/// [`CompileError`] in the error list but do not prevent other lines from
25/// being parsed (best-effort / error-recovery).
26pub fn parse_program(source: &str) -> (Program, Vec<CompileError>) {
27    let mut lines = Vec::new();
28    let mut errors = Vec::new();
29    // A `def` block spans lines, so it is collected before being parsed.
30    let mut block: Option<(usize, Vec<&str>)> = None;
31    let mut depth = 0usize;
32    // The group currently open, if any. Group members stay ordinary lines;
33    // only their `group` tag records the membership.
34    let mut open_group: Option<(usize, String)> = None;
35    // The section currently open, if any. A group nests one level inside a
36    // section, so both can be open at once and a `}` closes the inner one.
37    let mut open_section: Option<(usize, String)> = None;
38
39    let report = |errors: &mut Vec<CompileError>, line: usize, message: String| {
40        errors.push(CompileError {
41            kind: CompileErrorKind::ParseError,
42            location: SourceLocation {
43                line,
44                column: 1,
45                file: None,
46            },
47            message,
48            suggestion: None,
49        });
50    };
51
52    for (line_idx, raw) in source.lines().enumerate() {
53        if let Some((_, collected)) = &mut block {
54            depth += count_braces(raw, '{');
55            depth -= count_braces(raw, '}').min(depth);
56            collected.push(raw);
57            if depth == 0 {
58                let (start, collected) = block.take().expect("inside a block");
59                match parse_instrument_def(&collected) {
60                    Ok(definition) => lines.push(SourceLine::Def(Box::new(definition))),
61                    Err(message) => {
62                        report(&mut errors, start + 1, message);
63                        // Preserve the block's text so nothing is silently lost.
64                        for line in collected {
65                            lines.push(SourceLine::Comment(line.to_string()));
66                        }
67                    }
68                }
69            }
70            continue;
71        }
72
73        if raw.trim_start().starts_with("def ") || raw.trim() == "def" {
74            if let Some((_, group)) = &open_group {
75                report(
76                    &mut errors,
77                    line_idx + 1,
78                    format!("def: an instrument definition cannot live inside group '{group}'"),
79                );
80            } else if let Some((_, section)) = &open_section {
81                report(
82                    &mut errors,
83                    line_idx + 1,
84                    format!(
85                        "def: an instrument definition cannot live inside section '{section}' \
86                         — move it above the section"
87                    ),
88                );
89            }
90            depth = count_braces(raw, '{');
91            depth -= count_braces(raw, '}').min(depth);
92            if depth == 0 {
93                // A block opened and closed on one line.
94                match parse_instrument_def(&[raw]) {
95                    Ok(definition) => lines.push(SourceLine::Def(Box::new(definition))),
96                    Err(message) => {
97                        report(&mut errors, line_idx + 1, message);
98                        lines.push(SourceLine::Comment(raw.to_string()));
99                    }
100                }
101            } else {
102                block = Some((line_idx, vec![raw]));
103            }
104            continue;
105        }
106
107        match parse_line(raw) {
108            Ok(source_line) => {
109                match source_line {
110                    SourceLine::GroupStart { muted, name } => {
111                        if let Some((_, outer)) = &open_group {
112                            report(
113                                &mut errors,
114                                line_idx + 1,
115                                format!("group: groups don't nest; '{outer}' is still open"),
116                            );
117                        }
118                        open_group = Some((line_idx, name.clone()));
119                        lines.push(SourceLine::GroupStart { muted, name });
120                    }
121                    // A `}` closes the innermost open block: the group if one
122                    // is open, otherwise the section.
123                    SourceLine::GroupEnd(transforms) => {
124                        if open_group.take().is_some() {
125                            lines.push(SourceLine::GroupEnd(transforms));
126                        } else if open_section.take().is_some() {
127                            if !transforms.is_empty() {
128                                report(
129                                    &mut errors,
130                                    line_idx + 1,
131                                    "section: '}' takes no filters — a section is a span of \
132                                     time, not a bus; put them on a group inside it"
133                                        .to_string(),
134                                );
135                            }
136                            lines.push(SourceLine::SectionEnd);
137                        } else {
138                            report(
139                                &mut errors,
140                                line_idx + 1,
141                                "'}' without an open group or section".to_string(),
142                            );
143                            lines.push(SourceLine::GroupEnd(transforms));
144                        }
145                    }
146                    SourceLine::SectionStart {
147                        muted,
148                        name,
149                        cycles,
150                    } => {
151                        if let Some((_, group)) = &open_group {
152                            report(
153                                &mut errors,
154                                line_idx + 1,
155                                format!(
156                                    "section: '{name}' cannot open inside group '{group}' — a \
157                                     group nests inside a section, not the other way round"
158                                ),
159                            );
160                        } else if let Some((_, outer)) = &open_section {
161                            report(
162                                &mut errors,
163                                line_idx + 1,
164                                format!("section: sections don't nest; '{outer}' is still open"),
165                            );
166                        }
167                        open_section = Some((line_idx, name.clone()));
168                        lines.push(SourceLine::SectionStart {
169                            muted,
170                            name,
171                            cycles,
172                        });
173                    }
174                    SourceLine::SectionEnd => lines.push(SourceLine::SectionEnd),
175                    SourceLine::Pattern(mut def) => {
176                        def.group = open_group.as_ref().map(|(_, name)| name.clone());
177                        def.section = open_section.as_ref().map(|(_, name)| name.clone());
178                        if def.span.is_some() && def.section.is_none() {
179                            report(
180                                &mut errors,
181                                line_idx + 1,
182                                format!(
183                                    "'{}': a span needs a section — its cycles are counted \
184                                     from the section's start (§8.3)",
185                                    def.name
186                                ),
187                            );
188                            def.span = None;
189                        }
190                        lines.push(SourceLine::Pattern(def));
191                    }
192                    // A group holds patterns; a directive inside one would
193                    // silently act on the whole session, so it is refused.
194                    directive @ (SourceLine::Bpm(_)
195                    | SourceLine::Sig(_, _)
196                    | SourceLine::Phrase(_)
197                    | SourceLine::Scale(_, _)
198                    | SourceLine::Load(_)
199                    | SourceLine::Include(_)
200                    | SourceLine::Arrange(_)
201                    | SourceLine::Tail(_)
202                    | SourceLine::Seed(_))
203                        if open_group.is_some() =>
204                    {
205                        report(
206                            &mut errors,
207                            line_idx + 1,
208                            "group: directives cannot live inside a group — move this above it"
209                                .to_string(),
210                        );
211                        lines.push(directive);
212                    }
213                    // A section scopes `bpm`, `sig` and `scale` (§8.5). The
214                    // rest configure the whole buffer, so a section is the
215                    // wrong place to write them and they are refused rather
216                    // than silently applied to everything.
217                    directive @ (SourceLine::Phrase(_)
218                    | SourceLine::Load(_)
219                    | SourceLine::Include(_)
220                    | SourceLine::Arrange(_)
221                    | SourceLine::Tail(_)
222                    | SourceLine::Seed(_))
223                        if open_section.is_some() =>
224                    {
225                        report(
226                            &mut errors,
227                            line_idx + 1,
228                            format!(
229                                "section: '{}' configures the whole piece — move it above the \
230                                 section (a section scopes only bpm, sig and scale)",
231                                directive_keyword(&directive)
232                            ),
233                        );
234                        lines.push(directive);
235                    }
236                    other => lines.push(other),
237                }
238            }
239            Err(msg) => {
240                report(&mut errors, line_idx + 1, msg);
241                // Keep the line as a comment so we don't lose it
242                lines.push(SourceLine::Comment(raw.to_string()));
243            }
244        }
245    }
246
247    if let Some((start, name)) = open_group {
248        report(
249            &mut errors,
250            start + 1,
251            format!("group: '{name}' is never closed with '}}'"),
252        );
253    }
254
255    if let Some((start, name)) = open_section {
256        report(
257            &mut errors,
258            start + 1,
259            format!("section: '{name}' is never closed with '}}'"),
260        );
261    }
262
263    if let Some((start, collected)) = block {
264        report(
265            &mut errors,
266            start + 1,
267            "def: the block is never closed with '}'".to_string(),
268        );
269        for line in collected {
270            lines.push(SourceLine::Comment(line.to_string()));
271        }
272    }
273
274    (Program { lines }, errors)
275}
276
277/// The keyword a directive was written with, for diagnostics.
278fn directive_keyword(line: &SourceLine) -> &'static str {
279    match line {
280        SourceLine::Bpm(_) => "bpm",
281        SourceLine::Sig(_, _) => "sig",
282        SourceLine::Phrase(_) => "phrase",
283        SourceLine::Scale(_, _) => "scale",
284        SourceLine::Load(_) => "load",
285        SourceLine::Include(_) => "include",
286        SourceLine::Arrange(_) => "arrange",
287        SourceLine::Tail(_) => "tail",
288        SourceLine::Seed(_) => "seed",
289        _ => "directive",
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::ast::*;
297
298    #[test]
299    fn test_group_block() {
300        let source = "\
301include kick
302include hihat
303
304group drums {
305  kick kick \"x ~ x ~\"
306  ; hat hihat \"x*8\"
307} | lpf 1800 | gain 0.9
308
309lead bass \"0 3 5 3\"";
310        let (prog, errs) = parse_program(source);
311        assert!(errs.is_empty(), "{errs:?}");
312        let patterns: Vec<&PatternDef> = prog
313            .lines
314            .iter()
315            .filter_map(|line| match line {
316                SourceLine::Pattern(def) => Some(def),
317                _ => None,
318            })
319            .collect();
320        // Members are tagged; the line after the close is not.
321        assert_eq!(patterns[0].group.as_deref(), Some("drums"));
322        assert_eq!(patterns[1].group.as_deref(), Some("drums"));
323        assert!(patterns[1].muted, "member mute survives inside a group");
324        assert_eq!(patterns[2].group, None);
325        // The close carries the shared chain.
326        let end = prog
327            .lines
328            .iter()
329            .find_map(|line| match line {
330                SourceLine::GroupEnd(transforms) => Some(transforms),
331                _ => None,
332            })
333            .unwrap();
334        assert_eq!(end.len(), 2);
335    }
336
337    #[test]
338    fn test_muted_group_and_bare_close() {
339        let (prog, errs) = parse_program("; group drums {\nkick kick \"x\"\n}");
340        assert!(errs.is_empty(), "{errs:?}");
341        assert!(matches!(
342            &prog.lines[0],
343            SourceLine::GroupStart { muted: true, name } if name == "drums"
344        ));
345        assert!(matches!(&prog.lines[2], SourceLine::GroupEnd(t) if t.is_empty()));
346    }
347
348    #[test]
349    fn test_group_structural_errors() {
350        // Unclosed, stray close, nesting, directives and defs inside.
351        assert!(!parse_program("group drums {").1.is_empty());
352        assert!(!parse_program("}").1.is_empty());
353        assert!(
354            !parse_program("group a {\ngroup b {\n}\n}").1.is_empty(),
355            "groups must not nest"
356        );
357        assert!(!parse_program("group a {\nbpm 130\n}").1.is_empty());
358        assert!(
359            !parse_program("group a {\ndef x { tone sine }\n}")
360                .1
361                .is_empty(),
362            "defs must not live inside groups"
363        );
364        // Header must end with the brace; members go on their own lines.
365        assert!(
366            !parse_program("group drums { kick kick \"x\" }")
367                .1
368                .is_empty()
369        );
370    }
371
372    #[test]
373    fn test_parse_empty_program() {
374        let (prog, errs) = parse_program("");
375        assert!(errs.is_empty());
376        // "".lines() yields an empty iterator, so 0 lines
377        assert_eq!(prog.lines.len(), 0);
378    }
379
380    #[test]
381    fn test_parse_full_example() {
382        let source = "\
383-- techno
384bpm 128
385sig 4/4
386
387kick kick \"x ~ x ~\"
388; snare snare \"~ x ~ x\"
389lead saw \"c4 eb4 g4\" | rev | slow 2";
390
391        let (prog, errs) = parse_program(source);
392        assert!(errs.is_empty(), "errors: {:?}", errs);
393        assert_eq!(prog.lines.len(), 7);
394        assert!(matches!(prog.lines[0], SourceLine::Comment(_)));
395        assert!(matches!(prog.lines[1], SourceLine::Bpm(128)));
396        assert!(matches!(prog.lines[2], SourceLine::Sig(4, 4)));
397        assert!(matches!(prog.lines[3], SourceLine::Blank));
398        // kick pattern
399        if let SourceLine::Pattern(ref p) = prog.lines[4] {
400            assert_eq!(p.name, "kick");
401            assert_eq!(p.instrument, "kick");
402            assert!(!p.muted);
403            assert!(p.transforms.is_empty());
404        } else {
405            panic!("expected pattern, got {:?}", prog.lines[4]);
406        }
407        // muted snare
408        if let SourceLine::Pattern(ref p) = prog.lines[5] {
409            assert!(p.muted);
410            assert_eq!(p.name, "snare");
411        } else {
412            panic!("expected muted pattern, got {:?}", prog.lines[5]);
413        }
414        // lead with transforms
415        if let SourceLine::Pattern(ref p) = prog.lines[6] {
416            assert_eq!(p.name, "lead");
417            assert_eq!(p.transforms.len(), 2);
418            assert_eq!(p.transforms[0], Transform::Rev);
419            assert_eq!(p.transforms[1], Transform::Slow(Ramp::fixed(2.0)));
420        } else {
421            panic!("expected pattern, got {:?}", prog.lines[6]);
422        }
423    }
424
425    #[test]
426    fn test_error_recovery() {
427        let source = "\
428bpm 128
429this is garbage ???
430kick kick \"x ~ x ~\"";
431        let (prog, errs) = parse_program(source);
432        assert_eq!(errs.len(), 1);
433        assert_eq!(errs[0].location.line, 2);
434        // The valid lines are still parsed
435        assert!(matches!(prog.lines[0], SourceLine::Bpm(128)));
436        // Error line kept as comment
437        assert!(matches!(prog.lines[1], SourceLine::Comment(_)));
438        // Third line still valid
439        assert!(matches!(prog.lines[2], SourceLine::Pattern(_)));
440    }
441}