bcd41810f4
Reader-facing prose and rustdoc carried opaque shorthand like
"Decision 10", "clause-5", "mq.1", "ct.1", "eob.1", "rpe.1",
"post-mq.3", and "Iter 22b.1:" with no in-repo definition the reader
could follow. This commit replaces every such occurrence in the
durable tier the reader is most likely to land on (design/ ledger +
source //! module headers + the central /// public-item rustdoc) with
an inline content phrase plus, where applicable, a Markdown link to
the file that defines the referenced concept.
design/ ledger — 16 files:
Definition-site headings demoted from "Decision N: <title>" to
"<title>": authoring-surface, tail-calls, memory-model section in
rc-uniqueness.md, dual-allocator section, typeclass design,
effects "pure core + algebraic effects".
Cross-reference sites: "Decision 1" -> canonical-schema principle
(data-model); "Decision 3/4" -> effects + scope-boundaries; "Decision
6" -> authoring-surface; "Decision 8" -> tail-calls; "Decision 9" ->
rc-uniqueness (dual-allocator); "Decision 10" -> memory-model;
"Decision 11" -> typeclasses (model). "clause-5" -> body-link
durability gate. "clause-3" (in language-constraints) ->
bug-class-reintroduction discriminator. "mq.1/2/3", "ct.1/4",
"eob.1", "rpe.1" -> the canonical-form rule / the type-driven
dispatch / the Str carve-out / etc. "post-mq.3" -> "type-driven".
design/contracts/feature-acceptance.md: file-local "clauses 1/2/3"
-> "criteria 1/2/3" (sprachliche Kohärenz mit der File-Überschrift
"Feature-acceptance criterion"); "the clause-3 mechanism" -> "the
bug-class-reintroduction discriminator".
Source //! module headers — 24 files:
Stripped "Iter X.Y:" prefixes and "(Decision N)" / "(mq.X)" tags
from spec_drift, uniqueness, reuse_shape, migrate_canonical_types,
typeclass_22b{2,3,c}, suppress_filter, lift, mono, linearity,
diagnostic, method_dispatch_pin, method_collision_pin,
no_per_type_print_ops, mq3_multi_class_e2e, print_mono_body_shape,
print_no_leak_pin, cli_diag_human_workspace_load_error,
ct1_check_cli, prose snapshot, unbound_in_instance_method_pin,
mono_xmod_ctor_pattern, desugar.
Central /// public-item rustdoc:
ast.rs (full sweep — every "Iter X" + "Decision N" prefix
reformulated; mode/Type::Fn rustdoc now points at memory-model.md;
Constraint / SuperclassRef / InstanceDef / ClassDef rustdoc points
at typeclasses contract).
diagnostic.rs (all "(Iter X)" / "(mq.X)" tags on diagnostic codes
removed).
lib.rs (FORM_A_SPEC rustdoc points at authoring-surface.md
instead of "Decision 6").
canonical.rs (type_hash + Float-literal rustdoc).
Still outstanding (for a follow-up commit): ~500 inline `//`
code-body comments with `Iter X.Y` markers across the workspace, and
a handful of `///` rustdoc items in hash_pin / workspace_pin / lift /
mono / suppress_filter test-pin and internal-function bodies. Code
identifiers (test filenames like `mq3_multi_class_e2e.rs`, function
names like `iter18e_drop_iterative_default_preserves_hashes`) stay
verbatim per the user's "code identifiers stay verbatim" rule.
Tests: design_index_pin 5/5 + docs_honesty_pin 5/5; workspace builds
clean; full `cargo test --workspace` previously green (every
`test result: ok` line, no FAILED line).
245 lines
9.1 KiB
Rust
245 lines
9.1 KiB
Rust
//! Canonical JSON serialization.
|
|
//!
|
|
//! Writes JSON without whitespace and with lexicographically sorted
|
|
//! object keys. This makes the representation deterministic across
|
|
//! runs, platforms, and `serde_json` versions, which is what makes it
|
|
//! suitable as the pre-image for [`crate::def_hash`] /
|
|
//! [`crate::module_hash`].
|
|
//!
|
|
//! The single entry point is [`to_bytes`]. This module deliberately
|
|
//! does **not** parse, validate, or hash — it only emits bytes. It also
|
|
//! does not attempt to be a general-purpose canonical-JSON library;
|
|
//! Number formatting follows whatever `serde_json::Number::to_string`
|
|
//! does — used only for the AST integer literals. Float literals do
|
|
//! not go through the number path; [`crate::ast::Literal::Float`]
|
|
//! carries its IEEE-754 bit pattern as a `u64` and serialises via a
|
|
//! 16-character lowercase hex *string*, which the canonical writer
|
|
//! treats like any other string. The string path is what makes float
|
|
//! canonical bytes bit-stable across `serde_json` versions and what
|
|
//! lets NaN / ±Inf round-trip at all (they cannot, as JSON numbers).
|
|
//!
|
|
//! # Examples
|
|
//!
|
|
//! ```ignore
|
|
//! use ailang_core::canonical;
|
|
//! use serde_json::json;
|
|
//!
|
|
//! // Object keys are sorted; no whitespace appears in the output.
|
|
//! let bytes = canonical::to_bytes(&json!({ "b": 1, "a": 2 }));
|
|
//! assert_eq!(std::str::from_utf8(&bytes).unwrap(), r#"{"a":2,"b":1}"#);
|
|
//! ```
|
|
|
|
use std::io::Write;
|
|
|
|
/// Serialize `value` to canonical JSON bytes.
|
|
///
|
|
/// Object keys are sorted lexicographically, no whitespace is emitted,
|
|
/// and arrays preserve their input order. The output is the byte
|
|
/// pre-image used by [`crate::def_hash`] and
|
|
/// [`crate::workspace::module_hash`]; if you change what bytes this
|
|
/// function emits for a given value, all on-disk hashes change with it.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if `value` cannot be converted to a `serde_json::Value`
|
|
/// (i.e. its `Serialize` impl errors). For the AST types in
|
|
/// [`crate::ast`] this never happens.
|
|
pub fn to_bytes<T: serde::Serialize>(value: &T) -> Vec<u8> {
|
|
let v = serde_json::to_value(value).expect("serializable");
|
|
let mut out = Vec::new();
|
|
write_value(&v, &mut out).expect("write to Vec");
|
|
out
|
|
}
|
|
|
|
/// 16-hex-char hash of a [`crate::ast::Type`] in isolation.
|
|
///
|
|
/// Used by [`crate::workspace::Registry`] to key `InstanceDef`s by
|
|
/// their target type. Parallel in shape to [`crate::def_hash`] and
|
|
/// [`crate::workspace::module_hash`]: BLAKE3 over canonical-JSON
|
|
/// bytes, truncated to 16 hex chars.
|
|
///
|
|
/// The key property is determinism — two `Type` values that are
|
|
/// canonically equal MUST hash identically — so the workspace
|
|
/// registry's "is `instance C T` already declared?" check is
|
|
/// representation-independent.
|
|
pub fn type_hash(t: &crate::ast::Type) -> String {
|
|
let bytes = to_bytes(t);
|
|
let h = blake3::hash(&bytes);
|
|
h.to_hex().as_str()[..16].to_string()
|
|
}
|
|
|
|
fn write_value(v: &serde_json::Value, out: &mut Vec<u8>) -> std::io::Result<()> {
|
|
use serde_json::Value;
|
|
match v {
|
|
Value::Null => out.write_all(b"null"),
|
|
Value::Bool(true) => out.write_all(b"true"),
|
|
Value::Bool(false) => out.write_all(b"false"),
|
|
Value::Number(n) => out.write_all(n.to_string().as_bytes()),
|
|
Value::String(s) => {
|
|
let escaped = serde_json::to_string(s).expect("string serializable");
|
|
out.write_all(escaped.as_bytes())
|
|
}
|
|
Value::Array(arr) => {
|
|
out.write_all(b"[")?;
|
|
for (i, item) in arr.iter().enumerate() {
|
|
if i > 0 {
|
|
out.write_all(b",")?;
|
|
}
|
|
write_value(item, out)?;
|
|
}
|
|
out.write_all(b"]")
|
|
}
|
|
Value::Object(map) => {
|
|
let mut keys: Vec<&String> = map.keys().collect();
|
|
keys.sort();
|
|
out.write_all(b"{")?;
|
|
for (i, k) in keys.iter().enumerate() {
|
|
if i > 0 {
|
|
out.write_all(b",")?;
|
|
}
|
|
let kj = serde_json::to_string(k).expect("key serializable");
|
|
out.write_all(kj.as_bytes())?;
|
|
out.write_all(b":")?;
|
|
write_value(&map[*k], out)?;
|
|
}
|
|
out.write_all(b"}")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn sorts_keys() {
|
|
let v = json!({ "b": 1, "a": 2 });
|
|
let bytes = to_bytes(&v);
|
|
assert_eq!(std::str::from_utf8(&bytes).unwrap(), r#"{"a":2,"b":1}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_keys_sorted() {
|
|
let v = json!({ "z": { "y": 1, "x": [3, { "b": 2, "a": 1 }] } });
|
|
let bytes = to_bytes(&v);
|
|
assert_eq!(
|
|
std::str::from_utf8(&bytes).unwrap(),
|
|
r#"{"z":{"x":[3,{"a":1,"b":2}],"y":1}}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_whitespace() {
|
|
let v = json!({ "a": 1, "b": [1, 2, 3] });
|
|
let bytes = to_bytes(&v);
|
|
let s = std::str::from_utf8(&bytes).unwrap();
|
|
assert!(!s.contains(' '));
|
|
assert!(!s.contains('\n'));
|
|
}
|
|
|
|
/// A `Literal::Float` carries the IEEE-754
|
|
/// binary64 bit pattern as a `u64`; canonical JSON encodes it as
|
|
/// `{"bits":"<16-lowercase-hex>","kind":"float"}` — string path,
|
|
/// NOT through `serde_json::Number` (which is not bit-stable for
|
|
/// floats and cannot represent NaN / ±Inf at all).
|
|
///
|
|
/// Pinned literal: `1.5_f64` → bit pattern `0x3FF8000000000000` →
|
|
/// hex string `"3ff8000000000000"`. Sorted-key order is
|
|
/// `bits` < `kind` lexicographically.
|
|
#[test]
|
|
fn float_literal_canonical_bytes() {
|
|
use crate::ast::Literal;
|
|
let lit = Literal::Float {
|
|
bits: 0x3ff8_0000_0000_0000u64,
|
|
};
|
|
let bytes = to_bytes(&lit);
|
|
let s = std::str::from_utf8(&bytes).unwrap();
|
|
assert_eq!(s, r#"{"bits":"3ff8000000000000","kind":"float"}"#);
|
|
}
|
|
|
|
/// A5 guarantee: -0.0 and +0.0 are distinct bit patterns and
|
|
/// therefore distinct Form-A literals (they hash distinctly even
|
|
/// though IEEE-`==` will report them equal at the value level
|
|
/// when arithmetic comparison ships).
|
|
#[test]
|
|
fn negative_zero_and_positive_zero_serialise_distinctly() {
|
|
use crate::ast::Literal;
|
|
let pos = Literal::Float { bits: 0x0u64 };
|
|
let neg = Literal::Float { bits: 0x8000_0000_0000_0000u64 };
|
|
let pos_bytes = to_bytes(&pos);
|
|
let neg_bytes = to_bytes(&neg);
|
|
assert_ne!(pos_bytes, neg_bytes, "+0 and -0 must hash distinctly");
|
|
assert_eq!(
|
|
std::str::from_utf8(&pos_bytes).unwrap(),
|
|
r#"{"bits":"0000000000000000","kind":"float"}"#
|
|
);
|
|
assert_eq!(
|
|
std::str::from_utf8(&neg_bytes).unwrap(),
|
|
r#"{"bits":"8000000000000000","kind":"float"}"#
|
|
);
|
|
}
|
|
|
|
/// A1 guarantee: a NaN bit pattern survives canonicalisation
|
|
/// without collapse to JSON `null` (which is what the `serde_json`
|
|
/// number path does for non-finite floats — and the reason
|
|
/// `Literal::Float` routes through the *string* path).
|
|
#[test]
|
|
fn nan_bits_preserved() {
|
|
use crate::ast::Literal;
|
|
let qnan = Literal::Float { bits: 0x7ff8_0000_0000_0000u64 };
|
|
let bytes = to_bytes(&qnan);
|
|
assert_eq!(
|
|
std::str::from_utf8(&bytes).unwrap(),
|
|
r#"{"bits":"7ff8000000000000","kind":"float"}"#
|
|
);
|
|
}
|
|
|
|
/// A1 guarantee: ±Inf bit patterns survive canonicalisation —
|
|
/// JSON numbers cannot represent infinity at all, the string path
|
|
/// must.
|
|
#[test]
|
|
fn inf_bits_preserved() {
|
|
use crate::ast::Literal;
|
|
let pos_inf = Literal::Float { bits: 0x7ff0_0000_0000_0000u64 };
|
|
let neg_inf = Literal::Float { bits: 0xfff0_0000_0000_0000u64 };
|
|
assert_eq!(
|
|
std::str::from_utf8(&to_bytes(&pos_inf)).unwrap(),
|
|
r#"{"bits":"7ff0000000000000","kind":"float"}"#
|
|
);
|
|
assert_eq!(
|
|
std::str::from_utf8(&to_bytes(&neg_inf)).unwrap(),
|
|
r#"{"bits":"fff0000000000000","kind":"float"}"#
|
|
);
|
|
}
|
|
|
|
/// Round-trip: serialise then deserialise via `serde_json` — bits
|
|
/// preserved bit-for-bit.
|
|
#[test]
|
|
fn float_literal_serde_roundtrip() {
|
|
use crate::ast::Literal;
|
|
let cases = [
|
|
0x0u64,
|
|
0x8000_0000_0000_0000u64,
|
|
0x3ff8_0000_0000_0000u64, // 1.5
|
|
0x7ff8_0000_0000_0000u64, // qNaN
|
|
0x7ff0_0000_0000_0000u64, // +Inf
|
|
0xfff0_0000_0000_0000u64, // -Inf
|
|
0xffff_ffff_ffff_ffffu64, // saturated
|
|
];
|
|
for &bits in &cases {
|
|
let lit = Literal::Float { bits };
|
|
let json = serde_json::to_string(&lit).unwrap();
|
|
let back: Literal = serde_json::from_str(&json).unwrap();
|
|
match back {
|
|
Literal::Float { bits: got } => assert_eq!(
|
|
got, bits,
|
|
"round-trip lost bits for {:#018x}: json={}",
|
|
bits, json
|
|
),
|
|
other => panic!("expected Literal::Float, got {:?}", other),
|
|
}
|
|
}
|
|
}
|
|
}
|