Parser/Transformer Patterns¶
This guide documents the established patterns for extending the MIDI Markdown parser and transformer.
Overview¶
The parser follows a three-stage pipeline: 1. Lark Grammar → Defines syntax rules 2. AST Nodes → Python dataclasses representing parsed structure 3. Transformer → Converts Lark tree to AST nodes
Adding a New MIDI Command¶
Follow this exact sequence when adding new MIDI command types:
Step 1: Add Grammar Rule¶
File: src/midi_markdown/parser/mmd.lark
Pattern Rules:
- Start with - (bullet point)
- Provide full name AND abbreviated form ("your_command" | "yc")
- Use param for parameters (handles variables/literals)
- Separate parameters with . for MIDI channel/value syntax
Step 2: Add AST Node (if needed)¶
File: src/midi_markdown/parser/ast_nodes.py
Most MIDI commands use the existing MIDICommand dataclass:
@dataclass
class MIDICommand:
type: str # CRITICAL: Use abbreviated form ("pc", "cc", "yc")
channel: int | None = None
data1: int | None = None
data2: int | None = None
timing: TimingMarker | None = None
source_line: int | None = None
Only create new AST node if:
- Command requires unique fields not in MIDICommand
- Command is a meta-construct (like @loop, @alias)
Step 3: Add Transformer Method¶
File: src/midi_markdown/parser/transformer.py
def your_command(self, param1, param2):
"""Transform your command to AST node.
Args:
param1: First parameter (from grammar rule)
param2: Second parameter (from grammar rule)
Returns:
MIDICommand with type="yc" (abbreviated!)
"""
# CRITICAL: Always resolve parameters first
param1_val = self._resolve_param(param1)
param2_val = self._resolve_param(param2)
# CRITICAL: Check isinstance before int() conversion
# Tuples indicate forward references (unresolved variables)
param1_int = int(param1_val) if not isinstance(param1_val, tuple) else param1_val
param2_int = int(param2_val) if not isinstance(param2_val, tuple) else param2_val
# CRITICAL: Use abbreviated type ("yc", NOT "your_command")
return MIDICommand(
type="yc", # Abbreviated form!
channel=param1_int,
data1=param2_int,
)
Required Pattern Checks:
- ✅ Resolve parameters:
self._resolve_param()handles variables - ✅ Check tuple type: Forward references are tuples, not ints
- ✅ Use abbreviated type: "pc" not "program_change"
- ✅ Add docstring: Explain what the command does
Step 4: Add Validation¶
File: src/midi_markdown/utils/validation/value_validator.py
def validate_your_command(channel: int, value: int) -> None:
"""Validate your command parameters.
Args:
channel: MIDI channel (1-16)
value: Command value
Raises:
ValueError: If parameters are out of range
"""
if not (1 <= channel <= 16):
raise ValueError(f"Channel {channel} out of range (1-16)")
if not (0 <= value <= 127):
raise ValueError(f"Value {value} out of range (0-127)")
Step 5: Add IR Support (if needed)¶
File: src/midi_markdown/core/ir.py
If this is a new event category (not just another CC/PC variant):
class EventType(str, Enum):
NOTE_ON = "note_on"
NOTE_OFF = "note_off"
CC = "cc"
PC = "pc"
YOUR_TYPE = "your_command" # Add new enum value
Step 6: Add MIDI Codegen¶
File: src/midi_markdown/codegen/midi_file.py
In the _event_to_midi() function:
def _event_to_midi(self, event: MIDIEvent) -> mido.Message:
"""Convert IR event to Mido MIDI message."""
# ... existing cases ...
elif event.type == EventType.YOUR_TYPE:
return mido.Message(
'your_midi_type',
channel=event.channel - 1, # MIDI channels are 0-indexed in Mido
value=event.data1,
)
Step 7: Write Tests¶
File: tests/unit/test_midi_commands.py
class TestYourCommand:
"""Test your_command MIDI command."""
@pytest.mark.unit
def test_basic_your_command(self, parser):
"""Test basic your_command parsing."""
mml = """
[00:00.000]
- yc 1.42
"""
doc = parser.parse_string(mml)
assert len(doc.events) == 1
assert doc.events[0]["type"] == "yc"
assert doc.events[0]["channel"] == 1
assert doc.events[0]["data1"] == 42
@pytest.mark.unit
def test_your_command_with_variable(self, parser):
"""Test your_command with variable substitution."""
mml = """
@define MY_VAL 99
[00:00.000]
- yc 1.${MY_VAL}
"""
doc = parser.parse_string(mml)
assert doc.events[0]["data1"] == 99
@pytest.mark.unit
def test_your_command_validation(self, parser):
"""Test your_command parameter validation."""
mml = """
[00:00.000]
- yc 1.255
"""
with pytest.raises(ValueError, match="out of range"):
parser.parse_string(mml)
Critical Patterns¶
Always Resolve Parameters¶
# ❌ WRONG - doesn't handle variables
def program_change(self, channel, program):
return MIDICommand(type="pc", channel=int(channel), data1=int(program))
# ✅ CORRECT - resolves variables first
def program_change(self, channel, program):
channel_val = self._resolve_param(channel)
program_val = self._resolve_param(program)
channel_int = int(channel_val) if not isinstance(channel_val, tuple) else channel_val
program_int = int(program_val) if not isinstance(program_val, tuple) else program_int
return MIDICommand(type="pc", channel=channel_int, data1=program_int)
Check isinstance Before int()¶
# ❌ WRONG - crashes on forward references
channel = int(channel_param) # TypeError if channel_param is ('var', 'MY_CHANNEL')
# ✅ CORRECT - preserves forward references
channel = int(channel_param) if not isinstance(channel_param, tuple) else channel_param
Use Abbreviated Command Types¶
# ❌ WRONG - validation won't match
MIDICommand(type="program_change", ...)
# ✅ CORRECT - matches validation logic
MIDICommand(type="pc", ...)
Example: Program Change Command¶
Complete implementation from actual codebase:
# Grammar (mml.lark:97)
program_change: "-" ("program_change" | "pc") param "." param
# AST Node (ast_nodes.py:32-48)
@dataclass
class MIDICommand:
type: str # "pc" for program change
channel: int | None = None
data1: int | None = None # Program number
# ... other fields
# Transformer (transformer.py:295-313)
def program_change(self, channel, program):
"""Transform program change (PC) MIDI command.
Syntax: - pc 1.42 (load program 42 on channel 1)
Args:
channel: MIDI channel (1-16)
program: Program number (0-127)
Returns:
MIDICommand with type="pc"
"""
channel_val = self._resolve_param(channel)
program_val = self._resolve_param(program)
channel_int = int(channel_val) if not isinstance(channel_val, tuple) else channel_val
program_int = int(program_val) if not isinstance(program_val, tuple) else program_val
return MIDICommand(type="pc", channel=channel_int, data1=program_int)
# Validation (value_validator.py)
def validate_program_change(channel: int, program: int) -> None:
"""Validate program change parameters."""
if not (1 <= channel <= 16):
raise ValueError(f"Channel {channel} out of range (1-16)")
if not (0 <= program <= 127):
raise ValueError(f"Program {program} out of range (0-127)")
# Codegen (midi_file.py)
elif event.type == EventType.PC:
return mido.Message(
'program_change',
channel=event.channel - 1, # 0-indexed
program=event.data1,
)
Testing Patterns¶
Test Matrix¶
Every new command should have these tests:
- Basic parsing - Command with literal values
- Variable substitution - Command with
${VAR} - Validation - Out-of-range values
- Edge cases - Min/max values, channel boundaries
- Integration - Command in loops/aliases
Minimal Test Example¶
@pytest.mark.unit
def test_your_command(self, parser):
mml = "[00:00.000]\n- yc 1.42"
doc = parser.parse_string(mml)
assert len(doc.events) == 1
assert doc.events[0]["type"] == "yc"
assert doc.events[0]["channel"] == 1
assert doc.events[0]["data1"] == 42
Reference¶
See also: - specification.md - Full MIDI command reference - anti-patterns.md - Common mistakes to avoid - timing-system.md - Timing calculation patterns