Skip to content

Commit e3bc04f

Browse files
codex1: tighten core serialization contract
1 parent 206ce38 commit e3bc04f

File tree

8 files changed

+160
-18
lines changed

8 files changed

+160
-18
lines changed

core-no-std-plan.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ Current semantics draft:
121121
- Comments between `:` and the value normalize to entry-prefix comments.
122122
- Simple lists and variants stay on one line when they are short and comment-free; multiline containers omit commas in canonical output.
123123
- On the compact owned `Value` core writer, unit variants may be bare only in root/value position; map-key variants stay explicit with `()`.
124+
- On the compact owned `Value` and typed core writers, empty root maps stay explicit as `{}`, while non-empty root maps remain brace-less even when the first key is composite.
124125
- On the legacy owned `Value` formatter, unit variants collapse to quoted strings.
125126
- Exact roundtrip guarantees currently documented:
126127
- Formatter-core canonical output is idempotent on the tested subset of root maps/lists/values, variants, strings, comments, and composite-root-key shapes.
@@ -152,7 +153,7 @@ Tasks:
152153
- [ ] Keep identifiers, numbers, comments, and unescaped strings borrowed
153154
- [ ] Represent escaped strings as raw slice plus "needs decode" metadata
154155
- [ ] Avoid building owned `Value` trees on the critical path unless requested
155-
- [ ] Add tests that prove borrowed paths work across representative inputs
156+
- [x] Add tests that prove borrowed paths work across representative inputs
156157

157158
Exit criteria:
158159

@@ -455,3 +456,5 @@ Entries:
455456
- `2026-03-31 | codex1 | WS1/WS8 | Added a short semantics draft to the tracker and tightened the documented exact-roundtrip exclusions: legacy and core keyword map keys canonicalize to strings, legacy unit variants collapse to strings, and fuzz-contract comments now point at tested behavior | next semantics work is to finish the remaining unsupported/tolerated ambiguity list rather than leave it implied`
456457
- `2026-03-31 | codex1 | WS1/WS8 | Clarified that owned-Value 'exact roundtrip' means Value equality rather than source spelling or numeric storage identity, and marked the value-roundtrip fuzz contract task complete | next semantics work is to review whether any remaining exclusions still need to be promoted from implementation detail to documented non-guarantee`
457458
- `2026-03-31 | codex2 | WS7/WS9 | Added scripts/run_benchmark_baseline.sh and benchmark-data/README.md with a reproducible local baseline for bench_parse and bench_core_vs_serde at 477118a | next performance slice is release-size tracking or stronger benchmark automation`
459+
- `2026-03-31 | codex1 | WS1/WS3 | Aligned the compact owned/typed core writers with parser and formatter-core root-map semantics: empty root maps now stay explicit as '{}' and composite first keys no longer force outer braces; added direct regression coverage for both serializers and exact-roundtrip tests for the owned Value path | next semantics work is to review any remaining implementation-tolerated ambiguities that still are not either guaranteed or explicitly excluded`
460+
- `2026-03-31 | codex1 | WS2 | Added borrowed-slice parser tests in eon_core covering identifiers, numbers, raw escaped strings, and quoted variant heads, so the zero-copy contract is now enforced on representative common tokens | next zero-copy work is the actual allocation audit and deciding whether escaped-string metadata should become more explicit than the current raw-token model`

crates/eon/src/serde/core_serializer.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,10 @@ where
398398
));
399399
}
400400

401-
if self.mode == MapMode::Explicit {
402-
self.writer.write_char('}').map_err(fmt_error)?;
401+
match self.mode {
402+
MapMode::Explicit => self.writer.write_char('}').map_err(fmt_error)?,
403+
MapMode::PendingRootMap => self.writer.write_str("{}").map_err(fmt_error)?,
404+
MapMode::ImplicitRoot => {}
403405
}
404406

405407
Ok(())
@@ -435,12 +437,7 @@ where
435437
MapMode::PendingRootMap => {
436438
debug_assert!(self.first);
437439
let rendered = serialize_fragment(key, Position::MapKey)?;
438-
if rendered.starts_with('{') {
439-
self.writer.write_char('{').map_err(fmt_error)?;
440-
self.mode = MapMode::Explicit;
441-
} else {
442-
self.mode = MapMode::ImplicitRoot;
443-
}
440+
self.mode = MapMode::ImplicitRoot;
444441
self.writer.write_str(&rendered).map_err(fmt_error)?;
445442
}
446443
MapMode::Explicit | MapMode::ImplicitRoot => {

crates/eon/src/value_to_core.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,7 @@ where
8787
}
8888

8989
fn root_map_can_be_implicit(map: &Map) -> bool {
90-
let Some((first_key, _)) = map.iter().next() else {
91-
return true;
92-
};
93-
94-
!matches!(first_key, Value::Map(_))
90+
!map.is_empty()
9591
}
9692

9793
fn write_variant<W>(out: &mut W, variant: &Variant, position: Position) -> fmt::Result

crates/eon/tests/test_core_stringify.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,10 @@ fn test_core_stringify_quotes_non_identifier_unit_variants() {
6565

6666
assert_eq!(value_to_string_with_core(&value), "\"kebab-case\"()");
6767
}
68+
69+
#[test]
70+
fn test_core_stringify_keeps_empty_root_maps_explicit() {
71+
let value = Value::Map(Map::new());
72+
73+
assert_eq!(value_to_string_with_core(&value), "{}");
74+
}

crates/eon/tests/test_core_typed_stringify.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,25 @@ fn test_typed_core_stringify_matches_value_core_for_root_map() {
5353
assert_eq!(direct, via_value);
5454
assert_eq!(direct, "alpha: 1, beta: 2");
5555
}
56+
57+
#[test]
58+
fn test_typed_core_stringify_matches_value_core_for_composite_root_map_key() {
59+
let document = BTreeMap::from([(BTreeMap::from([("nested".to_owned(), 1_u32)]), 2_u32)]);
60+
61+
let direct = experimental::to_string_with_core(&document).unwrap();
62+
let via_value = to_value(&document).unwrap().to_string_with_core();
63+
64+
assert_eq!(direct, via_value);
65+
assert_eq!(direct, "{nested: 1}: 2");
66+
}
67+
68+
#[test]
69+
fn test_typed_core_stringify_keeps_empty_root_maps_explicit() {
70+
let document = BTreeMap::<String, u32>::new();
71+
72+
let direct = experimental::to_string_with_core(&document).unwrap();
73+
let via_value = to_value(&document).unwrap().to_string_with_core();
74+
75+
assert_eq!(direct, via_value);
76+
assert_eq!(direct, "{}");
77+
}

crates/eon/tests/test_roundtrip_contract.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
use eon::{Map, Value, Variant, experimental};
22

3+
#[test]
4+
fn test_core_value_roundtrip_keeps_empty_root_maps_explicit() {
5+
let original = Value::Map(Map::new());
6+
7+
let serialized = experimental::value_to_string_with_core(&original);
8+
assert_eq!(serialized, "{}");
9+
10+
let parsed = experimental::value_from_str_with_core(&serialized).unwrap();
11+
assert_eq!(parsed, original);
12+
}
13+
14+
#[test]
15+
fn test_core_value_roundtrip_keeps_composite_root_map_keys_braceless() {
16+
let original = Value::Map(Map::from_iter([(
17+
Value::Map(Map::from_iter([(Value::from("nested"), Value::from(1))])),
18+
Value::from(2),
19+
)]));
20+
21+
let serialized = experimental::value_to_string_with_core(&original);
22+
assert_eq!(serialized, "{nested: 1}: 2");
23+
24+
let parsed = experimental::value_from_str_with_core(&serialized).unwrap();
25+
assert_eq!(parsed, original);
26+
}
27+
328
#[test]
429
fn test_core_value_roundtrip_excludes_keyword_map_keys() {
530
let original = Value::Map(Map::from_iter([

crates/eon/tests/test_security_regressions.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,13 @@ fn test_core_typed_stringify_quotes_keyword_variant_names() {
211211
}
212212

213213
#[test]
214-
fn test_core_value_stringify_wraps_root_map_when_first_key_is_a_map() {
214+
fn test_core_value_stringify_keeps_root_map_with_first_map_key_braceless() {
215215
let key = Value::Map(Map::from_iter([(Value::from("nested"), Value::from(1))]));
216216
let value = Value::Map(Map::from_iter([(key, Value::from("safe"))]));
217217

218218
let serialized = experimental::value_to_string_with_core(&value);
219219

220-
assert!(serialized.starts_with("{{"));
220+
assert_eq!(serialized, "{nested: 1}: \"safe\"");
221221
let roundtripped = experimental::value_from_str_with_core(&serialized).unwrap();
222222
assert_eq!(roundtripped, value);
223223
}

crates/eon_core/src/parser.rs

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,10 +595,32 @@ impl<'a> EventSink<'a> for NoopSink {
595595

596596
#[cfg(test)]
597597
mod tests {
598-
use std::string::String;
598+
use std::{string::String, vec::Vec};
599599

600600
use super::NoopSink;
601-
use crate::{Error, ErrorKind, EventWriter, ParseError, parse, parse_with_limit};
601+
use crate::{
602+
Error, ErrorKind, Event, EventSink, EventWriter, ParseError, Scalar, Span, SpannedEvent,
603+
StringKind, VariantName, parse, parse_with_limit,
604+
};
605+
606+
#[derive(Default)]
607+
struct CollectSink<'a> {
608+
events: Vec<SpannedEvent<'a>>,
609+
}
610+
611+
impl<'a> EventSink<'a> for CollectSink<'a> {
612+
type Error = core::convert::Infallible;
613+
614+
fn event(&mut self, event: SpannedEvent<'a>) -> core::result::Result<(), Self::Error> {
615+
self.events.push(event);
616+
Ok(())
617+
}
618+
}
619+
620+
fn assert_borrowed_slice(source: &str, span: Span, slice: &str) {
621+
assert_eq!(slice, &source[span.start..span.end]);
622+
assert_eq!(slice.as_ptr(), source[span.start..].as_ptr());
623+
}
602624

603625
#[test]
604626
fn parse_empty_document_as_implicit_map() {
@@ -670,6 +692,76 @@ mod tests {
670692
assert_eq!(out, "{nested: true}: answer");
671693
}
672694

695+
#[test]
696+
fn parser_borrows_identifier_and_number_tokens_from_source() {
697+
let source = "mode: EnumValue(42)";
698+
let mut sink = CollectSink::default();
699+
parse(source, &mut sink).unwrap();
700+
701+
let (identifier_span, identifier) = sink
702+
.events
703+
.iter()
704+
.find_map(|event| match event.event {
705+
Event::Scalar(Scalar::Identifier(identifier)) if identifier == "mode" => {
706+
Some((event.span, identifier))
707+
}
708+
_ => None,
709+
})
710+
.unwrap();
711+
assert_borrowed_slice(source, identifier_span, identifier);
712+
713+
let (number_span, number) = sink
714+
.events
715+
.iter()
716+
.find_map(|event| match event.event {
717+
Event::Scalar(Scalar::Number(number)) => Some((event.span, number)),
718+
_ => None,
719+
})
720+
.unwrap();
721+
assert_eq!(number, "42");
722+
assert_borrowed_slice(source, number_span, number);
723+
}
724+
725+
#[test]
726+
fn parser_borrows_raw_escaped_string_tokens_from_source() {
727+
let source = "label: \"he\\nllo\"";
728+
let mut sink = CollectSink::default();
729+
parse(source, &mut sink).unwrap();
730+
731+
let (string_span, raw, kind) = sink
732+
.events
733+
.iter()
734+
.find_map(|event| match event.event {
735+
Event::Scalar(Scalar::String(token)) => Some((event.span, token.raw, token.kind)),
736+
_ => None,
737+
})
738+
.unwrap();
739+
assert_eq!(kind, StringKind::Basic);
740+
assert_eq!(raw, "\"he\\nllo\"");
741+
assert_borrowed_slice(source, string_span, raw);
742+
}
743+
744+
#[test]
745+
fn parser_borrows_quoted_variant_heads_from_source() {
746+
let source = "mode: \"kebab-case\"()";
747+
let mut sink = CollectSink::default();
748+
parse(source, &mut sink).unwrap();
749+
750+
let (variant_span, raw, kind) = sink
751+
.events
752+
.iter()
753+
.find_map(|event| match event.event {
754+
Event::BeginVariant {
755+
name: VariantName::String(token),
756+
} => Some((event.span, token.raw, token.kind)),
757+
_ => None,
758+
})
759+
.unwrap();
760+
assert_eq!(kind, StringKind::Basic);
761+
assert_eq!(raw, "\"kebab-case\"");
762+
assert_borrowed_slice(source, variant_span, raw);
763+
}
764+
673765
#[test]
674766
fn depth_limit_counts_root_containers_once() {
675767
parse_with_limit("[1]", NoopSink, 1).unwrap();

0 commit comments

Comments
 (0)