✨ feat: replace tombi with an in-repo TOML model - #448
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
gaborbernat
marked this pull request as draft
August 29, 2026 04:39
The formatters read TOML through tombi, whose 1.5 release removed the mutable tree they were built on. Replace it with `toml-doc`, a lossless document model of this repository's own: `toml_parser` reads the grammar, and the model holds every byte a file wrote, so a pass can move an entry without touching the whitespace, comments and quoting around it. The model holds its own invariants, so a pass cannot write a document no reader accepts. Rebuild `common` on that model as a set of passes (sections, nesting, layout, strings, arrays, disabled keys, spacing, PEP 508 and column widths), and port both formatters onto them. A new `tox-rules` crate holds the tox policy the two share. The Python wrappers now read the settings a file writes with the same parser that reads the file, and check each one against the flag that reads it. The port fixes thirteen defects the released formatters reproduce, from a version specifier written with a space to a `set_env` table that has to keep the order tox reads it in. It also removes the quadratic walks the old tree forced: 800 tox environments format in 0.12 s rather than 18.6 s.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tombi 1.5 deleted the mutable red-green tree this formatter was built on.
tombi-syntaxis gone,clone_for_updateandsplice_childrenno longer exist, and the newSyntaxNodeexposes little pastkind,spanandtext. Staying on 1.4.0 meant tracking a tree nobody else uses; upgrading meant rewriting the engine anyway. So this PR writes the engine and drops tombi.What replaces it
toml-docis a new crate: a format-preserving TOML document model with a mutation API. It parses withtoml_parser, the lossless lexer and push parser behindtoml_edit, which tracks TOML 1.1.0 and carriesforbid(unsafe_code). The model above that event stream is ours.Comments and blank lines lead the item below them, so
document.sections.reverse()carries each header's comments along. A member owns the comma that follows it and the comment that closes its line, so a reorder leaves the separators alone and no comment lands on someone else's line. Unchanged text borrows from the source, so parsing allocates for structure alone.commonnow sits on that model. Its passes arelayout,sections,arrays,strings,nesting,spacing,disabled,settings,shapeandbuild, each a walk that sets fields the document already carries. Nothing clones a tree to mutate it, re-parses between passes, or countsLINE_BREAKtokens to work out which entry a comment belonged to.common/src/{array,table,string,create,util,format_options}.rsare gone, the two formatters' ~40 tool modules now takefix(document: &mut Document<'_>), and a newtox-rulescrate holds the tox policy both of them share.The model holds its own invariants rather than trusting callers.
Wstakes spaces and tabs,Commenttakes text that opens with#and stays on one line, and a key segment takes a name rather than any token a value could carry. A pass that goes through the model's own editors cannot write a document no reader accepts.Bug fixes
Each one reproduces against pyproject-fmt 2.28.2 and tox-toml-fmt 1.9.3 from PyPI.
1. A requirement written with a space after its operator read as no requirement at all.
>= 3.12did not parse as a version bound, so the generated classifiers fell back to the configured floor and ceiling.pypa/build, whose file saysrequires-python = ">= 3.10", got a 3.9 classifier it does not support.2. The classifier window worked at series precision, so a bound below a patch release lost a whole series.
3.10.0 satisfies
<3.10.1, so 3.10 belongs. Nothing pins the interpreter to Python 3 either, so3 :: Onlygoes.3. A literal string carried no sort key, so it held its place while the same value in double quotes sorted.
4. A comment written before a member's comma moved to a line of its own, leaving the comma stranded below it. The comma is what says which member a comment belongs to: one written before it closes that member's line, one written after it leads the next member. Both travel with the member they belong to, and neither did before.
Before, the comment took a line of its own and the comma that ends
"b"took another:After:
5. Writing
classifiersas a string and asking for generated classifiers replaced the string with an array. The key holds text, not a list, so there is nothing to add to; the value stays as the file wrote it.6. Folding sub-tables into their parent depended on the order the file wrote them in.
8. A dependency group sorted its
include-groupentries away from the requirements around them. Aninclude-grouppulls its group in where it is written, so moving it changes what the group resolves to.9. Free-form license text came back rewritten as though it were an SPDX expression. The formatter now rewrites the value only once it parses as an SPDX expression over registered identifiers.
10. A tox
depslist holding a pip option, path or URL sorted anyway. pip reads that list the way it reads a requirements file, where a later--index-urlreplaces the one before it, so the order carries meaning.11. A tox
set_envtable came back alphabetized. tox reads the table in order, so a key written afterfileoverrides what that file said and a key written before it does not.12.
use_develop = truebeside an existingpackagekey dropped theuse_developand kept the other mode. tox readsuse_developfirst and installs an editable package whateverpackagesays.13. An environment whose name the file quoted never matched its
env_listentry.[env."3.14"]is the environmentenv_listnames as"3.14", and comparing the spelled key against the plain name never said so, so its table fell in with the ones the list does not name at all.14. A nested array closed its last member without the comma the outer form writes.
15. The
*catch-all in a setuptools data table was matched against the wrong spelling.*is not a name TOML reads bare, so the file writes it quoted and a rule matching it has to spell it the same way. The catch-all led the table only because a quote happens to sort before a letter.Behaviour this changes on purpose
A string now measures from the start of its key. A long key can be what pushes a value past
column_width. Measuring the value alone left lines running past the column the setting asks for.Deep input comes back as an error rather than a crash. Reading a value, writing it and dropping it each walk it by calling themselves, so
toml-doccaps nesting at 256 and the PEP 508 marker parser caps parenthesis depth at 256. A 12,000-deep value used to end the process.Performance
The rewrite removes the quadratic walks the old tree forced. Both runs use an optimized wheel on the same machine.
Scaling is now close to linear: the tox case runs 5 ms / 7 ms / 33 ms / 120 ms at 100 / 200 / 400 / 800 environments.
Verification
1,214 Rust tests, run with
cargo nextestat 100% line and region coverage on each oftoml-doc,common,tox-rules,pyproject-fmtandtox-toml-fmt, measured the way each CI job measures it: every crate proves its own, rather than leaning on the tests of a crate that ships it. Every test lives besidesrcrather than inside it and reaches the crate the way a caller does; the two formatters build anrlibbeside thecdylibthe wheel needs so their tests can link against them. 94 Python tests fortoml-fmt-common, 75 for pyproject-fmt, 54 for tox-toml-fmt.toml-docround-trips the 268 valid cases of toml-test and every TOML file in this repository byte for byte, and rejects every UTF-8 case TOML 1.1.0 calls invalid, with a floor incompliance.rsto keep that from slipping.Every change formats the 38 files behind the projects at https://bernat.tech/oss/ and the 268 valid toml-test files, and compares both against a recorded baseline. Against 2.28.2 and 1.9.3, 27 of the 38 come out byte for byte the same; the other 11 carry the changes above and nothing else.
Running toml-test caught two defects on the way in. A lone
\rcame back out as\r\n, anda =with no value parsed clean, becausetoml_parservalidates when a value decodes rather than when it parses. Decoding each key, scalar and trivia run at parse time fixes both.