panproto_schema/abstract_schema.rs
1//! Typed newtypes for the abstract / decorated schema distinction.
2//!
3//! A bare [`Schema`] can be in either of two states: it is *abstract*
4//! when no constraint sort belongs to the layout enrichment fibre, and
5//! it is *decorated* when the parser walker has attached layout
6//! witnesses (byte spans, interstitials, CHOICE discriminators).
7//!
8//! These newtypes lift that distinction to a Rust type so that the
9//! parse/decorate/emit lens can be wired through the type system:
10//! `decorate` consumes an [`AbstractSchema`] and returns a
11//! [`DecoratedSchema`]; the operational `emit_pretty` and `decorate`
12//! entry points keep abstract and decorated inputs distinguishable
13//! at every call site without `Deref` erasure.
14//!
15//! ## Construction
16//!
17//! - [`AbstractSchema::from_layout_free`] validates that no
18//! layout-fibre constraint is present (returns
19//! [`LayoutConstraintsPresent`] when the invariant fails); this is
20//! the checked entry that callers should prefer.
21//! - [`AbstractSchema::from_layout_free_unchecked`] skips the scan
22//! for callers that just ran `forget_layout` themselves.
23//! - [`DecoratedSchema::wrap_unchecked`] wraps a [`Schema`] without
24//! checking the layout fibre. The legitimate sources are the
25//! parse walker's output and the `decorate` synthesis driver;
26//! misuse degrades emit correctness silently.
27//!
28//! Construction is *not* sealed at the type system level
29//! (panproto's `Schema` does not yet carry a phantom theory parameter
30//! that would let us refuse arbitrary cross-crate constructions).
31//! The checked / unchecked split is the load-bearing safety net.
32
33use crate::Schema;
34use crate::schema::Constraint;
35
36/// Returned by [`AbstractSchema::from_layout_free`] when the input
37/// schema carries constraints in the layout enrichment fibre and
38/// therefore cannot be treated as abstract.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
40#[error(
41 "cannot construct AbstractSchema: {count} layout-fibre constraint(s) present; \
42 call Schema::forget_layout first"
43)]
44pub struct LayoutConstraintsPresent {
45 /// Number of offending constraint entries detected.
46 pub count: usize,
47}
48
49/// A schema with no layout enrichment.
50///
51/// Carrying only vertex kinds, edges, and content-level constraints
52/// (`literal-value`, `field:*`, and any protocol-defined constraint
53/// sorts that are *not* in the layout fibre). Typical sources:
54///
55/// - [`SchemaBuilder::build_abstract`](crate::SchemaBuilder::build_abstract),
56/// which checks the invariant before wrapping.
57/// - [`DecoratedSchema::forget_layout`], which projects a decorated
58/// schema to its abstract base.
59/// - [`AbstractSchema::from_layout_free`] for callers wrapping a
60/// `Schema` produced by other means (validates on entry).
61#[derive(Clone, Debug)]
62pub struct AbstractSchema {
63 inner: Schema,
64}
65
66/// A schema carrying a complete layout enrichment over its abstract
67/// content.
68///
69/// Typical sources:
70///
71/// - The result of `ParserRegistry::parse_with_protocol` wrapped via
72/// [`DecoratedSchema::wrap_unchecked`].
73/// - The return value of `ParserRegistry::decorate` (the put-direction
74/// of the parse / decorate / emit lens).
75///
76/// Direct serialization round-trips a `Schema`; the newtype is
77/// enforced only at the Rust type level.
78#[derive(Clone, Debug)]
79pub struct DecoratedSchema {
80 inner: Schema,
81}
82
83/// Per-vertex view of the layout witness data carried by a
84/// [`DecoratedSchema`].
85///
86/// This is a read-only projection: it borrows the underlying
87/// constraint list so callers can inspect a vertex's byte span,
88/// interstitial text, or chosen CHOICE alternative without round-
89/// tripping through the schema-level constraint maps.
90#[derive(Clone, Copy, Debug)]
91pub struct LayoutWitness<'a> {
92 constraints: &'a [Constraint],
93}
94
95impl AbstractSchema {
96 /// Construct an [`AbstractSchema`] from a [`Schema`] that already
97 /// satisfies the no-layout invariant.
98 ///
99 /// The invariant is checked at runtime in every build (debug and
100 /// release): a non-layout-free schema is a programming error in
101 /// the caller, but a load-bearing one — emit and parse use the
102 /// type-level distinction to dispatch, and a silently-wrong
103 /// `AbstractSchema` would corrupt downstream behaviour. Returns
104 /// `Err(LayoutConstraintsPresent { count })` carrying the number
105 /// of offending constraint entries so callers can diagnose.
106 ///
107 /// # Errors
108 ///
109 /// Returns [`LayoutConstraintsPresent`] when `schema.is_layout_free()`
110 /// returns `false`. Use [`Schema::forget_layout`] first if a
111 /// decorated schema needs to be downcast.
112 pub fn from_layout_free(schema: Schema) -> Result<Self, LayoutConstraintsPresent> {
113 let offending = schema
114 .constraints
115 .values()
116 .flat_map(|cs| cs.iter())
117 .filter(|c| panproto_gat::is_layout_sort(c.sort.as_ref()))
118 .count();
119 if offending == 0 {
120 Ok(Self { inner: schema })
121 } else {
122 Err(LayoutConstraintsPresent { count: offending })
123 }
124 }
125
126 /// Construct an [`AbstractSchema`] from a [`Schema`] without
127 /// checking the layout-free invariant.
128 ///
129 /// Reserved for callers that have *just* run `forget_layout` on
130 /// the input and want to skip the redundant scan. Misuse degrades
131 /// emit/decorate correctness silently; prefer
132 /// [`from_layout_free`](Self::from_layout_free) elsewhere.
133 #[must_use]
134 pub const fn from_layout_free_unchecked(schema: Schema) -> Self {
135 Self { inner: schema }
136 }
137
138 /// Borrow the underlying schema for read-only consumption.
139 ///
140 /// This is the audited bridge to the raw [`Schema`] type: it is
141 /// explicit at every call site that we are crossing the typed
142 /// boundary in the get-only direction. There is no
143 /// `Deref<Target = Schema>` because that would silently erase the
144 /// type-level distinction; every consumer must opt in.
145 #[must_use]
146 pub const fn as_schema(&self) -> &Schema {
147 &self.inner
148 }
149
150 /// Returns the schema's protocol name.
151 #[must_use]
152 pub fn protocol(&self) -> &str {
153 &self.inner.protocol
154 }
155
156 /// Returns the number of vertices.
157 #[must_use]
158 pub fn vertex_count(&self) -> usize {
159 self.inner.vertex_count()
160 }
161}
162
163impl DecoratedSchema {
164 /// Wrap a [`Schema`] as a [`DecoratedSchema`] without checking the
165 /// layout-fibre invariant.
166 ///
167 /// Construction is *not* enforced at the type level (panproto's
168 /// `Schema` does not yet carry a phantom theory parameter), so
169 /// this constructor trusts the caller. The legitimate sources are:
170 ///
171 /// - Output of [`ParserRegistry::parse_with_protocol`](https://docs.rs/panproto-parse) —
172 /// the parse walker attaches a complete layout fibre.
173 /// - Output of [`ParserRegistry::decorate`](https://docs.rs/panproto-parse) —
174 /// the put-direction of the parse/emit lens.
175 ///
176 /// Wrapping a hand-built or otherwise abstract schema produces a
177 /// `DecoratedSchema` that subsequent `emit_pretty` calls will
178 /// fall back to grammar-walking on (since the layout fibre is
179 /// empty), which is well-defined but loses the "round-trips via
180 /// byte-position arithmetic" advantage of true decoration.
181 #[must_use]
182 pub const fn wrap_unchecked(schema: Schema) -> Self {
183 Self { inner: schema }
184 }
185
186 /// Borrow the underlying schema for read-only consumption.
187 ///
188 /// See [`AbstractSchema::as_schema`] for the rationale: this is an
189 /// explicit, audited bridge to the raw type, intentionally
190 /// non-`Deref`.
191 #[must_use]
192 pub const fn as_schema(&self) -> &Schema {
193 &self.inner
194 }
195
196 /// Returns the schema's protocol name.
197 #[must_use]
198 pub fn protocol(&self) -> &str {
199 &self.inner.protocol
200 }
201
202 /// Project to the abstract schema by forgetting all layout-fibre
203 /// constraints. This is the lens get-direction realised in types.
204 ///
205 /// Cannot fail: `Schema::forget_layout` always returns a
206 /// layout-free schema, so the invariant of [`AbstractSchema`] is
207 /// satisfied by construction.
208 #[must_use]
209 pub fn forget_layout(&self) -> AbstractSchema {
210 AbstractSchema::from_layout_free_unchecked(self.inner.forget_layout())
211 }
212
213 /// Returns a read-only view of the constraint set at `vertex_id`.
214 ///
215 /// Returns `None` when the vertex has no constraints recorded at
216 /// all (`schema.constraints.get(vertex_id) == None`). When the
217 /// vertex has constraints but none are in the layout fibre, the
218 /// returned witness is non-empty but [`LayoutWitness::iter`]
219 /// yields nothing — the layout accessors (`start_byte`,
220 /// `end_byte`, …) return `None` for missing entries.
221 #[must_use]
222 pub fn layout_witness(&self, vertex_id: &str) -> Option<LayoutWitness<'_>> {
223 let cs = self.inner.constraints.get(vertex_id)?;
224 Some(LayoutWitness { constraints: cs })
225 }
226}
227
228impl<'a> LayoutWitness<'a> {
229 /// Iterate over every layout-fibre constraint at this vertex.
230 pub fn iter(&self) -> impl Iterator<Item = &'a Constraint> + '_ {
231 self.constraints
232 .iter()
233 .filter(|c| panproto_gat::is_layout_sort(c.sort.as_ref()))
234 }
235
236 /// Return the value of the `start-byte` constraint, if present.
237 #[must_use]
238 pub fn start_byte(&self) -> Option<usize> {
239 self.constraints
240 .iter()
241 .find(|c| c.sort.as_ref() == "start-byte")
242 .and_then(|c| c.value.parse().ok())
243 }
244
245 /// Return the value of the `end-byte` constraint, if present.
246 #[must_use]
247 pub fn end_byte(&self) -> Option<usize> {
248 self.constraints
249 .iter()
250 .find(|c| c.sort.as_ref() == "end-byte")
251 .and_then(|c| c.value.parse().ok())
252 }
253
254 /// Return the `chose-alt-fingerprint` value, if recorded.
255 #[must_use]
256 pub fn chose_alt_fingerprint(&self) -> Option<&'a str> {
257 self.constraints
258 .iter()
259 .find(|c| c.sort.as_ref() == "chose-alt-fingerprint")
260 .map(|c| c.value.as_str())
261 }
262
263 /// Return the `chose-alt-child-kinds` value, if recorded.
264 #[must_use]
265 pub fn chose_alt_child_kinds(&self) -> Option<&'a str> {
266 self.constraints
267 .iter()
268 .find(|c| c.sort.as_ref() == "chose-alt-child-kinds")
269 .map(|c| c.value.as_str())
270 }
271}
272
273#[cfg(test)]
274#[allow(clippy::unwrap_used, clippy::expect_used)]
275mod tests {
276 use super::*;
277 use crate::{EdgeRule, Protocol, SchemaBuilder, SchemaError};
278 use panproto_gat::Name;
279
280 fn empty_protocol() -> Protocol {
281 Protocol {
282 name: "test".to_owned(),
283 schema_theory: "ThTest".to_owned(),
284 instance_theory: "ThWType".to_owned(),
285 edge_rules: vec![EdgeRule {
286 edge_kind: "child_of".to_owned(),
287 src_kinds: vec!["node".to_owned()],
288 tgt_kinds: vec!["node".to_owned()],
289 }],
290 obj_kinds: vec!["node".to_owned()],
291 ..Default::default()
292 }
293 }
294
295 #[test]
296 fn forget_layout_strips_layout_sorts_only() {
297 let p = empty_protocol();
298 let schema = SchemaBuilder::new(&p)
299 .vertex("v0", "node", None)
300 .unwrap()
301 .constraint("v0", "start-byte", "10")
302 .constraint("v0", "end-byte", "20")
303 .constraint("v0", "literal-value", "hi")
304 .build()
305 .unwrap();
306
307 let stripped = schema.forget_layout();
308 let cs = stripped.constraints.get(&Name::from("v0")).unwrap();
309 assert_eq!(cs.len(), 1);
310 assert_eq!(cs[0].sort.as_ref(), "literal-value");
311 assert!(stripped.is_layout_free());
312 }
313
314 #[test]
315 fn forget_layout_is_idempotent() {
316 let p = empty_protocol();
317 let schema = SchemaBuilder::new(&p)
318 .vertex("v0", "node", None)
319 .unwrap()
320 .constraint("v0", "interstitial-0", " ")
321 .constraint("v0", "chose-alt-fingerprint", "{ }")
322 .build()
323 .unwrap();
324 let once = schema.forget_layout();
325 let twice = once.forget_layout();
326 assert_eq!(once.constraints, twice.constraints);
327 assert!(twice.is_layout_free());
328 }
329
330 #[test]
331 fn decorated_layout_witness_round_trips_byte_span() {
332 let p = empty_protocol();
333 let schema = SchemaBuilder::new(&p)
334 .vertex("v0", "node", None)
335 .unwrap()
336 .constraint("v0", "start-byte", "3")
337 .constraint("v0", "end-byte", "7")
338 .build()
339 .unwrap();
340 let decorated = DecoratedSchema::wrap_unchecked(schema);
341 let w = decorated.layout_witness("v0").unwrap();
342 assert_eq!(w.start_byte(), Some(3));
343 assert_eq!(w.end_byte(), Some(7));
344 }
345
346 #[test]
347 fn build_abstract_accepts_layout_free_input() {
348 let p = empty_protocol();
349 let result = SchemaBuilder::new(&p)
350 .vertex("v0", "node", None)
351 .unwrap()
352 .constraint("v0", "literal-value", "hi")
353 .build_abstract();
354 assert!(
355 result.is_ok(),
356 "build_abstract should accept content-only constraints"
357 );
358 assert!(result.unwrap().as_schema().is_layout_free());
359 }
360
361 #[test]
362 fn build_abstract_rejects_layout_constraints() {
363 let p = empty_protocol();
364 let result = SchemaBuilder::new(&p)
365 .vertex("v0", "node", None)
366 .unwrap()
367 .constraint("v0", "start-byte", "0")
368 .build_abstract();
369 assert!(matches!(
370 result,
371 Err(SchemaError::LayoutConstraintsOnAbstractBuild)
372 ));
373 }
374
375 #[test]
376 fn build_decorated_accepts_any_constraint_set() {
377 let p = empty_protocol();
378 let result = SchemaBuilder::new(&p)
379 .vertex("v0", "node", None)
380 .unwrap()
381 .constraint("v0", "start-byte", "0")
382 .constraint("v0", "end-byte", "4")
383 .build_decorated();
384 assert!(
385 result.is_ok(),
386 "build_decorated does not validate the fibre"
387 );
388 }
389
390 #[test]
391 fn layout_witness_iter_filters_to_layout_only() {
392 let p = empty_protocol();
393 let schema = SchemaBuilder::new(&p)
394 .vertex("v0", "node", None)
395 .unwrap()
396 .constraint("v0", "start-byte", "0")
397 .constraint("v0", "literal-value", "hi")
398 .constraint("v0", "interstitial-0", " ")
399 .build()
400 .unwrap();
401 let decorated = DecoratedSchema::wrap_unchecked(schema);
402 let w = decorated.layout_witness("v0").unwrap();
403 let sorts: Vec<&str> = w.iter().map(|c| c.sort.as_ref()).collect();
404 assert!(sorts.contains(&"start-byte"));
405 assert!(sorts.contains(&"interstitial-0"));
406 assert!(!sorts.contains(&"literal-value"));
407 }
408
409 #[test]
410 fn layout_witness_returns_chose_alt_constraints() {
411 let p = empty_protocol();
412 let schema = SchemaBuilder::new(&p)
413 .vertex("v0", "node", None)
414 .unwrap()
415 .constraint("v0", "chose-alt-fingerprint", "{ }")
416 .constraint("v0", "chose-alt-child-kinds", "symbol punctuation")
417 .build()
418 .unwrap();
419 let decorated = DecoratedSchema::wrap_unchecked(schema);
420 let w = decorated.layout_witness("v0").unwrap();
421 assert_eq!(w.chose_alt_fingerprint(), Some("{ }"));
422 assert_eq!(w.chose_alt_child_kinds(), Some("symbol punctuation"));
423 }
424
425 #[test]
426 fn layout_witness_returns_none_for_missing_vertex() {
427 let p = empty_protocol();
428 let schema = SchemaBuilder::new(&p)
429 .vertex("v0", "node", None)
430 .unwrap()
431 .build()
432 .unwrap();
433 let decorated = DecoratedSchema::wrap_unchecked(schema);
434 assert!(decorated.layout_witness("nonexistent").is_none());
435 }
436
437 #[test]
438 fn from_layout_free_reports_offending_count() {
439 let p = empty_protocol();
440 let schema = SchemaBuilder::new(&p)
441 .vertex("v0", "node", None)
442 .unwrap()
443 .constraint("v0", "start-byte", "0")
444 .constraint("v0", "end-byte", "4")
445 .constraint("v0", "chose-alt-fingerprint", "{")
446 .build()
447 .unwrap();
448 let err = AbstractSchema::from_layout_free(schema).unwrap_err();
449 assert_eq!(err.count, 3);
450 }
451}