tonalis

Pure lead-sheet / chord-chart language core (parse/lint/AST/JSON/text). Format-agnostic.

 1"""Pure lead-sheet / chord-chart language core (parse/lint/AST/JSON/text). Format-agnostic."""
 2
 3from tonalis.ast import (
 4    Barline,
 5    Cell,
 6    LeadSheet,
 7    LintFinding,
 8    Measure,
 9    ParseResult,
10    Section,
11    SectionKind,
12)
13from tonalis.chords import is_valid_chord
14from tonalis.lint import lint
15from tonalis.parser import parse_dsl
16from tonalis.serialize import ast_from_json, ast_to_json, from_json, to_json
17from tonalis.serialize_text import serialize
18
19__all__ = [
20    "parse_dsl",
21    "lint",
22    "is_valid_chord",
23    "serialize",
24    "ast_to_json",
25    "ast_from_json",
26    "to_json",
27    "from_json",
28    "LeadSheet",
29    "Cell",
30    "Measure",
31    "Section",
32    "SectionKind",
33    "Barline",
34    "LintFinding",
35    "ParseResult",
36]
def parse_dsl(text):
 81def parse_dsl(text):
 82    findings = []
 83
 84    def err(line, code, msg):
 85        findings.append(LintFinding("error", line, code, msg))
 86
 87    def warn(line, code, msg):
 88        findings.append(LintFinding("warning", line, code, msg))
 89
 90    if len(text.encode("utf-8", "replace")) > MAX_BYTES:
 91        err(0, "too-big", "input exceeds MAX_BYTES")
 92        return ParseResult(None, findings)
 93    text = text.lstrip("")
 94    lines = _split_lines(text)
 95    if len(lines) > MAX_LINES:
 96        err(0, "too-big", "too many lines")
 97        return ParseResult(None, findings)
 98    for n, raw in enumerate(lines, 1):
 99        if len(raw) > MAX_LINE_LEN:
100            err(n, "too-big", "line too long")
101            return ParseResult(None, findings)
102
103    meta = {}
104    i, total = 0, len(lines)
105
106    def assign_meta(k, v, lineno):
107        # Header keys are singletons. Re-stating one is a duplicate (same value, redundant)
108        # or a conflict (different value, mutually exclusive) — never silently last-wins.
109        if k in meta:
110            if meta[k] == v:
111                warn(lineno, "duplicate-header", f"duplicate header {k!r}")
112            else:
113                err(
114                    lineno,
115                    "conflicting-header",
116                    f"conflicting header {k!r}: {meta[k]!r} vs {v!r}",
117                )
118            return  # keep the first value; a conflict already blocks compile
119        meta[k] = v
120
121    # --- header ---
122    while i < total:
123        raw = lines[i]
124        line = raw.strip()
125        if line == "" or line.startswith("#"):
126            i += 1
127            continue
128        if line[0] in "[|{":
129            break
130        m = re.match(r"([A-Za-z]+)\s*:\s*(.*)$", raw)  # value read literally to EOL
131        if not m:
132            break
133        key, val = m.group(1).lower(), m.group(2).rstrip()
134        if "=" in val:
135            err(i + 1, "meta-delimiter", f"'=' not allowed in metadata value: {val!r}")
136        if key == "time":
137            # ASCII digits only (SPEC.md §1.1); both components must be <= MAX_METER (§1.4).
138            tm = re.match(r"([0-9]+)\s*/\s*([0-9]+)$", val)
139            if tm and int(tm.group(1)) <= MAX_METER and int(tm.group(2)) <= MAX_METER:
140                assign_meta("time", (int(tm.group(1)), int(tm.group(2))), i + 1)
141            else:
142                err(i + 1, "bad-time", f"bad time signature: {val!r}")
143        elif key in _META_KEYS:
144            assign_meta(key, val, i + 1)
145        else:
146            warn(i + 1, "unknown-key", f"unknown header key: {key}")
147        i += 1
148
149    for k in _REQUIRED:
150        if k not in meta:
151            err(0, "missing-header", f"missing required header: {k}")
152    if meta.get("title", "") == "":
153        err(0, "missing-header", "title must be non-empty")
154
155    # --- body ---
156    sections = []
157    cur = None
158    pending_nav = []
159    pending_hints = []
160    measure_count = 0
161    current_time = meta.get("time", (4, 4))  # for redundant mid-chart [time:] detection
162
163    def ensure_section():
164        nonlocal cur
165        if cur is None:
166            cur = Section(label="A", kind=SectionKind.A)
167            sections.append(cur)
168        return cur
169
170    while i < total:
171        raw = lines[i]
172        line = raw.strip()
173        ln = i + 1
174        i += 1
175        if line == "" or line.startswith("#"):
176            continue
177        tm = _TIME_RE.match(line)
178        if line.startswith("[time") and tm:
179            n, d = int(tm.group(1)), int(tm.group(2))
180            if n > MAX_METER or d > MAX_METER:
181                # recognized [time:…] shape but the meter is out of range (SPEC.md §1.4/§4.4):
182                # reject it as bad-time; it contributes no nav.
183                err(ln, "bad-time", f"meter component exceeds MAX_METER: {n}/{d}")
184                continue
185            newt = (n, d)
186            if newt == current_time:
187                warn(
188                    ln,
189                    "redundant-time",
190                    f"[time: {newt[0]}/{newt[1]}] repeats the current meter",
191                )
192            current_time = newt
193            pending_nav.append(("time", newt))
194            continue
195        sec = _SECTION_RE.match(line)
196        if sec:
197            name = sec.group(1)
198            kind = _SECTIONS.get(name)
199            if kind is None:
200                err(
201                    ln,
202                    "unknown-section",
203                    f"unknown section {name!r}; allowed: A-D, Intro, Verse",
204                )
205                kind = SectionKind.A
206            cur = Section(label=name + sec.group(2), kind=kind)
207            sections.append(cur)
208            continue
209        if line.startswith("@"):
210            if "segno" in line:
211                pending_nav.append(("segno",))
212            elif "tocoda" in line or "to coda" in line or "to-coda" in line:
213                # "To Coda" jump-point: a Q AFTER the PRECEDING measure's chords (not the
214                # next one). Checked before "coda" because "tocoda" contains "coda".
215                if cur is not None and cur.measures:
216                    cur.measures[-1].nav = cur.measures[-1].nav + (("tocoda",),)
217                else:
218                    warn(
219                        ln, "tocoda-without-measure", "@tocoda has no preceding measure"
220                    )
221            elif "fine" in line:
222                pending_nav.append(("fine",))
223            elif "coda" in line:
224                pending_nav.append(("coda",))
225            elif "break" in line or "newline" in line:
226                # page-layout only — NO lead-sheet semantics. Demoted to an opaque
227                # render-hint (Phase 2 §3.3); a downstream codec reads it, text-target ignores it.
228                pending_hints.append("break")
229            else:
230                warn(ln, "unknown-nav", f"unknown @directive: {line}")
231            continue
232        if line.startswith("<") and line.endswith(">"):
233            pending_nav.append(("text", line))
234            continue
235        # otherwise: a measure line
236        section = ensure_section()
237        measures = _parse_measure_line(line, ln, err, warn)
238        for m in measures:
239            if pending_nav:
240                m.nav = tuple(pending_nav) + m.nav
241                pending_nav = []
242            if pending_hints:
243                m.hints = tuple(pending_hints) + m.hints
244                pending_hints = []
245            section.measures.append(m)
246            measure_count += 1
247            if measure_count > MAX_MEASURES:
248                err(ln, "too-big", "too many measures")
249                return ParseResult(LeadSheet(meta=meta, sections=sections), findings)
250
251    if not sections or all(not s.measures for s in sections):
252        err(0, "empty-chart", "no sections or measures")
253    return ParseResult(LeadSheet(meta=meta, sections=sections), findings)
def lint(chart):
 38def lint(chart):
 39    findings = []
 40
 41    def err(line, code, msg):
 42        findings.append(LintFinding("error", line, code, msg))
 43
 44    def warn(line, code, msg):
 45        findings.append(LintFinding("warning", line, code, msg))
 46
 47    measures = _all_measures(chart)
 48    numerator = (chart.meta.get("time") or (4, 4))[0]
 49
 50    # repeats / endings balance, codas/segno, final bar, per-measure beats + chords
 51    balance = n_coda = n_segno = 0
 52    last_idx = len(measures) - 1
 53    for idx, (section, m) in enumerate(measures):
 54        for nav in m.nav:
 55            if nav == ("coda",):
 56                n_coda += 1
 57            elif nav == ("segno",):
 58                n_segno += 1
 59            elif nav and nav[0] == "time":
 60                numerator = nav[1][0]
 61        if m.bar_open:
 62            balance += 1
 63        if m.barline == Barline.REPEAT_END:
 64            if balance == 0:
 65                err(m.line, "unbalanced-repeat", "'}' with no matching '{'")
 66            else:
 67                balance -= 1
 68        if m.barline == Barline.FINAL and idx != last_idx:
 69            warn(
 70                m.line,
 71                "mid-final-bar",
 72                "final barline (Z/||) is not on the last measure",
 73            )
 74
 75        # chords
 76        for c in m.cells:
 77            # skip is_valid_chord for tokens that already triggered token-too-long (parser
 78            # error already emitted; MusicDSL would reject them as bad-chord too but the
 79            # conformance spec treats over-length tokens as a single token-too-long error)
 80            if c.chord and len(c.chord) <= MAX_CHORD_TOKEN_LEN and not is_valid_chord(c.chord):
 81                err(m.line, "bad-chord", f"invalid chord token: {c.chord!r}")
 82            if c.alt:
 83                for tok in c.alt.strip("()").split():
 84                    if not is_valid_chord(tok):
 85                        err(m.line, "bad-chord", f"invalid alt chord: {tok!r}")
 86
 87        # empty measure
 88        if not m.cells:
 89            warn(m.line, "empty-measure", "measure has no chords (emitted as N.C.)")
 90            continue
 91
 92        # beats. Pickup relaxation only excuses an UNDER-full first/last bar (anacrusis);
 93        # an OVER-full bar is always wrong, even for a pickup.
 94        pattern, ok = _beat_pattern(m, numerator)
 95        total = sum(pattern)
 96        is_pickup = idx in (0, last_idx)
 97        if total > numerator:
 98            err(m.line, "beat-sum", f"beats {pattern} overflow a {numerator}-beat bar")
 99        elif not ok and not is_pickup:
100            err(
101                m.line,
102                "beat-sum",
103                f"beats {pattern} do not fill a {numerator}-beat bar",
104            )
105        elif ok and len(set(pattern)) > 1 and pattern not in _ALLOWED_UNEVEN:
106            warn(
107                m.line,
108                "beat-unsupported",
109                f"uneven beat layout {pattern} not in the allowlist; even-split fallback",
110            )
111
112    if balance != 0:
113        err(0, "unbalanced-repeat", f"{balance} unclosed '{{'")
114    # an empty section (header but no measures) silently drops in render — surface it
115    for section in chart.sections:
116        if not section.measures:
117            warn(
118                0,
119                "empty-section",
120                f"section [{section.label}] has no measures (dropped)",
121            )
122    # nth endings must belong to a repeat (a section with endings needs a bar_open)
123    for section in chart.sections:
124        has_open = any(m.bar_open for m in section.measures)
125        if any(m.ending for m in section.measures) and not has_open:
126            ln = next((m.line for m in section.measures if m.ending), 0)
127            err(ln, "ending-without-repeat", "nth ending outside a repeat block")
128    if n_coda > 2:
129        err(
130            0,
131            "coda-count",
132            f"{n_coda} coda points (max 2 — the encoder cannot flatten more)",
133        )
134    elif n_coda == 2 and n_segno == 0:
135        warn(
136            0,
137            "coda-needs-segno",
138            "2-coda jump without @segno (will play D.C., not D.S.)",
139        )
140
141    return findings
def is_valid_chord(token: str) -> bool:
27def is_valid_chord(token: str) -> bool:
28    """True iff the token is a well-formed chord OR a no-chord / bass marker."""
29    if token in ("N.C.", "n"):
30        return True
31    if not token:
32        return False
33    t = token.replace("*", "")  # tolerate the augmented layout-star artifact (Bb*7+*)
34    if _SLASH_BASS.match(t):
35        return True
36    try:
37        Chord(t)
38        return True
39    except InvalidChordStringException:
40        return False

True iff the token is a well-formed chord OR a no-chord / bass marker.

def serialize(chart: LeadSheet) -> str:
106def serialize(chart: LeadSheet) -> str:
107    """Canonical DSL text for a LeadSheet (trailing newline; section blocks blank-line separated)."""
108    parts = [_header(chart.meta)]
109    for s in chart.sections:
110        parts.append("")  # blank line before each section
111        parts.append(_section_text(s))
112    return "\n".join(parts) + "\n"

Canonical DSL text for a LeadSheet (trailing newline; section blocks blank-line separated).

def ast_to_json(chart: LeadSheet) -> dict:
112def ast_to_json(chart: LeadSheet) -> dict:
113    """Serialize a :class:`LeadSheet` to the canonical JSON-able dict (SPEC.md §2.1)."""
114    return {
115        "schema_version": SCHEMA_VERSION,
116        "meta": _meta_to_json(chart.meta),
117        "sections": [_section_to_json(s) for s in chart.sections],
118    }

Serialize a LeadSheet to the canonical JSON-able dict (SPEC.md §2.1).

def ast_from_json(d: dict) -> LeadSheet:
142def ast_from_json(d: dict) -> LeadSheet:
143    """Inverse of :func:`ast_to_json`. Rejects an unknown major schema version, an unknown
144    ``Section.kind``, or an unknown ``Measure.barline`` with a clean ValueError (the canonical
145    JSON is a public interface — malformed input is rejected uniformly across all three ports)."""
146    _check_schema_version(d)
147    return LeadSheet(
148        meta=_meta_from_json(d.get("meta", {})),
149        sections=[_section_from_json(s) for s in d.get("sections", [])],
150    )

Inverse of ast_to_json(). Rejects an unknown major schema version, an unknown Section.kind, or an unknown Measure.barline with a clean ValueError (the canonical JSON is a public interface — malformed input is rejected uniformly across all three ports).

def to_json(chart: LeadSheet) -> dict:
112def ast_to_json(chart: LeadSheet) -> dict:
113    """Serialize a :class:`LeadSheet` to the canonical JSON-able dict (SPEC.md §2.1)."""
114    return {
115        "schema_version": SCHEMA_VERSION,
116        "meta": _meta_to_json(chart.meta),
117        "sections": [_section_to_json(s) for s in chart.sections],
118    }

Serialize a LeadSheet to the canonical JSON-able dict (SPEC.md §2.1).

def from_json(d: dict) -> LeadSheet:
142def ast_from_json(d: dict) -> LeadSheet:
143    """Inverse of :func:`ast_to_json`. Rejects an unknown major schema version, an unknown
144    ``Section.kind``, or an unknown ``Measure.barline`` with a clean ValueError (the canonical
145    JSON is a public interface — malformed input is rejected uniformly across all three ports)."""
146    _check_schema_version(d)
147    return LeadSheet(
148        meta=_meta_from_json(d.get("meta", {})),
149        sections=[_section_from_json(s) for s in d.get("sections", [])],
150    )

Inverse of ast_to_json(). Rejects an unknown major schema version, an unknown Section.kind, or an unknown Measure.barline with a clean ValueError (the canonical JSON is a public interface — malformed input is rejected uniformly across all three ports).

@dataclass
class LeadSheet:
77@dataclass
78class LeadSheet:
79    meta: dict = field(default_factory=dict)  # title/composer/style/key/time
80    sections: List[Section] = field(default_factory=list)
LeadSheet( meta: dict = <factory>, sections: List[Section] = <factory>)
meta: dict
sections: List[Section]
@dataclass
class Cell:
33@dataclass
34class Cell:
35    chord: str
36    beats: Optional[int] = None  # explicit :N, else None (even split of the bar)
37    alt: Optional[str] = None  # raw "(A-7 D7)" alt-chord text, if any
38
39    @property
40    def chord_obj(self):
41        """Lazy MusicDSL Chord for a present chord token; None for empty / no-chord /
42        bare-slash cells; raises InvalidChordStringException for a malformed-present token."""
43        from music_dsl.domain.chords.chord import Chord  # lazy: keeps the dep at use-time
44        t = (self.chord or "").replace("*", "")
45        if not t or t in ("N.C.", "n") or t.startswith("/"):
46            return None
47        return Chord(t)
48
49    @property
50    def chord_obj_or_none(self):
51        """Non-raising form of chord_obj (None for malformed tokens too)."""
52        from music_dsl.domain.chords.abstract_chord import InvalidChordStringException
53        try:
54            return self.chord_obj
55        except InvalidChordStringException:
56            return None
Cell(chord: str, beats: Optional[int] = None, alt: Optional[str] = None)
chord: str
beats: Optional[int] = None
alt: Optional[str] = None
chord_obj
39    @property
40    def chord_obj(self):
41        """Lazy MusicDSL Chord for a present chord token; None for empty / no-chord /
42        bare-slash cells; raises InvalidChordStringException for a malformed-present token."""
43        from music_dsl.domain.chords.chord import Chord  # lazy: keeps the dep at use-time
44        t = (self.chord or "").replace("*", "")
45        if not t or t in ("N.C.", "n") or t.startswith("/"):
46            return None
47        return Chord(t)

Lazy MusicDSL Chord for a present chord token; None for empty / no-chord / bare-slash cells; raises InvalidChordStringException for a malformed-present token.

chord_obj_or_none
49    @property
50    def chord_obj_or_none(self):
51        """Non-raising form of chord_obj (None for malformed tokens too)."""
52        from music_dsl.domain.chords.abstract_chord import InvalidChordStringException
53        try:
54            return self.chord_obj
55        except InvalidChordStringException:
56            return None

Non-raising form of chord_obj (None for malformed tokens too).

@dataclass
class Measure:
59@dataclass
60class Measure:
61    cells: List[Cell] = field(default_factory=list)
62    ending: Optional[int] = None  # 1./2. nth-ending number
63    bar_open: bool = False  # preceded by '{'
64    barline: Barline = Barline.NORMAL  # neutral right-barline (was bar_close: str)
65    nav: Tuple = ()  # ('segno',), ('coda',), ('fine',), ('text', '<...>'), ('time', (n, d))
66    hints: Tuple[str, ...] = ()  # opaque render-hints, e.g. ('break',); NO semantics
67    line: int = 0  # source line for findings
Measure( cells: List[Cell] = <factory>, ending: Optional[int] = None, bar_open: bool = False, barline: Barline = <Barline.NORMAL: 'normal'>, nav: Tuple = (), hints: Tuple[str, ...] = (), line: int = 0)
cells: List[Cell]
ending: Optional[int] = None
bar_open: bool = False
barline: Barline = <Barline.NORMAL: 'normal'>
nav: Tuple = ()
hints: Tuple[str, ...] = ()
line: int = 0
@dataclass
class Section:
70@dataclass
71class Section:
72    label: str
73    kind: SectionKind  # neutral role (the parser derives it from the bracket name)
74    measures: List[Measure] = field(default_factory=list)
Section( label: str, kind: SectionKind, measures: List[Measure] = <factory>)
label: str
kind: SectionKind
measures: List[Measure]
class SectionKind(builtins.str, enum.Enum):
16class SectionKind(str, Enum):
17    """Neutral section role, derived by the parser from the bracket name."""
18    A = "a"
19    B = "b"
20    C = "c"
21    D = "d"
22    INTRO = "intro"
23    VERSE = "verse"

Neutral section role, derived by the parser from the bracket name.

A = <SectionKind.A: 'a'>
B = <SectionKind.B: 'b'>
C = <SectionKind.C: 'c'>
D = <SectionKind.D: 'd'>
INTRO = <SectionKind.INTRO: 'intro'>
VERSE = <SectionKind.VERSE: 'verse'>
class Barline(builtins.str, enum.Enum):
26class Barline(str, Enum):
27    """Neutral right-barline. ``'|'`` -> NORMAL, ``'}'`` -> REPEAT_END, ``'Z'``/``'||'`` -> FINAL."""
28    NORMAL = "normal"
29    REPEAT_END = "repeat_end"
30    FINAL = "final"

Neutral right-barline. '|' -> NORMAL, '}' -> REPEAT_END, 'Z'/'||' -> FINAL.

NORMAL = <Barline.NORMAL: 'normal'>
REPEAT_END = <Barline.REPEAT_END: 'repeat_end'>
FINAL = <Barline.FINAL: 'final'>
@dataclass
class LintFinding:
83@dataclass
84class LintFinding:
85    severity: str  # "error" | "warning"
86    line: int
87    code: str
88    message: str
LintFinding(severity: str, line: int, code: str, message: str)
severity: str
line: int
code: str
message: str
@dataclass
class ParseResult:
91@dataclass
92class ParseResult:
93    chart: Optional[LeadSheet]
94    findings: List[LintFinding] = field(default_factory=list)
ParseResult( chart: Optional[LeadSheet], findings: List[LintFinding] = <factory>)
chart: Optional[LeadSheet]
findings: List[LintFinding]