diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cff684..18481f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `FormatOptions` for configuring canonical formatting. + +### Deprecated + +- The `comments` argument to `format()`; use + `FormatOptions(normalize_comments=...)`. + ### Fixed - `promote_inline` and `promote_array` now preserve entry key and value formatting - `set_multiline()` now preserves closing-bracket indentation for nested arrays and inline tables. +- `format()` now preserves closing-bracket indentation when called on a + nested array or inline table directly. ## [2.0.2] - 2026-07-10 diff --git a/docs/api.md b/docs/api.md index d642abc..9b57f76 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,6 +12,7 @@ semver-stable API: | `loads`, `load` | function | | `dumps`, `dump` | function | | `Document`, `Table`, `Array`, `AoT` | class | +| `FormatOptions` | class | | `TomlInput` | type alias | | `TOMLError`, `TOMLParseError` | exception | @@ -26,6 +27,16 @@ code. ::: tomlrt.dumps ::: tomlrt.dump +## Formatting + +::: tomlrt.FormatOptions + options: + members: + - normalize_comments + - indent + - eol_comment_spaces + - multiline_trailing_comma + ## Containers ::: tomlrt.Document diff --git a/docs/layout.md b/docs/layout.md index 9ebd0f3..dc308c4 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -103,8 +103,19 @@ The operations above are format-preserving. When you instead want to _opt in_ to canonical formatting — to drop the original spacing of a single value, a section, or the whole document — call `format()`. -`Container.format(*, comments=True)` and `Array.format(*, comments=True)` -both mutate in place and return `None`, mirroring `list.sort()`. +`Container.format()` and `Array.format()` both mutate in place and return +`None`, mirroring `list.sort()`. Pass a `FormatOptions` object to configure +canonical formatting consistently at any scope. + +| Option | Default | Effect | +|--------|---------|--------| +| `normalize_comments` | `True` | Normalize comment text. | +| `indent` | `2` | Spaces added per nested multiline inline value. | +| `eol_comment_spaces` | `1` | Spaces before supported EOL comments. | +| `multiline_trailing_comma` | `True` | Emit a final comma in multiline arrays and inline tables. | + +For example, `doc.format(options=tomlrt.FormatOptions(indent=4))` uses a +four-space step at every nested multiline level. ```python import tomlrt @@ -163,11 +174,11 @@ x = { a = 1, b = 2 } - Slots outside the receiver's subtree are not touched: calling `format()` on a single section leaves sibling sections alone. -### The `comments` flag +### Comment normalization -When `comments=True` (the default), every comment reached by the walk -is rewritten so that there is exactly one space between `#` and the -body, and any trailing whitespace inside the comment is stripped: +By default, every comment reached by the walk is rewritten so that there is +exactly one space between `#` and the body, and any trailing whitespace inside +the comment is stripped: | Before | After | |--------|-------| @@ -176,7 +187,12 @@ body, and any trailing whitespace inside the comment is stripped: | # foo | # foo | | # (empty) | # | -Pass `comments=False` to leave comment text untouched. +Pass `FormatOptions(normalize_comments=False)` to leave comment text untouched. +The former `comments=` keyword remains available for compatibility but is +deprecated; do not pass it together with `options=`. + +`eol_comment_spaces` applies to key/value, section-header, array-item, and +inline-table-entry comments. Opening-bracket comment spacing remains authored. ### Detached views diff --git a/src/tomlrt/__init__.py b/src/tomlrt/__init__.py index a74e166..032ef46 100644 --- a/src/tomlrt/__init__.py +++ b/src/tomlrt/__init__.py @@ -4,12 +4,14 @@ from tomlrt._container import AoT, Array, Document, Table, TomlInput from tomlrt._errors import TOMLError, TOMLParseError +from tomlrt._format import FormatOptions from tomlrt._public import dump, dumps, load, loads __all__ = [ "AoT", "Array", "Document", + "FormatOptions", "TOMLError", "TOMLParseError", "Table", diff --git a/src/tomlrt/_array.py b/src/tomlrt/_array.py index 2bd1902..9e2e913 100644 --- a/src/tomlrt/_array.py +++ b/src/tomlrt/_array.py @@ -31,7 +31,8 @@ ) from tomlrt._errors import TOMLError from tomlrt._format import ( - _canon_inline_value, + _resolve_format_options, + format_inline_root, set_comma_value_multiline, ) from tomlrt._trivia import ( @@ -54,6 +55,7 @@ from tomlrt._comma_ops import ( CommaStyle, ) + from tomlrt._format import FormatOptions from tomlrt._trivia import TriviaPiece from tomlrt._values import ( Value, @@ -221,18 +223,24 @@ def leading_comments(self) -> MutableMapping[int, tuple[str, ...]]: """Leading-comment view, indexed by item position.""" return ArrayLeadingView(self) - def format(self, *, comments: bool = True) -> None: + def format( + self, + *, + options: FormatOptions | None = None, + comments: bool | None = None, + ) -> None: """Canonicalise this array's formatting in place. Rewrites whitespace, indentation, separators, and newlines while preserving shape (single-line stays single-line, multi-line stays multi-line) and orphan comment text. - When ``comments`` is true (the default), comment text is also - normalised: ``#foo`` and ``# foo`` both become ``# foo``, and - trailing whitespace inside comments is stripped. + ``comments=`` is deprecated; use + ``FormatOptions(normalize_comments=...)`` instead. Supplying both + arguments raises ``ValueError``. """ - _canon_inline_value(self._value, nl=self._doc_newline, comments=comments) + resolved = _resolve_format_options(options=options, comments=comments) + format_inline_root(self._value, nl=self._doc_newline, options=resolved) def set_multiline(self, *, multiline: bool, indent: int = 4) -> Array: """Switch this array between flush single-line and multi-line form. diff --git a/src/tomlrt/_container.py b/src/tomlrt/_container.py index 4a82971..97ff6d1 100644 --- a/src/tomlrt/_container.py +++ b/src/tomlrt/_container.py @@ -38,10 +38,11 @@ from tomlrt._errors import TOMLError from tomlrt._format import ( _canon_header_slot, - _canon_inline_value, _canon_kv_slot, _canon_leading, + _resolve_format_options, format_document_trailing, + format_inline_root, format_subtree, ) from tomlrt._inline_comments import InlineEolView, InlineLeadingView @@ -77,6 +78,7 @@ from _typeshed import SupportsKeysAndGetItem, SupportsRichComparison from typing_extensions import Self + from tomlrt._format import FormatOptions from tomlrt._slots import AoTEntry, Slot, SlotRef from tomlrt._trivia import EolTrivia, TriviaPiece from tomlrt._values import ( @@ -266,7 +268,12 @@ def _doc_newline(self) -> str: lr = self._layout_root return lr._newline if lr is not None else "\n" # noqa: SLF001 - def format(self, *, comments: bool = True) -> None: + def format( + self, + *, + options: FormatOptions | None = None, + comments: bool | None = None, + ) -> None: """Canonicalise this container's formatting in place. Rewrites this subtree to the canonical layout: @@ -280,19 +287,21 @@ def format(self, *, comments: bool = True) -> None: multi-line stays multi-line). * Newlines use the owning document's style. - With ``comments=True``, ``#foo`` and ``# foo`` both become - ``# foo`` and trailing comment whitespace is stripped. + ``comments=`` is deprecated; use + ``FormatOptions(normalize_comments=...)`` instead. Supplying both + arguments raises ``ValueError``. Detached factory-style containers (``Table.section()`` / ``Table.inline()`` not yet assigned anywhere) and inline dotted navigators are unsupported and raise `TOMLError`. """ + resolved = _resolve_format_options(options=options, comments=comments) kind = self._kind nl = self._doc_newline if kind is _Kind.INLINE_ROOT: assert self._value is not None - _canon_inline_value(self._value, nl=nl, comments=comments) + format_inline_root(self._value, nl=nl, options=resolved) return if kind in (_Kind.INLINE_FACTORY, _Kind.INLINE_DOTTED_INNER): @@ -306,10 +315,10 @@ def format(self, *, comments: bool = True) -> None: path=(), owner=None, nl=nl, - comments=comments, + options=resolved, ) - format_document_trailing(self._preamble, nl=nl, comments=comments) - format_document_trailing(self._trailing, nl=nl, comments=comments) + format_document_trailing(self._preamble, nl=nl, options=resolved) + format_document_trailing(self._trailing, nl=nl, options=resolved) return if kind is _Kind.SECTION: @@ -319,7 +328,7 @@ def format(self, *, comments: bool = True) -> None: path=self._path, owner=self._owner_aot_entry, nl=nl, - comments=comments, + options=resolved, ) return @@ -331,16 +340,16 @@ def format(self, *, comments: bool = True) -> None: for ref in list(self._refs): slot = ref.slot if isinstance(slot, KVSlot): - _canon_kv_slot(slot, nl=nl, comments=comments) + _canon_kv_slot(slot, nl=nl, options=resolved) elif isinstance(slot, StructuralHeaderSlot): - _canon_header_slot(slot, nl=nl, comments=comments) - _canon_leading(slot, nl=nl, target_blanks=None, comments=comments) + _canon_header_slot(slot, nl=nl, options=resolved) + _canon_leading(slot, nl=nl, target_blanks=None, options=resolved) for value in self.values(): if isinstance(value, (Container, Array)): - value.format(comments=comments) + value.format(options=resolved) elif isinstance(value, AoT): for entry in value: - entry.format(comments=comments) + entry.format(options=resolved) @property def _attached(self) -> bool: diff --git a/src/tomlrt/_format.py b/src/tomlrt/_format.py index ba26c8b..a01a61e 100644 --- a/src/tomlrt/_format.py +++ b/src/tomlrt/_format.py @@ -8,7 +8,7 @@ KV slot pre_eq=" ", post_eq=" ", key_seps=".", strip column-indent WS, - EOL trailing_ws = " " before any comment. + configurable EOL spacing before comments. Section / AoT-entry header inner_pre="", inner_post="", strip column-indent WS, EOL as for KV. @@ -20,21 +20,21 @@ Inline arrays / inline tables Single-line: ``[a, b, c]`` / ``{ a = 1, b = 2 }``. - Multi-line: each item on its own line, last item carries a - trailing comma. Indent inherited from the first item if present - else two spaces. + Multi-line: each item on its own line with configurable recursive + indentation and a configurable final comma. Orphan comment blocks (``# …`` runs separated from the slot/header by a blank line) and EOL / leading-attached comments are preserved in place — comment text is rewritten to ``# body`` form when -``comments=True``. +``normalize_comments`` is enabled. """ from __future__ import annotations +import warnings +from dataclasses import dataclass from typing import TYPE_CHECKING -from tomlrt._comma_ops import _put_eol, _take_eol from tomlrt._errors import TOMLError from tomlrt._slots import KVSlot, StructuralHeaderSlot from tomlrt._trivia import ( @@ -55,6 +55,7 @@ ArrayValue, InlineTableEntry, InlineTableValue, + item_eol_channel, item_has_any_comment, ) @@ -72,6 +73,65 @@ ) +def _validate_non_negative(value: int, name: str) -> None: + if value < 0: + msg = f"{name} must be non-negative" + raise ValueError(msg) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class FormatOptions: + """Canonical formatting options shared by all ``format()`` methods. + + ``normalize_comments`` rewrites comment text to canonical ``# body`` form + and strips trailing whitespace. Layout around comments is canonicalised + regardless. + + ``indent`` is the number of spaces added at each nested multiline array + or inline-table level. + + ``eol_comment_spaces`` is the number of spaces inserted before supported + end-of-line comments. + + ``multiline_trailing_comma`` controls whether the final item in a multiline + array or inline table has a comma. + """ + + normalize_comments: bool = True + indent: int = 2 + eol_comment_spaces: int = 1 + multiline_trailing_comma: bool = True + + def __post_init__(self) -> None: + _validate_non_negative(self.indent, "indent") + _validate_non_negative(self.eol_comment_spaces, "eol_comment_spaces") + + +_DEFAULT_FORMAT_OPTIONS = FormatOptions() + + +def _resolve_format_options( + *, + options: FormatOptions | None, + comments: bool | None, +) -> FormatOptions: + """Resolve public formatting arguments and warn for ``comments=``.""" + if options is not None and comments is not None: + msg = "cannot specify both options and deprecated comments" + raise ValueError(msg) + if comments is not None: + warnings.warn( + "comments= is deprecated; use " + "FormatOptions(normalize_comments=...) instead", + DeprecationWarning, + stacklevel=3, + ) + return FormatOptions(normalize_comments=comments) + if options is None: + return _DEFAULT_FORMAT_OPTIONS + return options + + # --------------------------------------------------------------------------- # Comment text + line cleanup # --------------------------------------------------------------------------- @@ -150,7 +210,7 @@ def _canon_leading( *, nl: str, target_blanks: int | None, - comments: bool, + options: FormatOptions, ) -> None: """Rewrite ``slot.leading`` to canonical form. @@ -177,7 +237,7 @@ def _canon_leading( middle_t = Trivia([p for line in middle for p in line]) retarget_trivia_newlines(middle_t, nl) - _canon_trivia_text(middle_t, comments=comments) + _canon_trivia_text(middle_t, comments=options.normalize_comments) # Preamble/subtree boundaries keep authored head gaps; attached # comment blocks clamp to 0/1 so separation intent survives. @@ -197,20 +257,27 @@ def _canon_leading( # --------------------------------------------------------------------------- -def _canon_eol(eol: EolTrivia, *, nl: str, comments: bool) -> None: +def _canon_eol(eol: EolTrivia, *, nl: str, options: FormatOptions) -> None: """Normalise an :class:`EolTrivia`. Retargets newline to ``nl`` (leaving ``None`` for a no-final-newline - tail), canonicalises optional comment text, and keeps exactly one - separator space before comments. + tail), canonicalises optional comment text, and applies the configured + separator before comments. """ retarget_eol_newline(eol, nl) if eol.comment is None: eol.trailing_ws = None return - if comments: + if options.normalize_comments: eol.comment.text = _canon_comment_text(eol.comment.text) - eol.trailing_ws = WhitespaceNode(" ") + eol.trailing_ws = _comment_separator(options) + + +def _comment_separator(options: FormatOptions) -> WhitespaceNode | None: + """Return the configured whitespace before an EOL comment.""" + if options.eol_comment_spaces == 0: + return None + return WhitespaceNode(" " * options.eol_comment_spaces) # --------------------------------------------------------------------------- @@ -230,7 +297,7 @@ def _canon_key_equals(node: KVSlot | InlineTableEntry) -> None: # --------------------------------------------------------------------------- -def _canon_kv_slot(slot: KVSlot, *, nl: str, comments: bool) -> None: +def _canon_kv_slot(slot: KVSlot, *, nl: str, options: FormatOptions) -> None: """Normalise a KV slot's body (key/eq/value/eol). Leading is handled separately by :func:`_canon_leading` so that @@ -238,15 +305,17 @@ def _canon_kv_slot(slot: KVSlot, *, nl: str, comments: bool) -> None: level. """ _canon_key_equals(slot) - _canon_value(slot.value, nl=nl, comments=comments) - _canon_eol(slot.eol, nl=nl, comments=comments) + _canon_value(slot.value, nl=nl, options=options) + _canon_eol(slot.eol, nl=nl, options=options) -def _canon_header_slot(slot: StructuralHeaderSlot, *, nl: str, comments: bool) -> None: +def _canon_header_slot( + slot: StructuralHeaderSlot, *, nl: str, options: FormatOptions +) -> None: slot.inner_pre = "" slot.inner_post = "" slot.key_seps = ["."] * (len(slot.key_parts) - 1) - _canon_eol(slot.eol, nl=nl, comments=comments) + _canon_eol(slot.eol, nl=nl, options=options) # --------------------------------------------------------------------------- @@ -258,25 +327,29 @@ def _canon_inline_value( v: ArrayValue | InlineTableValue, *, nl: str, - comments: bool, + options: FormatOptions, parent_indent: str = "", ) -> None: """Canonicalise inline array/table layout while preserving shape.""" items = v.items multi = v.is_multiline() - item_indent = parent_indent + " " if multi else parent_indent + item_indent = parent_indent + (" " * options.indent) if multi else parent_indent for it in items: if isinstance(it, InlineTableEntry): _canon_key_equals(it) - _canon_value(it.value, nl=nl, comments=comments, parent_indent=item_indent) + _canon_value(it.value, nl=nl, options=options, parent_indent=item_indent) if not multi: _canon_single_line_inline(v) return _canon_multiline_shape( - v, nl=nl, comments=comments, item_indent=item_indent, outer_indent=parent_indent + v, + nl=nl, + options=options, + item_indent=item_indent, + outer_indent=parent_indent, ) @@ -284,7 +357,7 @@ def _canon_multiline_shape( v: ArrayValue | InlineTableValue, *, nl: str, - comments: bool, + options: FormatOptions, item_indent: str, outer_indent: str, ) -> None: @@ -296,7 +369,12 @@ def _canon_multiline_shape( produces only empty/single-space trivia. """ items = v.items - last_line_open = _canon_multi_line_items(items, nl=nl, indent=item_indent) + last_line_open = _canon_multi_line_items( + items, + nl=nl, + indent=item_indent, + options=options, + ) if items: head_eol, _ = split_eol_section(v.header_trivia) _, head_above = split_above_block(v.header_trivia) @@ -335,7 +413,7 @@ def _canon_multiline_shape( _finalise_inline_trivia( v, nl=nl, - comments=comments, + options=options, item_indent=item_indent, final_first_line_is_eol=final_eol_first, ) @@ -353,8 +431,8 @@ def _compose_pad( Layout is ``head_eol`` (already terminated when non-empty), optional structural ``\n``, above-block, then trailing indent. Skip the - structural newline when ``head_eol`` or upstream - ``post_comma_trivia`` already closed the line. + structural newline when ``head_eol`` or the upstream item EOL channel + already closed the line. """ pieces: list[TriviaPiece] = list(head_eol.pieces) if not head_eol.pieces and not line_already_open: @@ -393,7 +471,7 @@ def _finalise_inline_trivia( v: ArrayValue | InlineTableValue, *, nl: str, - comments: bool, + options: FormatOptions, item_indent: str = "", final_first_line_is_eol: bool = False, ) -> None: @@ -411,13 +489,13 @@ def _finalise_inline_trivia( retarget_trivia_newlines(v.final_trivia, nl) _canon_trivia_text( v.header_trivia, - comments=comments, + comments=options.normalize_comments, comment_indent=item_indent, first_line_is_eol=True, ) _canon_trivia_text( v.final_trivia, - comments=comments, + comments=options.normalize_comments, comment_indent=item_indent, first_line_is_eol=final_first_line_is_eol, ) @@ -425,13 +503,23 @@ def _finalise_inline_trivia( retarget_trivia_newlines(it.leading, nl) retarget_trivia_newlines(it.trailing, nl) retarget_trivia_newlines(it.post_comma_trivia, nl) - _canon_trivia_text(it.leading, comments=comments, comment_indent=item_indent) + _canon_trivia_text( + it.leading, + comments=options.normalize_comments, + comment_indent=item_indent, + ) # ``trailing`` and ``post_comma_trivia`` are EOL contexts: # the space before ``#`` is the structural separator the - # callers have already canonicalised to one space. - _canon_trivia_text(it.trailing, comments=comments, strip_pre_comment_ws=False) + # caller has already canonicalised. + _canon_trivia_text( + it.trailing, + comments=options.normalize_comments, + strip_pre_comment_ws=False, + ) _canon_trivia_text( - it.post_comma_trivia, comments=comments, strip_pre_comment_ws=False + it.post_comma_trivia, + comments=options.normalize_comments, + strip_pre_comment_ws=False, ) @@ -440,23 +528,25 @@ def _canon_multi_line_items( *, nl: str, indent: str, + options: FormatOptions, ) -> bool: r"""Canonicalise per-item trivia for a multi-line inline value. - Returns whether the last item's ``post_comma_trivia`` closed its row, - so the caller can avoid adding a duplicate ``final_trivia`` newline. + Returns whether the last item's EOL channel closed its row, so the caller + can avoid adding a duplicate ``final_trivia`` newline. Item 0's structural pad lives in ``header_trivia``, so its leading is empty. Later items keep their above-item comment block but get canonical newline+indent, suppressed when the previous item's - ``post_comma_trivia`` already closed the row. + upstream EOL channel already closed the row. - All items use trailing-comma style. If an item lacked a comma, move - its EOL comment from ``trailing`` to ``post_comma_trivia`` so the - comma renders before the comment; then shrink ``post_comma_trivia`` - to an EOL section or empty. + Internal items and, when requested, the final item use commas. Any + EOL comment moves between ``trailing`` and ``post_comma_trivia`` as + the comma state changes; both channels are then reduced to the + canonical EOL section or empty. """ prev_line_open = False + last_index = len(items) - 1 for k, it in enumerate(items): if k == 0: it.leading = Trivia() @@ -469,34 +559,56 @@ def _canon_multi_line_items( trailing_indent=indent, line_already_open=prev_line_open, ) - # Synthesising a comma may shift the EOL row from ``trailing`` - # to ``post_comma_trivia``; the take/put pair preserves the - # comment across any has_comma flip. - eol = _take_eol(it) - it.has_comma = True + # Changing comma state may shift comments between ``trailing`` + # and ``post_comma_trivia``; collect both sides before clearing them. + comments = [ + p + for trivia in (it.trailing, it.post_comma_trivia) + for p in trivia.pieces + if isinstance(p, CommentNode) + ] + it.has_comma = k < last_index or options.multiline_trailing_comma it.trailing = Trivia() - _put_eol(it, eol) - prev_line_open = _canon_post_comma_trivia(it, nl=nl) + it.post_comma_trivia = Trivia() + prev_line_open = _canon_item_eol( + it, + comments, + nl=nl, + options=options, + indent=indent, + ) return prev_line_open -def _canon_post_comma_trivia(item: CommaItem, *, nl: str) -> bool: - r"""Canonicalise ``post_comma_trivia``; return True if the line was closed. +def _canon_item_eol( + item: CommaItem, + comments: Sequence[CommentNode], + *, + nl: str, + options: FormatOptions, + indent: str, +) -> bool: + r"""Write ``comments`` onto the item's EOL channel. + + Returns whether they close the row. - If the first non-whitespace piece is a comment it is preserved as - an EOL row (``" # comment\\n"``) and we report the line as - closed; otherwise the channel is cleared and the line stays open - for the next item's leading pad to terminate. + The first comment stays on the item row; further comments occupy indented + lines below it. With no comments, the row stays open for the next item's + leading pad to terminate. """ - first = next( - (p for p in item.post_comma_trivia.pieces if not isinstance(p, WhitespaceNode)), - None, - ) - if isinstance(first, CommentNode): - item.post_comma_trivia = Trivia([WhitespaceNode(" "), first, NewlineNode(nl)]) - return True - item.post_comma_trivia = Trivia() - return False + if not comments: + return False + pieces: list[TriviaPiece] = [] + for k, comment in enumerate(comments): + if k == 0: + separator = _comment_separator(options) + if separator is not None: + pieces.append(separator) + elif indent: + pieces.append(WhitespaceNode(indent)) + pieces.extend((comment, NewlineNode(nl))) + item_eol_channel(item).pieces = pieces + return True # --------------------------------------------------------------------------- @@ -504,9 +616,15 @@ def _canon_post_comma_trivia(item: CommaItem, *, nl: str) -> bool: # --------------------------------------------------------------------------- -def _canon_value(v: Value, *, nl: str, comments: bool, parent_indent: str = "") -> None: +def _canon_value( + v: Value, + *, + nl: str, + options: FormatOptions, + parent_indent: str = "", +) -> None: if isinstance(v, (ArrayValue, InlineTableValue)): - _canon_inline_value(v, nl=nl, comments=comments, parent_indent=parent_indent) + _canon_inline_value(v, nl=nl, options=options, parent_indent=parent_indent) # Other value kinds carry no formattable trivia. @@ -526,11 +644,7 @@ def set_comma_value_multiline( while inline tables keep their pad (``{ a = 1 }``). """ items = value.items - outer_indent = "" - if value.is_multiline() and value.final_trivia.pieces: - final_piece = value.final_trivia.pieces[-1] - if isinstance(final_piece, WhitespaceNode): - outer_indent = final_piece.text + outer_indent = _closing_indent(value) # The explicit single<->multi toggle is the one operation that can flip # shape without removing an item; drop the memo so it recomputes. value.reset_multiline_cache() @@ -553,16 +667,46 @@ def set_comma_value_multiline( _canon_single_line_inline(value) return for it in items: - _canon_value(it.value, nl=nl, comments=True, parent_indent=indent) + _canon_value( + it.value, + nl=nl, + options=_DEFAULT_FORMAT_OPTIONS, + parent_indent=indent, + ) _canon_multiline_shape( value, nl=nl, - comments=True, + options=_DEFAULT_FORMAT_OPTIONS, item_indent=indent, outer_indent=outer_indent, ) +def _closing_indent(value: ArrayValue | InlineTableValue) -> str: + """Return the whitespace immediately before a multiline closing bracket.""" + if not value.is_multiline() or not value.final_trivia.pieces: + return "" + final_piece = value.final_trivia.pieces[-1] + return final_piece.text if isinstance(final_piece, WhitespaceNode) else "" + + +def format_inline_root( + value: ArrayValue | InlineTableValue, + *, + nl: str, + options: FormatOptions, +) -> None: + """Canonicalise an inline array/table formatted on its own. + + Uses the value's existing closing-bracket column as the base indent, + so formatting it in isolation doesn't reset its position relative to + its enclosing document. + """ + _canon_inline_value( + value, nl=nl, options=options, parent_indent=_closing_indent(value) + ) + + # --------------------------------------------------------------------------- # Subtree walk and orchestration # --------------------------------------------------------------------------- @@ -603,7 +747,7 @@ def format_subtree( path: tuple[str, ...], owner: AoTEntry | None, nl: str, - comments: bool, + options: FormatOptions, ) -> None: """Canonicalise every slot in the subtree rooted at ``path``. @@ -628,14 +772,14 @@ def format_subtree( if prev.eol.newline is None: prev.eol.newline = NewlineNode(nl) if isinstance(slot, KVSlot): - _canon_kv_slot(slot, nl=nl, comments=comments) + _canon_kv_slot(slot, nl=nl, options=options) elif isinstance(slot, StructuralHeaderSlot): - _canon_header_slot(slot, nl=nl, comments=comments) + _canon_header_slot(slot, nl=nl, options=options) if prev is None: target: int | None = None else: target = 1 if isinstance(slot, StructuralHeaderSlot) else 0 - _canon_leading(slot, nl=nl, target_blanks=target, comments=comments) + _canon_leading(slot, nl=nl, target_blanks=target, options=options) prev = slot slot = slot._next # noqa: SLF001 @@ -644,7 +788,7 @@ def format_document_trailing( trailing: Trivia, *, nl: str, - comments: bool, + options: FormatOptions, ) -> None: """Canonicalise the trailing trivia of a :class:`Document`. @@ -654,15 +798,19 @@ def format_document_trailing( preamble/epilogue split is unaffected. """ retarget_trivia_newlines(trailing, nl) - _canon_trivia_text(trailing, comments=comments) + _canon_trivia_text(trailing, comments=options.normalize_comments) __all__ = [ + "FormatOptions", "_canon_header_slot", "_canon_inline_value", "_canon_kv_slot", "_canon_leading", + "_closing_indent", + "_resolve_format_options", "format_document_trailing", + "format_inline_root", "format_subtree", "set_comma_value_multiline", ] diff --git a/tests/test_format.py b/tests/test_format.py index b014806..72f2f69 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -2,6 +2,11 @@ from __future__ import annotations +import warnings +from dataclasses import FrozenInstanceError +from inspect import Parameter, signature +from typing import Any + import pytest import tomli @@ -12,7 +17,7 @@ def _roundtrip(src: str, *, comments: bool = True) -> str: doc = tomlrt.loads(src) - doc.format(comments=comments) + doc.format(options=tomlrt.FormatOptions(normalize_comments=comments)) return tomlrt.dumps(doc) @@ -191,6 +196,106 @@ def test_multiline_array_preserved_shape() -> None: """) +def test_format_options_omit_multiline_trailing_comma() -> None: + src = td(""" + items = [ + 1, + 2, + ] + """) + doc = tomlrt.loads(src) + options = tomlrt.FormatOptions(multiline_trailing_comma=False) + doc.format(options=options) + expected = td(""" + items = [ + 1, + 2 + ] + """) + assert tomlrt.dumps(doc) == expected + assert tomli.loads(expected) == {"items": [1, 2]} + doc.format(options=options) + assert tomlrt.dumps(doc) == expected + + +def test_no_trailing_comma_preserves_final_item_eol_comment() -> None: + src = td(""" + items = [ + 1, + 2, # final + ] + """) + doc = tomlrt.loads(src) + doc.array("items").format( + options=tomlrt.FormatOptions(multiline_trailing_comma=False) + ) + assert tomlrt.dumps(doc) == td(""" + items = [ + 1, + 2 # final + ] + """) + + +def test_no_trailing_comma_recurses_through_arrays_and_inline_tables() -> None: + src = td(""" + outer = [ + { + values = [ + 1, + ], + table = { + value = 2, + }, + }, + ] + """) + doc = tomlrt.loads(src) + doc.format(options=tomlrt.FormatOptions(multiline_trailing_comma=False)) + assert tomlrt.dumps(doc) == td(""" + outer = [ + { + values = [ + 1 + ], + table = { + value = 2 + } + } + ] + """) + + +def test_no_trailing_comma_preserves_empty_array_and_crlf() -> None: + src = td(""" + items = [ + ] + """).replace("\n", "\r\n") + doc = tomlrt.loads(src) + doc.format(options=tomlrt.FormatOptions(multiline_trailing_comma=False)) + assert tomlrt.dumps(doc) == src + + +def test_append_after_format_without_trailing_comma_preserves_style() -> None: + src = td(""" + items = [ + 1, + 2, # final + ] + """) + doc = tomlrt.loads(src) + items = doc.array("items") + items.format(options=tomlrt.FormatOptions(multiline_trailing_comma=False)) + items.append(3) + assert tomlrt.dumps(doc) == td(""" + items = [ + 1, + 2, # final + 3 + ] + """) + + def test_comma_first_array_keeps_comment() -> None: # A comma-first layout parks the item's EOL comment in ``trailing`` # *before* the comma, even though ``has_comma`` is set. format() @@ -225,6 +330,182 @@ def test_comma_first_inline_table_keeps_comment() -> None: """) +@pytest.mark.parametrize( + "options", + [ + tomlrt.FormatOptions(multiline_trailing_comma=True), + tomlrt.FormatOptions(multiline_trailing_comma=False), + ], +) +def test_comma_first_array_keeps_post_comma_comment( + options: tomlrt.FormatOptions, +) -> None: + src = td(""" + a = [ + 1 + , # after comma + 2 + ] + """) + doc = tomlrt.loads(src) + doc.format(options=options) + expected = ( + td(""" + a = [ + 1, # after comma + 2, + ] + """) + if options.multiline_trailing_comma + else td(""" + a = [ + 1, # after comma + 2 + ] + """) + ) + assert tomlrt.dumps(doc) == expected + + +@pytest.mark.parametrize( + "options", + [ + tomlrt.FormatOptions(multiline_trailing_comma=True), + tomlrt.FormatOptions(multiline_trailing_comma=False), + ], +) +def test_comma_first_inline_table_keeps_post_comma_comment( + options: tomlrt.FormatOptions, +) -> None: + src = td(""" + t = { + a = 1 + , # after comma + b = 2 + } + """) + doc = tomlrt.loads(src) + doc.format(options=options) + expected = ( + td(""" + t = { + a = 1, # after comma + b = 2, + } + """) + if options.multiline_trailing_comma + else td(""" + t = { + a = 1, # after comma + b = 2 + } + """) + ) + assert tomlrt.dumps(doc) == expected + + +@pytest.mark.parametrize( + "options", + [ + tomlrt.FormatOptions(multiline_trailing_comma=True), + tomlrt.FormatOptions(multiline_trailing_comma=False), + ], +) +def test_comma_first_array_keeps_comments_on_both_sides( + options: tomlrt.FormatOptions, +) -> None: + src = td(""" + a = [ + 1 # before comma + , # after comma + 2 + ] + """) + doc = tomlrt.loads(src) + doc.format(options=options) + expected = ( + td(""" + a = [ + 1, # before comma + # after comma + 2, + ] + """) + if options.multiline_trailing_comma + else td(""" + a = [ + 1, # before comma + # after comma + 2 + ] + """) + ) + assert tomlrt.dumps(doc) == expected + + +@pytest.mark.parametrize( + "options", + [ + tomlrt.FormatOptions(multiline_trailing_comma=True), + tomlrt.FormatOptions(multiline_trailing_comma=False), + ], +) +def test_comma_first_inline_table_keeps_comments_on_both_sides( + options: tomlrt.FormatOptions, +) -> None: + src = td(""" + t = { + a = 1 # before comma + , # after comma + b = 2 + } + """) + doc = tomlrt.loads(src) + doc.format(options=options) + expected = ( + td(""" + t = { + a = 1, # before comma + # after comma + b = 2, + } + """) + if options.multiline_trailing_comma + else td(""" + t = { + a = 1, # before comma + # after comma + b = 2 + } + """) + ) + assert tomlrt.dumps(doc) == expected + + +def test_comma_first_multiple_comments_with_zero_indent() -> None: + src = td(""" + a = [ + 1 # before comma + , # after comma + 2 + ] + """) + doc = tomlrt.loads(src) + doc.format( + options=tomlrt.FormatOptions( + indent=0, + multiline_trailing_comma=False, + ) + ) + assert tomlrt.dumps(doc) == td(""" + a = [ + 1, # before comma + # after comma + 2 + ] + """) + + def test_nested_multiline_array_indents_per_depth() -> None: src = td(""" outer = [ @@ -325,6 +606,86 @@ def test_nested_table_in_multiline_array_indents() -> None: """) +def test_format_options_indent_recurses_through_inline_values() -> None: + src = td(""" + outer = [ + { nested = [ + 1, + ] }, + ] + """) + doc = tomlrt.loads(src) + doc.format(options=tomlrt.FormatOptions(indent=4)) + assert tomlrt.dumps(doc) == td(""" + outer = [ + { nested = [ + 1, + ] }, + ] + """) + + +def test_array_scoped_format_preserves_outer_indent_with_custom_step() -> None: + src = td(""" + outer = [ + { nested = [ + 1, + ] }, + ] + """) + doc = tomlrt.loads(src) + doc.array("outer").table(0).array("nested").format( + options=tomlrt.FormatOptions(indent=4) + ) + assert tomlrt.dumps(doc) == td(""" + outer = [ + { nested = [ + 1, + ] }, + ] + """) + + +def test_table_scoped_format_preserves_outer_indent_with_custom_step() -> None: + src = td(""" + outer = [ + { nested = { + value=1, + } }, + ] + """) + doc = tomlrt.loads(src) + doc.array("outer").table(0).table("nested").format( + options=tomlrt.FormatOptions(indent=4) + ) + assert tomlrt.dumps(doc) == td(""" + outer = [ + { nested = { + value = 1, + } }, + ] + """) + + +def test_format_options_zero_indent() -> None: + src = td(""" + outer = [ + [ + 1, + ], + ] + """) + doc = tomlrt.loads(src) + doc.format(options=tomlrt.FormatOptions(indent=0)) + assert tomlrt.dumps(doc) == td(""" + outer = [ + [ + 1, + ], + ] + """) + + def test_aot_recursion() -> None: src = td(""" [[a]] @@ -362,6 +723,229 @@ def test_comments_true_normalises_text() -> None: """) +def test_format_options_eol_comment_spaces_cover_supported_comments() -> None: + src = td(""" + top=1#top + array = [ + 1,#array + ] + table = { + value=1,#entry + } + [section]#header + x=1 + """) + doc = tomlrt.loads(src) + doc.format(options=tomlrt.FormatOptions(eol_comment_spaces=3)) + assert tomlrt.dumps(doc) == td(""" + top = 1 # top + array = [ + 1, # array + ] + table = { + value = 1, # entry + } + + [section] # header + x = 1 + """) + + +def test_format_options_zero_eol_comment_spaces() -> None: + src = td(""" + key = 1 # key + values = [ + 1, # item + ] + """) + doc = tomlrt.loads(src) + doc.format(options=tomlrt.FormatOptions(eol_comment_spaces=0)) + assert tomlrt.dumps(doc) == td(""" + key = 1# key + values = [ + 1,# item + ] + """) + + +def test_comment_spacing_is_independent_of_text_normalization() -> None: + src = td(""" + key=1# text + values = [ + 1,# item + ] + """) + doc = tomlrt.loads(src) + doc.format( + options=tomlrt.FormatOptions( + normalize_comments=False, + eol_comment_spaces=2, + ) + ) + assert tomlrt.dumps(doc) == td(""" + key = 1 # text + values = [ + 1, # item + ] + """) + + +def test_eol_comment_spacing_preserves_opening_bracket_spacing() -> None: + src = td(""" + values = [ # opening + 1, # item + ] + """) + doc = tomlrt.loads(src) + doc.format(options=tomlrt.FormatOptions(eol_comment_spaces=2)) + assert tomlrt.dumps(doc) == td(""" + values = [ # opening + 1, # item + ] + """) + + +def test_eol_comment_spacing_preserves_crlf() -> None: + src = td(""" + values = [ + 1,# item + ] + """).replace("\n", "\r\n") + doc = tomlrt.loads(src) + doc.array("values").format(options=tomlrt.FormatOptions(eol_comment_spaces=2)) + assert tomlrt.dumps(doc) == td(""" + values = [ + 1, # item + ] + """).replace("\n", "\r\n") + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + pytest.param( + {"indent": -1}, + "indent must be non-negative", + id="indent", + ), + pytest.param( + {"eol_comment_spaces": -1}, + "eol_comment_spaces must be non-negative", + id="eol-comment-spaces", + ), + ], +) +def test_format_options_rejects_negative_values( + kwargs: Any, + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + tomlrt.FormatOptions(**kwargs) + + +def test_format_options_is_frozen_and_keyword_only() -> None: + options = tomlrt.FormatOptions() + assert options.normalize_comments is True + attribute = "normalize_comments" + with pytest.raises(FrozenInstanceError): + setattr(options, attribute, False) + parameters = signature(tomlrt.FormatOptions).parameters + assert all(p.kind is Parameter.KEYWORD_ONLY for p in parameters.values()) + + +def test_all_format_options_interact_without_changing_data() -> None: + src = td(""" + root=1# root + outer = [ + { + values = [ + 1, + 2,# final + ], + table = { + value=3,# entry + }, + }, + ] + [section]# header + key=4 + """) + doc = tomlrt.loads(src) + before = doc.to_dict() + options = tomlrt.FormatOptions( + normalize_comments=False, + indent=4, + eol_comment_spaces=2, + multiline_trailing_comma=False, + ) + doc.format(options=options) + expected = td(""" + root = 1 # root + outer = [ + { + values = [ + 1, + 2 # final + ], + table = { + value = 3 # entry + } + } + ] + + [section] # header + key = 4 + """) + assert tomlrt.dumps(doc) == expected + assert doc.to_dict() == before + assert tomli.loads(expected) == before + doc.format(options=options) + assert tomlrt.dumps(doc) == expected + + +def test_legacy_comments_warns_at_caller_for_each_receiver() -> None: + doc = tomlrt.loads( + td(""" + a = { x=1 } + b = [1,2 ] + """) + ) + receivers = (doc, doc.table("a"), doc.array("b")) + for receiver in receivers: + with pytest.warns( + DeprecationWarning, match="comments= is deprecated" + ) as caught: + receiver.format(comments=False) + assert caught[0].filename == __file__ + + +def test_format_rejects_options_with_legacy_comments_without_mutating() -> None: + src = "a =1\n" + doc = tomlrt.loads(src) + with pytest.raises(ValueError, match="cannot specify both"): + doc.format(options=tomlrt.FormatOptions(), comments=False) + assert tomlrt.dumps(doc) == src + + +def test_recursive_format_with_options_emits_no_deprecation_warning() -> None: + doc = tomlrt.loads( + td(""" + a.x =1 + [a.b] + y=2 + """) + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + doc.table("a").format(options=tomlrt.FormatOptions()) + assert caught == [] + assert tomlrt.dumps(doc) == td(""" + a.x = 1 + [a.b] + y = 2 + """) + + def test_bare_hash_comment_stays() -> None: assert _roundtrip("a = 1 #\n", comments=True) == "a = 1 #\n" diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py index e5b1c2e..fe1fc96 100644 --- a/tests/test_hypothesis.py +++ b/tests/test_hypothesis.py @@ -13,7 +13,7 @@ import tomlrt from _helpers import td -from tomlrt import Document +from tomlrt import Document, FormatOptions pytestmark = pytest.mark.slow @@ -247,6 +247,42 @@ def test_edge_cases_roundtrip(src: str) -> None: assert tomlrt.dumps(tomlrt.loads(src)) == src +_FORMAT_OPTIONS = st.builds( + FormatOptions, + normalize_comments=st.booleans(), + indent=st.integers(min_value=0, max_value=4), + eol_comment_spaces=st.integers(min_value=0, max_value=3), + multiline_trailing_comma=st.booleans(), +) + + +@given(options=_FORMAT_OPTIONS) +@settings(max_examples=100, database=None) +def test_format_options_preserve_data_and_are_idempotent( + options: FormatOptions, +) -> None: + src = td(""" + root = 1 # root + outer = [ + { + values = [ + 1, + 2, # final + ], + }, + ] + """) + expected = tomli.loads(src) + doc = tomlrt.loads(src) + doc.format(options=options) + once = tomlrt.dumps(doc) + assert _deep_equal(doc.to_dict(), expected) + assert _deep_equal(tomli.loads(once), expected) + doc.format(options=options) + assert tomlrt.dumps(doc) == once + assert tomlrt.dumps(tomlrt.loads(once)) == once + + # --------------------------------------------------------------------------- # Comment-view round-trip: writing back what we read must be a no-op, and # the rendered comment must read back as the value we wrote. These caught