diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a1a9a..82a577a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 every arm is asserted against the exact line it used to arrive already rendered with, and a real run whose records will not open is judged from outside the binary. +- **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. ### Fixed diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index 93e7324..36ed7da 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -2836,6 +2836,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 b22fbb8..04347e0 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 { //! The Python `test_worktree_storage`, re-pinned here when it retired with the @@ -1700,8 +1616,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"); @@ -1732,8 +1650,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}"], @@ -1770,8 +1690,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"); @@ -1801,8 +1723,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 6a423c5..1e9115b 100644 --- a/rust/devlaunch-core/src/json.rs +++ b/rust/devlaunch-core/src/json.rs @@ -1,15 +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` is spelled by -//! `dl`'s own formatter and still carries a copy of the escaping; devlaunch#346 -//! collapses it onto this one. +//! 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 @@ -140,21 +147,168 @@ 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). /// -/// 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. +/// 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. +/// +/// Only the layout differs from the compact [`PythonFormatter`] — this document +/// 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>, +} + +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) + } + + /// 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 + // 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,12 +567,113 @@ 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\"" + ); + } + + /// 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}"# ); } } diff --git a/rust/devlaunch-core/src/lib.rs b/rust/devlaunch-core/src/lib.rs index 06fa767..6101a1c 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 3fd41eb..e59810f 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -55,7 +55,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; @@ -228,139 +227,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) } // --------------------------------------------------------------------------- @@ -3493,13 +3374,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!( @@ -3510,11 +3392,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();