epiphany/crates/epiphany-textproj/src/parse.rs

1143 lines
46 KiB
Rust

//! Strict parsing of canonical Text Projection documents.
//!
//! Parsing is grammar-directed over [`epiphany_core::textvalue::Sexp`] values
//! read by [`epiphany_core::textvalue::read_sexp`], reports
//! [`epiphany_core::textvalue::TextError`], and delegates envelope productions
//! to [`epiphany_ops::parse_envelope`].
//!
//! # Free functions, not `TextValue` impls
//!
//! `TextValue` is a core-crate trait and every document-line type
//! (`Manifest`, `ProfileDeclaration`, ...) belongs to `epiphany-bundle`, which
//! does not depend on `epiphany-core`. Implementing a foreign trait for a
//! foreign type from this third crate is exactly what the orphan rule
//! forbids, and no document-line production needs it anyway: every one
//! bottoms out in `bytes`, `integer`, `bool`, `string`, `option`, or a closed
//! vocabulary, never a Chapter-5 `value`. So this module is free functions
//! throughout, one pair (or one parser, where there is no corresponding
//! `project_*` in this file) per grammar production.
//!
//! # Two rejections, and why each is a rejection rather than a normalization
//!
//! Every check below rejects rather than repairs, per `req:textproj:strict-parse`.
//! Two are load-bearing enough to call out specifically:
//!
//! * **Any `(blob ...)` line is rejected**, unconditionally, in
//! [`parse_document`]. At `crate::COMPANION_VERSION` no canonical operation
//! and no canonical reduced state can carry a `BlobId` (a source scan --
//! the companion `epiphany-core`/`epiphany-ops` crates never mention the
//! type), so canonical state cannot reference a blob and every blob line is
//! necessarily unreferenced (`req:textproj:reject-unreferenced-blobs`).
//! Accepting one would stage a blob into the bundle that the very next
//! projection silently drops -- losing data and falsifying
//! `project(serialize(parse(T))) == T` for that text. The `(blob ...)`
//! *production* itself still parses -- `parse_blob` is exercised directly
//! against synthetic data below -- so the accept side is ready the moment
//! the reachability predicate becomes non-empty; only the document-level
//! decision to use it is a permanent "no".
//! * **Any header version other than `crate::COMPANION_VERSION` is rejected
//! at line one** (`req:textproj:header-version`). Multi-version acceptance
//! and migrate-on-read are deferred spec decisions; this parser does not
//! speculate about them.
//!
//! # Section order is a sequence, not a set
//!
//! `projection ::= header document lineage? profile* extension*
//! canonical-base? blob* envelope*` is consumed by [`parse_document`] in
//! exactly that order, greedily and without backtracking: each stage takes
//! every line that belongs to it and stops at the first line that does not.
//! Anything left over once every stage has run -- a repeated singular
//! section, a section that shows up before the one it follows, or simple
//! garbage -- is rejected as a whole. A parser that instead recognised every
//! line by its own head symbol and sorted them into place would accept
//! exactly the disordered and repeated texts this design rejects; that is
//! the normalization `req:textproj:strict-parse` forbids, so this parser
//! does not do it.
use epiphany_bundle::{
ChunkKind, DocumentId, ExtensionId, FrontierBytes, LineageId, ProfileConstraints,
ProfileDeclaration, ProfileId, ProfileRegistryId, ReductionAlgorithmVersion, RetentionPolicy,
SchemaVersion, SemVer, SnapshotId, WallClockDuration,
};
use epiphany_core::textvalue::{read_sexp, Sexp, TextError, TextValue};
use epiphany_ops::{canonical_reduction_order, parse_envelope, OperationEnvelope};
use crate::{
TextBlob, TextCanonicalBase, TextChunk, TextDocument, TextExtension, COMPANION_VERSION,
};
// ===========================================================================
// The document-level driver.
// ===========================================================================
/// Parses a complete canonical Text Projection into a [`TextDocument`],
/// rejecting anything that is not the canonical projection of the document it
/// denotes (`req:textproj:strict-parse`).
///
/// `text` must be exactly what `req:textproj:envelope-per-line` describes:
/// lines separated by a single U+000A, a final U+000A, and nothing else
/// trailing. Every line is one complete s-expression, and the sequence of
/// lines must follow `projection`'s section order precisely -- see the
/// module documentation for why an out-of-order or repeated section is a
/// rejection rather than something to sort back into place.
pub fn parse_document(text: &str) -> Result<TextDocument, TextError> {
let raw_lines = split_lines(text)?;
let mut lines = Lines {
lines: raw_lines,
pos: 0,
};
// header: mandatory, first (`req:textproj:header-version`).
require_line(
&mut lines,
"text-projection",
"a projection must begin with a header line naming the companion version",
parse_header,
)?;
// document: mandatory, immediately after the header.
let document_id = require_line(
&mut lines,
"document",
"a projection must carry a document line immediately after its header",
parse_document_id_line,
)?;
// lineage?
let lineage_id = take_line(&mut lines, "lineage", parse_lineage)?;
// profile*, in canonical (profile_id, version) order -- no repeats.
let mut profiles = Vec::new();
while let Some(profile) = take_line(&mut lines, "profile", parse_profile)? {
profiles.push(profile);
}
if profiles
.windows(2)
.any(|w| (w[0].profile_id, w[0].version) >= (w[1].profile_id, w[1].version))
{
return Err(TextError::NotStrictlyIncreasing(
"profile declarations must be ordered, without repetition, by (profile_id, version)",
));
}
// extension*, in canonical (extension_id, version) order -- no repeats.
let mut extensions = Vec::new();
while let Some(extension) = take_line(&mut lines, "extension", parse_extension)? {
extensions.push(extension);
}
if extensions
.windows(2)
.any(|w| (w[0].extension_id, w[0].version) >= (w[1].extension_id, w[1].version))
{
return Err(TextError::NotStrictlyIncreasing(
"extension declarations must be ordered, without repetition, by (extension_id, version)",
));
}
// canonical-base?
let canonical_base = take_line(&mut lines, "canonical-base", parse_canonical_base)?;
// blob*: collected only so the rejection below can fire; see the module
// documentation and `req:textproj:reject-unreferenced-blobs`.
let mut blobs = Vec::new();
while let Some(blob) = take_line(&mut lines, "blob", parse_blob)? {
blobs.push(blob);
}
if !blobs.is_empty() {
return Err(TextError::NotCanonical(
"a (blob ...) line is unreferenced by canonical state: no canonical operation or \
reduced state can carry a BlobId at this companion version, so no blob line can \
ever be canonical here",
));
}
// envelope*, in canonical reduction order.
let mut envelopes = Vec::new();
while let Some(line) = lines.peek() {
let sexp = read_sexp(line)?;
if line_head(&sexp) != Some("envelope") {
break;
}
lines.advance();
envelopes.push(parse_envelope(line)?);
}
check_envelope_order(&envelopes)?;
// Anything left over is a line that belongs to no remaining stage: a
// repeated singular section, a section reappearing after a later one, or
// plain garbage after the envelopes. Every one of those is a rejection,
// never a re-sort.
if lines.peek().is_some() {
return Err(TextError::Syntax(
"a line appears out of the projection's normative section order, or a section that \
may occur at most once is repeated",
));
}
Ok(TextDocument {
document_id,
lineage_id,
profiles,
extensions,
canonical_base,
blobs: Vec::new(),
envelopes,
})
}
/// A cursor over a projection's lines, advanced only by [`take_line`] and
/// [`require_line`] so every stage of [`parse_document`] consumes lines in
/// exactly one pass.
struct Lines<'a> {
lines: Vec<&'a str>,
pos: usize,
}
impl<'a> Lines<'a> {
fn peek(&self) -> Option<&'a str> {
self.lines.get(self.pos).copied()
}
fn advance(&mut self) {
self.pos += 1;
}
}
/// Splits a whole projection into its lines, enforcing the layout half of
/// `req:textproj:envelope-per-line`: a single trailing U+000A and no other
/// trailing whitespace. Without this check a projection missing its final
/// U+000A would still split into the same lines by `str::split('\n')` and
/// parse identically -- the trailing-newline requirement would be silently
/// unenforced. An embedded blank line (a stray `U+000A U+000A`, anywhere)
/// needs no special case here: it becomes an empty line, and every stage
/// below rejects an empty line when it tries to read an s-expression from it.
fn split_lines(text: &str) -> Result<Vec<&str>, TextError> {
let body = text.strip_suffix('\n').ok_or(TextError::Syntax(
"a projection must end with exactly one U+000A and no other trailing whitespace",
))?;
Ok(body.split('\n').collect())
}
/// The head symbol of a line's s-expression, or `None` if it is not headed by
/// one (a bare leaf line is never a valid section head, so this only ever
/// hides a genuine mismatch).
fn line_head(sexp: &Sexp) -> Option<&str> {
sexp.as_list()?.first()?.as_symbol()
}
/// Consumes the next line and applies `parse` to it, but only if it is
/// present and its head symbol is `head`. Otherwise the cursor is left
/// untouched and `Ok(None)` is returned -- the mechanism by which every
/// optional or repeatable section in `projection` is optional, and by which a
/// line belonging to a later or earlier stage is left for another stage (or
/// the final leftover check) to deal with, rather than being consumed out of
/// place.
fn take_line<'a, T>(
lines: &mut Lines<'a>,
head: &str,
parse: impl FnOnce(&Sexp) -> Result<T, TextError>,
) -> Result<Option<T>, TextError> {
let Some(line) = lines.peek() else {
return Ok(None);
};
let sexp = read_sexp(line)?;
if line_head(&sexp) != Some(head) {
return Ok(None);
}
lines.advance();
Ok(Some(parse(&sexp)?))
}
/// As [`take_line`], but the line is mandatory: a missing or mismatched line
/// is `missing`, not `Ok(None)`. Used for the header and the document line,
/// the two productions `projection` requires unconditionally.
fn require_line<'a, T>(
lines: &mut Lines<'a>,
head: &str,
missing: &'static str,
parse: impl FnOnce(&Sexp) -> Result<T, TextError>,
) -> Result<T, TextError> {
let Some(line) = lines.peek() else {
return Err(TextError::Syntax(missing));
};
let sexp = read_sexp(line)?;
if line_head(&sexp) != Some(head) {
return Err(TextError::Syntax(missing));
}
lines.advance();
parse(&sexp)
}
/// `req:textproj:derived-ordering`'s "every other sequence keeps the binary
/// order" names the envelopes' binary order as
/// [`epiphany_ops::canonical_reduction_order`] -- a deterministic function of
/// the envelopes' own causal contexts and stamps, not a free choice a writer
/// makes. So a text whose envelope lines are already in that order is the
/// only text `req:textproj:canonical-text` permits for that envelope set, and
/// accepting some other order here -- and silently keeping it -- would be
/// exactly the normalization `req:textproj:strict-parse` forbids: the next
/// projection re-sorts the same envelopes into canonical order, so
/// `project(serialize(parse(T))) == T` fails for precisely the malformed `T`
/// this rejects up front.
fn check_envelope_order(envelopes: &[OperationEnvelope]) -> Result<(), TextError> {
let given: Vec<&OperationEnvelope> = envelopes.iter().collect();
let canonical = canonical_reduction_order(&given);
let already_canonical = given
.iter()
.zip(canonical.iter())
.all(|(a, b)| std::ptr::eq(*a, *b));
if !already_canonical {
return Err(TextError::NotStrictlyIncreasing(
"envelope lines must appear in canonical reduction order \
(epiphany_ops::canonical_reduction_order)",
));
}
Ok(())
}
// ===========================================================================
// Leaves shared by several productions.
// ===========================================================================
/// The lexical class of `s`, for error messages. Mirrors the private helper
/// of the same name in `epiphany_core::textvalue` and in every `textproj_*`
/// module of `epiphany-ops`: `Sexp::class` is not public, so each grammar-
/// directed module restates this one match rather than depend on it.
fn class_of(s: &Sexp) -> &'static str {
match s {
Sexp::List(_) => "list",
Sexp::Symbol(_) => "symbol",
Sexp::Int(_) => "integer",
Sexp::Bytes(_) => "byte string",
Sexp::Str(_) => "string",
}
}
/// Reads the grammar's opaque `bytes` terminal as a plain byte string.
/// `Vec<u8>`'s generic `TextValue` impl denotes a *sequence of integers*, a
/// different production, so it must never stand in for an opaque payload,
/// identifier, or hash -- the same trap the operation layer names in
/// `textproj_kind.rs`.
fn parse_bytes(s: &Sexp) -> Result<Vec<u8>, TextError> {
match s {
Sexp::Bytes(bytes) => Ok(bytes.clone()),
_ => Err(TextError::Expected {
expected: "byte string",
found: class_of(s),
}),
}
}
/// Reads a fixed 16-byte opaque identifier: a `DocumentId`, `LineageId`,
/// `SnapshotId`, `ExtensionId`, or a custom `ProfileId`'s registry id. `what`
/// names the identifier for the rejection message.
fn parse_id16(s: &Sexp, what: &'static str) -> Result<[u8; 16], TextError> {
parse_bytes(s)?
.try_into()
.map_err(|_| TextError::NotCanonical(what))
}
/// The grammar's anonymous `version ::= "(" integer " " integer " " integer
/// ")"` production, shared verbatim by the header (against
/// `crate::COMPANION_VERSION`) and by every `SemVer` field.
fn parse_version_triple(s: &Sexp) -> Result<(u32, u32, u32), TextError> {
let items = s.as_list().ok_or(TextError::Expected {
expected: "version",
found: class_of(s),
})?;
let [major, minor, patch] = items else {
return Err(TextError::Arity {
type_name: "version",
expected: 3,
found: items.len(),
});
};
Ok((u32::parse(major)?, u32::parse(minor)?, u32::parse(patch)?))
}
fn parse_semver(s: &Sexp) -> Result<SemVer, TextError> {
let (major, minor, patch) = parse_version_triple(s)?;
Ok(SemVer::new(major, minor, patch))
}
/// The grammar's `profile-id` production: a bare symbol for each
/// closed-vocabulary profile, and `(custom <bytes>)` -- the one profile
/// identity that is not a bare symbol -- for `ProfileId::Custom`, whose
/// registry id is 16 opaque bytes.
fn parse_profile_id(s: &Sexp) -> Result<ProfileId, TextError> {
if let Some(name) = s.as_symbol() {
return match name {
"full" => Ok(ProfileId::Full),
"read-only" => Ok(ProfileId::ReadOnly),
"lite" => Ok(ProfileId::Lite),
_ => Err(TextError::UnknownConstructor {
type_name: "ProfileId",
found: name.to_owned(),
}),
};
}
let fields = s.expect_struct("custom", 1)?;
let registry_id = parse_id16(
&fields[0],
"a custom ProfileId registry id is exactly 16 bytes",
)?;
Ok(ProfileId::Custom(ProfileRegistryId(registry_id)))
}
// ===========================================================================
// header, document, lineage.
// ===========================================================================
/// `header ::= "(text-projection " version ")"`. Accepts exactly
/// `crate::COMPANION_VERSION`; any other version is a rejection at line one
/// (`req:textproj:header-version`). Multi-version acceptance and
/// migrate-on-read are deferred spec decisions this parser does not
/// speculate about.
fn parse_header(s: &Sexp) -> Result<(), TextError> {
let fields = s.expect_struct("text-projection", 1)?;
let version = parse_version_triple(&fields[0])?;
if version != COMPANION_VERSION {
return Err(TextError::NotCanonical(
"the header names a companion version other than the one this crate implements",
));
}
Ok(())
}
/// `document ::= "(document " bytes ")"`.
fn parse_document_id_line(s: &Sexp) -> Result<DocumentId, TextError> {
let fields = s.expect_struct("document", 1)?;
Ok(DocumentId(parse_id16(
&fields[0],
"a DocumentId is exactly 16 bytes",
)?))
}
/// `lineage ::= "(lineage " bytes ")"`.
fn parse_lineage(s: &Sexp) -> Result<LineageId, TextError> {
let fields = s.expect_struct("lineage", 1)?;
Ok(LineageId(parse_id16(
&fields[0],
"a LineageId is exactly 16 bytes",
)?))
}
// ===========================================================================
// profile, constraints, retention.
// ===========================================================================
/// `profile ::= "(profile " profile-id " " version " " constraints ")"`.
fn parse_profile(s: &Sexp) -> Result<ProfileDeclaration, TextError> {
let fields = s.expect_struct("profile", 3)?;
Ok(ProfileDeclaration {
profile_id: parse_profile_id(&fields[0])?,
version: parse_semver(&fields[1])?,
constraints: parse_constraints(&fields[2])?,
})
}
/// `constraints ::= "(constraints " integer " " retention ")"`.
fn parse_constraints(s: &Sexp) -> Result<ProfileConstraints, TextError> {
let fields = s.expect_struct("constraints", 2)?;
Ok(ProfileConstraints {
max_uncompressed_block_size: u64::parse(&fields[0])?,
retention_policy: parse_retention(&fields[1])?,
})
}
/// `retention ::= "(retention " integer " " option " " bool ")"`.
///
/// The middle field is `Option<WallClockDuration>`, and `WallClockDuration` is
/// a newtype over `i64`: per `req:textproj:value-projection` clause 2 a
/// newtype projects transparently as its field alone, so the option wraps a
/// bare integer, not a `(wall-clock-duration <integer>)` struct. This is
/// **`epiphany_bundle::WallClockDuration`**, not `epiphany_core`'s
/// same-named type -- the retention policy is a bundle type through and
/// through.
fn parse_retention(s: &Sexp) -> Result<RetentionPolicy, TextError> {
let fields = s.expect_struct("retention", 3)?;
Ok(RetentionPolicy {
retain_previous_manifests: u32::parse(&fields[0])?,
retain_duration: Option::<i64>::parse(&fields[1])?.map(WallClockDuration),
retain_named_checkpoints: bool::parse(&fields[2])?,
})
}
// ===========================================================================
// extension, chunk, chunk-kind, schema.
// ===========================================================================
/// `extension ::= "(extension " bytes " " version " " bool " (" chunk* ") "
/// bytes " " bytes ")"` -- id, version, required, chunks, affected-kinds,
/// barriers, the ratified declaration order. `affected_object_kinds` and
/// `edit_barriers` are opaque byte strings, never structured: the bundle
/// preserves them without interpreting them, and this projection interprets
/// nothing the bundle does not.
fn parse_extension(s: &Sexp) -> Result<TextExtension, TextError> {
let fields = s.expect_struct("extension", 6)?;
Ok(TextExtension {
extension_id: ExtensionId(parse_id16(
&fields[0],
"an ExtensionId is exactly 16 bytes",
)?),
version: parse_semver(&fields[1])?,
required: bool::parse(&fields[2])?,
chunks: parse_chunks(&fields[3])?,
affected_object_kinds: parse_bytes(&fields[4])?,
edit_barriers: parse_bytes(&fields[5])?,
})
}
/// `chunk ::= "(chunk " chunk-kind " " schema " " bytes ")"`: a preserved
/// extension chunk root projected as kind, schema version, and uncompressed
/// payload -- never a `ChunkRef`, which the projection has no file to point
/// into (`req:textproj:derive-or-carry`).
fn parse_chunk(s: &Sexp) -> Result<TextChunk, TextError> {
let fields = s.expect_struct("chunk", 3)?;
Ok(TextChunk {
kind: parse_chunk_kind(&fields[0])?,
schema_version: parse_schema_version(&fields[1])?,
payload: parse_bytes(&fields[2])?,
})
}
/// The chunk* sequence inside an `extension` line. `req:textproj:derived-ordering`
/// orders and de-duplicates an extension's preserved chunk roots by their
/// *projected form* -- the binary `ChunkRef` order breaks ties on the file
/// offset, which this projection erases, so the binary order cannot be
/// inherited here. Accepting a disordered or duplicated chunk list and
/// re-sorting it at serialize time would be normalization; this rejects it
/// instead.
fn parse_chunks(s: &Sexp) -> Result<Vec<TextChunk>, TextError> {
let items = s.as_list().ok_or(TextError::Expected {
expected: "chunk sequence",
found: class_of(s),
})?;
let mut out = Vec::with_capacity(items.len());
let mut previous_rendered: Option<String> = None;
for item in items {
let rendered = item.render();
if previous_rendered
.as_deref()
.is_some_and(|previous| rendered.as_str() <= previous)
{
return Err(TextError::NotStrictlyIncreasing(
"an extension's preserved chunk roots must be ordered, without repetition, by \
projected form",
));
}
out.push(parse_chunk(item)?);
previous_rendered = Some(rendered);
}
Ok(out)
}
/// `chunk-kind`: a bare symbol per `ChunkKind` variant, in the same
/// declaration order as the discriminants `req:format:chunkkind-discriminants`
/// pins.
fn parse_chunk_kind(s: &Sexp) -> Result<ChunkKind, TextError> {
match s.as_symbol() {
Some("operation-envelope-block") => Ok(ChunkKind::OperationEnvelopeBlock),
Some("operation-index") => Ok(ChunkKind::OperationIndex),
Some("snapshot") => Ok(ChunkKind::Snapshot),
Some("blob") => Ok(ChunkKind::Blob),
Some("extension-data") => Ok(ChunkKind::ExtensionData),
Some("text-projection") => Ok(ChunkKind::TextProjection),
Some("layout-cache") => Ok(ChunkKind::LayoutCache),
Some("integrity-index") => Ok(ChunkKind::IntegrityIndex),
Some("manifest") => Ok(ChunkKind::Manifest),
Some(name) => Err(TextError::UnknownConstructor {
type_name: "ChunkKind",
found: name.to_owned(),
}),
None => Err(TextError::Expected {
expected: "chunk-kind",
found: class_of(s),
}),
}
}
/// `schema ::= "(schema " integer " " integer ")"`.
fn parse_schema_version(s: &Sexp) -> Result<SchemaVersion, TextError> {
let fields = s.expect_struct("schema", 2)?;
Ok(SchemaVersion::new(
u16::parse(&fields[0])?,
u16::parse(&fields[1])?,
))
}
// ===========================================================================
// canonical-base.
// ===========================================================================
/// `canonical-base ::= "(canonical-base " bytes " " bytes " " integer " "
/// profile-id " " schema " " bytes ")"` -- snapshot id, frontier, reduction
/// version, profile, root schema, root payload. The `SnapshotId` is the one
/// identity `req:textproj:derive-or-carry` carries verbatim rather than
/// re-deriving (schema major 0 has no snapshot producer, so it has nothing to
/// derive from); the root chunk's own id and content hash are re-derived by
/// whoever serializes this back into a bundle, never read from the text.
fn parse_canonical_base(s: &Sexp) -> Result<TextCanonicalBase, TextError> {
let fields = s.expect_struct("canonical-base", 6)?;
Ok(TextCanonicalBase {
snapshot_id: SnapshotId(parse_id16(&fields[0], "a SnapshotId is exactly 16 bytes")?),
covers_causal_frontier: FrontierBytes::from_bytes(parse_bytes(&fields[1])?),
reduction_algorithm_version: ReductionAlgorithmVersion(u32::parse(&fields[2])?),
profile_id: parse_profile_id(&fields[3])?,
root_schema_version: parse_schema_version(&fields[4])?,
root_payload: parse_bytes(&fields[5])?,
})
}
// ===========================================================================
// blob.
// ===========================================================================
/// `blob ::= "(blob " string " " option " " bytes ")"` -- media type,
/// declared maximum uncompressed length, payload.
///
/// This is the production-level parser only. It accepts a well-formed
/// `(blob ...)` line unconditionally, exactly as every other production
/// parser here does; the decision that **no** accepted blob line may reach a
/// [`TextDocument`] is made once, at the document level, in
/// [`parse_document`] (`req:textproj:reject-unreferenced-blobs`). Keeping the
/// two apart -- this function parses, `parse_document` rejects -- means the
/// accept side is already correct and already tested the day the
/// reachability predicate stops returning the empty set, rather than needing
/// to be written from scratch alongside a spec bump.
fn parse_blob(s: &Sexp) -> Result<TextBlob, TextError> {
let fields = s.expect_struct("blob", 3)?;
Ok(TextBlob {
media_type: String::parse(&fields[0])?,
declared_max_uncompressed_length: Option::<u64>::parse(&fields[1])?,
payload: parse_bytes(&fields[2])?,
})
}
// ===========================================================================
// Tests.
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::{OperationId, ReplicaId, WallClockTime};
use epiphany_ops::{
project_envelope, AuthorId, CausalContext, EnvelopeHash, HybridLogicalClock,
OperationStamp, ResolveEquivocationPayload,
};
// -----------------------------------------------------------------
// Test fixtures.
// -----------------------------------------------------------------
/// Joins `lines` with a single U+000A each, including a final one --
/// exactly `req:textproj:envelope-per-line`'s layout.
fn projection(lines: &[&str]) -> String {
let mut out = String::new();
for line in lines {
out.push_str(line);
out.push('\n');
}
out
}
// Bumped with `COMPANION_VERSION` (0.7.0 → 0.8.0, genesis G1). Kept a
// literal because `projection` takes `&[&str]` and a formatted String
// would ripple through every call site; `the_test_header_tracks_the_
// implemented_version` below fails loudly if the two ever drift.
const HEADER: &str = "(text-projection (0 8 0))";
const DOCUMENT: &str = "(document #x00000000000000000000000000000001)";
/// A minimal but complete valid projection: just the two mandatory lines.
fn minimal_valid_document() -> String {
projection(&[HEADER, DOCUMENT])
}
/// `HEADER` is a literal, so nothing but this test stops it drifting from
/// the version the crate actually implements. Without it a companion bump
/// leaves every fixture below silently rejected by the version gate, and
/// the failures point at section ordering rather than at the header.
#[test]
fn the_test_header_tracks_the_implemented_version() {
let (major, minor, patch) = COMPANION_VERSION;
assert_eq!(
HEADER,
format!("(text-projection ({major} {minor} {patch}))")
);
}
/// A simple, independent (empty causal context) envelope, so several of
/// these can be combined without any causal-order machinery beyond their
/// HLC physical time.
fn sample_envelope(replica: u64, counter: u64, physical_time: i64) -> OperationEnvelope {
let id = OperationId::new(ReplicaId(replica), counter);
OperationEnvelope {
id,
author: AuthorId(0x1122_3344),
stamp: OperationStamp::new(
HybridLogicalClock::new(WallClockTime(physical_time), 0),
id,
),
causal_context: CausalContext::new(),
transaction: None,
payload: epiphany_ops::OperationPayload::ResolveEquivocation(
ResolveEquivocationPayload {
target: id,
chosen: EnvelopeHash([7; 32]),
},
),
}
}
// -----------------------------------------------------------------
// The worked example: a real, spec-authored, multi-section document.
// -----------------------------------------------------------------
/// The Grammar chapter's worked example, read live from the companion so
/// this test cannot drift from the document it claims to parse.
fn worked_example() -> String {
const SPEC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../spec/text_projection.tex"
));
SPEC.split_once("\\chapter{A Worked Example}")
.expect("the specification contains the worked example")
.1
.split_once("\\begin{lstlisting}\n")
.expect("the worked example contains a projection listing")
.1
.split_once("\\end{lstlisting}")
.expect("the worked projection listing is closed")
.0
.to_owned()
}
#[test]
fn the_worked_example_parses_to_its_documented_fields() {
let text = worked_example();
let document = parse_document(&text).expect("the worked example is a valid projection");
assert_eq!(document.document_id, DocumentId([0x05; 16]));
assert_eq!(document.lineage_id, None);
assert_eq!(document.profiles.len(), 1);
assert_eq!(document.profiles[0].profile_id, ProfileId::Full);
assert_eq!(document.profiles[0].version, SemVer::new(0, 1, 0));
assert_eq!(
document.profiles[0].constraints.max_uncompressed_block_size,
67_108_864
);
assert_eq!(
document.profiles[0]
.constraints
.retention_policy
.retain_previous_manifests,
1
);
assert_eq!(
document.profiles[0]
.constraints
.retention_policy
.retain_duration,
None
);
assert!(
document.profiles[0]
.constraints
.retention_policy
.retain_named_checkpoints
);
assert!(document.extensions.is_empty());
let base = document
.canonical_base
.as_ref()
.expect("the worked example carries a canonical base");
assert_eq!(
base.snapshot_id,
SnapshotId([0x1f, 0x8b, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
);
assert_eq!(base.covers_causal_frontier, FrontierBytes::empty());
assert_eq!(
base.reduction_algorithm_version,
ReductionAlgorithmVersion(1)
);
assert_eq!(base.profile_id, ProfileId::Full);
assert_eq!(base.root_schema_version, SchemaVersion::V0);
assert_eq!(base.root_payload, vec![0u8, 0u8]);
assert!(document.blobs.is_empty());
assert_eq!(document.envelopes.len(), 1);
}
// -----------------------------------------------------------------
// A synthetic document exercising lineage, multiple profiles, an
// extension with preserved chunks, a canonical base, and multiple
// envelopes -- everything the worked example alone does not reach.
// -----------------------------------------------------------------
#[test]
fn a_rich_synthetic_document_parses_every_section() {
let lineage = "(lineage #x00000000000000000000000000000002)";
let profile_full = "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))";
let profile_read_only =
"(profile read-only (0 1 0) (constraints 1024 (retention 0 (some 5) false)))";
let extension = "(extension #x00000000000000000000000000000003 (1 0 0) true \
((chunk operation-envelope-block (schema 0 1) #xaa) (chunk snapshot (schema 0 1) #xbb)) \
#xaabb #xccdd)";
let base =
"(canonical-base #x00000000000000000000000000000004 #x 1 full (schema 0 1) #x0102)";
let e1 = sample_envelope(1, 1, 10);
let e2 = sample_envelope(2, 1, 20);
let e1_line = project_envelope(&e1);
let e2_line = project_envelope(&e2);
let text = projection(&[
HEADER,
DOCUMENT,
lineage,
profile_full,
profile_read_only,
extension,
base,
&e1_line,
&e2_line,
]);
let document = parse_document(&text).expect("a well-formed rich document parses");
assert_eq!(
document.lineage_id,
Some(LineageId([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]))
);
assert_eq!(document.profiles.len(), 2);
assert_eq!(document.extensions.len(), 1);
assert_eq!(document.extensions[0].chunks.len(), 2);
assert_eq!(
document.extensions[0].chunks[0].kind,
ChunkKind::OperationEnvelopeBlock
);
assert_eq!(document.extensions[0].chunks[1].kind, ChunkKind::Snapshot);
assert_eq!(
document.extensions[0].affected_object_kinds,
vec![0xaa, 0xbb]
);
assert_eq!(document.extensions[0].edit_barriers, vec![0xcc, 0xdd]);
assert!(document.canonical_base.is_some());
assert_eq!(document.envelopes, vec![e1, e2]);
}
// -----------------------------------------------------------------
// Rejection classes. Each is mutation-verified (see the task report):
// the anchor assertion below is deleted or inverted, a NAMED test in
// this list is confirmed to fail, and the code is restored.
// -----------------------------------------------------------------
#[test]
fn non_canonical_header_version_is_rejected() {
let text = projection(&["(text-projection (0 6 0))", DOCUMENT]);
assert_eq!(
parse_document(&text),
Err(TextError::NotCanonical(
"the header names a companion version other than the one this crate implements"
))
);
}
#[test]
fn the_companion_version_itself_is_accepted() {
// The mutation-testing counterpart of the above: the exact version
// this crate implements must still parse.
assert!(parse_document(&minimal_valid_document()).is_ok());
}
#[test]
fn a_blob_line_is_always_rejected() {
let blob_line = "(blob \"audio/wav\" () #x00)";
let text = projection(&[HEADER, DOCUMENT, blob_line]);
assert!(matches!(
parse_document(&text),
Err(TextError::NotCanonical(_))
));
}
#[test]
fn the_blob_production_itself_parses_synthetic_data() {
// Line-level parse of `(blob ...)`, exercised directly against
// synthetic data per the task contract: the accept side is ready
// even though `parse_document` never lets it through.
let sexp = read_sexp("(blob \"audio/wav\" (some 1024) #x0a0b)").unwrap();
let blob = parse_blob(&sexp).expect("a well-formed blob production parses");
assert_eq!(blob.media_type, "audio/wav");
assert_eq!(blob.declared_max_uncompressed_length, Some(1024));
assert_eq!(blob.payload, vec![0x0a, 0x0b]);
}
#[test]
fn missing_trailing_lf_is_rejected() {
let text = minimal_valid_document();
let without_final_lf = text.strip_suffix('\n').unwrap();
assert_eq!(
parse_document(without_final_lf),
Err(TextError::Syntax(
"a projection must end with exactly one U+000A and no other trailing whitespace"
))
);
}
#[test]
fn an_extra_trailing_blank_line_is_rejected() {
let mut text = minimal_valid_document();
text.push('\n'); // a second, stray trailing U+000A
assert!(parse_document(&text).is_err());
}
#[test]
fn missing_header_is_rejected() {
assert!(parse_document("").is_err());
}
#[test]
fn missing_document_line_is_rejected() {
let text = projection(&[HEADER]);
assert_eq!(
parse_document(&text),
Err(TextError::Syntax(
"a projection must carry a document line immediately after its header"
))
);
}
#[test]
fn a_repeated_header_line_is_rejected() {
let text = projection(&[HEADER, HEADER, DOCUMENT]);
assert!(parse_document(&text).is_err());
}
#[test]
fn a_repeated_document_line_is_rejected() {
let text = projection(&[HEADER, DOCUMENT, DOCUMENT]);
assert_eq!(
parse_document(&text),
Err(TextError::Syntax(
"a line appears out of the projection's normative section order, or a section \
that may occur at most once is repeated"
))
);
}
#[test]
fn out_of_order_sections_are_rejected_not_sorted() {
let profile = "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))";
let extension = "(extension #x00000000000000000000000000000003 (1 0 0) false () #x #x)";
let base = "(canonical-base #x00000000000000000000000000000004 #x 1 full (schema 0 1) #x)";
let lineage = "(lineage #x00000000000000000000000000000002)";
let e1_line = project_envelope(&sample_envelope(1, 1, 10));
let scenarios: &[&[&str]] = &[
// A profile line before the (mandatory) document line.
&[HEADER, profile, DOCUMENT],
// Lineage after a profile, instead of before it.
&[HEADER, DOCUMENT, profile, lineage],
// An extension before the profile section.
&[HEADER, DOCUMENT, extension, profile],
// A canonical base before the extension section.
&[HEADER, DOCUMENT, base, extension],
// An envelope before the canonical base.
&[HEADER, DOCUMENT, &e1_line, base],
];
for lines in scenarios {
let text = projection(lines);
assert!(
parse_document(&text).is_err(),
"expected rejection for out-of-order lines: {lines:?}"
);
}
}
#[test]
fn profile_declarations_must_be_ordered_and_unique() {
let full = "(profile full (0 1 0) (constraints 1 (retention 0 () false)))";
let read_only = "(profile read-only (0 1 0) (constraints 1 (retention 0 () false)))";
// Correct ascending order accepts.
let ok = projection(&[HEADER, DOCUMENT, full, read_only]);
assert!(parse_document(&ok).is_ok());
// Reversed order is rejected, not silently re-sorted.
let reversed = projection(&[HEADER, DOCUMENT, read_only, full]);
assert_eq!(
parse_document(&reversed),
Err(TextError::NotStrictlyIncreasing(
"profile declarations must be ordered, without repetition, by (profile_id, version)"
))
);
// An exact repeat (same key) is rejected too.
let repeated = projection(&[HEADER, DOCUMENT, full, full]);
assert!(parse_document(&repeated).is_err());
}
#[test]
fn extension_declarations_must_be_ordered_and_unique() {
let ext_a = "(extension #x00000000000000000000000000000001 (1 0 0) false () #x #x)";
let ext_b = "(extension #x00000000000000000000000000000002 (1 0 0) false () #x #x)";
let ok = projection(&[HEADER, DOCUMENT, ext_a, ext_b]);
assert!(parse_document(&ok).is_ok());
let reversed = projection(&[HEADER, DOCUMENT, ext_b, ext_a]);
assert_eq!(
parse_document(&reversed),
Err(TextError::NotStrictlyIncreasing(
"extension declarations must be ordered, without repetition, by (extension_id, version)"
))
);
let repeated = projection(&[HEADER, DOCUMENT, ext_a, ext_a]);
assert!(parse_document(&repeated).is_err());
}
#[test]
fn extension_chunk_roots_must_be_ordered_and_deduplicated() {
let chunk_a = "(chunk operation-envelope-block (schema 0 1) #xaa)";
let chunk_b = "(chunk snapshot (schema 0 1) #xbb)";
let ordered = format!(
"(extension #x00000000000000000000000000000001 (1 0 0) false ({chunk_a} {chunk_b}) #x #x)"
);
let text = projection(&[HEADER, DOCUMENT, &ordered]);
assert!(parse_document(&text).is_ok());
let reversed = format!(
"(extension #x00000000000000000000000000000001 (1 0 0) false ({chunk_b} {chunk_a}) #x #x)"
);
let text = projection(&[HEADER, DOCUMENT, &reversed]);
assert_eq!(
parse_document(&text),
Err(TextError::NotStrictlyIncreasing(
"an extension's preserved chunk roots must be ordered, without repetition, by \
projected form"
))
);
let duplicated = format!(
"(extension #x00000000000000000000000000000001 (1 0 0) false ({chunk_a} {chunk_a}) #x #x)"
);
let text = projection(&[HEADER, DOCUMENT, &duplicated]);
assert!(parse_document(&text).is_err());
}
#[test]
fn envelope_lines_must_be_in_canonical_reduction_order() {
let e1 = sample_envelope(1, 1, 10);
let e2 = sample_envelope(2, 1, 20);
let e1_line = project_envelope(&e1);
let e2_line = project_envelope(&e2);
// Ascending physical time is the canonical order: accepted.
let ok = projection(&[HEADER, DOCUMENT, &e1_line, &e2_line]);
let parsed = parse_document(&ok).expect("already-canonical envelope order is accepted");
assert_eq!(parsed.envelopes, vec![e1, e2]);
// Swapped: no longer canonical reduction order, rejected outright.
let swapped = projection(&[HEADER, DOCUMENT, &e2_line, &e1_line]);
assert_eq!(
parse_document(&swapped),
Err(TextError::NotStrictlyIncreasing(
"envelope lines must appear in canonical reduction order \
(epiphany_ops::canonical_reduction_order)"
))
);
}
#[test]
fn profile_id_accepts_every_closed_symbol_and_the_custom_form() {
for (text, expected) in [
("full", ProfileId::Full),
("read-only", ProfileId::ReadOnly),
("lite", ProfileId::Lite),
(
"(custom #x00000000000000000000000000000009)",
ProfileId::Custom(ProfileRegistryId([
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9,
])),
),
] {
let sexp = read_sexp(text).unwrap();
assert_eq!(parse_profile_id(&sexp).unwrap(), expected);
}
}
#[test]
fn profile_id_rejects_an_unknown_symbol_and_a_malformed_custom_id() {
assert!(parse_profile_id(&read_sexp("bespoke").unwrap()).is_err());
assert!(parse_profile_id(&read_sexp("(custom #x00)").unwrap()).is_err());
}
#[test]
fn chunk_kind_covers_exactly_the_nine_variants() {
for (name, kind) in [
(
"operation-envelope-block",
ChunkKind::OperationEnvelopeBlock,
),
("operation-index", ChunkKind::OperationIndex),
("snapshot", ChunkKind::Snapshot),
("blob", ChunkKind::Blob),
("extension-data", ChunkKind::ExtensionData),
("text-projection", ChunkKind::TextProjection),
("layout-cache", ChunkKind::LayoutCache),
("integrity-index", ChunkKind::IntegrityIndex),
("manifest", ChunkKind::Manifest),
] {
let sexp = read_sexp(name).unwrap();
assert_eq!(parse_chunk_kind(&sexp).unwrap(), kind);
}
assert!(parse_chunk_kind(&read_sexp("unknown-kind").unwrap()).is_err());
}
// -----------------------------------------------------------------
// The suite's own reach.
// -----------------------------------------------------------------
/// Distinct rejection classes this file's tests exercise. Each entry
/// corresponds to a check in `parse_document` or a leaf parser above that
/// rejects rather than normalizes; the list documents, and the assertion
/// pins, how many of them this file actually drives to a rejection.
const EXERCISED_REJECTION_CLASSES: &[&str] = &[
"non-canonical header version",
"a (blob ...) line, wherever it appears",
"missing final U+000A",
"an extra trailing U+000A",
"missing header line",
"missing document line",
"repeated header line",
"repeated document line",
"a section line before the mandatory document line",
"a section reappearing after a later section (lineage after profile)",
"an extension before the profile section",
"a canonical-base before the extension section",
"an envelope before the canonical base",
"profile declarations out of (profile_id, version) order",
"duplicate profile declaration key",
"extension declarations out of (extension_id, version) order",
"duplicate extension declaration key",
"extension chunk roots out of projected-form order",
"duplicate extension chunk roots",
"envelope lines out of canonical reduction order",
"an unknown ProfileId symbol",
"a malformed custom ProfileId registry id",
"an unknown ChunkKind symbol",
];
#[test]
fn the_suite_s_reach_is_counted() {
assert_eq!(
EXERCISED_REJECTION_CLASSES.len(),
23,
"update this count, and add a test, whenever a new rejection class is exercised"
);
}
}