Skip to main content

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}