From 0ed90eb431cb49e38977491d1739cc4b5050de5d Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 20:37:22 +0100 Subject: [PATCH 1/2] Collapse dl's indent=2 JSON formatter onto core's `dl --ls --json` and `metadata.json` are both `json.dumps(..., indent=2)` documents pinned against what the Python build wrote, and each had a hundred-line formatter of its own: the same nine layout methods forwarded to serde's `PrettyFormatter`, and the same `ensure_ascii` loop. Nothing held the two equal, and the last time that mattered the escaping gate was wrong about DEL in both and had to be closed twice. The indented spelling now lives in core's `json` module beside the compact one, exported as `as_python_writes_it_indented`, and the renderer calls it. The metadata store keeps going through the formatter directly, because its document is a struct and a `Value` would put its field order at the mercy of the map. Byte-identity is the bar, since `wf` parses `--ls --json`. Every pin on that document is the same assertion against the same call it was written against, with the formatter behind it deleted rather than edited. --- CHANGELOG.md | 19 ++ rust/devlaunch-core/public-api.rest.txt | 1 + rust/devlaunch-core/src/domain/metadata.rs | 132 +++---------- rust/devlaunch-core/src/json.rs | 212 +++++++++++++++++++-- rust/devlaunch-core/src/lib.rs | 10 +- rust/dl/src/render.rs | 172 +++-------------- 6 files changed, 279 insertions(+), 267 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38526318..3119c8dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 for every ASCII character at once, which says the same thing in the other direction too: nothing in `' '..'~'` is escaped that Python leaves bare. +### Changed + +- **One writer spells `dl --ls --json` and `metadata.json` now, not two.** Both + are `json.dumps(..., indent=2)` documents that have to agree with the Python + build byte for byte, and each was produced by a hundred-line formatter of its + own: the same nine layout methods forwarded to `serde_json`'s pretty printer, + and the same `ensure_ascii` loop, in two files that had to stay + character-for-character equal without anything holding them there. What that + cost is on the record directly above this entry, since the escaping gate was + wrong about DEL in both copies and closing it meant closing it twice. `dl` now + calls core's, and the tree has one indented spelling. + + Nothing about either document moves. The pins on `--ls --json` are the same + assertions against the same call they were written against, with the formatter + behind them deleted rather than edited: the shaped listing, the empty document, + an emoji as its surrogate pair, DEL, and the whole of ASCII against the line + `json.dumps` printed for it. `wf` parses that document, so the bar was + byte-identity and not equivalence. + ## [0.25.0] - 2026-08-28 ### Fixed diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index d67e4328..0e4468a9 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -2629,6 +2629,7 @@ impl core::fmt::Debug for devlaunch_core::json::JsonKind pub fn devlaunch_core::json::JsonKind::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::json::JsonKind impl core::marker::StructuralPartialEq for devlaunch_core::json::JsonKind +pub fn devlaunch_core::json::as_python_writes_it_indented(&serde_json::value::Value) -> alloc::string::String pub mod devlaunch_core::notices pub trait devlaunch_core::notices::Notices pub fn devlaunch_core::notices::Notices::say(&mut self, T) diff --git a/rust/devlaunch-core/src/domain/metadata.rs b/rust/devlaunch-core/src/domain/metadata.rs index 9f83dd8e..5d2ed3d0 100644 --- a/rust/devlaunch-core/src/domain/metadata.rs +++ b/rust/devlaunch-core/src/domain/metadata.rs @@ -1083,10 +1083,20 @@ fn resolve_link(path: &Path) -> PathBuf { } /// Serialize `document` the way Python's `json.dump(..., indent=2)` does. +/// +/// The formatter is the crate's one indented spelling, which lives down in +/// `json` beside the compact one rather than here, because `dl --ls --json` +/// needs the same bytes and used to get them from a second copy (#346). A +/// `Document` is a struct rather than a [`serde_json::Value`], so it goes +/// through the formatter directly: routing it through a `Value` first would put +/// its field order at the mercy of the map implementation, and the field order +/// is part of what this file is pinned on. fn encode(document: &Document<'_>) -> Result, MetadataError> { let mut bytes = Vec::new(); - let mut serializer = - serde_json::Serializer::with_formatter(&mut bytes, PythonJsonFormatter::default()); + let mut serializer = serde_json::Serializer::with_formatter( + &mut bytes, + crate::json::PythonPrettyFormatter::default(), + ); document .serialize(&mut serializer) .map_err(|error| MetadataError::Encode { @@ -1095,100 +1105,6 @@ fn encode(document: &Document<'_>) -> Result, MetadataError> { Ok(bytes) } -/// `json.dump(..., indent=2)`, escaping included. -/// -/// Two-space indentation is what serde's pretty printer already does; what it -/// does not do is Python's `ensure_ascii`, which spells every character outside -/// `' '..'~'` as a `\uXXXX` escape — the printable range, not "non-ASCII", since -/// DEL is ASCII and Python escapes it too. A branch name with an umlaut in it is -/// enough to make the two builds write different bytes for the same data, so the -/// escaping is matched rather than left to chance. -/// -/// The layout is what differs from the compact [`crate::json::PythonFormatter`] — -/// this document is indented and that one is on one line — so the escaping is -/// the crate's one copy of it rather than a second loop here that had to stay -/// character-for-character equal to survive. (The compact formatter also spells -/// floats Python's way; this one does not, which no `Document` can reach today -/// because its numbers are all `i64`.) -#[derive(Default)] -struct PythonJsonFormatter<'indent> { - pretty: serde_json::ser::PrettyFormatter<'indent>, -} - -impl serde_json::ser::Formatter for PythonJsonFormatter<'_> { - fn write_string_fragment(&mut self, writer: &mut W, fragment: &str) -> io::Result<()> - where - W: ?Sized + io::Write, - { - crate::json::write_ensure_ascii(writer, fragment) - } - - // The rest is the pretty printer's layout, delegated unchanged. - - fn begin_array(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_array(writer) - } - - fn end_array(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_array(writer) - } - - fn begin_array_value(&mut self, writer: &mut W, first: bool) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_array_value(writer, first) - } - - fn end_array_value(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_array_value(writer) - } - - fn begin_object(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_object(writer) - } - - fn end_object(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_object(writer) - } - - fn begin_object_key(&mut self, writer: &mut W, first: bool) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_object_key(writer, first) - } - - fn begin_object_value(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_object_value(writer) - } - - fn end_object_value(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_object_value(writer) - } -} - #[cfg(test)] mod tests { //! `test/test_worktree_storage.py`, re-pinned. @@ -1699,8 +1615,10 @@ mod tests { #[test] fn the_indent_two_document_escapes_an_astral_character_as_the_pair() { let mut bytes = Vec::new(); - let mut serializer = - serde_json::Serializer::with_formatter(&mut bytes, PythonJsonFormatter::default()); + let mut serializer = serde_json::Serializer::with_formatter( + &mut bytes, + crate::json::PythonPrettyFormatter::default(), + ); json!({ "branch": "feature/br\u{fc}nch", "tags": ["\u{1f680}", "plain"] }) .serialize(&mut serializer) .expect("a Vec never fails to write"); @@ -1731,8 +1649,10 @@ mod tests { #[test] fn the_indent_two_document_spells_the_serde_escapes_pythons_way() { let mut bytes = Vec::new(); - let mut serializer = - serde_json::Serializer::with_formatter(&mut bytes, PythonJsonFormatter::default()); + let mut serializer = serde_json::Serializer::with_formatter( + &mut bytes, + crate::json::PythonPrettyFormatter::default(), + ); json!({ "branch": "a\"b\\c\nd\te\rf\u{8}g\u{c}h", "tags": ["\u{0}\u{1}\u{1f}", "/slash/", "\u{2028}\u{2029}"], @@ -1769,8 +1689,10 @@ mod tests { #[test] fn the_indent_two_document_escapes_del_the_way_python_does() { let mut bytes = Vec::new(); - let mut serializer = - serde_json::Serializer::with_formatter(&mut bytes, PythonJsonFormatter::default()); + let mut serializer = serde_json::Serializer::with_formatter( + &mut bytes, + crate::json::PythonPrettyFormatter::default(), + ); json!({ "branch": "a\u{7f}b", "tags": ["\u{7f}"] }) .serialize(&mut serializer) .expect("a Vec never fails to write"); @@ -1800,8 +1722,10 @@ mod tests { fn the_indent_two_document_spells_every_ascii_character_pythons_way() { let all_of_ascii: String = (0u8..=0x7f).map(char::from).collect(); let mut bytes = Vec::new(); - let mut serializer = - serde_json::Serializer::with_formatter(&mut bytes, PythonJsonFormatter::default()); + let mut serializer = serde_json::Serializer::with_formatter( + &mut bytes, + crate::json::PythonPrettyFormatter::default(), + ); json!({ "branch": all_of_ascii }) .serialize(&mut serializer) .expect("a Vec never fails to write"); diff --git a/rust/devlaunch-core/src/json.rs b/rust/devlaunch-core/src/json.rs index 6a423c54..035be069 100644 --- a/rust/devlaunch-core/src/json.rs +++ b/rust/devlaunch-core/src/json.rs @@ -7,9 +7,10 @@ //! than a style choice. One copy of it in this crate, at the bottom of it, //! because a second copy is how one of those documents drifts: the timing //! document was written with `serde_json::to_string` and lost the spacing while -//! its own docstring promised byte-comparability. `dl --ls --json` is spelled by -//! `dl`'s own formatter and still carries a copy of the escaping; devlaunch#346 -//! collapses it onto this one. +//! its own docstring promised byte-comparability. `dl --ls --json` was the last +//! of them spelled somewhere else, by a formatter of `dl`'s own; devlaunch#346 +//! collapsed that onto [`as_python_writes_it_indented`], so the enumeration above +//! is now the whole of what this module spells and there is nowhere else to look. //! //! Below the four layers on purpose: `timing` is the crate root's own module and //! `flows` sits at the top, so a shared helper either lives here or gets reached @@ -140,21 +141,147 @@ impl serde_json::ser::Formatter for PythonFormatter { } } -/// One unescaped run of a JSON string, with Python's `ensure_ascii` applied. +/// A JSON value spelled the way `json.dumps(value, indent=2)` spells it. +/// +/// The indented half of the contract [`as_python_writes_it`] carries for the +/// compact one, and two documents want it. The metadata store writes +/// `metadata.json` with it, through the formatter below rather than through here, +/// because its document is a struct and routing a struct through a +/// [`serde_json::Value`] would put its field order at the mercy of the map. And +/// `dl` writes `dl --ls --json` with it, which is a wire format `wf` parses +/// rather than a rendering choice, so a byte of it is not `dl`'s to change. +/// +/// `dl` used to spell that document with a formatter of its own: the third copy +/// of the escaping, and a second copy of the layout delegation with it, which had +/// to stay character-for-character equal to this one for the two documents to go +/// on agreeing with the same Python. It is this function now (devlaunch#346). +/// +/// Indented and compact stay two spellings, because that difference is real: this +/// document is laid out over lines and that one is on one line. The escaping and +/// the delegation underneath them are what may not stay two. +pub fn as_python_writes_it_indented(value: &serde_json::Value) -> String { + let mut out = Vec::new(); + let mut serializer = + serde_json::Serializer::with_formatter(&mut out, PythonPrettyFormatter::default()); + if value.serialize(&mut serializer).is_err() { + // Not reachable from a `serde_json::Value`, and an empty document is the + // one answer that cannot be mistaken for a listing. + return String::new(); + } + String::from_utf8(out).unwrap_or_default() +} + +/// `json.dump(..., indent=2)`, escaping included. +/// +/// Two-space indentation is what serde's pretty printer already does, and it puts +/// the `": "` after a key where Python puts it, so the whole of the layout is +/// delegated to it unchanged. What it does not do is Python's `ensure_ascii`, +/// which spells every character outside `' '..'~'` as a `\uXXXX` escape. That is +/// the printable range and not "non-ASCII": DEL is ASCII and Python escapes it +/// too. A branch name with an umlaut in it is enough to make two builds write +/// different bytes for the same data, so the escaping is matched rather than left +/// to chance, and it is matched by calling [`write_ensure_ascii`] rather than by +/// a loop here. /// -/// The escaping half of the spelling, and the copy this crate keeps: the pretty -/// formatter the metadata store writes `metadata.json` with needs exactly this -/// and differs from [`PythonFormatter`] in layout, so it calls here too. A -/// second copy is the drift this module's docstring is about, and it had one — -/// two hand-written loops that had to stay character-for-character equal for the -/// two documents to keep agreeing with the same Python. +/// Only the layout differs from the compact [`PythonFormatter`] — this document +/// is indented and that one is on one line. (The compact formatter also spells +/// floats Python's way and this one does not, which no document that reaches here +/// can tell: the metadata store's numbers are all `i64`, and the listing's +/// `disk` is one too.) +#[derive(Default)] +pub(crate) struct PythonPrettyFormatter<'indent> { + pretty: serde_json::ser::PrettyFormatter<'indent>, +} + +impl serde_json::ser::Formatter for PythonPrettyFormatter<'_> { + fn write_string_fragment(&mut self, writer: &mut W, fragment: &str) -> io::Result<()> + where + W: ?Sized + io::Write, + { + write_ensure_ascii(writer, fragment) + } + + // The rest is the pretty printer's layout, delegated unchanged. The nine + // methods below are exactly the nine `PrettyFormatter` overrides; a tenth + // delegation `dl`'s deleted copy carried, for `end_object_key`, forwarded the + // trait's own no-op to a `PrettyFormatter` that does not override it either, + // which is why the two copies wrote identical bytes despite disagreeing on + // how many methods the job takes. + + fn begin_array(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.begin_array(writer) + } + + fn end_array(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.end_array(writer) + } + + fn begin_array_value(&mut self, writer: &mut W, first: bool) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.begin_array_value(writer, first) + } + + fn end_array_value(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.end_array_value(writer) + } + + fn begin_object(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.begin_object(writer) + } + + fn end_object(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.end_object(writer) + } + + fn begin_object_key(&mut self, writer: &mut W, first: bool) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.begin_object_key(writer, first) + } + + fn begin_object_value(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.begin_object_value(writer) + } + + fn end_object_value(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + self.pretty.end_object_value(writer) + } +} + +/// One unescaped run of a JSON string, with Python's `ensure_ascii` applied. /// -/// **A third copy is still live**, in `dl`'s own pretty formatter for -/// `dl --ls --json` (`dl/src/render.rs`). Collapsing it onto this one needs a new -/// `pub` item on this crate, which moves a public-API snapshot, so it waits on -/// devlaunch#346 — named here rather than left for the next reader to discover, -/// because a docstring that claims one copy while a second is live is the very -/// drift this module's own docstring holds up as the cautionary case. +/// The escaping half of the spelling, and now the only copy of it anywhere in +/// either binary: [`PythonFormatter`] and [`PythonPrettyFormatter`] differ in +/// layout and call here for the rest, and there is no third formatter left to +/// call anything. There were three hand-written loops once, each having to stay +/// character-for-character equal to the others with nothing holding them there, +/// and the bill came in exactly as you would expect: the gate was wrong about DEL +/// in every copy that was still standing, so devlaunch#349 fixed the same +/// character twice in two files. devlaunch#346 retired the second copy. /// /// Anything Python does not write as itself becomes `\uXXXX` in lowercase hex, /// and a character outside the basic plane becomes the two escapes of its UTF-16 @@ -413,6 +540,59 @@ mod tests { ); } + /// The indented spelling's layout, at the shape `dl --ls --json` writes: a + /// list of objects, carrying the three value types the listing puts in one. + /// + /// Expectation is the literal `json.dumps` printed for the same data with + /// `indent=2`. Layout is the half of this spelling that is not escaping, and + /// it is the half a delegating formatter gets wrong quietly: serde's pretty + /// printer agrees with Python on the two-space indent and on the `": "` after + /// a key, and the pin is what holds it to going on agreeing. + #[test] + fn an_indented_document_is_laid_out_the_way_json_dumps_lays_it_out() { + let document = serde_json::json!([{ "id": "ws", "devlaunch": true, "unsaved": null }]); + assert_eq!( + as_python_writes_it_indented(&document), + "[\n {\n \"id\": \"ws\",\n \"devlaunch\": true,\n \"unsaved\": null\n }\n]" + ); + // Python indents nothing it does not have to: an empty list is two + // characters under `indent=2`, not two characters and a newline. + assert_eq!(as_python_writes_it_indented(&serde_json::json!([])), "[]"); + } + + /// The escaping reaching the indented document through a nest, rather than at + /// a bare string where no layout is ever asked for. + /// + /// Two levels deep is where a formatter that delegates layout to serde and + /// escaping to this module has to hand off correctly in both directions at + /// once. The rocket is astral, so Python writes the two escapes of its UTF-16 + /// surrogate pair rather than one escape. Expectation is the literal + /// `json.dumps` printed for the same data with `indent=2`. + #[test] + fn an_indented_document_escapes_through_the_nesting() { + assert_eq!( + as_python_writes_it_indented( + &serde_json::json!({ "a": [1, "\u{1f680}"], "b": { "c": null } }) + ), + "{\n \"a\": [\n 1,\n \"\\ud83d\\ude80\"\n ],\n \"b\": {\n \"c\": null\n }\n}" + ); + } + + /// DEL at the indented document. + /// + /// The one non-printable ASCII character serde hands to the fragment writer + /// rather than escaping from its own table, so it is the character that says + /// whether the indented spelling reaches this module's escaping or carries a + /// copy that spelled the gate `is_ascii()`. Expectation from + /// `json.dumps(..., indent=2)`. + #[test] + fn an_indented_document_escapes_del_the_way_json_dumps_does() { + assert_eq!( + as_python_writes_it_indented(&serde_json::json!("a\u{7f}b")), + "\"a\\u007fb\"" + ); + } + #[test] fn integers_are_untouched() { // `disk` in `dl --ls --json` is an integer, and Python writes it bare. diff --git a/rust/devlaunch-core/src/lib.rs b/rust/devlaunch-core/src/lib.rs index a901066d..42592d28 100644 --- a/rust/devlaunch-core/src/lib.rs +++ b/rust/devlaunch-core/src/lib.rs @@ -94,9 +94,13 @@ pub mod osext; // Leaf like `runner`: the env-gated span registry everything above may use // (even `locks` spans a contended wait), depending on nothing itself. // -// `json` is pub only for [`json::JsonKind`], which the typed refusals above -// carry and the `dl` binary renders; the Python-spelling writers stay -// `pub(crate)`. +// `json` is pub for [`json::JsonKind`], which the typed refusals above carry and +// the `dl` binary renders, and for one writer: `as_python_writes_it_indented`, +// which spells `dl --ls --json`. That document is a wire format `wf` parses, and +// the binary spelled it with a formatter of its own until the two copies were +// collapsed onto this one (#346); the rest of the Python-spelling writers stay +// `pub(crate)`, because nothing outside the crate composes a document a piece at +// a time. // // binary surface — not part of the frozen wf API (#251 §7) pub mod json; diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index c0318ebb..ef2ea600 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -50,7 +50,6 @@ use devlaunch_core::notices::Notices; use devlaunch_core::shell; use devlaunch_runner::{Exit, OsFailure}; use serde_json::Value; -use serde_json::ser::{Formatter, PrettyFormatter}; use crate::select::Chosen; use crate::session::StartupError; @@ -181,139 +180,21 @@ fn widest<'a>(texts: impl Iterator) -> usize { /// A JSON document spelled the way `json.dumps(value, indent=2)` spells it. /// /// Grade A: `wf` parses `dl --ls --json`, so this is a wire format and not a -/// rendering choice. Two-space indentation, `": "` after a key, and — the part -/// `serde_json` does not do on its own — every character outside `' '..'~'` -/// escaped as `\uXXXX`, which is Python's `ensure_ascii=True`. That range is -/// CPython's, and it is one character narrower than "ASCII": DEL is ASCII and -/// Python escapes it. -pub(crate) fn python_json_document(value: &Value) -> String { - let mut out = Vec::new(); - let mut serializer = serde_json::Serializer::with_formatter(&mut out, PythonPretty::default()); - match serde::Serialize::serialize(value, &mut serializer) { - // A document that cannot be serialized is not reachable from a - // `serde_json::Value`, and an empty string is the one answer that cannot - // be mistaken for a listing. - Err(_) => String::new(), - Ok(()) => String::from_utf8(out).unwrap_or_default(), - } -} - -/// `PrettyFormatter` with `ensure_ascii`. +/// rendering choice, and a byte of it is not this file's to pick. /// -/// Delegates the whole of the indentation to `serde_json`'s own pretty formatter, -/// which lays a document out exactly as Python's `indent=2` does, and overrides -/// only the one thing Python does differently: it escapes every character outside -/// `' '..'~'`, as the surrogate pair for anything outside the basic plane. -#[derive(Default)] -struct PythonPretty { - pretty: PrettyFormatter<'static>, -} - -impl Formatter for PythonPretty { - fn begin_array(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_array(writer) - } - - fn end_array(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_array(writer) - } - - fn begin_array_value(&mut self, writer: &mut W, first: bool) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_array_value(writer, first) - } - - fn end_array_value(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_array_value(writer) - } - - fn begin_object(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_object(writer) - } - - fn end_object(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_object(writer) - } - - fn begin_object_key(&mut self, writer: &mut W, first: bool) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_object_key(writer, first) - } - - fn end_object_key(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_object_key(writer) - } - - fn begin_object_value(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.begin_object_value(writer) - } - - fn end_object_value(&mut self, writer: &mut W) -> io::Result<()> - where - W: ?Sized + io::Write, - { - self.pretty.end_object_value(writer) - } - - /// The run of string bytes `serde_json` did not have to escape, which includes - /// every non-ASCII one — it escapes only the control characters, `"` and `\`. - /// Python escapes the rest too, and this is where that happens. - /// - /// "The rest" is everything outside `' '..'~'`, which is CPython's `S_CHAR` - /// and is one character wider than `is_ascii()`: DEL (`U+007F`) is ASCII and - /// Python still escapes it. This is the third copy of core's - /// `write_ensure_ascii` (devlaunch#346 collapses it), so it carried the same - /// wrong gate and is corrected in step with it. - fn write_string_fragment(&mut self, writer: &mut W, fragment: &str) -> io::Result<()> - where - W: ?Sized + io::Write, - { - // CPython's `S_CHAR`, spelled exactly as core's `python_writes_it_bare` - // spells it, quote and backslash excluded: serde escapes those before a - // fragment is cut, and excluding them is the arm that stays valid JSON if - // it ever stops. - let written_bare = - |character: char| matches!(character, ' '..='~') && !matches!(character, '"' | '\\'); - if fragment.bytes().map(char::from).all(written_bare) { - return writer.write_all(fragment.as_bytes()); - } - let mut units = [0u16; 2]; - for character in fragment.chars() { - if written_bare(character) { - writer.write_all(character.encode_utf8(&mut [0u8; 4]).as_bytes())?; - continue; - } - for unit in character.encode_utf16(&mut units) { - writer.write_all(format!("\\u{unit:04x}").as_bytes())?; - } - } - Ok(()) - } +/// One line, because the spelling is core's. It was a formatter here until +/// devlaunch#346 — a hundred lines forwarding every layout method to +/// `serde_json`'s pretty printer, plus an `ensure_ascii` loop, standing beside a +/// formatter in core doing the same for `metadata.json`. Two copies of one fact, +/// and what they cost was visible before they were merged: the escaping gate was +/// wrong about DEL in both, and closing it meant closing it twice. +/// +/// The name stays here rather than the call moving to core's, because the +/// document's own pins hang off it: the tests below assert the same literals +/// against the same call they asserted against the deleted formatter, so what +/// they now measure is that the collapse changed no byte. +pub(crate) fn python_json_document(value: &Value) -> String { + devlaunch_core::json::as_python_writes_it_indented(value) } // --------------------------------------------------------------------------- @@ -3173,13 +3054,14 @@ mod tests { ); } - /// DEL, the one non-printable ASCII character serde hands to this formatter - /// rather than escaping itself. + /// DEL, the one non-printable ASCII character serde hands to the fragment + /// writer rather than escaping itself. /// - /// `--ls --json` is a wire format `wf` parses, so the third live copy of the - /// escaping (devlaunch#346 collapses it onto core's) carried the same - /// divergence core's did and is closed here alongside it. Expectation from - /// `json.dumps`. + /// `--ls --json` is a wire format `wf` parses, so when this document was + /// spelled by a formatter of its own it carried the same divergence core's + /// did, and it was closed in both at once (devlaunch#349). The assertion has + /// not moved since; what has moved is what stands behind it, and that is the + /// point of leaving it exactly as it was. Expectation from `json.dumps`. #[test] fn del_is_escaped_as_python_escapes_it() { assert_eq!( @@ -3190,11 +3072,13 @@ mod tests { /// The whole ASCII range at once, against the line Python wrote for it. /// - /// This copy of the escaping and core's are supposed to be spelled the same - /// until devlaunch#346 merges them, so it gets core's sweep too: nothing bare - /// that `json.dumps` escapes, nothing escaped that it leaves bare. Expectation - /// is the literal `json.dumps` printed for - /// `''.join(chr(c) for c in range(0x80))`. + /// This sweep went in while `dl` still had an escaping loop of its own, to + /// hold it character-for-character equal to core's: nothing bare that + /// `json.dumps` escapes, nothing escaped that it leaves bare. The loop is gone + /// (devlaunch#346) and the sweep is unchanged, which turns it from a + /// cross-check between two copies into the evidence that retiring one of them + /// moved no byte of a document `wf` parses. Expectation is the literal + /// `json.dumps` printed for `''.join(chr(c) for c in range(0x80))`. #[test] fn every_ascii_character_is_spelled_the_way_python_spells_it() { let all_of_ascii: String = (0u8..=0x7f).map(char::from).collect(); From 5688e388311b0bff4ebdae708f95cc5aad9cd494 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 21:44:01 +0100 Subject: [PATCH 2/2] fix: the float caveat named the wrong types, and the enumeration was short one document The comment on `PythonPrettyFormatter` said the listing's `disk` "is an i64". It is not: `disk` is an object (`{"exclusiveBytes": u64}`, or `{"atLeastBytes", "unreadable"}`) or null, and `unsaved` went unmentioned altogether. The conclusion held -- no float reaches either wire -- but the reason given was not the reason, which is a poor way to end a change whose whole point was retiring a divergence that prose had been holding. So the claim is enforced instead of asserted: `write_f64` forwards to `python_repr`, the same spelling the compact formatter uses. It moves no byte of either document, because neither carries a float -- `metadata.json`'s only bare number is its `version: i64`, and every number in the listing sits inside `disk`. It is there because `as_python_writes_it_indented` is `pub` and takes any `Value`, so the day one does arrive nobody has to have remembered the paragraph. The module docstring enumerated five documents and called that "the whole of what this module spells" while spelling a sixth: `metadata.json`, which this same change had just routed through here. Named now, with a pointer to the list a compiler will give you, since the hand-maintained one is the half that rots -- and an over-promising docstring is this module's own cautionary tale. Adds the full-ASCII sweep at the indented seam, which is the pin the compact formatter and `metadata.json` both already had and this one did not: the per-class tests each pin a class someone thought to name, which is how DEL stayed wrong through three copies. Inside an object rather than at a bare string, so it measures layout and escaping together rather than being the compact sweep under a second name. Escaping `/` fails it and no other indented test. --- rust/devlaunch-core/src/json.rs | 109 +++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 17 deletions(-) diff --git a/rust/devlaunch-core/src/json.rs b/rust/devlaunch-core/src/json.rs index 035be069..1e9115b5 100644 --- a/rust/devlaunch-core/src/json.rs +++ b/rust/devlaunch-core/src/json.rs @@ -1,16 +1,22 @@ //! JSON written the way CPython's `json.dumps` writes it. //! //! Both binaries write documents the cutover checklist compares byte for byte — -//! the `completions.json` cache, `dl --ls --json`, `dl --completion-data`, the -//! `SOURCE` column's rendering of a source dl cannot read, and the -//! `DEVLAUNCH_TIMING=json` line — so the spelling is part of the contract rather -//! than a style choice. One copy of it in this crate, at the bottom of it, -//! because a second copy is how one of those documents drifts: the timing -//! document was written with `serde_json::to_string` and lost the spacing while -//! its own docstring promised byte-comparability. `dl --ls --json` was the last -//! of them spelled somewhere else, by a formatter of `dl`'s own; devlaunch#346 -//! collapsed that onto [`as_python_writes_it_indented`], so the enumeration above -//! is now the whole of what this module spells and there is nowhere else to look. +//! the `metadata.json` store, the `completions.json` cache, `dl --ls --json`, +//! `dl --completion-data`, the `SOURCE` column's rendering of a source dl cannot +//! read, and the `DEVLAUNCH_TIMING=json` line — so the spelling is part of the +//! contract rather than a style choice. One copy of it in this crate, at the +//! bottom of it, because a second copy is how one of those documents drifts: +//! the timing document was written with `serde_json::to_string` and lost the +//! spacing while its own docstring promised byte-comparability. `dl --ls --json` +//! was the last of them spelled somewhere else, by a formatter of `dl`'s own; +//! devlaunch#346 collapsed that onto [`as_python_writes_it_indented`], so all six +//! are now spelled from here and there is nowhere else to look. +//! +//! That list is hand-maintained, so it is the part of this paragraph that rots. +//! The one a compiler will give you is the callers of [`as_python_writes_it`], +//! [`serialize_as_python`], [`as_python_writes_it_indented`] and +//! [`PythonPrettyFormatter`]; a seventh document that reaches none of them is a +//! second spelling, whatever this paragraph has come to say by then. //! //! Below the four layers on purpose: `timing` is the crate root's own module and //! `flows` sits at the top, so a shared helper either lives here or gets reached @@ -184,10 +190,9 @@ pub fn as_python_writes_it_indented(value: &serde_json::Value) -> String { /// a loop here. /// /// Only the layout differs from the compact [`PythonFormatter`] — this document -/// is indented and that one is on one line. (The compact formatter also spells -/// floats Python's way and this one does not, which no document that reaches here -/// can tell: the metadata store's numbers are all `i64`, and the listing's -/// `disk` is one too.) +/// is indented and that one is on one line. The float spelling is the same one, +/// forwarded below, so the two cannot come to disagree about a number the way +/// they came to disagree about DEL. #[derive(Default)] pub(crate) struct PythonPrettyFormatter<'indent> { pretty: serde_json::ser::PrettyFormatter<'indent>, @@ -201,6 +206,28 @@ impl serde_json::ser::Formatter for PythonPrettyFormatter<'_> { write_ensure_ascii(writer, fragment) } + /// Floats, spelled by the same [`python_repr`] the compact formatter uses. + /// + /// Unreachable from either document as they stand, and it moves no byte of + /// them: `metadata.json`'s only bare number is its `version`, an `i64`, and + /// every number in `dl --ls --json` sits inside `disk` — which is an object + /// (`{"exclusiveBytes": u64}`, or `{"atLeastBytes": u64, "unreadable": + /// usize}`) or `null`, and never a number itself. `unsaved` is an object + /// holding a bool or a string, and `lastSweep` holds a token and a string. + /// + /// It is three lines anyway, because the sentence it replaces was a claim + /// about callers held in prose, and prose is precisely what this module's + /// own cautionary tale is about. [`as_python_writes_it_indented`] is `pub` + /// and takes any [`serde_json::Value`], so the day a float does arrive it is + /// spelled Python's way rather than ryu's, and nobody has to have remembered + /// this paragraph. + fn write_f64(&mut self, writer: &mut W, value: f64) -> io::Result<()> + where + W: ?Sized + io::Write, + { + writer.write_all(python_repr(value).as_bytes()) + } + // The rest is the pretty printer's layout, delegated unchanged. The nine // methods below are exactly the nine `PrettyFormatter` overrides; a tenth // delegation `dl`'s deleted copy carried, for `end_object_key`, forwarded the @@ -593,12 +620,60 @@ mod tests { ); } + /// The whole ASCII range at once, at the indented seam. + /// + /// The pin the crate's other two spellings already had and this one did not: + /// the compact formatter is swept by + /// [`every_ascii_character_is_spelled_the_way_json_dumps_spells_it`] and + /// `metadata.json` by `the_indent_two_document_spells_every_ascii_character_pythons_way`, + /// and both exist because the per-class tests each pin a class someone + /// thought to name — which is how DEL stayed wrong through three copies of + /// the escaper. Sweeping closes the gap in both directions at once: nothing + /// bare that Python escapes, nothing escaped that it leaves bare. + /// + /// Inside an object rather than at a bare string, because a bare string asks + /// for no layout at all and would make this the compact sweep under a second + /// name. Expectation is the literal `json.dumps` printed for + /// `{"branch": ''.join(chr(c) for c in range(0x80))}` with `indent=2`, under + /// the frozen Python build (3.14). + #[test] + fn an_indented_document_spells_every_ascii_character_the_way_json_dumps_does() { + let all_of_ascii: String = (0u8..=0x7f).map(char::from).collect(); + assert_eq!( + as_python_writes_it_indented(&serde_json::json!({ "branch": all_of_ascii })), + concat!( + "{\n \"branch\": \"", + r##"\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\t\n\u000b\f\r\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\u007f"##, + "\"\n}", + ) + ); + } + + /// The indented spelling's floats, which are the compact one's. + /// + /// No document that reaches [`as_python_writes_it_indented`] carries a float + /// today — see [`PythonPrettyFormatter::write_f64`] for why — so this pins + /// the override rather than a wire, and it is here because an override with + /// nothing measuring it is how the two formatters would drift apart again. + /// `1e-05` is the decade where ryu and CPython disagree, `1e+16` the exponent + /// spelling they disagree on, and `-0.0` and `1.0` the shapes Rust's `Display` + /// writes without the fraction. Expectation is the literal `json.dumps` + /// printed for the same list with `indent=2`. + #[test] + fn an_indented_document_spells_floats_the_way_json_dumps_does() { + assert_eq!( + as_python_writes_it_indented(&serde_json::json!([1e-5, 1e16, -0.0, 1.0])), + "[\n 1e-05,\n 1e+16,\n -0.0,\n 1.0\n]" + ); + } + #[test] fn integers_are_untouched() { - // `disk` in `dl --ls --json` is an integer, and Python writes it bare. + // The listing's numbers are `disk`'s, and Python writes them bare. `disk` + // itself is an object or null, never a number. assert_eq!( - as_python_writes_it(&serde_json::json!({ "disk": 4096 })), - r#"{"disk": 4096}"# + as_python_writes_it(&serde_json::json!({ "exclusiveBytes": 4096 })), + r#"{"exclusiveBytes": 4096}"# ); } }