Skip to content

feat: sort object properties and array elements - #15

Merged
dsherret merged 4 commits into
mainfrom
feat/sorting
Sep 12, 2026
Merged

dsherret merged 4 commits into
mainfrom
feat/sorting

Conversation

@dsherret

@dsherret dsherret commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Exposes the CST sorting from dprint/jsonc-parser#88, released as 0.33.2. Sorting moves what was written with a member along with it — the comments and blank lines above it, and a comment written after it on the same line — which is the thing you can't get by reading the document out and writing it back.

const root = parse(`{
  // the version to publish
  "version": "1.0.0",
  "name": "example"
}`);

root.asObjectOrThrow().sortProperties();

root.toString();
// {
//   "name": "example",
//   // the version to publish
//   "version": "1.0.0"
// }

API

sortProperties(compare?: PropertyComparator, options?: SortOptions<ObjectProp>): void
sortElements(compare?: ElementComparator, options?: SortOptions<Node>): void

interface SortOptions<TMember> {
  pinCommentHeaders?: boolean | ((member: TMember, comments: Node[]) => number);
  withinGroups?: boolean;
}

Properties sort by name and elements by their text when no comparator is given. The comparator is shaped like the callback Array.prototype.sort takes, so a conventional field order reads the way you'd expect:

const order = ["name", "version", "description", "dependencies"];

root.asObjectOrThrow().sortProperties((a, b) =>
  order.indexOf(a.decodedName() ?? "") - order.indexOf(b.decodedName() ?? "")
);

Both sorts are stable, and both keep whichever of a trailing comma or none the container was written with.

Deciding what travels

A comment above a member travels with it, which is right for one describing it and wrong for one heading a group. The options say which is which:

  • pinCommentHeaders: true — a comment with a blank line above it stays where it was written while members sort past it. The blank line is what tells the two kinds apart, so a comment flush against its member still travels.
  • pinCommentHeaders: fn — handed a member and the comments above it, returns how many of them, counting from the top, stay put. For a block that is partly a heading and partly a note about the member beneath it, which a boolean can't express.
  • withinGroups: true — each run of members between blank lines sorts on its own and no member crosses one. A blank line and whatever is under it is the boundary, and boundaries don't move.

pinCommentHeaders pins the comment but members still sort past the blank line; withinGroups is the one that stops them. The README covers the distinction.

Comparator errors, and comparators JS actually writes

The comparing runs to completion before anything moves, and the resulting order is then applied as a key. A comparator that throws therefore leaves the container exactly as it was and the error is rethrown — rather than half sorted, which is what Array.prototype.sort would leave you.

Two things a first cut got wrong here, both now covered by tests:

  • A contradictory comparator trapped the wasm module. (a, b) => a.name > b.name is a normal way to write one in JS, and Rust's sort answers that by panicking — through wasm, RuntimeError: unreachable, with the whole module down. The order is now merged by hand, so such a comparator gets an unspecified-but-safe order, the same as JS gives.
  • A comparator returning a boolean silently did nothing, because only real numbers were accepted where JS applies ToNumber. Read the same way now, so true is 1.

Accessors that came along

Each is something a comparator or pin rule needs and none existed:

  • ObjectProp.decodedName() — the name to sort or look up by, with escapes resolved.
  • ObjectProp.toString() — without it a property comparator can't see the text of what it's comparing.
  • Node.toString() — RootNode had this but Node didn't, so the text the default element sort compares was unreachable from a comparator. My first sortElements test silently did nothing because a.toString() was [object Object].
  • hasBlankLineBefore() on both — a pinCommentHeaders function can't tell the two kinds of comment apart without it.

SortOptions takes the member type as a parameter because a property sort hands the rule an ObjectProp and an element sort hands it a Node; before that, reading decodedName() off the member was a type error against a working runtime.

Passing options where the comparator goes now says so, instead of failing with arg0.call is not a function.

Testing

176 tests pass, deno fmt, deno lint and wasm32 cargo clippy are clean. New cases cover: default and comparator sorts for both containers, comments travelling with their member, stability with duplicate names, trailing-comma style preserved, nested containers untouched, a conventional field order, all three pinCommentHeaders forms, withinGroups on objects and arrays, the two combined, a throwing comparator leaving the container byte-identical, a contradictory comparator, a boolean-returning comparator, and the options-in-the-comparator-slot guard.

The three cargo fmt diffs in rs_lib/src/lib.rs are pre-existing on main and untouched.

Exposes the CST sorting added in dprint/jsonc-parser#88, which moves what
was written with a member along with it: the comments and blank lines
above it, and a comment written after it on the same line. Commas are
moved to suit the new order.

    root.asObjectOrThrow().sortProperties();
    root.asArrayOrThrow().sortElements();

Both take an optional comparator, shaped like the callback
Array.prototype.sort takes. Properties are sorted by name and elements by
their text when it is omitted.

The comparing runs before anything is moved, so a comparator that throws
leaves the container exactly as it was and the error is rethrown, and the
order it produced is applied as a key rather than by comparing again,
which keeps the sort itself consistent whatever the comparator answered.

Two accessors came along because a comparator needs them: decodedName on
ObjectProp, the name to sort by with its escapes resolved, and toString on
Node, which is what the default element sort compares and was otherwise
unreachable.

jsonc-parser is patched to a sibling checkout until a release carries the
sorting; the patch in Cargo.toml comes back out then.
Follows the jsonc-parser API through: sortProperties and sortElements take
an options object alongside the comparator.

  pinCommentHeaders?: boolean | ((member, comments) => number)
  withinGroups?: boolean

true pins every comment that has a blank line above it, which reads as a
heading for the members beneath rather than as a description of the first
of them. A function is asked per member and handed the comments written
above it, returning how many of them stay where they are, which covers a
block that is partly a heading and partly a note about the member beneath.

withinGroups sorts each run of members between blank lines on its own, so
that no member crosses one.

hasBlankLineBefore comes along on ObjectProp and Node, since that is what
a pinCommentHeaders function needs to tell the two kinds of comment apart.
A comparator that contradicts itself is ordinary in JavaScript -- writing
`(a, b) => a.name > b.name` is a common way to get one -- and the order
was worked out with Rust's sort, which answers that by panicking. Through
wasm that is a trap with no message that takes the whole module down.
Merging by hand instead leaves such a comparator with an order nobody
promised anything about, the same as Array.prototype.sort does.

The comparator's answer is also read the way Array.prototype.sort reads
it, through ToNumber rather than only accepting a number, so the `>`
comparator above sorts instead of silently doing nothing.

A pin rule is handed an ObjectProp when sorting properties and a Node when
sorting elements, so SortOptions takes the member type as a parameter and
each sort declares which it passes. Before this, reading `decodedName()`
off the member was a type error against a working runtime.

ObjectProp gains toString, without which a property comparator cannot see
the text of what it is comparing.

Passing options where the comparator goes now says so, rather than failing
with `arg0.call is not a function`.
Drops the patch that pointed at a sibling checkout while the CST sorting
was unreleased, and puts the registry source and checksum back in the lock
file.
@dsherret
dsherret merged commit 8d8947d into main Sep 12, 2026
2 checks passed
@dsherret
dsherret deleted the feat/sorting branch September 12, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant