summaryrefslogtreecommitdiff
path: root/src/mml_composemusic_mcp/parser_ppmck.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/mml_composemusic_mcp/parser_ppmck.py')
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py618
1 files changed, 260 insertions, 358 deletions
diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py
index 67e5093..9540844 100644
--- a/src/mml_composemusic_mcp/parser_ppmck.py
+++ b/src/mml_composemusic_mcp/parser_ppmck.py
@@ -1,22 +1,25 @@
-"""PPMCK-style MML parser."""
-
-from .ir import (
- DutyEvent,
- ErrorCode,
- ErrorDetail,
- NoteEvent,
- NoteSequence,
- RepeatEvent,
- RestEvent,
- TempoEvent,
+"""PPMCK-style MML parser: tokens -> AST."""
+
+from .ast_nodes import (
+ ASTNode,
+ BarStmt,
+ DutyStmt,
+ Header,
+ LengthStmt,
+ NoteStmt,
+ OctaveStmt,
+ Program,
+ RepeatEndStmt,
+ RepeatStartStmt,
+ RestStmt,
+ TempoStmt,
+ TieStmt,
+ Track,
+ VolumeStmt,
)
+from .ir import ErrorCode, ErrorDetail
from .lexer import Token, TokenType
-from .parser_base import (
- ParserContext,
- clamp,
- length_value_to_ticks,
- note_to_midi,
-)
+from .parser_base import ParserContext, context_line
CHANNEL_MAP = {
"A": "Pulse1",
@@ -24,111 +27,94 @@ CHANNEL_MAP = {
"T": "Triangle",
"N": "Noise",
}
-PULSE_CHANNELS = {"Pulse1", "Pulse2"}
class PpmckParser:
def __init__(self, source: str, tokens: list[Token]) -> None:
self.source = source
self.ctx = ParserContext(tokens)
- self.note_sequence = NoteSequence()
- self.current_channel: str | None = None
- self.octave = 4
- self.default_length = 4
- self.velocity = 15
- self.duty = 2
- self.tick_position = 0
- self.headers: list[tuple[str, str]] = []
- self.loop_start: int | None = None
self.tracks_seen: set[str] = set()
- self._tie_pending: bool = False
- def _context_line(self, token: Token) -> str:
- lines = self.source.splitlines()
- if 1 <= token.line <= len(lines):
- return lines[token.line - 1]
- return ""
+ def parse(self) -> tuple[Program, list[ErrorDetail]]:
+ program = Program(line=1, column=1, tracks=[])
+ headers: list[Header] = []
- def parse(self) -> tuple[NoteSequence, list[ErrorDetail]]:
while self.ctx.peek().type != TokenType.EOF:
token = self.ctx.peek()
if token.type == TokenType.HEADER:
- self._parse_header()
+ headers.append(self._parse_header())
continue
if token.type == TokenType.COMMENT:
self.ctx.advance()
continue
if token.type == TokenType.TRACK_HEADER:
- self._parse_track_header()
+ track = self._parse_track()
+ if track is not None:
+ track.headers = list(headers)
+ program.tracks.append(track)
continue
- if self.current_channel is None:
- self._warn_outside_track(token)
- self.ctx.advance()
+ if token.type == TokenType.INVALID:
+ self._handle_invalid(token)
continue
- self._parse_statement()
-
- self._validate_tracks()
-
- if self.loop_start is not None:
- max_tick = max(
- ch.total_ticks for ch in self.note_sequence.channels.values()
- )
- for ch in self.note_sequence.channels.values():
- ch.events.append(
- RepeatEvent(
- start_tick=self.loop_start,
- end_tick=max_tick,
- repeat_count=0,
- )
- )
+ # Anything else before a track header is outside a track.
+ self._warn_outside_track(token)
+ self.ctx.advance()
- for ch in self.note_sequence.channels.values():
- ch.total_ticks = max(
- (e.tick_position + getattr(e, "duration", 0) for e in ch.events),
- default=0,
- )
+ # Attach headers to the first track if no track exists yet
+ if headers and not program.tracks:
+ pass # headers are simply discarded if no tracks
+ elif headers and program.tracks:
+ # Headers already attached to first parsed track; nothing to do
+ pass
- return self.note_sequence, self.ctx.errors
+ return program, self.ctx.errors
- def _parse_header(self) -> None:
+ def _parse_header(self) -> Header:
token = self.ctx.advance()
raw = token.value
parts = raw.split(None, 1)
key = parts[0] if parts else raw
value = ""
- if len(parts) > 1 and parts[1].startswith('"'):
- value = parts[1].strip('"')
- if not parts[1].endswith('"') or parts[1] == '"':
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNTERMINATED_HEADER,
- line=token.line,
- column=token.column,
- message=f"ヘッダー '{key}' の引用符が閉じられていません。",
- severity="error",
- hint='二重引用符 " で値を閉じてください。例: #TITLE "My Song"',
- context=self._context_line(token),
- )
- self.headers.append((key, value))
-
- def _warn_outside_track(self, token: Token) -> None:
- if token.type in (TokenType.EOF, TokenType.COMMENT):
- return
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_OUTSIDE_TRACK,
+ unterminated = False
+ if len(parts) > 1:
+ rest = parts[1]
+ if rest.startswith('"'):
+ if not rest.endswith('"') or rest == '"':
+ unterminated = True
+ value = rest[1:]
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_HEADER,
+ line=token.line,
+ column=token.column,
+ message=f"ヘッダー '{key}' の引用符が閉じられていません。",
+ severity="error",
+ hint='二重引用符 " で値を閉じてください。例: #TITLE "My Song"',
+ context=context_line(self.source, token),
+ )
+ else:
+ value = rest[1:-1]
+ else:
+ value = rest
+ return Header(
line=token.line,
column=token.column,
- message=f"'{token.raw}' はトラック外に書かれています。",
- severity="warning",
- hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。",
- context=self._context_line(token),
+ key=key,
+ value=value,
+ unterminated=unterminated,
)
- def _parse_track_header(self) -> None:
+ def _parse_track(self) -> Track | None:
token = self.ctx.advance()
ch = token.value.upper()
if ch == "L":
- self.loop_start = 0
- return
+ return Track(
+ line=token.line,
+ column=token.column,
+ track_id="L",
+ channel="Loop",
+ mode="ppmck",
+ statements=[],
+ )
if ch not in CHANNEL_MAP:
self.ctx.add_error(
code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER,
@@ -137,12 +123,11 @@ class PpmckParser:
message=f"無効なトラックヘッダー '{ch}' です。",
severity="error",
hint="A, B, T, N, L のいずれかを使用してください。",
- context=self._context_line(token),
+ context=context_line(self.source, token),
)
- self.current_channel = None
- return
- self.current_channel = CHANNEL_MAP[ch]
- if self.current_channel in self.tracks_seen:
+ return None
+ channel = CHANNEL_MAP[ch]
+ if channel in self.tracks_seen:
self.ctx.add_error(
code=ErrorCode.SYNTAX_DUPLICATE_TRACK,
line=token.line,
@@ -150,64 +135,87 @@ class PpmckParser:
message=f"トラック '{ch}' が複数回定義されています。",
severity="error",
hint="各トラックは1回のみ定義できます。重複定義を削除してください。",
- context=self._context_line(token),
+ context=context_line(self.source, token),
)
- self.tracks_seen.add(self.current_channel)
- self.octave = 4
- self.default_length = 4
- self.velocity = 15
- self.duty = 2
- self.tick_position = 0
-
- def _parse_statement(self) -> None:
+ self.tracks_seen.add(channel)
+ track = Track(
+ line=token.line,
+ column=token.column,
+ track_id=ch,
+ channel=channel,
+ mode="ppmck",
+ statements=[],
+ )
+ while self.ctx.peek().type not in (
+ TokenType.TRACK_HEADER,
+ TokenType.EOF,
+ ):
+ stmt = self._parse_statement()
+ if stmt is not None:
+ track.statements.append(stmt)
+ return track
+
+ def _parse_statement(self) -> ASTNode | None:
token = self.ctx.peek()
- handlers = {
- TokenType.NOTE: self._parse_note,
- TokenType.REST: self._parse_rest,
- TokenType.OCTAVE: self._parse_octave,
- TokenType.OCTAVE_UP: self._parse_octave_up,
- TokenType.OCTAVE_DOWN: self._parse_octave_down,
- TokenType.LENGTH: self._parse_length,
- TokenType.VOLUME: self._parse_volume,
- TokenType.DUTY: self._parse_duty,
- TokenType.TEMPO: self._parse_tempo,
- TokenType.TIE: self._parse_tie,
- TokenType.REPEAT_START: self._parse_repeat_start,
- TokenType.REPEAT_END: self._parse_repeat_end,
- TokenType.BAR: self._parse_bar,
- TokenType.COMMENT: lambda: self.ctx.advance(),
- }
- handler = handlers.get(token.type)
- if handler:
- handler()
- else:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
- line=token.line,
- column=token.column,
- message=f"'{token.raw}' はここでは使用できません。",
- severity="error",
- hint="音符、休符、コマンド、小節線を配置してください。",
- context=self._context_line(token),
- )
+ if token.type == TokenType.INVALID:
+ self._handle_invalid(token)
+ return None
+ if token.type == TokenType.COMMENT:
self.ctx.advance()
+ return None
+ if token.type == TokenType.NOTE:
+ return self._parse_note()
+ if token.type == TokenType.REST:
+ return self._parse_rest()
+ if token.type == TokenType.OCTAVE:
+ return self._parse_octave()
+ if token.type == TokenType.OCTAVE_UP:
+ return self._parse_octave_up()
+ if token.type == TokenType.OCTAVE_DOWN:
+ return self._parse_octave_down()
+ if token.type == TokenType.LENGTH:
+ return self._parse_length()
+ if token.type == TokenType.VOLUME:
+ return self._parse_volume()
+ if token.type == TokenType.DUTY:
+ return self._parse_duty()
+ if token.type == TokenType.TEMPO:
+ return self._parse_tempo()
+ if token.type == TokenType.TIE:
+ return self._parse_tie()
+ if token.type == TokenType.REPEAT_START:
+ return self._parse_repeat_start()
+ if token.type == TokenType.REPEAT_END:
+ return self._parse_repeat_end()
+ if token.type == TokenType.BAR:
+ self.ctx.advance()
+ return BarStmt(line=token.line, column=token.column)
+ # Unknown token in track context
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=token.line,
+ column=token.column,
+ message=f"'{token.raw}' はここでは使用できません。",
+ severity="error",
+ hint="音符、休符、コマンド、小節線を配置してください。",
+ context=context_line(self.source, token),
+ )
+ self.ctx.advance()
+ return None
- def _read_length(self) -> tuple[int, int]:
- dots = 0
+ def _read_length(self, token: Token) -> tuple[int | None, int]:
length_token = self.ctx.match(TokenType.NUMBER)
- if length_token is None:
- length = self.default_length
- else:
+ length: int | None = None
+ if length_token is not None:
length = int(length_token.value)
+ dots = 0
while self.ctx.match(TokenType.DOT):
dots += 1
return length, dots
- def _parse_note(self) -> None:
+ def _parse_note(self) -> NoteStmt:
token = self.ctx.advance()
- note = token.value.lower()
- length, dots = self._read_length()
- ticks = length_value_to_ticks(length, dots)
+ length, dots = self._read_length(token)
accidental = 0
while True:
if self.ctx.match(TokenType.SHARP):
@@ -216,263 +224,157 @@ class PpmckParser:
accidental -= 1
else:
break
+ return NoteStmt(
+ line=token.line,
+ column=token.column,
+ note_name=token.value.lower(),
+ accidental=accidental,
+ length=length,
+ dots=dots,
+ )
- if self._tie_pending:
- # Tie: extend the last note/rest's duration instead of creating a new event
- self._extend_last_note(ticks)
- self.tick_position += ticks
- self._tie_pending = False
- return
-
- if self.current_channel == "Noise":
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_CHANNEL_MISMATCH,
- line=token.line,
- column=token.column,
- message="チャンネル 'Noise' で音高指定は使用できません。",
- severity="warning",
- hint="Noiseチャンネルでは音高ではなく音長と音量のみ指定してください。",
- context=self._context_line(token),
- )
- midi = 0
- else:
- midi = note_to_midi(note, self.octave, accidental)
- if not 0 <= midi <= 127:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_NOTE_OUT_OF_RANGE,
- line=token.line,
- column=token.column,
- message=f"音高が範囲外です(MIDI音番号 {midi})。",
- severity="warning",
- hint="オクターブを調整してください。有効範囲: o0〜o7。",
- context=self._context_line(token),
- )
- midi = clamp(midi, 0, 127)
+ def _parse_rest(self) -> RestStmt:
+ token = self.ctx.advance()
+ length, dots = self._read_length(token)
+ return RestStmt(
+ line=token.line,
+ column=token.column,
+ length=length,
+ dots=dots,
+ )
- event = NoteEvent(
- tick_position=self.tick_position,
- duration=ticks,
- note_number=midi,
- velocity=self.velocity,
- duty=self.duty,
- gate_time=1.0,
+ def _parse_octave(self) -> OctaveStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 4
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ direction=None,
)
- self._add_event(event)
- self.tick_position += ticks
- def _parse_rest(self) -> None:
- self.ctx.advance()
- length, dots = self._read_length()
- ticks = length_value_to_ticks(length, dots)
- if self._tie_pending:
- self._extend_last_note(ticks)
- self.tick_position += ticks
- self._tie_pending = False
- return
- event = RestEvent(tick_position=self.tick_position, duration=ticks)
- self._add_event(event)
- self.tick_position += ticks
+ def _parse_octave_up(self) -> OctaveStmt:
+ token = self.ctx.advance()
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=None,
+ direction="up",
+ )
- def _parse_octave(self) -> None:
+ def _parse_octave_down(self) -> OctaveStmt:
token = self.ctx.advance()
- value = int(token.value)
- if not 0 <= value <= 7:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE,
- line=token.line,
- column=token.column,
- message=f"'o' の値 {value} は範囲外です。有効範囲: 0〜7。",
- severity="error",
- hint="例: o4 のように指定してください。",
- context=self._context_line(token),
- )
- return
- self.octave = value
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=None,
+ direction="down",
+ )
- def _parse_octave_up(self) -> None:
- self.ctx.advance()
- if self.octave < 7:
- self.octave += 1
+ def _parse_length(self) -> LengthStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 4
+ return LengthStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_octave_down(self) -> None:
- self.ctx.advance()
- if self.octave > 0:
- self.octave -= 1
+ def _parse_volume(self) -> VolumeStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 0
+ return VolumeStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_length(self) -> None:
+ def _parse_duty(self) -> DutyStmt:
token = self.ctx.advance()
- value = int(token.value)
- if not 1 <= value <= 192:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE,
- line=token.line,
- column=token.column,
- message=f"'l' の値 {value} は範囲外です。有効範囲: 1〜192。",
- severity="error",
- hint="例: l8 のように指定してください。",
- context=self._context_line(token),
- )
- return
- self.default_length = value
+ value = int(token.value) if token.value else 0
+ return DutyStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_volume(self) -> None:
+ def _parse_tempo(self) -> TempoStmt:
token = self.ctx.advance()
- if self.current_channel == "Triangle":
- # Silently ignored per spec
- return
- value = int(token.value)
- if not 0 <= value <= 15:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE,
- line=token.line,
- column=token.column,
- message=f"'v' の値 {value} は範囲外です。有効範囲: 0〜15。",
- severity="error",
- hint="例: v15 のように指定してください。",
- context=self._context_line(token),
- )
- return
- self.velocity = value
+ value = int(token.value) if token.value else 120
+ return TempoStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_duty(self) -> None:
+ def _parse_tie(self) -> TieStmt:
token = self.ctx.advance()
- if self.current_channel not in PULSE_CHANNELS:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_CHANNEL_MISMATCH,
+ next_token = self.ctx.peek()
+ if next_token.type == TokenType.NUMBER:
+ self.ctx.advance()
+ return TieStmt(
line=token.line,
column=token.column,
- message=f"チャンネル '{self.current_channel}' で 'q' は使用できません。",
- severity="warning",
- hint="Triangleチャンネルはデューティ比を持ちません。q コマンドを削除してください。",
- context=self._context_line(token),
+ target=int(next_token.value),
)
- return
- value = int(token.value)
- if not 0 <= value <= 3:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE,
+ if next_token.type == TokenType.NOTE:
+ return TieStmt(
line=token.line,
column=token.column,
- message=f"'q' の値 {value} は範囲外です。有効範囲: 0〜3。",
- severity="error",
- hint="例: q2 のように指定してください。",
- context=self._context_line(token),
+ target=self._parse_note(),
)
- return
- self.duty = value
- self._add_event(DutyEvent(tick_position=self.tick_position, value=value))
-
- def _parse_tempo(self) -> None:
- token = self.ctx.advance()
- value = int(token.value)
- if value < 1:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE,
+ if next_token.type == TokenType.REST:
+ return TieStmt(
line=token.line,
column=token.column,
- message=f"'t' の値 {value} は範囲外です。有効範囲: 1以上。",
- severity="error",
- hint="例: t150 のように指定してください。",
- context=self._context_line(token),
+ target=self._parse_rest(),
)
- return
- self.note_sequence.bpm = value
- self._add_event(TempoEvent(tick_position=self.tick_position, bpm=value))
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_TIE,
+ line=token.line,
+ column=token.column,
+ message="タイ '&' の後に音符が見つかりません。",
+ severity="error",
+ hint="& の後に音符(例: c4)を続けてください。音長のみ(例: &16)も可能です。",
+ context=context_line(self.source, token),
+ )
+ return TieStmt(line=token.line, column=token.column, target=None)
- def _parse_tie(self) -> None:
+ def _parse_repeat_start(self) -> RepeatStartStmt:
token = self.ctx.advance()
- next_token = self.ctx.peek()
- if next_token.type not in (TokenType.NOTE, TokenType.REST, TokenType.NUMBER):
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNTERMINATED_TIE,
- line=token.line,
- column=token.column,
- message="タイ '&' の後に音符が見つかりません。",
- severity="error",
- hint="& の後に音符(例: c4)を続けてください。",
- context=self._context_line(token),
- )
- return
- if next_token.type == TokenType.NUMBER:
- self.ctx.advance()
- ticks = length_value_to_ticks(int(next_token.value), 0)
- self._extend_last_note(ticks)
- self.tick_position += ticks
- return
- # For note/rest: back up tick_position so the next event starts at the
- # end of the last note/rest. The next note's duration will be added
- # to the previous note's duration via _tie_pending flag.
- self.tick_position = self._last_note_end()
- self._tie_pending = True
+ return RepeatStartStmt(line=token.line, column=token.column)
- def _last_note_end(self) -> int:
- ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
- for ev in reversed(ch.events):
- if isinstance(ev, (NoteEvent, RestEvent)):
- return ev.tick_position + ev.duration
- return self.tick_position
-
- def _extend_last_note(self, ticks: int) -> None:
- ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
- for ev in reversed(ch.events):
- if isinstance(ev, (NoteEvent, RestEvent)):
- ev.duration += ticks
- return
-
- def _parse_repeat_start(self) -> None:
- # Stage 2 feature: keep token but do not expand
+ def _parse_repeat_end(self) -> RepeatEndStmt:
token = self.ctx.advance()
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ count: int | None = None
+ if token.value:
+ count = int(token.value)
+ return RepeatEndStmt(
line=token.line,
column=token.column,
- message="区間リピート '[' は第2段階で実装されます。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(token),
+ count=count,
)
- def _parse_repeat_end(self) -> None:
- token = self.ctx.advance()
+ def _handle_invalid(self, token: Token) -> None:
+ self.ctx.add_invalid_token_error(token, context_line(self.source, token))
+ self.ctx.advance()
+
+ def _warn_outside_track(self, token: Token) -> None:
+ if token.type in (TokenType.EOF, TokenType.COMMENT):
+ return
self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ code=ErrorCode.SEMANTIC_OUTSIDE_TRACK,
line=token.line,
column=token.column,
- message="区間リピート ']' は第2段階で実装されます。",
+ message=f"'{token.raw}' はトラック外に書かれています。",
severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(token),
+ hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。",
+ context=context_line(self.source, token),
)
- def _parse_bar(self) -> None:
- self.ctx.advance()
-
- def _add_event(self, event) -> None: # type: ignore[no-untyped-def]
- ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
- ch.events.append(event)
-
- def _validate_tracks(self) -> None:
- for raw, name in CHANNEL_MAP.items():
- ch = self.note_sequence.channels[name]
- if name in self.tracks_seen and not ch.events:
- # Determine approximate line of header (last track header token)
- header_token = None
- for tok in self.ctx.tokens:
- if tok.type == TokenType.TRACK_HEADER and tok.value.upper() == raw:
- header_token = tok
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_EMPTY_TRACK,
- line=header_token.line if header_token else 1,
- column=header_token.column if header_token else 1,
- message=f"トラック '{raw}' に音符または休符がありません。",
- severity="error",
- hint="少なくとも1つの音符または休符を記述してください。",
- context=self._context_line(header_token) if header_token else "",
- )
-
def parse_ppmck(
source: str, tokens: list[Token]
-) -> tuple[NoteSequence, list[ErrorDetail]]:
+) -> tuple[Program, list[ErrorDetail]]:
parser = PpmckParser(source, tokens)
return parser.parse()