toml-tidy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dongmin Yu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: toml-tidy
3
+ Version: 0.1.0
4
+ Summary: Sort TOML keys without changing table hierarchy
5
+ Keywords: toml,sort,formatter,cli,tomlkit,pyproject
6
+ Author: Dongmin Yu
7
+ Author-email: Dongmin Yu <ydm2790@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Classifier: Topic :: Text Processing :: Markup
18
+ Requires-Dist: tomlkit>=0.15.0,<0.16
19
+ Requires-Dist: typer>=0.16,<1
20
+ Requires-Python: >=3.12
21
+ Project-URL: Repository, https://github.com/AndrewDongminYoo/toml_tidy
22
+ Project-URL: Issues, https://github.com/AndrewDongminYoo/toml_tidy/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # toml-tidy
26
+
27
+ Sort TOML keys while preserving table hierarchy and source formatting where `tomlkit` supports it.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ uv tool install .
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ toml-tidy pyproject.toml
39
+ toml-tidy pyproject.toml --check
40
+ toml-tidy pyproject.toml --in-place --order natural
41
+ ```
42
+
43
+ Without `--in-place`, sorted TOML is written to standard output.
44
+
45
+ `--check` writes nothing and exits with status `1` when the file requires sorting.
46
+
47
+ `--in-place` rewrites the file only when sorting changes it.
48
+
49
+ ## Ordering
50
+
51
+ `natural` is the default and compares digit runs numerically, so `item2` precedes `item10`.
52
+
53
+ `alpha` uses case-insensitive lexical order.
54
+
55
+ Both modes compare TOML's parsed logical key, not source quoting.
56
+
57
+ Dotted keys such as `b.a = 2` sort with their sibling direct keys by their parsed dotted path, segment by segment, so `a` precedes `b.a`, which precedes `b.z`.
58
+
59
+ For example, `[plugins.omo]` precedes `[plugins."omo-kit"]`, while the quoted spelling remains unchanged in output.
60
+
61
+ ## Preservation
62
+
63
+ Direct keys and sibling explicit table declarations are sorted recursively within their parent table.
64
+
65
+ Array-of-tables declarations such as `[[items]]` sort by name among their sibling tables, while the element order inside each array of tables remains unchanged.
66
+
67
+ Parent-child hierarchy remains unchanged.
68
+
69
+ Standalone comments move with the following key or table declaration.
70
+
71
+ Whitespace between entries remains after the preceding entry, and trailing whitespace remains at its table boundary.
72
+
73
+ Inline comments, value formatting, and key quoting remain attached to their parsed `tomlkit` items.
74
+
75
+ Keys inside inline tables are not reordered.
@@ -0,0 +1,51 @@
1
+ # toml-tidy
2
+
3
+ Sort TOML keys while preserving table hierarchy and source formatting where `tomlkit` supports it.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uv tool install .
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ toml-tidy pyproject.toml
15
+ toml-tidy pyproject.toml --check
16
+ toml-tidy pyproject.toml --in-place --order natural
17
+ ```
18
+
19
+ Without `--in-place`, sorted TOML is written to standard output.
20
+
21
+ `--check` writes nothing and exits with status `1` when the file requires sorting.
22
+
23
+ `--in-place` rewrites the file only when sorting changes it.
24
+
25
+ ## Ordering
26
+
27
+ `natural` is the default and compares digit runs numerically, so `item2` precedes `item10`.
28
+
29
+ `alpha` uses case-insensitive lexical order.
30
+
31
+ Both modes compare TOML's parsed logical key, not source quoting.
32
+
33
+ Dotted keys such as `b.a = 2` sort with their sibling direct keys by their parsed dotted path, segment by segment, so `a` precedes `b.a`, which precedes `b.z`.
34
+
35
+ For example, `[plugins.omo]` precedes `[plugins."omo-kit"]`, while the quoted spelling remains unchanged in output.
36
+
37
+ ## Preservation
38
+
39
+ Direct keys and sibling explicit table declarations are sorted recursively within their parent table.
40
+
41
+ Array-of-tables declarations such as `[[items]]` sort by name among their sibling tables, while the element order inside each array of tables remains unchanged.
42
+
43
+ Parent-child hierarchy remains unchanged.
44
+
45
+ Standalone comments move with the following key or table declaration.
46
+
47
+ Whitespace between entries remains after the preceding entry, and trailing whitespace remains at its table boundary.
48
+
49
+ Inline comments, value formatting, and key quoting remain attached to their parsed `tomlkit` items.
50
+
51
+ Keys inside inline tables are not reordered.
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "toml-tidy"
3
+ version = "0.1.0"
4
+ description = "Sort TOML keys without changing table hierarchy"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Dongmin Yu", email = "ydm2790@gmail.com" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["toml", "sort", "formatter", "cli", "tomlkit", "pyproject"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development :: Quality Assurance",
19
+ "Topic :: Text Processing :: Markup",
20
+ ]
21
+ dependencies = ["tomlkit>=0.15.0,<0.16", "typer>=0.16,<1"]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/AndrewDongminYoo/toml_tidy"
25
+ Issues = "https://github.com/AndrewDongminYoo/toml_tidy/issues"
26
+
27
+ [project.scripts]
28
+ toml-tidy = "toml_tidy.cli:app"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.11.0,<0.12.0"]
32
+ build-backend = "uv_build"
33
+
34
+ [dependency-groups]
35
+ dev = ["basedpyright>=1.29", "pytest>=8", "ruff>=0.11"]
36
+
37
+ [tool.basedpyright]
38
+ typeCheckingMode = "all"
39
+ pythonVersion = "3.12"
40
+ pythonPlatform = "All"
41
+ include = ["src", "tests"]
42
+ exclude = ["**/__pycache__", "**/.venv", "**/build", "**/dist"]
43
+ reportUnusedCallResult = "warning"
44
+ reportUnnecessaryTypeIgnoreComment = "error"
45
+ reportUnusedVariable = "error"
46
+ reportMissingParameterType = "error"
47
+ reportPrivateUsage = "error"
48
+
49
+ [tool.ruff]
50
+ target-version = "py312"
51
+ line-length = 88
52
+ src = ["src", "tests"]
53
+
54
+ [tool.ruff.lint]
55
+ select = ["ALL"]
56
+ ignore = [
57
+ "COM812",
58
+ "CPY001",
59
+ "D203",
60
+ "D213",
61
+ "FBT001",
62
+ "FBT002",
63
+ "FIX002",
64
+ "ISC001",
65
+ "Q003",
66
+ "TD002",
67
+ "TD003",
68
+ "TRY003",
69
+ ]
70
+
71
+ [tool.ruff.lint.per-file-ignores]
72
+ "tests/**/*.py" = ["ARG", "D", "PLR2004", "S101", "SLF001"]
73
+
74
+ [tool.ruff.lint.pydocstyle]
75
+ convention = "google"
76
+
77
+ [tool.ruff.format]
78
+ quote-style = "double"
79
+ indent-style = "space"
80
+ docstring-code-format = true
81
+ docstring-code-line-length = "dynamic"
82
+
83
+ [tool.pytest.ini_options]
84
+ minversion = "8.0"
85
+ testpaths = ["tests"]
86
+ addopts = ["-ra", "--strict-config", "--strict-markers"]
87
+ filterwarnings = ["error"]
@@ -0,0 +1 @@
1
+ """Hierarchical TOML key sorting."""
@@ -0,0 +1,87 @@
1
+ """Command-line interface for hierarchical TOML sorting."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Annotated, Final
6
+
7
+ import typer
8
+ from tomlkit.exceptions import TOMLKitError
9
+
10
+ from toml_tidy.sorter import OrderMode, sort_toml
11
+
12
+ app = typer.Typer(help="Sort TOML keys while preserving table hierarchy.")
13
+ _MUTUALLY_EXCLUSIVE_OPTIONS: Final = "--in-place and --check cannot be used together"
14
+ _LONE_LF: Final = re.compile(r"(?<!\r)\n")
15
+
16
+
17
+ def _detect_linesep(content: str) -> str:
18
+ """Detect the file's dominant line ending, mirroring TOMLFile.read()."""
19
+ num_newline = content.count("\n")
20
+ if num_newline == 0:
21
+ return "\n"
22
+ num_win_eol = content.count("\r\n")
23
+ if num_win_eol == num_newline:
24
+ return "\r\n"
25
+ if num_win_eol == 0:
26
+ return "\n"
27
+ return "mixed"
28
+
29
+
30
+ def _apply_linesep(content: str, linesep: str) -> str:
31
+ """Restore a previously detected line ending, mirroring TOMLFile.write().
32
+
33
+ "mixed" input (inconsistent endings) is passed through unchanged: the
34
+ caller never normalized it before parsing, so tomlkit's per-line trivia
35
+ already preserves each original ending.
36
+ """
37
+ if linesep == "\r\n":
38
+ return _LONE_LF.sub("\r\n", content)
39
+ if linesep == "\n":
40
+ return content.replace("\r\n", "\n")
41
+ return content
42
+
43
+
44
+ @app.command()
45
+ def sort_file(
46
+ path: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
47
+ in_place: Annotated[bool, typer.Option("--in-place")] = False,
48
+ check: Annotated[bool, typer.Option("--check")] = False,
49
+ order: Annotated[OrderMode, typer.Option("--order")] = OrderMode.NATURAL,
50
+ ) -> None:
51
+ """Sort TOML keys while preserving table hierarchy."""
52
+ if in_place and check:
53
+ raise typer.BadParameter(_MUTUALLY_EXCLUSIVE_OPTIONS)
54
+
55
+ try:
56
+ with path.open(encoding="utf-8", newline="") as handle:
57
+ raw_source = handle.read()
58
+ linesep = _detect_linesep(raw_source)
59
+ # Mixed endings: parse the raw source unchanged so tomlkit's per-line
60
+ # trivia keeps each line's original ending, mirroring TOMLFile's
61
+ # passthrough behavior instead of flattening everything to LF.
62
+ source = raw_source if linesep == "mixed" else raw_source.replace("\r\n", "\n")
63
+ sorted_source = sort_toml(source, order)
64
+ except (TOMLKitError, UnicodeDecodeError, OSError, RecursionError) as error:
65
+ typer.echo(f"{path}: {error}", err=True)
66
+ raise typer.Exit(code=2) from None
67
+
68
+ if check:
69
+ if source != sorted_source:
70
+ raise typer.Exit(code=1)
71
+ return
72
+
73
+ output = _apply_linesep(sorted_source, linesep)
74
+
75
+ if in_place:
76
+ if source != sorted_source:
77
+ try:
78
+ with path.open("w", encoding="utf-8", newline="") as handle:
79
+ _ = handle.write(output)
80
+ except OSError as error:
81
+ typer.echo(f"{path}: {error}", err=True)
82
+ raise typer.Exit(code=2) from None
83
+ return
84
+
85
+ # Bytes bypass text-mode newline translation: on Windows a str write would
86
+ # rewrite the already-restored "\r\n" endings to "\r\r\n".
87
+ typer.echo(output.encode("utf-8"), nl=False)
@@ -0,0 +1,423 @@
1
+ """Sort TOML key entries while retaining parsed formatting items."""
2
+
3
+ import re
4
+ from enum import StrEnum
5
+ from typing import Literal
6
+
7
+ import tomlkit
8
+ from tomlkit.container import Container
9
+ from tomlkit.items import AoT, Comment, Item, Key, Table, Whitespace
10
+
11
+ type BodyEntry = tuple[Key | None, Item]
12
+ type SegmentKind = Literal["key", "table"]
13
+ type SortKey = tuple[tuple[int, str | tuple[int, str]], ...]
14
+
15
+ _NATURAL_PARTS = re.compile(r"(\d+)")
16
+
17
+
18
+ class OrderMode(StrEnum):
19
+ """Supported direct-key ordering modes."""
20
+
21
+ NATURAL = "natural"
22
+ ALPHA = "alpha"
23
+
24
+
25
+ def sort_toml(source: str, order: OrderMode = OrderMode.NATURAL) -> str:
26
+ """Return source with direct keys sorted recursively."""
27
+ # A trailing lone "\r" is invalid TOML; appending "\n" would turn it into
28
+ # a valid CRLF line instead of letting the parser reject it.
29
+ if source and not source.endswith(("\n", "\r")):
30
+ source += "\n"
31
+ document = tomlkit.parse(source)
32
+ _sort_document(document, order)
33
+ return tomlkit.dumps(document)
34
+
35
+
36
+ def _sort_document(container: Container, order: OrderMode) -> None:
37
+ """Sort a container's tree, then restore key-to-index map consistency.
38
+
39
+ ``_sort_container`` reorders and splices bodies throughout the tree
40
+ (slice assignment, sibling-table merges, comment re-attachment), all of
41
+ which bypass tomlkit's private ``_map`` bookkeeping. A mutation made
42
+ while restoring one container's comment attachment can delete entries
43
+ from an already-visited descendant's body, so rebuilding each
44
+ container's map inline during the recursive sort is not safe: the
45
+ rebuild has to run once, after every mutation in the tree is done.
46
+ """
47
+ _sort_container(container, order)
48
+ _restore_maps(container)
49
+
50
+
51
+ def _restore_maps(container: Container) -> None:
52
+ """Rebuild this container's map and recurse into every descendant table."""
53
+ _rebuild_map(container)
54
+ for _, item in container.body:
55
+ match item:
56
+ case Table():
57
+ _restore_maps(item.value)
58
+ case AoT():
59
+ for table in item.body:
60
+ _restore_maps(table.value)
61
+ case _:
62
+ continue
63
+
64
+
65
+ def _sort_container(container: Container, order: OrderMode) -> None:
66
+ """Sort direct key segments and then visit descendant table containers."""
67
+ container.body[:] = _sort_segments(container.body, order)
68
+
69
+ for _, item in container.body:
70
+ match item:
71
+ case Table():
72
+ _sort_container(item.value, order)
73
+ case AoT():
74
+ for table in item.body:
75
+ _sort_container(table.value, order)
76
+ case _:
77
+ continue
78
+
79
+ container.body[:] = _restore_comment_attachment(container.body)
80
+
81
+
82
+ def _rebuild_map(container: Container) -> None:
83
+ """Rebuild a container's key-to-index map to match its current body.
84
+
85
+ Sorting reorders ``body`` via direct slice assignment and splicing,
86
+ which bypasses tomlkit's ``append``/``_raw_append`` bookkeeping that
87
+ keeps the private ``_map`` in sync. Left stale, key lookups on the live
88
+ document (e.g. ``doc['a']``) resolve through the old index and return
89
+ whatever entry now occupies that slot. This mirrors ``_raw_append``'s
90
+ map bookkeeping: a repeated key (out-of-order tables) collects a tuple
91
+ of every index it occupies instead of a single int.
92
+ """
93
+ new_map: dict[Key, int | tuple[int, ...]] = {}
94
+ for index, (key, _) in enumerate(container.body):
95
+ if key is None:
96
+ continue
97
+ existing = new_map.get(key)
98
+ if existing is None:
99
+ new_map[key] = index
100
+ elif isinstance(existing, tuple):
101
+ new_map[key] = (*existing, index)
102
+ else:
103
+ new_map[key] = (existing, index)
104
+ container._map = new_map # pyright: ignore[reportPrivateUsage] # noqa: SLF001
105
+
106
+
107
+ def _sort_segments(entries: list[BodyEntry], order: OrderMode) -> list[BodyEntry]:
108
+ """Sort direct keys and sibling tables without crossing their boundaries."""
109
+ sorted_entries: list[BodyEntry] = []
110
+ segment: list[BodyEntry] = []
111
+ segment_kind: SegmentKind | None = None
112
+
113
+ for entry in entries:
114
+ key, item = entry
115
+ match item:
116
+ case Table() | AoT() if key is None or not key.is_dotted():
117
+ if segment_kind == "key":
118
+ # The key segment's trailing comment run annotates the
119
+ # upcoming table header; carry it into the table segment
120
+ # so it travels as that table's leading-comment group.
121
+ tail = len(segment)
122
+ while tail and segment[tail - 1][0] is None:
123
+ tail -= 1
124
+ kept, carried = _split_before_first_comment(segment[tail:])
125
+ segment = [*segment[:tail], *kept]
126
+ sorted_entries.extend(_sort_segment(segment, order))
127
+ segment = carried
128
+ segment.append(entry)
129
+ segment_kind = "table"
130
+ case _:
131
+ if key is not None and segment_kind == "table":
132
+ sorted_entries.extend(_sort_segment(segment, order))
133
+ segment = []
134
+ segment.append(entry)
135
+ if key is not None:
136
+ segment_kind = "key"
137
+
138
+ sorted_entries.extend(_sort_segment(segment, order))
139
+ return sorted_entries
140
+
141
+
142
+ def _sort_segment(entries: list[BodyEntry], order: OrderMode) -> list[BodyEntry]:
143
+ """Move leading comments with keys while keeping whitespace after prior keys."""
144
+ entries = _hoist_header_comments(entries)
145
+ leading: list[BodyEntry] = []
146
+ pending: list[BodyEntry] = []
147
+ groups: list[tuple[tuple[str, ...], list[BodyEntry]]] = []
148
+
149
+ for entry in entries:
150
+ key, item = entry
151
+ if key is None:
152
+ pending.append(entry)
153
+ continue
154
+
155
+ whitespace, comments = _split_before_first_comment(pending)
156
+ pending = []
157
+ if groups:
158
+ groups[-1][1].extend(whitespace)
159
+ else:
160
+ leading.extend(whitespace)
161
+ groups.append((_key_path(key, item), [*comments, entry]))
162
+
163
+ groups = _merge_sibling_tables(groups)
164
+ sorted_groups = sorted(
165
+ groups,
166
+ key=lambda group: _path_sort_key(group[0], order),
167
+ )
168
+ return [
169
+ *leading,
170
+ *(entry for _, group in sorted_groups for entry in group),
171
+ *pending,
172
+ ]
173
+
174
+
175
+ def _merge_sibling_tables(
176
+ groups: list[tuple[tuple[str, ...], list[BodyEntry]]],
177
+ ) -> list[tuple[tuple[str, ...], list[BodyEntry]]]:
178
+ """Fold duplicate-key sibling table entries into a single body entry.
179
+
180
+ ``[a.y] … [b] … [a.x]`` parses as two body entries keyed ``a``. Sorting
181
+ them as separate entries is never idempotent: reparsing the output merges
182
+ them into one super table whose children then sort together, so pass 2
183
+ could interleave children across the pass-1 entry boundary. Merging here
184
+ makes pass 1 produce the shape a reparse would.
185
+ """
186
+ merged: list[tuple[tuple[str, ...], list[BodyEntry]]] = []
187
+ indexes: dict[str, int] = {}
188
+
189
+ for path, entries in groups:
190
+ key, item = entries[-1]
191
+ if key is None or key.is_dotted() or not isinstance(item, Table):
192
+ merged.append((path, entries))
193
+ continue
194
+
195
+ index = indexes.get(key.key)
196
+ if index is not None:
197
+ _, dest_item = merged[index][1][-1]
198
+ if isinstance(dest_item, Table) and (
199
+ dest_item.is_super_table() or item.is_super_table()
200
+ ):
201
+ if item.is_super_table():
202
+ _splice_super_table(dest_item, item, entries[:-1])
203
+ else:
204
+ # The concrete table owns the header; fold the super in.
205
+ _splice_super_table(item, dest_item, merged[index][1][:-1])
206
+ merged[index] = (path, entries)
207
+ surviving = merged[index][1]
208
+ merged[index] = (_key_path(key, surviving[-1][1]), surviving)
209
+ continue
210
+
211
+ indexes[key.key] = len(merged)
212
+ merged.append((path, entries))
213
+ return merged
214
+
215
+
216
+ def _splice_super_table(dest: Table, src: Table, comments: list[BodyEntry]) -> None:
217
+ """Move src's body (with its hoisted leading comments) under dest.
218
+
219
+ Descends the shared single-child super-table chain so the splice happens
220
+ at the first level where the chains diverge, keeping each comment
221
+ directly above the concrete header it annotates. Duplicates created at a
222
+ deeper level are folded when that container's segment is sorted.
223
+ """
224
+ while True:
225
+ children = [entry for entry in src.value.body if entry[0] is not None]
226
+ if len(children) != 1:
227
+ break
228
+ child_key, child_item = children[0]
229
+ if not isinstance(child_item, Table) or not child_item.is_super_table():
230
+ break
231
+ existing = None
232
+ for entry_key, entry_item in dest.value.body:
233
+ if entry_key == child_key and isinstance(entry_item, Table):
234
+ existing = entry_item
235
+ break
236
+ if existing is None or not existing.is_super_table():
237
+ break
238
+ dest, src = existing, child_item
239
+ dest.value.body.extend([*comments, *src.value.body])
240
+
241
+
242
+ def _split_before_first_comment(
243
+ trivia: list[BodyEntry],
244
+ ) -> tuple[list[BodyEntry], list[BodyEntry]]:
245
+ """Split a trivia run at its first comment, keeping the interleaved order."""
246
+ first_comment = next(
247
+ (i for i, (_, item) in enumerate(trivia) if not isinstance(item, Whitespace)),
248
+ len(trivia),
249
+ )
250
+ return trivia[:first_comment], trivia[first_comment:]
251
+
252
+
253
+ def _hoist_header_comments(entries: list[BodyEntry]) -> list[BodyEntry]:
254
+ """Splice each table's trailing comment run in front of the next sibling.
255
+
256
+ A standalone comment written directly above a ``[table]`` or ``[[aot]]``
257
+ header is parsed into the tail of the previous declaration's body, so
258
+ reordering siblings would strand it. Hoisting it to the segment level lets
259
+ it travel as the next declaration's leading-comment group. The last
260
+ declaration keeps its tail; an AoT's tail lives in its last element.
261
+ """
262
+ table_indexes = [
263
+ index
264
+ for index, (key, item) in enumerate(entries)
265
+ if isinstance(item, Table | AoT) and (key is None or not key.is_dotted())
266
+ ]
267
+ if not table_indexes:
268
+ return entries
269
+
270
+ hoisted: list[BodyEntry] = []
271
+ for index, entry in enumerate(entries):
272
+ hoisted.append(entry)
273
+ if index in table_indexes[:-1]:
274
+ match entry[1]:
275
+ case Table() as table:
276
+ hoisted.extend(_pop_trailing_comment_run(table.value))
277
+ case AoT() as aot if aot.body:
278
+ hoisted.extend(_pop_trailing_comment_run(aot.body[-1].value))
279
+ case _:
280
+ pass
281
+ return hoisted
282
+
283
+
284
+ def _restore_comment_attachment(entries: list[BodyEntry]) -> list[BodyEntry]:
285
+ """Re-nest container-level comment runs the way a reparse would attach them.
286
+
287
+ tomlkit renders a super table's implicit header as soon as a comment
288
+ entry sits directly in its body, so a comment left at container level by
289
+ hoisting or splicing would materialize an ``[a]`` line on the next pass.
290
+ Sinking each trivia run that follows a table into that table's deepest
291
+ tail, and lifting a comment run that leads a super table's body up to the
292
+ parent, reproduces the shape the parser builds — keeping repeated sorts
293
+ byte-identical.
294
+ """
295
+ result: list[BodyEntry] = []
296
+ target: Container | None = None
297
+
298
+ for entry in entries:
299
+ key, item = entry
300
+ if key is None and target is not None:
301
+ target.body.append(entry)
302
+ continue
303
+ if isinstance(item, Table) and item.is_super_table():
304
+ body = item.value.body
305
+ end = 0
306
+ while end < len(body) and body[end][0] is None:
307
+ end += 1
308
+ whitespace, comments = _split_before_first_comment(body[:end])
309
+ if comments:
310
+ del body[len(whitespace) : end]
311
+ if target is not None:
312
+ target.body.extend(comments)
313
+ else:
314
+ result.extend(comments)
315
+ result.append(entry)
316
+ match item:
317
+ case Table() if key is None or not key.is_dotted():
318
+ target = _trailing_container(item.value)
319
+ case AoT() if item.body:
320
+ target = _trailing_container(item.body[-1].value)
321
+ case _:
322
+ target = None
323
+ return result
324
+
325
+
326
+ def _trailing_container(container: Container) -> Container | None:
327
+ """Return the deepest container a reparse would attach trailing trivia to."""
328
+ while container.body:
329
+ key, item = container.body[-1]
330
+ match item:
331
+ case Table() if key is None or not key.is_dotted():
332
+ container = item.value
333
+ case AoT():
334
+ if not item.body:
335
+ return None
336
+ container = item.body[-1].value
337
+ case _:
338
+ break
339
+ return container
340
+
341
+
342
+ def _pop_trailing_comment_run(container: Container) -> list[BodyEntry]:
343
+ """Remove and return the trailing comment run of the deepest last body.
344
+
345
+ The run starts at the first comment of the body's trailing trivia; pure
346
+ whitespace before it stays at the table boundary, while whitespace
347
+ interleaved with the comments moves along in its original order.
348
+ """
349
+ target = _trailing_container(container)
350
+ if target is None:
351
+ return []
352
+
353
+ body = target.body
354
+ start = len(body)
355
+ for index in range(len(body) - 1, -1, -1):
356
+ key, item = body[index]
357
+ if key is not None or not isinstance(item, Comment | Whitespace):
358
+ break
359
+ if isinstance(item, Comment):
360
+ start = index
361
+
362
+ run = body[start:]
363
+ del body[start:]
364
+ return run
365
+
366
+
367
+ def _key_path(key: Key, item: Item) -> tuple[str, ...]:
368
+ """Return a key's logical segments, expanding dotted keys and super tables.
369
+
370
+ ``[a.y]`` at root parses as a super-table entry keyed ``a``; two such
371
+ siblings would otherwise compare equal, keep their original order, and
372
+ only sort after a reparse merges them — breaking idempotence. Walking
373
+ single-child super tables recovers the effective header path instead.
374
+ """
375
+ path = [key.key]
376
+ while isinstance(item, Table) and item.is_super_table():
377
+ children = [
378
+ (child_key, child_item)
379
+ for child_key, child_item in item.value.body
380
+ if child_key is not None
381
+ ]
382
+ if len(children) != 1:
383
+ break
384
+ child_key, item = children[0]
385
+ path.append(child_key.key)
386
+ return tuple(path)
387
+
388
+
389
+ def _path_sort_key(
390
+ path: tuple[str, ...], order: OrderMode
391
+ ) -> tuple[tuple[SortKey, ...], tuple[str, ...]]:
392
+ """Compare normalized segments first; raw spelling breaks full-path ties only.
393
+
394
+ Folding the raw key into each segment would let a case-only difference in
395
+ an early segment (``A`` vs ``a``) decide the order before later segments
396
+ are compared, misplacing ``A.z`` after ``a.a``.
397
+ """
398
+ return tuple(_sort_key(segment, order) for segment in path), path
399
+
400
+
401
+ def _sort_key(key: str, order: OrderMode) -> SortKey:
402
+ """Produce a comparable key from a TOML key's parsed logical value."""
403
+ match order:
404
+ case OrderMode.NATURAL:
405
+ return _natural_key(key)
406
+ case OrderMode.ALPHA:
407
+ return ((0, key.casefold()),)
408
+
409
+
410
+ def _natural_key(key: str) -> SortKey:
411
+ """Return case-insensitive text and numeric runs in natural comparison order."""
412
+ parts: list[tuple[int, str | tuple[int, str]]] = []
413
+
414
+ for part in _NATURAL_PARTS.split(key):
415
+ if part.isdecimal():
416
+ # (digit count, digits) compares numerically without materializing
417
+ # an int, which would raise past sys.get_int_max_str_digits().
418
+ digits = part.lstrip("0")
419
+ parts.append((1, (len(digits), digits)))
420
+ else:
421
+ parts.append((0, part.casefold()))
422
+
423
+ return tuple(parts)