Source code for simvx.core.scene_io.layout

"""Where an emitted statement breaks its lines.

A scene file is code the author reads, edits and formats alongside everything
else in their project, so what this layer writes has to look like the rest of
it. A constructor carrying a material, a texture and a transform runs well past
any sane column on one line; broken up by hand it would read one keyword
argument per line, and that is what :func:`wrap_statement` produces.

The rule implemented here is the one a formatter run at :data:`LINE_LIMIT`
columns applies, so a file this layer writes and a file the author formatted
are the same file:

* a statement that fits on one line stays on one line;
* a call that does not puts its arguments on a single indented line of their
  own, if they fit there;
* one that still does not puts each argument on its own line, with a trailing
  comma, descending into any argument that is itself too long;
* a list, tuple or dict that has to break at all breaks all the way: a
  collection with more than one item never takes the single-line-of-its-own
  form a call can;
* a group whose items already end in a comma stays broken out however short it
  is, because that comma is how an author says so -- unless the comma is what
  makes the value what it is, as in a one-item tuple.

Width is counted in columns rather than characters throughout
(:func:`display_width`), which is what a limit is and what the formatter
counts: a construction carrying a Chinese label is written wider than it is
long, and measured by its length would be left whole well past the limit.

An import line is the one statement that departs from the rule, and
:func:`wrap_import` is where it does: it never takes the single-line-of-its-own
form, because the comma a broken import ends up with is one the author could
have typed and therefore means what theirs means.

Everything here is text in, text out: no parse, no tree. The emitter builds the
one-line form it always did and hands it over; :mod:`~simvx.core.scene_io.edits`
applies the same rule to a call it has just edited in an existing file.
"""

from __future__ import annotations

import unicodedata
from collections.abc import Iterator

__all__ = [
    "LINE_LIMIT",
    "call_layout",
    "carries_magic_comma",
    "display_width",
    "ends_with_comma",
    "is_collection_head",
    "split_call",
    "split_items",
    "trailing_comma_is_syntax",
    "wrap_import",
    "wrap_statement",
]


#: Columns a written statement is kept within. The project's own width, and the
#: one the emitted file is read and re-formatted at.
LINE_LIMIT = 120

#: One level of continuation indent, in columns.
INDENT = 4

_OPENERS = {"(": ")", "[": "]", "{": "}"}
_CLOSERS = frozenset(")]}")
#: Longest first: ``'''`` must be recognised before ``'``.
_QUOTES = ("'''", '"""', "'", '"')

#: Categories of character written over the one in front of it rather than
#: beside it, which therefore claims no column of its own.
_COMBINING = frozenset({"Mn", "Me", "Mc", "Cf"})
#: The emoji modifiers -- skin tones -- which combine like the above without
#: being classified with them.
_MODIFIERS = range(0x1F3FB, 0x1F400)


[docs] def display_width(text: str) -> int: """The columns ``text`` occupies, which is not always how many characters it has. A limit is a count of columns, and East Asian script does not spend one character per column: a label of Chinese measured with :func:`len` comes out half the width it is written at, and a line of it is left alone well past the limit. Every width this module compares against a limit is counted here instead, and so is every width :mod:`~simvx.core.scene_io.edits` measures off a parsed file. A character is two columns wide when its East Asian width is wide or fullwidth, and one otherwise. The few wide characters that attach to the one in front of them -- a Japanese voiced-sound mark, an emoji skin tone -- are not counted wide, being written over their neighbour rather than beside it. That is the count the project's formatter makes, for every character the standard library's Unicode tables know about. """ if text.isascii(): return len(text) return sum(_char_width(char) for char in text)
[docs] def wrap_statement( text: str, *, indent: int = 0, limit: int = LINE_LIMIT, explode: bool = False, suffix: str = "" ) -> str: """``text`` laid out to fit ``limit`` columns, starting at column ``indent``. ``text`` is one statement written on a single line -- ``hero = Sprite2D(...)``, ``super().__init__(...)``, ``self.add_child(hero)``. The returned string carries no indent on its first line, because the caller is already placing that statement at ``indent``; every continuation line it contains is indented in full. ``explode`` forces the one-argument-per-line form regardless of width, which is what an existing call's trailing comma asks for. ``suffix`` is what follows the group on its last line without being part of it -- the ``:`` of ``class Hero(Node2D):`` -- and comes back on the end. Passing it rather than writing it in is what lets the group be found and broken at all, and its width counts against ``limit`` like everything else sharing the line. A statement with nothing to break -- one ending in no bracketed group, or in an empty one -- comes back as it went in, over the limit: reflowing the inside of a value is not this layer's business, and a line that cannot be broken is better left long than broken wrongly. A group whose one argument is itself too long for any line still gets broken open around it, which is where the argument has the most room, and is what a formatter does with it. """ return _wrap(text, indent, limit, explode, suffix=len(suffix)) + suffix
[docs] def wrap_import(module: str, names: list[str], *, limit: int = LINE_LIMIT, explode: bool = False) -> str: """``from <module> import ...`` for ``names``, laid out to fit ``limit``. One line for as long as the names fit on one, without the brackets a file may have had round them: a pair no longer holding anything apart is a pair a formatter takes off. Past that the names go inside brackets, one per line, each with a comma after it, and never onto the single shared line of their own that a call's arguments may take. That last is the one place an import parts company with a call, and the reason is the comma. A broken group's trailing comma is how an author asks for a line per item (:func:`carries_magic_comma`), and an import may carry one -- ``from x import (a, b,)`` is legal where ``from x import a, b,`` is not -- so the comma this layout writes would be read back as that request the next time the line is laid out. Writing the form that request asks for is what keeps a second pass over an import agreeing with the first. ``explode`` is that request, found on an import already in the file. ``names`` are written in the order given, and the statement is written at column zero: an import is a module-level statement. """ line = f"from {module} import {', '.join(names)}" if not explode and display_width(line) <= limit: return line body = "".join(f"{' ' * INDENT}{name},\n" for name in names) return f"from {module} import (\n{body})"
[docs] def split_call(text: str) -> tuple[str, list[str], str] | None: """``(head, items, closer)`` for the bracketed group ``text`` ends with. ``Sprite2D(name='Hero', position=Vec2(1.0, 2.0))`` splits into ``"Sprite2D("``, the two arguments, and ``")"``. The group taken is the last one that opens at the top level and closes on the final character, which is what makes ``super().__init__(...)`` split at ``__init__`` rather than at the empty ``super()`` in front of it, and ``self.add_child(Sprite2D(...))`` split at ``add_child`` rather than at the construction inside it. ``None`` when ``text`` does not end in a bracket that opened at the top level: there is then no group to break. Brackets and commas inside string literals are text and are skipped. """ stack: list[int] = [] groups: list[tuple[int, int]] = [] for index, char in _outside_strings(text): if char in _OPENERS: stack.append(index) elif char in _CLOSERS and stack: opened = stack.pop() if not stack: groups.append((opened, index)) if not groups: return None opened, closed = groups[-1] if closed != len(text) - 1: return None return text[: opened + 1], split_items(text[opened + 1 : closed]), text[closed:]
[docs] def split_items(body: str) -> list[str]: """The comma-separated items of a bracket body, in order. Commas inside nested brackets and inside string literals do not separate, and neither do the ones between a ``lambda``'s parameters: ``lambda a, b: x`` is one argument, and breaking the line at its comma would leave a fragment that is not an expression at all. A trailing comma yields no empty final item; ask :func:`ends_with_comma` about the one that was there. """ items: list[str] = [] depth = 0 start = 0 #: The bracket depth a ``lambda`` opened its parameters at, until its colon. params: int | None = None for index, char in _outside_strings(body): if char in _OPENERS: depth += 1 elif char in _CLOSERS: depth -= 1 elif char == "l" and params is None and _opens_lambda(body, index): params = depth elif char == ":" and params == depth: params = None elif char == "," and depth == 0 and params is None: items.append(body[start:index].strip()) start = index + 1 tail = body[start:].strip() if tail: items.append(tail) return items
[docs] def ends_with_comma(body: str) -> bool: """Does this bracket body end in a comma the layout has to keep? For a one-item tuple the comma is what makes it a tuple, so dropping it while breaking the line would change the value the file loads back. """ return body.rstrip().endswith(",")
[docs] def trailing_comma_is_syntax(opener: str, *, collection: bool, item_count: int) -> bool: """Is this group's trailing comma part of the value rather than a request? An author's comma after the last of several arguments is a request to keep them on separate lines. Two commas are not requests at all, because without them the expression means something else: the one in a one-item tuple ``(value,)``, and the one in a one-item subscript ``frames[0,]``, each of which is what makes that expression a tuple. A one-item list, dict or call has no such excuse and is broken out like any other. """ if item_count != 1: return False return (opener == "(" and collection) or (opener == "[" and not collection)
[docs] def carries_magic_comma(text: str) -> bool: """Does ``text`` hold, at any depth, a comma asking for the broken-out form? A comma an author left after the last item does not only break the group it is in: everything that group sits inside breaks with it, or the group could not have its own lines. So the question has to be asked of a whole value before a line is chosen for it, not only of the group being laid out. """ split = split_call(text) if split is None: return False head, items, closer = split if not items: return False if ends_with_comma(text[len(head) : -len(closer)]) and not trailing_comma_is_syntax( head[-1], collection=is_collection_head(head), item_count=len(items) ): return True return any(carries_magic_comma(item) for item in items)
[docs] def is_collection_head(head: str) -> bool: """Is the group ``head`` opens a collection literal rather than a call? ``[`` and ``{`` and ``(`` mean one thing after a name and another after an ``=``: ``Vec2(1, 2)`` and ``ART['icon']`` are a call and a subscript, while ``[1, 2]`` and ``(1, 2)`` are a list and a tuple. What decides is the character in front of the bracket, and what turns on it is whether the group may put its items on a single line of their own: a collection that has to break at all breaks one item per line. """ before = head[:-1].rstrip() if not before: return True return not (before[-1].isalnum() or before[-1] in "_)]}")
[docs] def call_layout( items: list[str], *, indent: int, prefix_width: int, suffix_width: int = 0, limit: int = LINE_LIMIT, explode: bool = False, collection: bool = False, extra: int = 0, ) -> str: """How a bracketed group with these ``items`` should be laid out. ``"line"`` keeps the whole group on the line it starts on, ``"row"`` puts every item on one indented line of its own, ``"column"`` gives each item a line and a trailing comma. ``prefix_width`` is the column the group's opening bracket sits at plus one -- everything already written on that line -- and ``suffix_width`` what follows its closing bracket, since both share the line and both count against ``limit``. ``collection`` marks a list, tuple or dict literal, which takes ``"column"`` wherever a call would take ``"row"``, unless it holds a single item and so has nothing to separate. ``extra`` is what the body carries beyond the items themselves: the one column of a retained trailing comma, without which a group would be judged a character narrower than it is going to be written. Shared with :func:`wrap_statement` so that a call laid out from text and one laid out by editing a parsed file arrive at the same answer. """ if not items: return "line" if explode: return "column" body = display_width(", ".join(items)) + extra if prefix_width + body + 1 + suffix_width <= limit: return "line" if (not collection or len(items) == 1) and indent + INDENT + body <= limit: return "row" return "column"
# --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- def _char_width(char: str) -> int: if ord(char) in _MODIFIERS or unicodedata.category(char) in _COMBINING: return 1 return 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 def _wrap(text: str, indent: int, limit: int, explode: bool, suffix: int = 0) -> str: """``text`` at column ``indent``, with ``suffix`` columns following it. ``suffix`` is what shares the last line without being part of ``text``: the comma after an item of an exploded group. It counts against ``limit``, so a value that fits only because its comma was forgotten is broken like any other that does not fit. """ pad = " " * indent inner = " " * (indent + INDENT) split = split_call(text) if split is None: return text head, items, closer = split if not items: return text # Asked before the width, not after: a group whose items already end in a # comma is broken out however short it is, unless that comma is part of the # value (:func:`trailing_comma_is_syntax`). collection = is_collection_head(head) had_comma = ends_with_comma(text[len(head) : -len(closer)]) syntax = trailing_comma_is_syntax(head[-1], collection=collection, item_count=len(items)) explode = explode or (had_comma and not syntax) or any(carries_magic_comma(item) for item in items) if not explode and indent + display_width(text) + suffix <= limit: return text layout = call_layout( items, indent=indent, prefix_width=indent + display_width(head), suffix_width=suffix, limit=limit, explode=explode, collection=collection, extra=1 if had_comma else 0, ) if layout == "line": return text if layout == "row": # The comma is kept where it was part of the value: dropping the one in # ``(value,)`` while breaking the line would leave a parenthesised # expression where the file had a tuple. return f"{head}\n{inner}{', '.join(items)}{',' if had_comma else ''}\n{pad}{closer}" if len(items) == 1 and not had_comma: # A lone item takes no trailing comma: it is the group's whole body, so # there is nothing for a comma to separate it from -- and that holds # however the group came to be broken, its own doing or its contents'. return f"{head}\n{inner}{_wrap(items[0], indent + INDENT, limit, False)}\n{pad}{closer}" lines = [head] lines.extend(f"{inner}{_wrap(item, indent + INDENT, limit, False, suffix=1)}," for item in items) lines.append(f"{pad}{closer}") return "\n".join(lines) def _opens_lambda(text: str, index: int) -> bool: """Does the word ``lambda`` start at ``index``, rather than a name containing it? ``lambdas=[]`` and ``last_lambda`` are names; only the keyword standing on its own holds a comma back from separating arguments. """ if not text.startswith("lambda", index): return False before = text[index - 1] if index else "" after = text[index + 6] if index + 6 < len(text) else "" return not _is_word_char(before) and not _is_word_char(after) def _is_word_char(char: str) -> bool: return bool(char) and (char.isalnum() or char == "_") def _outside_strings(text: str) -> Iterator[tuple[int, str]]: """``(index, character)`` for every character of ``text`` outside a string literal. String bodies are skipped whole, so a bracket or a comma inside ``Texture('tiles[2].png')`` is not mistaken for punctuation. Escapes are honoured and both triple-quote forms are recognised; a prefix letter (``f``, ``rb``) needs no special handling, being an ordinary character followed by an ordinary quote. """ index = 0 length = len(text) while index < length: char = text[index] if char in "'\"": quote = next(q for q in _QUOTES if text.startswith(q, index)) index += len(quote) while index < length: if text[index] == "\\": index += 2 continue if text.startswith(quote, index): index += len(quote) break index += 1 continue yield index, char index += 1