Skip to main content

pedalkernel/
board.rs

1//! Parser for `.board` pedalboard definition files.
2//!
3//! A `.board` file defines a signal chain of `.pedal` files with optional
4//! per-pedal knob overrides.
5//!
6//! ```text
7//! board "Blues Rig" {
8//!   ts: "tube_screamer.pedal" { Drive = 0.6, Level = 0.7 }
9//!   bd: "blues_driver.pedal"
10//! }
11//! ```
12
13use nom::{
14    branch::alt,
15    bytes::complete::{tag, take_while, take_while1},
16    character::complete::{char, multispace1, not_line_ending},
17    combinator::{opt, recognize, value},
18    multi::{many0, separated_list1},
19    number::complete::double,
20    sequence::pair,
21    IResult,
22};
23use std::path::{Path, PathBuf};
24
25// ---------------------------------------------------------------------------
26// AST
27// ---------------------------------------------------------------------------
28
29/// Top-level pedalboard definition.
30#[derive(Debug, Clone, PartialEq)]
31pub struct BoardDef {
32    pub name: String,
33    pub entries: Vec<BoardEntry>,
34}
35
36impl BoardDef {
37    /// Flat list of all pedal entries (for backward compatibility).
38    /// FX loop entries are flattened into the list.
39    pub fn pedals(&self) -> Vec<&BoardPedalEntry> {
40        let mut out = Vec::new();
41        for entry in &self.entries {
42            match entry {
43                BoardEntry::Pedal(p) => out.push(p),
44                BoardEntry::FxLoop(group) => {
45                    for p in &group.pedals {
46                        out.push(p);
47                    }
48                }
49            }
50        }
51        out
52    }
53}
54
55/// An entry in the board signal chain — either a single pedal or an FX loop group.
56#[derive(Debug, Clone, PartialEq)]
57pub enum BoardEntry {
58    /// A single pedal in the chain.
59    Pedal(BoardPedalEntry),
60    /// An FX send/return loop containing a sub-chain of pedals.
61    FxLoop(FxLoopGroup),
62}
63
64/// An FX send/return loop group.
65///
66/// References a pedal that has `fx_send`/`fx_return` in its circuit.
67/// The pedal is split at that boundary: everything before `fx_send` runs first,
68/// then the FX loop effects chain, then everything after `fx_return`.
69#[derive(Debug, Clone, PartialEq)]
70pub struct FxLoopGroup {
71    /// ID of the pedal that provides the send/return (e.g. "amp").
72    /// This pedal must have `fx_send` and `fx_return` nodes in its .pedal file.
73    pub target_id: String,
74    /// Pedals inside the FX loop (processed in order between send and return).
75    pub pedals: Vec<BoardPedalEntry>,
76    /// Wet/dry mix (0.0 = fully dry/bypass, 1.0 = fully wet). Defaults to 1.0.
77    pub mix: f64,
78}
79
80/// A single pedal entry in the board.
81#[derive(Debug, Clone, PartialEq)]
82pub struct BoardPedalEntry {
83    /// Identifier for this pedal slot (e.g. "ts").
84    pub id: String,
85    /// Path to the `.pedal` file (resolved relative to the `.board` file).
86    pub path: String,
87    /// Optional knob overrides (e.g. `[("Drive", 0.7)]`).
88    pub overrides: Vec<(String, f64)>,
89}
90
91/// Global board definition — pedals auto-injected at the start/end of every board.
92#[derive(Debug, Clone, PartialEq)]
93pub struct GlobalBoardDef {
94    pub start: Vec<BoardPedalEntry>,
95    pub end: Vec<BoardPedalEntry>,
96}
97
98// ---------------------------------------------------------------------------
99// Helpers (same patterns as dsl.rs)
100// ---------------------------------------------------------------------------
101
102/// Consume whitespace **and** `# …` comments.
103fn ws_comments(input: &str) -> IResult<&str, ()> {
104    let (input, _) = many0(alt((
105        value((), multispace1),
106        value((), pair(char('#'), not_line_ending)),
107    )))(input)?;
108    Ok((input, ()))
109}
110
111/// Identifier: starts with alpha/underscore, continues with alphanumeric/underscore.
112fn identifier(input: &str) -> IResult<&str, &str> {
113    recognize(pair(
114        take_while1(|c: char| c.is_ascii_alphabetic() || c == '_'),
115        take_while(|c: char| c.is_ascii_alphanumeric() || c == '_'),
116    ))(input)
117}
118
119/// Quoted string: `"Foo Bar"`
120fn quoted_string(input: &str) -> IResult<&str, &str> {
121    nom::sequence::delimited(char('"'), take_while(|c: char| c != '"'), char('"'))(input)
122}
123
124// ---------------------------------------------------------------------------
125// Knob override parsers
126// ---------------------------------------------------------------------------
127
128/// Parse a single knob override: `Drive = 0.7`
129fn knob_override(input: &str) -> IResult<&str, (String, f64)> {
130    let (input, _) = ws_comments(input)?;
131    let (input, name) = identifier(input)?;
132    let (input, _) = ws_comments(input)?;
133    let (input, _) = char('=')(input)?;
134    let (input, _) = ws_comments(input)?;
135    let (input, val) = double(input)?;
136    Ok((input, (name.to_string(), val)))
137}
138
139/// Parse knob overrides block: `{ Drive = 0.6, Level = 0.7 }`
140fn knob_overrides(input: &str) -> IResult<&str, Vec<(String, f64)>> {
141    let (input, _) = ws_comments(input)?;
142    let (input, _) = char('{')(input)?;
143    let (input, _) = ws_comments(input)?;
144    let (input, overrides) = separated_list1(
145        |i| {
146            let (i, _) = ws_comments(i)?;
147            let (i, _) = char(',')(i)?;
148            let (i, _) = ws_comments(i)?;
149            Ok((i, ()))
150        },
151        knob_override,
152    )(input)?;
153    let (input, _) = ws_comments(input)?;
154    // Allow optional trailing comma
155    let (input, _) = opt(char(','))(input)?;
156    let (input, _) = ws_comments(input)?;
157    let (input, _) = char('}')(input)?;
158    Ok((input, overrides))
159}
160
161// ---------------------------------------------------------------------------
162// Pedal entry parser
163// ---------------------------------------------------------------------------
164
165/// Parse a single pedal entry: `ts: "tube_screamer.pedal" { Drive = 0.6 }`
166fn pedal_entry(input: &str) -> IResult<&str, BoardPedalEntry> {
167    let (input, _) = ws_comments(input)?;
168    let (input, id) = identifier(input)?;
169    let (input, _) = ws_comments(input)?;
170    let (input, _) = char(':')(input)?;
171    let (input, _) = ws_comments(input)?;
172    let (input, path) = quoted_string(input)?;
173    let (input, overrides) = opt(knob_overrides)(input)?;
174    Ok((
175        input,
176        BoardPedalEntry {
177            id: id.to_string(),
178            path: path.to_string(),
179            overrides: overrides.unwrap_or_default(),
180        },
181    ))
182}
183
184// ---------------------------------------------------------------------------
185// FX loop parser
186// ---------------------------------------------------------------------------
187
188/// Parse an FX loop group: `fx_loop(amp_id) { ... }`
189///
190/// The `amp_id` references a pedal entry earlier in the board that has
191/// `fx_send`/`fx_return` nodes. Optionally: `fx_loop(amp_id, 0.5)` for wet/dry mix.
192fn fx_loop_entry(input: &str) -> IResult<&str, BoardEntry> {
193    let (input, _) = ws_comments(input)?;
194    let (input, _) = tag("fx_loop")(input)?;
195    let (input, _) = ws_comments(input)?;
196    let (input, _) = char('(')(input)?;
197    let (input, _) = ws_comments(input)?;
198    let (input, target_id) = identifier(input)?;
199    // Optional mix parameter after comma
200    let (input, mix) = opt(|i| {
201        let (i, _) = ws_comments(i)?;
202        let (i, _) = char(',')(i)?;
203        let (i, _) = ws_comments(i)?;
204        let (i, val) = double(i)?;
205        Ok((i, val))
206    })(input)?;
207    let (input, _) = ws_comments(input)?;
208    let (input, _) = char(')')(input)?;
209    let (input, _) = ws_comments(input)?;
210    let (input, _) = char('{')(input)?;
211    let (input, pedals) = many0(pedal_entry)(input)?;
212    let (input, _) = ws_comments(input)?;
213    let (input, _) = char('}')(input)?;
214    Ok((
215        input,
216        BoardEntry::FxLoop(FxLoopGroup {
217            target_id: target_id.to_string(),
218            pedals,
219            mix: mix.unwrap_or(1.0),
220        }),
221    ))
222}
223
224/// Parse a board entry — either a pedal or an FX loop.
225fn board_entry(input: &str) -> IResult<&str, BoardEntry> {
226    alt((fx_loop_entry, |i| {
227        let (i, entry) = pedal_entry(i)?;
228        Ok((i, BoardEntry::Pedal(entry)))
229    }))(input)
230}
231
232// ---------------------------------------------------------------------------
233// Top-level
234// ---------------------------------------------------------------------------
235
236/// Parse a complete `.board` file.
237fn parse_board(input: &str) -> IResult<&str, BoardDef> {
238    let (input, _) = ws_comments(input)?;
239    let (input, _) = tag("board")(input)?;
240    let (input, _) = ws_comments(input)?;
241    let (input, name) = quoted_string(input)?;
242    let (input, _) = ws_comments(input)?;
243    let (input, _) = char('{')(input)?;
244    let (input, entries) = many0(board_entry)(input)?;
245    let (input, _) = ws_comments(input)?;
246    let (input, _) = char('}')(input)?;
247    let (input, _) = ws_comments(input)?;
248    Ok((
249        input,
250        BoardDef {
251            name: name.to_string(),
252            entries,
253        },
254    ))
255}
256
257/// Convenience wrapper that returns `Result`.
258pub fn parse_board_file(src: &str) -> Result<BoardDef, String> {
259    match parse_board(src) {
260        Ok(("", def)) => Ok(def),
261        Ok((rest, _)) => Err(format!("Trailing input: {:?}", &rest[..rest.len().min(60)])),
262        Err(e) => Err(format!("Parse error: {e}")),
263    }
264}
265
266// ---------------------------------------------------------------------------
267// Global board parser
268// ---------------------------------------------------------------------------
269
270/// Parse a `start { ... }` or `end { ... }` section inside a global block.
271fn global_section<'a>(
272    keyword: &'static str,
273) -> impl FnMut(&'a str) -> IResult<&'a str, Vec<BoardPedalEntry>> {
274    move |input: &'a str| {
275        let (input, _) = ws_comments(input)?;
276        let (input, _) = tag(keyword)(input)?;
277        let (input, _) = ws_comments(input)?;
278        let (input, _) = char('{')(input)?;
279        let (input, entries) = many0(pedal_entry)(input)?;
280        let (input, _) = ws_comments(input)?;
281        let (input, _) = char('}')(input)?;
282        Ok((input, entries))
283    }
284}
285
286/// Parse a complete `.global.board` file.
287fn parse_global_board(input: &str) -> IResult<&str, GlobalBoardDef> {
288    let (input, _) = ws_comments(input)?;
289    let (input, _) = tag("global")(input)?;
290    let (input, _) = ws_comments(input)?;
291    let (input, _) = char('{')(input)?;
292
293    // Both sections are optional and can appear in any order
294    let mut start = Vec::new();
295    let mut end = Vec::new();
296    let mut input = input;
297
298    loop {
299        let (rest, _) = ws_comments(input)?;
300        input = rest;
301
302        if let Ok((rest, entries)) = global_section("start")(input) {
303            start = entries;
304            input = rest;
305        } else if let Ok((rest, entries)) = global_section("end")(input) {
306            end = entries;
307            input = rest;
308        } else {
309            break;
310        }
311    }
312
313    let (input, _) = ws_comments(input)?;
314    let (input, _) = char('}')(input)?;
315    let (input, _) = ws_comments(input)?;
316
317    Ok((input, GlobalBoardDef { start, end }))
318}
319
320/// Parse a `.global.board` file, returning a `Result`.
321pub fn parse_global_board_file(src: &str) -> Result<GlobalBoardDef, String> {
322    match parse_global_board(src) {
323        Ok(("", def)) => Ok(def),
324        Ok((rest, _)) => Err(format!("Trailing input: {:?}", &rest[..rest.len().min(60)])),
325        Err(e) => Err(format!("Parse error: {e}")),
326    }
327}
328
329/// Search for a `.global.board` file by walking up from `start_dir`.
330///
331/// Returns the parsed definition and the directory it was found in
332/// (for resolving relative pedal paths).
333pub fn find_global_board(start_dir: &Path) -> Option<(GlobalBoardDef, PathBuf)> {
334    let mut dir = start_dir.canonicalize().ok()?;
335    loop {
336        let candidate = dir.join(".global.board");
337        if candidate.is_file() {
338            let source = std::fs::read_to_string(&candidate).ok()?;
339            let def = parse_global_board_file(&source).ok()?;
340            return Some((def, dir));
341        }
342        if !dir.pop() {
343            return None;
344        }
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Tests
350// ---------------------------------------------------------------------------
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn parse_simple_board() {
358        let src = r#"
359board "Blues Rig" {
360  ts: "tube_screamer.pedal"
361  bd: "blues_driver.pedal"
362}
363"#;
364        let board = parse_board_file(src).unwrap();
365        assert_eq!(board.name, "Blues Rig");
366        assert_eq!(board.pedals().len(), 2);
367        assert_eq!(board.pedals()[0].id, "ts");
368        assert_eq!(board.pedals()[0].path, "tube_screamer.pedal");
369        assert!(board.pedals()[0].overrides.is_empty());
370        assert_eq!(board.pedals()[1].id, "bd");
371        assert_eq!(board.pedals()[1].path, "blues_driver.pedal");
372    }
373
374    #[test]
375    fn parse_board_with_overrides() {
376        let src = r#"
377board "Test" {
378  ts: "tube_screamer.pedal" { Drive = 0.6, Level = 0.7 }
379  bd: "blues_driver.pedal" { Gain = 0.5 }
380}
381"#;
382        let board = parse_board_file(src).unwrap();
383        assert_eq!(board.pedals().len(), 2);
384        assert_eq!(board.pedals()[0].overrides.len(), 2);
385        assert_eq!(board.pedals()[0].overrides[0], ("Drive".to_string(), 0.6));
386        assert_eq!(board.pedals()[0].overrides[1], ("Level".to_string(), 0.7));
387        assert_eq!(board.pedals()[1].overrides.len(), 1);
388        assert_eq!(board.pedals()[1].overrides[0], ("Gain".to_string(), 0.5));
389    }
390
391    #[test]
392    fn parse_board_with_comments() {
393        let src = r#"
394# My awesome board
395board "Stage Rig" {
396  # Compressor first
397  comp: "dyna_comp.pedal"
398  # Then overdrive
399  ts: "tube_screamer.pedal" { Drive = 0.8 }
400}
401"#;
402        let board = parse_board_file(src).unwrap();
403        assert_eq!(board.name, "Stage Rig");
404        assert_eq!(board.pedals().len(), 2);
405        assert_eq!(board.pedals()[0].id, "comp");
406        assert_eq!(board.pedals()[1].id, "ts");
407        assert_eq!(board.pedals()[1].overrides[0].1, 0.8);
408    }
409
410    #[test]
411    fn parse_empty_board() {
412        let src = r#"board "Empty" {}"#;
413        let board = parse_board_file(src).unwrap();
414        assert_eq!(board.name, "Empty");
415        assert!(board.pedals().is_empty());
416    }
417
418    #[test]
419    fn parse_board_trailing_input_error() {
420        let src = r#"board "X" {} garbage"#;
421        assert!(parse_board_file(src).is_err());
422    }
423
424    #[test]
425    fn parse_single_pedal_board() {
426        let src = r#"
427board "Solo" {
428  od: "tube_screamer.pedal" { Drive = 1.0, Level = 0.5 }
429}
430"#;
431        let board = parse_board_file(src).unwrap();
432        assert_eq!(board.pedals().len(), 1);
433        assert_eq!(board.pedals()[0].id, "od");
434        assert_eq!(board.pedals()[0].overrides.len(), 2);
435    }
436
437    #[test]
438    fn parse_board_with_fx_loop() {
439        let src = r#"
440board "FX Loop Test" {
441  ts: "tube_screamer.pedal"
442  amp: "tweed_deluxe.pedal" { Volume = 0.7 }
443  fx_loop(amp) {
444    delay: "delay.pedal"
445    chorus: "chorus.pedal"
446  }
447}
448"#;
449        let board = parse_board_file(src).unwrap();
450        assert_eq!(board.name, "FX Loop Test");
451        assert_eq!(board.entries.len(), 3);
452        // First entry: pedal
453        assert!(matches!(&board.entries[0], BoardEntry::Pedal(p) if p.id == "ts"));
454        // Second entry: pedal (amp)
455        assert!(matches!(&board.entries[1], BoardEntry::Pedal(p) if p.id == "amp"));
456        // Third entry: FX loop
457        if let BoardEntry::FxLoop(group) = &board.entries[2] {
458            assert_eq!(group.target_id, "amp");
459            assert_eq!(group.pedals.len(), 2);
460            assert_eq!(group.pedals[0].id, "delay");
461            assert_eq!(group.pedals[1].id, "chorus");
462            assert!((group.mix - 1.0).abs() < f64::EPSILON); // default mix
463        } else {
464            panic!("Expected FxLoop entry");
465        }
466        // Flat pedals should include amp + ts + fx loop contents
467        assert_eq!(board.pedals().len(), 4);
468    }
469
470    #[test]
471    fn parse_board_fx_loop_with_mix() {
472        let src = r#"
473board "Mix Test" {
474  amp: "amp.pedal"
475  fx_loop(amp, 0.5) {
476    delay: "delay.pedal"
477  }
478}
479"#;
480        let board = parse_board_file(src).unwrap();
481        if let BoardEntry::FxLoop(group) = &board.entries[1] {
482            assert!((group.mix - 0.5).abs() < f64::EPSILON);
483        } else {
484            panic!("Expected FxLoop entry");
485        }
486    }
487
488    // ── Global board tests ──────────────────────────────────────────────
489
490    #[test]
491    fn parse_global_board_both_sections() {
492        let src = r#"
493# Global board
494global {
495  start {
496    gate: "noise_gate.pedal" { Threshold = 0.3 }
497  }
498  end {
499    amp: "clean_fender.pedal" { Volume = 0.7 }
500    cab: "cabinet.pedal"
501  }
502}
503"#;
504        let def = parse_global_board_file(src).unwrap();
505        assert_eq!(def.start.len(), 1);
506        assert_eq!(def.start[0].id, "gate");
507        assert_eq!(def.start[0].path, "noise_gate.pedal");
508        assert_eq!(def.start[0].overrides, vec![("Threshold".to_string(), 0.3)]);
509        assert_eq!(def.end.len(), 2);
510        assert_eq!(def.end[0].id, "amp");
511        assert_eq!(def.end[1].id, "cab");
512        assert!(def.end[1].overrides.is_empty());
513    }
514
515    #[test]
516    fn parse_global_board_start_only() {
517        let src = r#"
518global {
519  start {
520    comp: "dyna_comp.pedal"
521  }
522}
523"#;
524        let def = parse_global_board_file(src).unwrap();
525        assert_eq!(def.start.len(), 1);
526        assert!(def.end.is_empty());
527    }
528
529    #[test]
530    fn parse_global_board_end_only() {
531        let src = r#"
532global {
533  end {
534    amp: "clean_fender.pedal" { Volume = 0.5 }
535  }
536}
537"#;
538        let def = parse_global_board_file(src).unwrap();
539        assert!(def.start.is_empty());
540        assert_eq!(def.end.len(), 1);
541    }
542
543    #[test]
544    fn parse_global_board_empty() {
545        let src = "global {}";
546        let def = parse_global_board_file(src).unwrap();
547        assert!(def.start.is_empty());
548        assert!(def.end.is_empty());
549    }
550
551    #[test]
552    fn parse_global_board_reversed_order() {
553        let src = r#"
554global {
555  end {
556    amp: "clean_fender.pedal"
557  }
558  start {
559    gate: "noise_gate.pedal"
560  }
561}
562"#;
563        let def = parse_global_board_file(src).unwrap();
564        assert_eq!(def.start.len(), 1);
565        assert_eq!(def.start[0].id, "gate");
566        assert_eq!(def.end.len(), 1);
567        assert_eq!(def.end[0].id, "amp");
568    }
569
570    #[test]
571    fn find_global_board_in_same_dir() {
572        use std::io::Write;
573        let tmp = tempfile::tempdir().unwrap();
574        let global_path = tmp.path().join(".global.board");
575        let mut f = std::fs::File::create(&global_path).unwrap();
576        writeln!(f, "global {{ start {{ g: \"gate.pedal\" }} }}").unwrap();
577
578        let result = find_global_board(tmp.path());
579        assert!(result.is_some());
580        let (def, dir) = result.unwrap();
581        assert_eq!(def.start.len(), 1);
582        assert_eq!(def.start[0].id, "g");
583        assert_eq!(dir, tmp.path().canonicalize().unwrap());
584    }
585
586    #[test]
587    fn find_global_board_walks_up() {
588        use std::io::Write;
589        let tmp = tempfile::tempdir().unwrap();
590        // Put .global.board in root
591        let global_path = tmp.path().join(".global.board");
592        let mut f = std::fs::File::create(&global_path).unwrap();
593        writeln!(f, "global {{ end {{ a: \"amp.pedal\" }} }}").unwrap();
594
595        // Create a subdirectory
596        let sub = tmp.path().join("boards");
597        std::fs::create_dir(&sub).unwrap();
598
599        let result = find_global_board(&sub);
600        assert!(result.is_some());
601        let (def, dir) = result.unwrap();
602        assert_eq!(def.end.len(), 1);
603        assert_eq!(dir, tmp.path().canonicalize().unwrap());
604    }
605
606    #[test]
607    fn find_global_board_none() {
608        let tmp = tempfile::tempdir().unwrap();
609        let result = find_global_board(tmp.path());
610        assert!(result.is_none());
611    }
612}