diff options
Diffstat (limited to 'src/mml_composemusic_mcp/parser_ppmck.py')
| -rw-r--r-- | src/mml_composemusic_mcp/parser_ppmck.py | 618 |
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() |
