1use 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#[derive(Debug, Clone, PartialEq)]
31pub struct BoardDef {
32 pub name: String,
33 pub entries: Vec<BoardEntry>,
34}
35
36impl BoardDef {
37 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#[derive(Debug, Clone, PartialEq)]
57pub enum BoardEntry {
58 Pedal(BoardPedalEntry),
60 FxLoop(FxLoopGroup),
62}
63
64#[derive(Debug, Clone, PartialEq)]
70pub struct FxLoopGroup {
71 pub target_id: String,
74 pub pedals: Vec<BoardPedalEntry>,
76 pub mix: f64,
78}
79
80#[derive(Debug, Clone, PartialEq)]
82pub struct BoardPedalEntry {
83 pub id: String,
85 pub path: String,
87 pub overrides: Vec<(String, f64)>,
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub struct GlobalBoardDef {
94 pub start: Vec<BoardPedalEntry>,
95 pub end: Vec<BoardPedalEntry>,
96}
97
98fn 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
111fn 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
119fn quoted_string(input: &str) -> IResult<&str, &str> {
121 nom::sequence::delimited(char('"'), take_while(|c: char| c != '"'), char('"'))(input)
122}
123
124fn 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
139fn 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 let (input, _) = opt(char(','))(input)?;
156 let (input, _) = ws_comments(input)?;
157 let (input, _) = char('}')(input)?;
158 Ok((input, overrides))
159}
160
161fn 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
184fn 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 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
224fn 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
232fn 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
257pub 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
266fn 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
286fn 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 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
320pub 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
329pub 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#[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 assert!(matches!(&board.entries[0], BoardEntry::Pedal(p) if p.id == "ts"));
454 assert!(matches!(&board.entries[1], BoardEntry::Pedal(p) if p.id == "amp"));
456 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); } else {
464 panic!("Expected FxLoop entry");
465 }
466 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 #[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 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 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}