Skip to main content

blockworx/
document_file.rs

1//! The document file: JSON is the one document format (D13), and
2//! [`blockworx_doc::document::Document`] is the single source of truth for
3//! it — serde's derives on the model *are* the codec, both directions
4//! (`docs/log-vs-snapshot.md` §8). This module is only the two doors: the
5//! spanned parse diagnostic, and the pretty-printed write.
6//!
7//! [`ParseFailure`] carries the source text (for `miette` diagnostics), so
8//! it is large by design — callers accept that rather than boxing.
9#![allow(clippy::result_large_err)]
10
11use blockworx_doc::document::Document;
12use miette::{Diagnostic, NamedSource, SourceSpan};
13use thiserror::Error;
14
15/// A JSON document this build will not read, located in the source. serde
16/// reports a line and column; the span is that position in bytes, so the
17/// rendered report underlines the offending token rather than quoting an
18/// offset the reader has to count to.
19#[derive(Debug, Error, Diagnostic)]
20#[error("{message}")]
21pub struct ParseFailure {
22    message: String,
23    #[label("here")]
24    span: SourceSpan,
25    #[source_code]
26    src: NamedSource<String>,
27}
28
29/// Parse a JSON document.
30///
31/// A version this build does not read is refused by the model itself
32/// ([`FormatVersion`](blockworx_doc::document::FormatVersion)), so it
33/// arrives here as an ordinary decode failure with the offending token
34/// underlined.
35///
36/// # Errors
37/// [`ParseFailure`], carrying the source so the failure points at the
38/// offending line.
39pub fn parse(src: &str, src_name: &str) -> Result<Document, ParseFailure> {
40    serde_json::from_str(src).map_err(|error| {
41        let offset = byte_offset(src, error.line(), error.column());
42        ParseFailure {
43            message: error.to_string(),
44            span: (offset, 1.min(src.len().saturating_sub(offset))).into(),
45            src: NamedSource::new(src_name, src.to_owned()),
46        }
47    })
48}
49
50/// Serialize `doc` as the document format: pretty-printed JSON, one field
51/// per line, so a diff of two folds reads as the edits between them (F5).
52// `serde_json` fails only on a map key that is not a string or a
53// non-finite float, and the document model holds neither.
54#[expect(clippy::expect_used, clippy::missing_panics_doc)]
55pub fn to_json(doc: &Document) -> String {
56    serde_json::to_string_pretty(doc).expect("the document model serializes infallibly")
57}
58
59/// A 1-based line/column pair as a byte offset. serde reports `(0, 0)` for a
60/// failure with no position (an early EOF), which lands at the start.
61fn byte_offset(src: &str, line: usize, column: usize) -> usize {
62    let start: usize = src
63        .split_inclusive('\n')
64        .take(line.saturating_sub(1))
65        .map(str::len)
66        .sum();
67    (start + column.saturating_sub(1)).min(src.len())
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    /// Every document the repository ships as test data, through the
75    /// property P2's gate is: the file is what the model says, so writing
76    /// a parsed document and reading it back lands on the same value.
77    /// This replaces `schema/roundtrip.rs`, and is stronger — it compares
78    /// documents rather than two renderings of them.
79    ///
80    /// `BLOCKWORX_UPDATE_GOLDENS=1` rewrites each fixture in the spelling
81    /// this build writes, which is how the corpus is carried across a
82    /// deliberate format change.
83    #[test]
84    fn every_fixture_survives_a_write_and_a_read() {
85        let update = std::env::var_os("BLOCKWORX_UPDATE_GOLDENS").is_some();
86        let mut seen = 0;
87        for entry in std::fs::read_dir("fixtures").expect("fixtures/ is there") {
88            let path = entry.expect("a directory entry").path();
89            if path.extension().and_then(|e| e.to_str()) != Some("json") {
90                continue;
91            }
92            let name = path.display().to_string();
93            let src = std::fs::read_to_string(&path).expect("the fixture reads");
94            let doc = parse(&src, &name).unwrap_or_else(|e| panic!("{name} parses:\n{e:?}"));
95            let mut written = to_json(&doc);
96            written.push('\n');
97            if update {
98                std::fs::write(&path, &written).expect("the fixture writes");
99            }
100            let read =
101                parse(&written, &name).unwrap_or_else(|e| panic!("{name} re-parses:\n{e:?}"));
102            assert!(read == doc, "{name} did not survive the round trip");
103            seen += 1;
104        }
105        assert!(seen >= 13, "the corpus shrank to {seen} documents");
106    }
107
108    /// The bytes the fixture corpus cannot pin: what a document *looks
109    /// like*, which is the whole of F5's diff-readability claim.
110    /// Regenerate with `BLOCKWORX_UPDATE_GOLDENS=1 cargo test --lib
111    /// document_file` and read the diff — it is the format changing.
112    #[test]
113    fn the_document_format_is_byte_stable() {
114        const GOLDEN_PATH: &str = "src/goldens/document.json";
115        const GOLDEN: &str = include_str!("goldens/document.json");
116
117        let src = std::fs::read_to_string("fixtures/root-scope.json").expect("the fixture reads");
118        let mut text = to_json(&parse(&src, "root-scope.json").expect("it parses"));
119        text.push('\n');
120        if std::env::var_os("BLOCKWORX_UPDATE_GOLDENS").is_some() {
121            std::fs::write(GOLDEN_PATH, &text).expect("the golden is written");
122            return;
123        }
124        assert_eq!(
125            text, GOLDEN,
126            "the document format changed; if that is intended, regenerate with \
127             `BLOCKWORX_UPDATE_GOLDENS=1 cargo test --lib document_file` and review the diff",
128        );
129    }
130
131    /// The miette diagnostic survives the schema's deletion: a file that
132    /// will not parse points at the line that stopped it, with the source
133    /// attached, rather than reporting a byte offset a reader must count
134    /// to (CLAUDE.md, developer-facing tooling).
135    #[test]
136    fn a_parse_failure_points_at_the_offending_line_with_its_source() {
137        let src = "{\n  \"version\": 3,\n  \"blocks\": [\n}\n";
138        let failure = parse(src, "broken.json").expect_err("it does not parse");
139        assert_eq!(
140            src[..failure.span.offset()].lines().count(),
141            3,
142            "the span lands on the line serde stopped at: {src:?}"
143        );
144        let attached = failure
145            .source_code()
146            .expect("the report carries its source")
147            .read_span(&failure.span, 1, 1)
148            .expect("the span reads back against it");
149        assert_eq!(attached.name(), Some("broken.json"));
150        assert!(
151            String::from_utf8_lossy(attached.data()).contains("\"blocks\""),
152            "the rendered line is the one that failed"
153        );
154    }
155
156    /// The newer-build guard, through the real door (§14.3.1).
157    #[test]
158    fn a_document_from_a_newer_build_is_refused_with_a_span() {
159        let failure = parse(r#"{"version": 99}"#, "future.json").expect_err("it is refused");
160        assert!(
161            failure.to_string().contains("newer than this build reads"),
162            "{failure}"
163        );
164    }
165}