// Copyright (C) 2026 Michael Wilson <mike@mdwn.dev>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//
// Main file rule - can contain any combination of fixture types, venues, light shows, sequences, and tempo
file = { SOI ~ (fixture_type | venue | light_show | sequence | tempo)* ~ EOI }
// Light show rules
// Allow optional show names so a single unnamed show per file is valid.
// When multiple shows are present, the parser will enforce that each has a name.
light_show = { "show" ~ show_name? ~ show_content }
show_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
show_content = { "{" ~ (tempo | cue)* ~ "}" }
cue = { (time_string | measure_time) ~ (effect | layer_command | sequence_reference | stop_sequence_command | offset_command | reset_measures_command | inline_loop)* }
// Sequence definition rules
sequence = { "sequence" ~ sequence_name ~ sequence_content }
sequence_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
sequence_content = { "{" ~ (tempo | sequence_cue)* ~ "}" }
sequence_cue = { (time_string | measure_time) ~ (effect | layer_command | sequence_reference | stop_sequence_command | offset_command | reset_measures_command | inline_loop)* }
// Sequence reference in a cue
sequence_reference = { "sequence" ~ sequence_name ~ (","? ~ sequence_params)? }
sequence_params = { sequence_param ~ ("," ~ sequence_param)* }
sequence_param = { sequence_param_name ~ ":" ~ sequence_param_value }
sequence_param_name = { "loop" }
sequence_param_value = { loop_parameter | number_value }
// Stop sequence command
stop_sequence_command = { "stop" ~ "sequence" ~ sequence_name }
// Inline loop - allows repeating a block of cues inline
inline_loop = { "loop" ~ "{" ~ inline_loop_cue* ~ "}" ~ ","? ~ "repeats" ~ ":" ~ number_value }
// Cues inside an inline loop (timing is relative to loop start)
inline_loop_cue = { (time_string | measure_time) ~ (effect | layer_command | stop_sequence_command | offset_command | reset_measures_command)* }
// Measure offset commands
offset_command = { "offset" ~ number_value ~ "measures" }
reset_measures_command = { "reset_measures" }
// Layer control commands (grandMA-inspired)
layer_command = { layer_command_type ~ "(" ~ layer_command_params? ~ ")" }
layer_command_type = { "release" | "clear" | "freeze" | "unfreeze" | "master" }
layer_command_params = { layer_command_param ~ ("," ~ layer_command_param)* }
layer_command_param = { layer_command_param_name ~ ":" ~ layer_command_param_value }
layer_command_param_name = { "layer" | "time" | "intensity" | "speed" }
layer_command_param_value = { layer_parameter | time_parameter | percentage | number_value }
time_string = @{ "@" ~ (time_mm_ss_mmm | time_ss_mmm) }
measure_time = @{ "@" ~ ASCII_DIGIT+ ~ "/" ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }
time_mm_ss_mmm = @{ ASCII_DIGIT+ ~ ":" ~ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ }
time_ss_mmm = @{ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ }
// Parameters are optional; support both `effect_type param: v` and `effect_type, param: v`
effect = { group_list ~ ":" ~ effect_type ~ (","? ~ parameters)? }
group_list = { group_name ~ ("," ~ group_name)* }
group_name = { (ASCII_ALPHANUMERIC | "_" | "-")+ }
effect_type = { "static" | "cycle" | "strobe" | "pulse" | "chase" | "dimmer" | "rainbow" }
parameters = { parameter ~ ("," ~ parameter)* }
parameter = { parameter_name ~ ":" ~ parameter_value }
// Prefer identifiers for names; allow quoted names when needed
parameter_name = { identifier | string }
parameter_value = {
percentage |
time_parameter |
direction_parameter |
chase_pattern_parameter |
loop_parameter |
step_parameter |
transition_parameter |
layer_parameter |
blend_mode_parameter |
color_parameter |
number_value |
string |
bare_identifier
}
// Restrictive bare identifier to avoid swallowing typos
// Atomic to prevent backtracking issues when it's the last parameter
bare_identifier = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* }
color_parameter = {
quoted_hex_color |
quoted_rgb_color |
hex_color |
rgb_color |
named_color
}
hex_color = @{ "#" ~ ASCII_HEX_DIGIT{6} }
// RGB color with optional spaces after commas - make atomic to control whitespace
rgb_color = @{ "rgb(" ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ ")" }
named_color = { "\"" ~ ("red" | "green" | "blue" | "white" | "black" | "yellow" | "cyan" | "magenta" | "orange" | "purple") ~ "\"" }
quoted_hex_color = @{ "\"" ~ "#" ~ ASCII_HEX_DIGIT{6} ~ "\"" }
quoted_rgb_color = @{ "\"" ~ "rgb(" ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ ")" ~ "\"" }
// Time-based parameters (duration, fade, etc.) all use the same format
// Supports absolute time (ms, s) and musical time (beats, measures)
time_parameter = @{ time_value ~ time_unit }
direction_parameter = {
"forward" | "backward" | "random" | "pingpong" |
"left_to_right" | "right_to_left" |
"top_to_bottom" | "bottom_to_top" |
"clockwise" | "counter_clockwise"
}
chase_pattern_parameter = { "linear" | "snake" | "random" }
loop_parameter = { "once" | "loop" | "pingpong" | "random" }
step_parameter = { "[" ~ step_list ~ "]" }
step_list = { step ~ ("," ~ step)* }
step = { "{" ~ step_content ~ "}" }
step_content = { step_param ~ ("," ~ step_param)* }
step_param = { parameter_name ~ ":" ~ parameter_value }
transition_parameter = { "snap" | "fade" | "crossfade" }
layer_parameter = { "background" | "midground" | "foreground" }
blend_mode_parameter = { "replace" | "multiply" | "add" | "overlay" | "screen" }
percentage = @{ ASCII_DIGIT+ ~ "%" }
// Canonical number form reused across numeric parameters
number_value = { ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }
time_value = { number_value }
time_unit = @{ "measures" | "measure" | "beats" | "beat" | "ms" | "s" }
string = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
identifier = { (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* }
// Tempo section rules
tempo = { "tempo" ~ "{" ~ tempo_content ~ "}" }
tempo_content = { (tempo_start | tempo_bpm | tempo_time_signature | tempo_changes)* }
tempo_start = { "start" ~ ":" ~ time_parameter }
tempo_bpm = { "bpm" ~ ":" ~ number_value }
tempo_time_signature = { "time_signature" ~ ":" ~ time_sig_value }
time_sig_value = @{ ASCII_DIGIT+ ~ "/" ~ ASCII_DIGIT+ }
tempo_changes = { "changes" ~ ":" ~ "[" ~ tempo_change_list? ~ "]" }
tempo_change_list = { tempo_change ~ ("," ~ tempo_change)* }
tempo_change = { (time_string | measure_time) ~ "{" ~ tempo_change_content ~ "}" }
tempo_change_content = { tempo_change_param ~ ("," ~ tempo_change_param)* }
tempo_change_param = {
tempo_change_bpm | tempo_change_time_signature | tempo_change_transition
}
tempo_change_bpm = { "bpm" ~ ":" ~ number_value }
tempo_change_time_signature = { "time_signature" ~ ":" ~ time_sig_value }
tempo_change_transition = { "transition" ~ ":" ~ tempo_transition_duration }
tempo_transition_duration = { tempo_transition_measures | tempo_transition_beats | tempo_transition_snap }
tempo_transition_measures = @{ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? ~ "m" }
tempo_transition_beats = { number_value }
tempo_transition_snap = { "snap" }
COMMENT = _{ ("//" | "#") ~ (!"\n" ~ ANY)* }
// Fixture type rules
fixture_type = { "fixture_type" ~ fixture_type_name ~ "{" ~ fixture_type_content ~ "}" }
fixture_type_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
fixture_type_content = {
(channels | channel_map | max_strobe_frequency | min_strobe_frequency | strobe_dmx_offset | special_cases)*
}
channels = { "channels" ~ ":" ~ number_value }
max_strobe_frequency = { "max_strobe_frequency" ~ ":" ~ number_value }
min_strobe_frequency = { "min_strobe_frequency" ~ ":" ~ number_value }
strobe_dmx_offset = { "strobe_dmx_offset" ~ ":" ~ number_value }
channel_map = { "channel_map" ~ ":" ~ "{" ~ channel_mapping_list ~ "}" }
channel_mapping_list = { channel_mapping ~ ("," ~ channel_mapping)* }
channel_mapping = { channel_name ~ ":" ~ channel_number }
channel_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
channel_number = { number_value }
special_cases = { "special_cases" ~ ":" ~ "[" ~ special_case_list ~ "]" }
special_case_list = { special_case ~ ("," ~ special_case)* }
special_case = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
// Venue rules - support both simple and complex formats
venue = { "venue" ~ string ~ "{" ~ venue_content ~ "}" }
venue_content = { (fixture | group)* }
fixture = { "fixture" ~ string ~ identifier ~ "@" ~ universe_num ~ ":" ~ address_num ~ tags? }
universe_num = { ASCII_DIGIT+ }
address_num = { ASCII_DIGIT+ }
tags = { "tags" ~ tag_list }
tag_list = { "[" ~ string ~ ("," ~ string)* ~ "]" }
group = { "group" ~ string ~ "=" ~ identifier_list }
identifier_list = { identifier ~ ("," ~ identifier)* }
WHITESPACE = _{ " " | "\t" | "\r" | "\n" | COMMENT }