diff --git a/packages/tilia-core/src/tilia_core/timemap/pattern.py b/packages/tilia-core/src/tilia_core/timemap/pattern.py new file mode 100644 index 000000000..236c76e56 --- /dev/null +++ b/packages/tilia-core/src/tilia_core/timemap/pattern.py @@ -0,0 +1,197 @@ +""" +Parsing for the beat pattern syntax. + +A pattern is a whitespace-separated list of items. An item is either a bar +length or a repeated group of items:: + + items := item (whitespace item)* + item := INT | INT? "[" items "]" + +So ``4 4 3`` is three bars, ``10[4] 3 15[4]`` is ten bars of 4, one of 3 and +fifteen of 4, and ``2[4 3[3]]`` expands to ``4 3 3 3 4 3 3 3``. A group with no +count (``[4 3]``) is played once. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, auto + +# Guards against inputs like "999999[999999[4]]" expanding into a list that +# would exhaust memory. Far above the bar count of any real piece. +MAX_BARS = 100_000 +# Groups are parsed recursively, so nesting is capped well below Python's +# recursion limit. Far deeper than any pattern worth writing. +MAX_DEPTH = 32 +# Significant digits in a number. Any more and the number is far above +# MAX_BARS, and int() refuses strings past a few thousand digits. +MAX_DIGITS = 9 +# Only ASCII digits: str.isdigit() also accepts characters such as "²" that +# int() rejects. +_DIGITS = frozenset("0123456789") + + +class ParseStatus(Enum): + COMPLETE = auto() + # A valid start that more typing could complete, e.g. "10[" or "". + INCOMPLETE = auto() + INVALID = auto() + + +@dataclass(frozen=True) +class ParseResult: + status: ParseStatus + bars: list[int] = field(default_factory=list) + error: str = "" + position: int | None = None + + @property + def is_complete(self) -> bool: + return self.status == ParseStatus.COMPLETE + + +@dataclass(frozen=True) +class BarRun: + """Consecutive bars with the same length. `start` is a 0-based bar index.""" + + start: int + count: int + beats: int + + +class _Incomplete(Exception): + def __init__(self, message: str, position: int) -> None: + super().__init__(message) + self.message = message + self.position = position + + +class _Invalid(Exception): + def __init__(self, message: str, position: int) -> None: + super().__init__(message) + self.message = message + self.position = position + + +class _Parser: + def __init__(self, text: str) -> None: + self.text = text + self.pos = 0 + self.depth = 0 + + def at_end(self) -> bool: + return self.pos >= len(self.text) + + def peek(self) -> str: + return self.text[self.pos] + + def at_digit(self) -> bool: + return not self.at_end() and self.peek() in _DIGITS + + def skip_whitespace(self) -> None: + while not self.at_end() and self.peek().isspace(): + self.pos += 1 + + def parse_int(self) -> int: + start = self.pos + while self.at_digit(): + self.pos += 1 + digits = self.text[start : self.pos].lstrip("0") + if len(digits) > MAX_DIGITS: + raise _Invalid(f"Numbers can have at most {MAX_DIGITS} digits.", start) + return int(digits or "0") + + def parse_items(self, open_position: int | None = None) -> list[int]: + """ + Parses items up to the end of the text, or up to the "]" closing the + group opened at `open_position`. Leaves that "]" unconsumed. + """ + in_group = open_position is not None + bars: list[int] = [] + while True: + self.skip_whitespace() + if self.at_end(): + if in_group: + raise _Incomplete("Unclosed '['.", open_position) + break + if self.peek() == "]": + if in_group: + break + raise _Invalid("Unmatched ']'.", self.pos) + item_start = self.pos + bars.extend(self.parse_item()) + if len(bars) > MAX_BARS: + raise _Invalid( + f"Pattern expands to more than {MAX_BARS} bars.", item_start + ) + + if not bars: + if in_group: + raise _Invalid("Empty group '[]'.", open_position) + raise _Incomplete("Enter at least one bar length.", 0) + + return bars + + def parse_item(self) -> list[int]: + start = self.pos + count = self.parse_int() if self.at_digit() else None + self.skip_whitespace() + + if not self.at_end() and self.peek() == "[": + open_position = self.pos + if self.depth >= MAX_DEPTH: + raise _Invalid( + f"Groups can be nested at most {MAX_DEPTH} deep.", open_position + ) + self.pos += 1 + self.depth += 1 + group = self.parse_items(open_position) + self.depth -= 1 + self.pos += 1 # the closing "]" + repeats = 1 if count is None else count + if repeats < 1: + raise _Invalid("Repeat count must be at least 1.", start) + if len(group) * repeats > MAX_BARS: + raise _Invalid(f"Pattern expands to more than {MAX_BARS} bars.", start) + return group * repeats + + if count is None: + raise _Invalid(f"Unexpected '{self.peek()}'.", self.pos) + if count < 1: + raise _Invalid("Bar lengths must be at least 1.", start) + return [count] + + +def parse(text: str) -> ParseResult: + """Parses a beat pattern into the list of bar lengths it expands to.""" + try: + bars = _Parser(text).parse_items() + except _Incomplete as e: + return ParseResult(ParseStatus.INCOMPLETE, error=e.message, position=e.position) + except _Invalid as e: + return ParseResult(ParseStatus.INVALID, error=e.message, position=e.position) + + return ParseResult(ParseStatus.COMPLETE, bars) + + +def group_runs(bars: list[int]) -> list[BarRun]: + """Groups consecutive bars of equal length, e.g. for previewing a pattern.""" + runs: list[BarRun] = [] + for index, beats in enumerate(bars): + if runs and runs[-1].beats == beats: + last = runs[-1] + runs[-1] = BarRun(last.start, last.count + 1, beats) + else: + runs.append(BarRun(index, 1, beats)) + return runs + + +def format_bars(bars: list[int]) -> str: + """ + Writes bar lengths as a pattern, collapsing runs of equal bars: + ``[4, 4, 4, 3]`` becomes ``3[4] 3``. Nested groups are not recovered. + """ + return " ".join( + f"{run.count}[{run.beats}]" if run.count > 1 else str(run.beats) + for run in group_runs(bars) + ) diff --git a/packages/tilia-core/tests/timemap/test_timemap_pattern.py b/packages/tilia-core/tests/timemap/test_timemap_pattern.py new file mode 100644 index 000000000..90d052b86 --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_pattern.py @@ -0,0 +1,171 @@ +import pytest + +from tilia_core.timemap.pattern import ( + MAX_BARS, + MAX_DEPTH, + MAX_DIGITS, + BarRun, + ParseStatus, + format_bars, + group_runs, + parse, +) + + +class TestComplete: + @pytest.mark.parametrize( + "text, bars", + [ + ("4", [4]), + ("4 4 3", [4, 4, 3]), + (" 4\t4\n3 ", [4, 4, 3]), + ("10[4] 3 15[4]", [4] * 10 + [3] + [4] * 15), + ("2[4 3[3]]", [4, 3, 3, 3, 4, 3, 3, 3]), + ("2[[4] 3[3]]", [4, 3, 3, 3, 4, 3, 3, 3]), + ("[4 3]", [4, 3]), + ("1[4]", [4]), + ("3 [2]", [2, 2, 2]), + ("2[3]4", [3, 3, 4]), + ("12", [12]), + ("007", [7]), + ], + ) + def test_expands(self, text, bars): + result = parse(text) + assert result.status == ParseStatus.COMPLETE + assert result.is_complete + assert result.bars == bars + assert result.error == "" + + def test_max_bars_is_allowed(self): + assert parse(f"{MAX_BARS}[1]").bars == [1] * MAX_BARS + + +class TestIncomplete: + @pytest.mark.parametrize( + "text, position", + [ + ("", 0), + (" ", 0), + ("10[", 2), + ("2[4 3", 1), + ("2[4 3[3]", 1), + ("2[4 3[3", 5), + ], + ) + def test_incomplete(self, text, position): + result = parse(text) + assert result.status == ParseStatus.INCOMPLETE + assert not result.is_complete + assert result.bars == [] + assert result.error + assert result.position == position + + +class TestInvalid: + @pytest.mark.parametrize( + "text, position, message", + [ + ("4 ]", 2, "Unmatched ']'."), + ("2[4]]", 4, "Unmatched ']'."), + ("4 a", 2, "Unexpected 'a'."), + ("4, 4", 1, "Unexpected ','."), + ("-4", 0, "Unexpected '-'."), + ("0", 0, "Bar lengths must be at least 1."), + ("4 0", 2, "Bar lengths must be at least 1."), + ("0[4]", 0, "Repeat count must be at least 1."), + ("2[]", 1, "Empty group '[]'."), + ("2[ ]", 1, "Empty group '[]'."), + ], + ) + def test_invalid(self, text, position, message): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.bars == [] + assert result.error == message + assert result.position == position + + @pytest.mark.parametrize( + "text", + [f"{MAX_BARS + 1}[1]", "999999999[999999999[4]]", f"{MAX_BARS}[1] 1"], + ) + def test_too_many_bars(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert "more than" in result.error + + @pytest.mark.parametrize("text", ["²", "4 ²", "①[4]", "٣"]) + def test_non_ascii_digits(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error.startswith("Unexpected") + + @pytest.mark.parametrize("text", ["9" * 5000, f"{'9' * 10}[4]"]) + def test_too_many_digits(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error == f"Numbers can have at most {MAX_DIGITS} digits." + assert result.position == 0 + + def test_leading_zeros_do_not_count_as_digits(self): + assert parse("0" * 20 + "4").bars == [4] + + def test_max_depth_is_allowed(self): + text = "[" * MAX_DEPTH + "4" + "]" * MAX_DEPTH + assert parse(text).bars == [4] + + @pytest.mark.parametrize("closed", [True, False]) + def test_too_deep(self, closed): + depth = MAX_DEPTH + 1 + text = "[" * depth + "4" + ("]" * depth if closed else "") + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error == f"Groups can be nested at most {MAX_DEPTH} deep." + assert result.position == MAX_DEPTH + + def test_very_deep_nesting_does_not_recurse(self): + result = parse("[" * 5000 + "4" + "]" * 5000) + assert result.status == ParseStatus.INVALID + + +class TestGroupRuns: + def test_empty(self): + assert group_runs([]) == [] + + def test_groups_consecutive_equal_bars(self): + assert group_runs([4] * 10 + [3] + [4] * 15) == [ + BarRun(0, 10, 4), + BarRun(10, 1, 3), + BarRun(11, 15, 4), + ] + + def test_does_not_merge_non_consecutive_bars(self): + assert group_runs([4, 3, 4]) == [ + BarRun(0, 1, 4), + BarRun(1, 1, 3), + BarRun(2, 1, 4), + ] + + +class TestFormatBars: + @pytest.mark.parametrize( + "bars, text", + [ + ([4], "4"), + ([4, 4, 4, 3], "3[4] 3"), + ([4] * 10 + [3] + [4] * 15, "10[4] 3 15[4]"), + ([4, 3, 4], "4 3 4"), + ], + ) + def test_collapses_runs_of_equal_bars(self, bars, text): + assert format_bars(bars) == text + + def test_does_not_recover_nested_groups(self): + assert format_bars(parse("2[4 3]").bars) == "4 3 4 3" + + @pytest.mark.parametrize( + "text", ["4 4 3", "10[4] 3 15[4]", "2[4 3[3]]", f"{MAX_BARS}[1]"] + ) + def test_parses_back_to_the_same_bars(self, text): + bars = parse(text).bars + assert parse(format_bars(bars)).bars == bars