diff --git a/crates/diffr-core/src/protocol/mod.rs b/crates/diffr-core/src/protocol/mod.rs index 774ba5011..9515b96a3 100644 --- a/crates/diffr-core/src/protocol/mod.rs +++ b/crates/diffr-core/src/protocol/mod.rs @@ -11,10 +11,17 @@ //! last line. Columns are 0-based byte offsets into the UTF-8 text on the //! wire. All ranges are half-open. //! +//! Records end at `\n` only. U+0085, U+2028 and U+2029 are written as +//! `\u0085`, `\u2028` and `\u2029`, because some line readers also end a +//! line at them. +//! //! Sides are always `lhs` (before) and `rhs` (after). A `Pairing` says which //! sides exist and serializes by presence: `{lhs, rhs}`, `{lhs}`, or `{rhs}`. +use std::io::{self, Write}; + use serde::{Deserialize, Serialize}; +use serde_json::ser::Formatter; use crate::pairing::Pairing; @@ -23,6 +30,40 @@ pub mod project; /// The current wire version. Changes within a version are additive. pub const VERSION: u32 = 3; +/// Write `event` as one record: compact JSON and a `\n`. +pub fn write_record(writer: &mut W, event: &Event) -> serde_json::Result<()> { + event.serialize(&mut serde_json::Serializer::with_formatter( + &mut *writer, + RecordFormatter, + ))?; + writer.write_all(b"\n").map_err(serde_json::Error::io) +} + +/// Compact JSON that also escapes U+0085, U+2028 and U+2029. +struct RecordFormatter; + +impl Formatter for RecordFormatter { + fn write_string_fragment( + &mut self, + writer: &mut W, + fragment: &str, + ) -> io::Result<()> { + fragment + .split_inclusive(['\u{85}', '\u{2028}', '\u{2029}']) + .try_for_each(|piece| { + let mut chars = piece.chars(); + let escape: &[u8] = match chars.next_back() { + Some('\u{85}') => b"\\u0085", + Some('\u{2028}') => b"\\u2028", + Some('\u{2029}') => b"\\u2029", + _ => return writer.write_all(piece.as_bytes()), + }; + writer.write_all(chars.as_str().as_bytes())?; + writer.write_all(escape) + }) + } +} + // ── stream ──────────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -590,6 +631,26 @@ mod tests { } } + #[test] + fn records_escape_line_separators() { + let event = Event::Complete { + succeeded: 0, + failed: 1, + aborted: Some(Problem { + code: "x".to_owned(), + message: "a\u{2028}b\u{2029}c\u{85}d\u{2028}".to_owned(), + }), + }; + let mut record = Vec::new(); + write_record(&mut record, &event).unwrap(); + let line = String::from_utf8(record).unwrap(); + assert!( + line.ends_with("\"message\":\"a\\u2028b\\u2029c\\u0085d\\u2028\"}}\n"), + "{line}" + ); + assert_eq!(serde_json::from_str::(&line).unwrap(), event); + } + #[test] fn defaults_are_omitted() { let line = serde_json::to_string(&example_file()).unwrap(); diff --git a/src/run.rs b/src/run.rs index d16ebe878..cd427c5d5 100644 --- a/src/run.rs +++ b/src/run.rs @@ -7,7 +7,7 @@ use crate::options::DiffOptions; use crate::plugin::{Classifier, MutationFailed, Pipeline}; use crate::present::present; use crate::protocol::project::{self, Inputs}; -use crate::protocol::{Diff, Event, FileChange, Outcome, Problem, VERSION}; +use crate::protocol::{self, Diff, Event, FileChange, Outcome, Problem, VERSION}; use crate::summary::{DiffResult, FallbackCause, FileContent, FileFormat}; use crate::tags; use std::io::{BufWriter, Write}; @@ -102,8 +102,7 @@ pub(crate) fn stream( ended.failed |= *failed > 0; ended.aborted = aborted.is_some(); } - serde_json::to_writer(&mut output, &event)?; - output.write_all(b"\n")?; + protocol::write_record(&mut output, &event)?; output.flush()?; } Ok(ended)