"""PPMCK-style MML parser: tokens -> AST.""" from .ast_nodes import ( ASTNode, BarStmt, DetuneCmdStmt, DutyEnvelopeDefStmt, DutyEnvelopeUseStmt, DutyStmt, Header, LengthStmt, LfoDefStmt, LfoOffStmt, LfoUseStmt, NoteEnvDefStmt, NoteEnvOffStmt, NoteEnvUseStmt, NoteStmt, OctaveStmt, PitchEnvDefStmt, PitchEnvOffStmt, PitchEnvUseStmt, Program, QuantizeStmt, RelativeVolumeStmt, RepeatBreakStmt, RepeatEndStmt, RepeatStartStmt, RestStmt, SweepStmt, TempoStmt, TieCmdStmt, TieStmt, Track, VolumeEnvelopeDefStmt, VolumeEnvelopeUseStmt, VolumeStmt, ) from .ir import ErrorCode, ErrorDetail from .lexer import Token, TokenType from .parser_base import ParserContext, context_line CHANNEL_MAP = { "A": "Pulse1", "B": "Pulse2", "T": "Triangle", "N": "Noise", } HEADER_KEYS = { "#TITLE", "#COMPOSER", "#PROGRAMER", "#OCTAVE-REV", "#INCLUDE", "#EX-DISKFM", "#EX-NAMCO106", "#BANK-CHANGE", "#EFFECT-INCLUDE", } class PpmckParser: def __init__(self, source: str, tokens: list[Token]) -> None: self.source = source self.ctx = ParserContext(tokens) self.tracks_seen: set[str] = set() self.track_ids_seen: set[str] = set() self.repeat_stack: list[Token] = [] self.repeat_break_seen: list[bool] = [] self.seen_global_definition = False def parse(self) -> tuple[Program, list[ErrorDetail]]: program = Program(line=1, column=1, tracks=[]) headers: list[Header] = [] while self.ctx.peek().type != TokenType.EOF: token = self.ctx.peek() if token.type == TokenType.HEADER: if program.tracks or self.seen_global_definition: self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message="ヘッダーはトラック定義より前に記述してください。", severity="error", hint="すべての # ヘッダーを最初のトラックより前へ移動してください。", context=context_line(self.source, token), ) headers.append(self._parse_header()) continue if token.type == TokenType.COMMENT: self.ctx.advance() continue if token.type == TokenType.TRACK_HEADER: track = self._parse_track() if track is not None: track.headers = list(headers) program.tracks.append(track) continue if token.type == TokenType.INVALID: self._handle_invalid(token) continue if token.type in { TokenType.VOL_ENV, TokenType.DUTY, TokenType.LFO_DEF, TokenType.PITCH_ENV_DEF, TokenType.NOTE_ENV_DEF, }: statement = self._parse_statement() if isinstance( statement, ( VolumeEnvelopeDefStmt, DutyEnvelopeDefStmt, LfoDefStmt, PitchEnvDefStmt, NoteEnvDefStmt, ), ): program.global_statements.append(statement) self.seen_global_definition = True else: self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message="トラック外ではエフェクト定義だけを使用できます。", severity="error", hint="選択コマンドはトラック内へ移動してください。", context=context_line(self.source, token), ) continue # Anything else before a track header is outside a track. self._error_outside_track(token) self.ctx.advance() # 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 program, self.ctx.errors 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 = "" unterminated = False if key.upper() not in HEADER_KEYS: self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message=f"未知のヘッダー '{key}' です。", severity="error", hint="MML_BNF.md に定義されたヘッダーを使用してください。", context=context_line(self.source, token), ) 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 self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message=f"ヘッダー '{key}' の値は二重引用符で囲んでください。", severity="error", hint=f'#TITLE "{value}" のように記述してください。', context=context_line(self.source, token), ) return Header( line=token.line, column=token.column, key=key, value=value, unterminated=unterminated, ) def _parse_track(self) -> Track | None: token = self.ctx.advance() ch = token.value if ch in self.track_ids_seen: self.ctx.add_error( code=ErrorCode.SYNTAX_DUPLICATE_TRACK, line=token.line, column=token.column, message=f"トラック '{ch}' が複数回定義されています。", severity="error", hint="各トラックは1回のみ定義できます。重複定義を削除してください。", context=context_line(self.source, token), ) self.track_ids_seen.add(ch) if ch not in (*CHANNEL_MAP, "L"): self.ctx.add_error( code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER, line=token.line, column=token.column, message=f"無効なトラックヘッダー '{ch}' です。", severity="error", hint="A, B, T, N, L のいずれかを使用してください。", context=context_line(self.source, token), ) return None channel = "Loop" if ch == "L" else CHANNEL_MAP[ch] self.tracks_seen.add(channel) track = Track( line=token.line, column=token.column, track_id=ch, channel=channel, mode="ppmck", statements=[], ) self.repeat_stack = [] self.repeat_break_seen = [] while self.ctx.peek().type not in ( TokenType.TRACK_HEADER, TokenType.EOF, ): stmt = self._parse_statement() if stmt is not None: if channel == "Loop" and not isinstance( stmt, ( LengthStmt, RestStmt, RepeatStartStmt, RepeatBreakStmt, RepeatEndStmt, BarStmt, ), ): self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=stmt.line, column=stmt.column, message="Lトラックでは時間指定以外のコマンドを使用できません。", severity="error", hint="l、休符、リピート、小節線だけを使用してください。", context=context_line(self.source, stmt), ) else: track.statements.append(stmt) for start in self.repeat_stack: self.ctx.add_error( code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT, line=start.line, column=start.column, message="リピート '[' に対応する ']' が見つかりません。", severity="error", hint="] を追加してリピートを閉じてください。", context=context_line(self.source, start), ) self.repeat_stack = [] self.repeat_break_seen = [] return track def _parse_statement(self) -> ASTNode | None: token = self.ctx.peek() 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.QUANTIZE: return self._parse_quantize() if token.type == TokenType.TEMPO: return self._parse_tempo() if token.type == TokenType.TIE: return self._parse_tie() if token.type == TokenType.TIE_CMD: return self._parse_tie_cmd() 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() if self.repeat_stack: if self.repeat_break_seen[-1]: self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message="1つのリピート内に終端分岐 '|' は1つだけ指定できます。", severity="error", hint="余分な '|' を削除してください。", context=context_line(self.source, token), ) self.repeat_break_seen[-1] = True return RepeatBreakStmt(line=token.line, column=token.column) return BarStmt(line=token.line, column=token.column) if token.type == TokenType.DETUNE: return self._parse_detune() if token.type == TokenType.SWEEP: return self._parse_sweep() if token.type == TokenType.REL_VOL_UP: return self._parse_rel_vol(up=True) if token.type == TokenType.REL_VOL_DOWN: return self._parse_rel_vol(up=False) if token.type == TokenType.VOL_ENV: return self._parse_vol_env() if token.type == TokenType.DUTY_ENV_USE: return self._parse_duty_env_use() if token.type == TokenType.LFO_DEF: return self._parse_lfo_def() if token.type == TokenType.LFO_USE: return self._parse_lfo_use() if token.type == TokenType.LFO_OFF: return self._parse_lfo_off() if token.type == TokenType.PITCH_ENV_DEF: return self._parse_pitch_env_def() if token.type == TokenType.PITCH_ENV_USE: return self._parse_pitch_env_use() if token.type == TokenType.PITCH_ENV_OFF: return self._parse_pitch_env_off() if token.type == TokenType.NOTE_ENV_DEF: return self._parse_note_env_def() if token.type == TokenType.NOTE_ENV_USE: return self._parse_note_env_use() if token.type == TokenType.NOTE_ENV_OFF: return self._parse_note_env_off() # 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, token: Token) -> tuple[int | None, int]: length_token = self.ctx.match(TokenType.NUMBER) 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) -> NoteStmt: token = self.ctx.advance() accidental = 0 if self.ctx.match(TokenType.SHARP): accidental = 1 elif self.ctx.match(TokenType.FLAT): accidental = -1 length, dots = self._read_length(token) return NoteStmt( line=token.line, column=token.column, note_name=token.value.lower(), accidental=accidental, length=length, dots=dots, ) 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, ) def _parse_octave(self) -> OctaveStmt: token = self.ctx.advance() self._require_value(token, "o") value = int(token.value) if token.value else 4 return OctaveStmt( line=token.line, column=token.column, value=value, direction=None, ) 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_down(self) -> OctaveStmt: token = self.ctx.advance() return OctaveStmt( line=token.line, column=token.column, value=None, direction="down", ) def _parse_length(self) -> LengthStmt: token = self.ctx.advance() self._require_value(token, "l") value = int(token.value) if token.value else 4 return LengthStmt( line=token.line, column=token.column, value=value, ) def _parse_volume(self) -> VolumeStmt: token = self.ctx.advance() self._require_value(token, "v") value = int(token.value) if token.value else 0 return VolumeStmt( line=token.line, column=token.column, value=value, ) def _parse_duty(self) -> DutyStmt: token = self.ctx.advance() self._require_value(token, "@") value = int(token.value) if token.value else 2 # @ = { ... | ... } → duty envelope definition if self.ctx.peek().type == TokenType.EQUAL: return self._parse_duty_env_def_rest(token, value) return DutyStmt( line=token.line, column=token.column, value=value, ) def _parse_quantize(self) -> QuantizeStmt: token = self.ctx.advance() self._require_value(token, "q") value = int(token.value) if token.value else 8 return QuantizeStmt( line=token.line, column=token.column, value=value, ) def _parse_tempo(self) -> TempoStmt: token = self.ctx.advance() self._require_value(token, "t") value = int(token.value) if token.value else 120 return TempoStmt( line=token.line, column=token.column, value=value, ) def _require_value(self, token: Token, command: str) -> None: if token.value in ("", "+", "-"): self.ctx.add_missing_number_error( token, command, context_line(self.source, token) ) def _parse_tie(self) -> TieStmt: token = self.ctx.advance() next_token = self.ctx.peek() if next_token.type == TokenType.NUMBER: self.ctx.advance() return TieStmt( line=token.line, column=token.column, target=int(next_token.value), ) if next_token.type == TokenType.NOTE: return TieStmt( line=token.line, column=token.column, target=self._parse_note(), ) if next_token.type == TokenType.REST: return TieStmt( line=token.line, column=token.column, target=self._parse_rest(), ) 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_cmd(self) -> TieCmdStmt: """Parse ^ (tie) command.""" token = self.ctx.advance() next_token = self.ctx.peek() if next_token.type == TokenType.NUMBER: self.ctx.advance() return TieCmdStmt( line=token.line, column=token.column, target=int(next_token.value), ) if next_token.type == TokenType.NOTE: return TieCmdStmt( line=token.line, column=token.column, target=self._parse_note(), ) self.ctx.add_error( code=ErrorCode.SYNTAX_UNTERMINATED_TIE, line=token.line, column=token.column, message="タイ '^' の後に音符または音長が必要です。", severity="error", hint="^16 または ^c4 のように対象を続けてください。", context=context_line(self.source, token), ) return TieCmdStmt(line=token.line, column=token.column, target=None) def _parse_repeat_start(self) -> RepeatStartStmt: token = self.ctx.advance() self.repeat_stack.append(token) self.repeat_break_seen.append(False) return RepeatStartStmt(line=token.line, column=token.column) def _parse_repeat_end(self) -> RepeatEndStmt: token = self.ctx.advance() if not self.repeat_stack: self.ctx.add_error( code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END, line=token.line, column=token.column, message="']' に対応する '[' が見つかりません。", severity="error", hint="余分な ']' を削除してください。", context=context_line(self.source, token), ) else: self.repeat_stack.pop() self.repeat_break_seen.pop() count: int | None = None if token.value: count = int(token.value) return RepeatEndStmt( line=token.line, column=token.column, count=count, ) def _parse_detune(self) -> DetuneCmdStmt: token = self.ctx.advance() self._require_value(token, "D") value = int(token.value) if token.value not in ("", "+", "-") else 0 return DetuneCmdStmt( line=token.line, column=token.column, value=value, ) def _parse_sweep(self) -> SweepStmt: token = self.ctx.advance() parts = token.value.split(",") valid_parts = ( len(parts) == 2 and parts[0].isdigit() and parts[1].lstrip("+-").isdigit() ) if "," not in token.raw or not valid_parts: self.ctx.add_missing_number_error( token, "s,", context_line(self.source, token) ) speed = int(parts[0]) if valid_parts else 0 depth = int(parts[1]) if valid_parts else 0 return SweepStmt( line=token.line, column=token.column, speed=speed, depth=depth, ) def _parse_rel_vol(self, up: bool) -> RelativeVolumeStmt: token = self.ctx.advance() value = int(token.value) if token.value else 1 delta = value if up else -value return RelativeVolumeStmt( line=token.line, column=token.column, delta=delta, ) def _parse_vol_env(self) -> ASTNode: """@v → VolumeEnvelopeUseStmt or VolumeEnvelopeDefStmt.""" token = self.ctx.advance() self._require_value(token, "@v") slot = int(token.value) if token.value else 0 if self.ctx.peek().type == TokenType.EQUAL: self.ctx.advance() # consume = points, loop_points = self._parse_envelope_body() return VolumeEnvelopeDefStmt( line=token.line, column=token.column, slot=slot, points=points, loop_points=loop_points, ) return VolumeEnvelopeUseStmt( line=token.line, column=token.column, slot=slot, ) def _parse_duty_env_use(self) -> DutyEnvelopeUseStmt: token = self.ctx.advance() self._require_value(token, "@@") slot = int(token.value) if token.value else 0 return DutyEnvelopeUseStmt( line=token.line, column=token.column, slot=slot, ) def _parse_duty_env_def_rest(self, token: Token, slot: int) -> DutyEnvelopeDefStmt: """@ = { ... | ... } → DutyEnvelopeDefStmt.""" self.ctx.advance() # consume = points, loop_points = self._parse_envelope_body() return DutyEnvelopeDefStmt( line=token.line, column=token.column, slot=slot, points=points, loop_points=loop_points, ) def _parse_lfo_def(self) -> LfoDefStmt: """@MP = { p1, p2, p3, p4 } → LfoDefStmt.""" token = self.ctx.advance() self._require_value(token, "@MP") slot = int(token.value) if token.value else 0 self._expect_token(TokenType.EQUAL, "'='") self._expect_token(TokenType.BRACE_OPEN, "'{'") values: list[int] = [] for index in range(4): values.append(self._expect_number("@MPのパラメータ")) if index < 3: self._expect_token(TokenType.COMMA, "','") self._expect_token(TokenType.BRACE_CLOSE, "'}'") return LfoDefStmt( line=token.line, column=token.column, slot=slot, delay=values[0], speed=values[1], depth=values[2], transition=values[3], ) def _parse_lfo_use(self) -> LfoUseStmt: token = self.ctx.advance() slot = int(token.value) if token.value else 0 return LfoUseStmt( line=token.line, column=token.column, slot=slot, ) def _parse_lfo_off(self) -> LfoOffStmt: token = self.ctx.advance() return LfoOffStmt(line=token.line, column=token.column) def _parse_pitch_env_def(self) -> PitchEnvDefStmt: token = self.ctx.advance() self._require_value(token, "@EP") slot = int(token.value) if token.value else 0 if self.ctx.peek().type == TokenType.EQUAL: self.ctx.advance() points, loop_points = self._parse_envelope_body(signed=True) return PitchEnvDefStmt( line=token.line, column=token.column, slot=slot, points=points, loop_points=loop_points, ) return PitchEnvDefStmt( line=token.line, column=token.column, slot=slot, ) def _parse_pitch_env_use(self) -> PitchEnvUseStmt: token = self.ctx.advance() slot = int(token.value) if token.value else 0 return PitchEnvUseStmt( line=token.line, column=token.column, slot=slot, ) def _parse_pitch_env_off(self) -> PitchEnvOffStmt: token = self.ctx.advance() return PitchEnvOffStmt(line=token.line, column=token.column) def _parse_note_env_def(self) -> NoteEnvDefStmt: token = self.ctx.advance() self._require_value(token, "@EN") slot = int(token.value) if token.value else 0 if self.ctx.peek().type == TokenType.EQUAL: self.ctx.advance() points, loop_points = self._parse_envelope_body(signed=True) return NoteEnvDefStmt( line=token.line, column=token.column, slot=slot, points=points, loop_points=loop_points, ) return NoteEnvDefStmt( line=token.line, column=token.column, slot=slot, ) def _parse_note_env_use(self) -> NoteEnvUseStmt: token = self.ctx.advance() slot = int(token.value) if token.value else 0 return NoteEnvUseStmt( line=token.line, column=token.column, slot=slot, ) def _parse_note_env_off(self) -> NoteEnvOffStmt: token = self.ctx.advance() return NoteEnvOffStmt(line=token.line, column=token.column) def _parse_envelope_body( self, *, signed: bool = False ) -> tuple[list[int], list[int]]: """Parse a non-empty envelope body with an optional loop divider.""" if self._expect_token(TokenType.BRACE_OPEN, "'{'") is None: self.ctx.synchronize(TokenType.BRACE_CLOSE, TokenType.TRACK_HEADER) self.ctx.match(TokenType.BRACE_CLOSE) return [], [] points: list[int] = [] loop_points: list[int] = [] in_loop = False while self.ctx.peek().type not in ( TokenType.BRACE_CLOSE, TokenType.TRACK_HEADER, TokenType.EOF, ): token = self.ctx.peek() if token.type == TokenType.BAR: self.ctx.advance() if in_loop: self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message="エンベロープのループ区切り '|' は1つだけ指定できます。", severity="error", context=context_line(self.source, token), ) in_loop = True continue if token.type == TokenType.COMMA: self.ctx.advance() continue if token.type == TokenType.NUMBER: self.ctx.advance() val = int(token.value) if in_loop: loop_points.append(val) else: points.append(val) continue if signed and token.type in (TokenType.SHARP, TokenType.FLAT): sign = token.raw self.ctx.advance() number = self.ctx.match(TokenType.NUMBER) if sign not in ("+", "-") or number is None: self.ctx.add_missing_number_error( token, "符号付き値", context_line(self.source, token) ) continue val = int(number.value) * (-1 if sign == "-" else 1) (loop_points if in_loop else points).append(val) continue # Unexpected token inside envelope body 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() if not points: token = self.ctx.peek() self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message="エンベロープ定義には1つ以上の値が必要です。", severity="error", context=context_line(self.source, token), ) self._expect_token(TokenType.BRACE_CLOSE, "'}'") return points, loop_points def _expect_token(self, token_type: TokenType, label: str) -> Token | None: token = self.ctx.match(token_type) if token is not None: return token actual = self.ctx.peek() self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=actual.line, column=actual.column, message=f"{label} が必要ですが '{actual.raw}' が見つかりました。", severity="error", hint=f"{label} を正しい位置に追加してください。", context=context_line(self.source, actual), ) return None def _expect_number(self, label: str) -> int: token = self.ctx.match(TokenType.NUMBER) if token is not None: return int(token.value) actual = self.ctx.peek() self.ctx.add_missing_number_error( actual, label, context_line(self.source, actual) ) if actual.type not in (TokenType.EOF, TokenType.TRACK_HEADER): self.ctx.advance() return 0 def _handle_invalid(self, token: Token) -> None: self.ctx.add_invalid_token_error(token, context_line(self.source, token)) self.ctx.advance() def _error_outside_track(self, token: Token) -> None: if token.type in (TokenType.EOF, TokenType.COMMENT): return self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message=f"'{token.raw}' はトラック外に書かれています。", severity="error", hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。", context=context_line(self.source, token), ) def parse_ppmck(source: str, tokens: list[Token]) -> tuple[Program, list[ErrorDetail]]: parser = PpmckParser(source, tokens) return parser.parse()