"""The subset of Python the scene editor can read, and how it refuses the rest.
A scene is an ordinary Python module and the engine runs whatever Python it
contains. The *editor* is the narrower surface: it reads and rewrites a scene
through parso, which trails CPython's grammar, so five constructs load and run
correctly and cannot be edited structurally.
Measured against parso 0.8.7, whose 3.13 and 3.14 grammars are byte-identical,
so this does not age out with the next Python:
* ``match`` / ``case`` (3.10)
* ``except*`` (3.11)
* ``type X = ...`` alias statements (3.12)
* an f-string quoted with the same character it nests, escaped, or written
over several lines (PEP 701, 3.12)
* unparenthesised ``except A, B:`` (PEP 758, 3.14)
PEP 695 generic parameter lists (``class Foo[T]``, ``def f[T]()``) parse, and
so does the older f-string nesting that alternates quote characters.
None of the five is hard to avoid inside a scene's ``__init__``, which is
mostly node construction. What matters is that the editor says so: it opens the
file, shows it, diffs it and reads it, and refuses the edits it cannot make
without naming the construct and the line
(:class:`UnsupportedSceneSyntaxError`).
The classification is not a list of these five. It is read back from what parso
could not parse (:func:`syntax_issues`), so a file broken in any other way is
refused on the same terms with the parser's own message.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple
import parso
if TYPE_CHECKING:
from parso.python.tree import Module
from parso.tree import Leaf
__all__ = [
"SyntaxIssue",
"UnsupportedSceneSyntaxError",
"explain_at",
"first_unexplained",
"issue_lines",
"refusal",
"syntax_issues",
]
#: Where the subset is written down, quoted at the end of every refusal so the
#: message points at the whole rule rather than only the line that broke it.
SUBSET_REFERENCE = "simvx.core.scene_io.syntax"
[docs]
class UnsupportedSceneSyntaxError(ValueError):
"""The scene holds source the editor's parser cannot read.
Raised by the structural edits on
:class:`~simvx.core.scene_io.SceneClass` -- adding, removing or reordering
a child -- when the lines the edit would rewrite sit inside a construct
parso could not parse. The file still loads, still runs and still opens for
reading: what is refused is the rewrite, because a removal inside a block
the parser flattened emits a file that raises ``IndentationError``.
:attr:`issues` carries the parse issues behind the refusal, first one
first, for a caller that wants to place a marker rather than show a
message.
"""
def __init__(self, message: str, issues: list[SyntaxIssue]) -> None:
self.issues: list[SyntaxIssue] = list(issues)
super().__init__(message)
[docs]
class SyntaxIssue(NamedTuple):
"""One place parso could not read, and the construct that explains it."""
#: 1-indexed line, matching the editor's cursor convention.
line: int
#: 0-indexed column, as parso reports it.
column: int
#: The construct the line holds (``"a `match` statement"``), or ``None``
#: when the tokens on it match none of the known ones -- source that is
#: simply malformed, which is refused on the same terms.
construct: str | None
#: parso's own message, kept so a refusal over unrecognised source still
#: says something true about it.
message: str
[docs]
def describe(self) -> str:
"""One line naming the construct and where it is, for a refusal."""
what = self.construct or "source that does not parse"
return f"{what} on line {self.line}"
[docs]
def syntax_issues(module: Module) -> list[SyntaxIssue]:
"""Every place parso could not read ``module``, classified, in source order.
Empty for a module that parses, which is the ordinary case and the one this
is cheapest on: ``iter_errors`` walks the tree once and the classification
runs only over the lines it reports.
Requires a module parsed with ``error_recovery=True``; a strict parse
raises before there is a tree to ask.
"""
grammar = parso.load_grammar()
issues = list(grammar.iter_errors(module))
if not issues:
return []
lines = {issue.start_pos[0] for issue in issues}
constructs = _constructs_by_line(module, lines)
return [
SyntaxIssue(
line=issue.start_pos[0],
column=issue.start_pos[1],
construct=constructs.get(issue.start_pos[0]),
message=issue.message,
)
for issue in issues
]
[docs]
def refusal(issues: list[SyntaxIssue], *, subject: str, consequence: str) -> UnsupportedSceneSyntaxError:
"""The error for a refusal, naming the first construct and its line.
``subject`` is what holds it (a path, ``"this scene"``) and ``consequence``
says what is being refused, in a whole sentence -- the two halves this
cannot know.
Source that is simply malformed gets the parser's own message and none of
the subset talk: telling the author of a truncated file that it "loads and
runs" would be a falsehood, and pointing them at a list of five constructs
theirs is not on would send them looking in the wrong place.
"""
if not issues: # pragma: no cover - a refusal is only built over issues
return UnsupportedSceneSyntaxError(f"{subject} does not parse", [])
first = issues[0]
if first.construct is None:
return UnsupportedSceneSyntaxError(
f"{subject} does not parse: {first.message} at line {first.line}, column {first.column}",
issues,
)
return UnsupportedSceneSyntaxError(
f"{subject} holds {first.describe()}, which the scene editor's parser cannot read. "
f"{consequence} The subset it reads is documented in {SUBSET_REFERENCE}.",
issues,
)
[docs]
def issue_lines(issues: list[SyntaxIssue]) -> set[int]:
"""The line numbers ``issues`` covers, for a membership test."""
return {issue.line for issue in issues}
[docs]
def first_unexplained(issues: list[SyntaxIssue]) -> SyntaxIssue | None:
"""The first issue no documented construct accounts for, or ``None``.
``None`` for a scene that is outside parso's grammar and inside the
format's documented subset: one construct it cannot read, and then the
indentation errors its recovery reports over the block body it lifted out.
Such a file opens for reading and refuses its edits.
Anything else -- a truncated file, an unclosed bracket -- has an issue with
no classified construct at or above it, and stays the hard refusal at load
that it has always been. There is nothing to show a reader of a file that
is simply broken, and no subset rule to point them at.
"""
for issue in issues:
explanation = explain_at(issues, issue.line)
if explanation is None or explanation.construct is None:
return issue
return None
[docs]
def explain_at(issues: list[SyntaxIssue], line: int) -> SyntaxIssue | None:
"""The issue that explains ``line``: the one on it, else the last one above it.
A block the parser could not read reports its header (``match mode:``) and
then an indentation error on each line of the body it lifted out, and the
header is what a reader needs to be told. So a line inside the block is
explained by the nearest issue at or before it, which is the header when
the body's own line carries none.
"""
best: SyntaxIssue | None = None
for issue in issues:
if issue.line > line:
break
if issue.construct is not None or best is None:
best = issue
return best
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
def _constructs_by_line(module: Module, lines: set[int]) -> dict[int, str]:
"""Name the construct on each of ``lines``, from the tokens parso kept.
Read off the leaves rather than the text: recovery keeps every token,
including the ones it could not fit into a node, so the line's tokens are
still there to be asked and no re-lexing or text matching is needed.
"""
tokens: dict[int, list[tuple[str, str]]] = {line: [] for line in lines}
leaf: Leaf | None = module.get_first_leaf()
while leaf is not None:
bucket = tokens.get(leaf.start_pos[0])
if bucket is not None and leaf.value.strip():
bucket.append((leaf.type, leaf.value))
leaf = leaf.get_next_leaf()
named: dict[int, str] = {}
for line, line_tokens in tokens.items():
construct = _classify(line_tokens)
if construct is not None:
named[line] = construct
return named
def _classify(tokens: list[tuple[str, str]]) -> str | None:
"""Which unsupported construct these tokens spell, if any."""
if not tokens:
return None
values = [value for _kind, value in tokens]
kinds = [kind for kind, _value in tokens]
head = values[0]
if head in ("match", "case") and values[-1] == ":":
return "a `match` statement"
if head == "except":
if len(values) > 1 and values[1] == "*":
return "an `except*` clause"
if "," in values[1:]:
return "an unparenthesised `except A, B:` clause"
if head == "type" and len(values) > 2 and kinds[1] == "name" and values[2] == "=":
return "a `type` alias statement"
if "fstring_start" in kinds and "error_leaf" in kinds:
return "an f-string quoted with the character it nests (PEP 701)"
return None