Skip to main content

type_bridge_schema/
document.rs

1use std::collections::BTreeMap;
2
3use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
4use type_bridge_contract::schema::{
5    DocumentFingerprint, DocumentId, SchemaDiagnostic, SchemaDiagnostics,
6    SchemaDocumentSetFingerprint, SourceSpan,
7};
8
9use crate::schema_set::SchemaSetManifestDocument;
10
11/// Default maximum number of documents in one schema document set.
12pub const DEFAULT_MAX_DOCUMENTS: usize = 4_096;
13/// Default maximum aggregate UTF-8 source bytes in one document set.
14pub const DEFAULT_MAX_AGGREGATE_BYTES: usize = 64 * 1024 * 1024;
15/// Default maximum UTF-8 source bytes in one document.
16pub const DEFAULT_MAX_DOCUMENT_BYTES: usize = 16 * 1024 * 1024;
17/// Default maximum YAML node nesting depth.
18pub const DEFAULT_MAX_DEPTH: usize = 64;
19/// Default maximum YAML nodes in one document.
20pub const DEFAULT_MAX_NODES: usize = 65_536;
21/// Default maximum source-token bytes in one YAML scalar.
22///
23/// Canonical decoded strings have their own lower contract bound. The parser
24/// permits quoting and escape overhead up to the already-bounded document
25/// source ceiling.
26pub const DEFAULT_MAX_SCALAR_SOURCE_BYTES: usize = DEFAULT_MAX_DOCUMENT_BYTES;
27
28/// Resource ceilings applied before a schema document becomes trusted input.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub struct SchemaParseLimits {
31    max_documents: usize,
32    max_aggregate_bytes: usize,
33    max_document_bytes: usize,
34    max_depth: usize,
35    max_nodes: usize,
36    max_scalar_source_bytes: usize,
37}
38
39impl SchemaParseLimits {
40    /// Creates explicit parser ceilings.
41    #[must_use]
42    pub const fn new(
43        max_documents: usize,
44        max_aggregate_bytes: usize,
45        max_document_bytes: usize,
46        max_depth: usize,
47        max_nodes: usize,
48        max_scalar_source_bytes: usize,
49    ) -> Self {
50        Self {
51            max_documents,
52            max_aggregate_bytes,
53            max_document_bytes,
54            max_depth,
55            max_nodes,
56            max_scalar_source_bytes,
57        }
58    }
59
60    /// Returns the document-count ceiling.
61    #[must_use]
62    pub const fn max_documents(self) -> usize {
63        self.max_documents
64    }
65
66    /// Returns the aggregate-source-byte ceiling.
67    #[must_use]
68    pub const fn max_aggregate_bytes(self) -> usize {
69        self.max_aggregate_bytes
70    }
71
72    /// Returns the per-document source-byte ceiling.
73    #[must_use]
74    pub const fn max_document_bytes(self) -> usize {
75        self.max_document_bytes
76    }
77
78    /// Returns the node-depth ceiling.
79    #[must_use]
80    pub const fn max_depth(self) -> usize {
81        self.max_depth
82    }
83
84    /// Returns the per-document node-count ceiling.
85    #[must_use]
86    pub const fn max_nodes(self) -> usize {
87        self.max_nodes
88    }
89
90    /// Returns the YAML scalar source-token byte ceiling.
91    #[must_use]
92    pub const fn max_scalar_source_bytes(self) -> usize {
93        self.max_scalar_source_bytes
94    }
95
96    /// Returns the YAML scalar source-token byte ceiling.
97    ///
98    /// This compatibility spelling preserves callers of the original parser
99    /// limit API; the limit applies to exact YAML source bytes, not decoded
100    /// canonical string bytes.
101    #[must_use]
102    pub const fn max_scalar_bytes(self) -> usize {
103        self.max_scalar_source_bytes()
104    }
105}
106
107impl Default for SchemaParseLimits {
108    fn default() -> Self {
109        Self::new(
110            DEFAULT_MAX_DOCUMENTS,
111            DEFAULT_MAX_AGGREGATE_BYTES,
112            DEFAULT_MAX_DOCUMENT_BYTES,
113            DEFAULT_MAX_DEPTH,
114            DEFAULT_MAX_NODES,
115            DEFAULT_MAX_SCALAR_SOURCE_BYTES,
116        )
117    }
118}
119
120/// YAML scalar spelling retained by the lossless document layer.
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum YamlScalarStyle {
123    /// Unquoted scalar.
124    Plain,
125    /// Single-quoted scalar.
126    SingleQuoted,
127    /// Double-quoted scalar.
128    DoubleQuoted,
129    /// Literal block scalar.
130    Literal,
131    /// Folded block scalar.
132    Folded,
133}
134
135/// YAML collection spelling retained by the lossless document layer.
136#[derive(Clone, Copy, Debug, Eq, PartialEq)]
137pub enum YamlCollectionStyle {
138    /// Indentation-delimited collection.
139    Block,
140    /// Bracket-delimited collection.
141    Flow,
142}
143
144/// Placement reported for a source comment.
145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub enum CommentPlacement {
147    /// Comment immediately above the associated syntax.
148    Above,
149    /// Comment to the right of syntax on the same line.
150    Right,
151    /// Free-standing comment.
152    Free,
153    /// Comment after the last item in a collection.
154    Last,
155}
156
157/// A comment retained with its exact source location.
158#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct SchemaComment {
160    text: String,
161    placement: CommentPlacement,
162    span: SourceSpan,
163}
164
165impl SchemaComment {
166    pub(crate) fn new(text: String, placement: CommentPlacement, span: SourceSpan) -> Self {
167        Self {
168            text,
169            placement,
170            span,
171        }
172    }
173
174    /// Returns the decoded comment text.
175    #[must_use]
176    pub fn text(&self) -> &str {
177        &self.text
178    }
179
180    /// Returns the parser-reported placement.
181    #[must_use]
182    pub const fn placement(&self) -> CommentPlacement {
183        self.placement
184    }
185
186    /// Returns the exact source span.
187    #[must_use]
188    pub const fn span(&self) -> &SourceSpan {
189        &self.span
190    }
191}
192
193/// A decoded YAML scalar with its exact spelling and source location.
194#[derive(Clone, Debug, Eq, PartialEq)]
195pub struct YamlScalar {
196    value: String,
197    raw: String,
198    style: YamlScalarStyle,
199    span: SourceSpan,
200}
201
202impl YamlScalar {
203    pub(crate) fn new(
204        value: String,
205        raw: String,
206        style: YamlScalarStyle,
207        span: SourceSpan,
208    ) -> Self {
209        Self {
210            value,
211            raw,
212            style,
213            span,
214        }
215    }
216
217    /// Returns the parser-decoded scalar value.
218    #[must_use]
219    pub fn value(&self) -> &str {
220        &self.value
221    }
222
223    /// Returns the exact scalar spelling from the source document.
224    #[must_use]
225    pub fn raw(&self) -> &str {
226        &self.raw
227    }
228
229    /// Returns the scalar style.
230    #[must_use]
231    pub const fn style(&self) -> YamlScalarStyle {
232        self.style
233    }
234
235    /// Returns the exact source span.
236    #[must_use]
237    pub const fn span(&self) -> &SourceSpan {
238        &self.span
239    }
240}
241
242/// One string-keyed YAML mapping entry.
243#[derive(Clone, Debug, Eq, PartialEq)]
244pub struct YamlMappingEntry {
245    key: YamlScalar,
246    value: YamlNode,
247}
248
249impl YamlMappingEntry {
250    pub(crate) const fn new(key: YamlScalar, value: YamlNode) -> Self {
251        Self { key, value }
252    }
253
254    /// Returns the string key.
255    #[must_use]
256    pub const fn key(&self) -> &YamlScalar {
257        &self.key
258    }
259
260    /// Returns the mapped node.
261    #[must_use]
262    pub const fn value(&self) -> &YamlNode {
263        &self.value
264    }
265}
266
267/// A lossless, insertion-ordered YAML mapping.
268#[derive(Clone, Debug, Eq, PartialEq)]
269pub struct YamlMapping {
270    entries: Vec<YamlMappingEntry>,
271    style: YamlCollectionStyle,
272    span: SourceSpan,
273}
274
275impl YamlMapping {
276    pub(crate) const fn new(
277        entries: Vec<YamlMappingEntry>,
278        style: YamlCollectionStyle,
279        span: SourceSpan,
280    ) -> Self {
281        Self {
282            entries,
283            style,
284            span,
285        }
286    }
287
288    /// Returns entries in source order.
289    #[must_use]
290    pub fn entries(&self) -> &[YamlMappingEntry] {
291        &self.entries
292    }
293
294    /// Returns the collection style.
295    #[must_use]
296    pub const fn style(&self) -> YamlCollectionStyle {
297        self.style
298    }
299
300    /// Returns the collection source span.
301    #[must_use]
302    pub const fn span(&self) -> &SourceSpan {
303        &self.span
304    }
305}
306
307/// A lossless YAML sequence.
308#[derive(Clone, Debug, Eq, PartialEq)]
309pub struct YamlSequence {
310    items: Vec<YamlNode>,
311    style: YamlCollectionStyle,
312    span: SourceSpan,
313}
314
315impl YamlSequence {
316    pub(crate) const fn new(
317        items: Vec<YamlNode>,
318        style: YamlCollectionStyle,
319        span: SourceSpan,
320    ) -> Self {
321        Self { items, style, span }
322    }
323
324    /// Returns items in source order.
325    #[must_use]
326    pub fn items(&self) -> &[YamlNode] {
327        &self.items
328    }
329
330    /// Returns the collection style.
331    #[must_use]
332    pub const fn style(&self) -> YamlCollectionStyle {
333        self.style
334    }
335
336    /// Returns the collection source span.
337    #[must_use]
338    pub const fn span(&self) -> &SourceSpan {
339        &self.span
340    }
341}
342
343/// A YAML node accepted by the closed schema-document grammar.
344#[derive(Clone, Debug, Eq, PartialEq)]
345pub enum YamlNode {
346    /// Scalar node.
347    Scalar(YamlScalar),
348    /// Sequence node.
349    Sequence(YamlSequence),
350    /// Mapping node.
351    Mapping(YamlMapping),
352}
353
354impl YamlNode {
355    /// Returns the node source span.
356    #[must_use]
357    pub const fn span(&self) -> &SourceSpan {
358        match self {
359            Self::Scalar(value) => value.span(),
360            Self::Sequence(value) => value.span(),
361            Self::Mapping(value) => value.span(),
362        }
363    }
364
365    /// Returns this node as a mapping, if it is one.
366    #[must_use]
367    pub const fn as_mapping(&self) -> Option<&YamlMapping> {
368        match self {
369            Self::Mapping(value) => Some(value),
370            Self::Scalar(_) | Self::Sequence(_) => None,
371        }
372    }
373
374    /// Returns this node as a sequence, if it is one.
375    #[must_use]
376    pub const fn as_sequence(&self) -> Option<&YamlSequence> {
377        match self {
378            Self::Sequence(value) => Some(value),
379            Self::Scalar(_) | Self::Mapping(_) => None,
380        }
381    }
382
383    /// Returns this node as a scalar, if it is one.
384    #[must_use]
385    pub const fn as_scalar(&self) -> Option<&YamlScalar> {
386        match self {
387            Self::Scalar(value) => Some(value),
388            Self::Sequence(_) | Self::Mapping(_) => None,
389        }
390    }
391}
392
393/// One parsed schema source document.
394#[derive(Clone, Debug, Eq, PartialEq)]
395pub struct SchemaDocument {
396    id: DocumentId,
397    source: String,
398    fingerprint: DocumentFingerprint,
399    root: YamlMapping,
400    comments: Vec<SchemaComment>,
401}
402
403impl SchemaDocument {
404    pub(crate) const fn new(
405        id: DocumentId,
406        source: String,
407        fingerprint: DocumentFingerprint,
408        root: YamlMapping,
409        comments: Vec<SchemaComment>,
410    ) -> Self {
411        Self {
412            id,
413            source,
414            fingerprint,
415            root,
416            comments,
417        }
418    }
419
420    /// Parses one document with the default resource ceilings.
421    pub fn parse(id: DocumentId, source: impl Into<String>) -> Result<Self, SchemaDiagnostics> {
422        Self::parse_with_limits(id, source, SchemaParseLimits::default())
423    }
424
425    /// Parses one document with explicit resource ceilings.
426    pub fn parse_with_limits(
427        id: DocumentId,
428        source: impl Into<String>,
429        limits: SchemaParseLimits,
430    ) -> Result<Self, SchemaDiagnostics> {
431        crate::yaml::parse_document_with_limits(id, source.into(), limits)
432    }
433
434    /// Returns the stable document identifier.
435    #[must_use]
436    pub const fn id(&self) -> &DocumentId {
437        &self.id
438    }
439
440    /// Returns the source exactly as supplied by the caller.
441    #[must_use]
442    pub fn source(&self) -> &str {
443        &self.source
444    }
445
446    /// Returns the source-byte fingerprint.
447    #[must_use]
448    pub const fn fingerprint(&self) -> &DocumentFingerprint {
449        &self.fingerprint
450    }
451
452    /// Returns the required root mapping.
453    #[must_use]
454    pub const fn root(&self) -> &YamlMapping {
455        &self.root
456    }
457
458    /// Returns all comments in source order.
459    #[must_use]
460    pub fn comments(&self) -> &[SchemaComment] {
461        &self.comments
462    }
463}
464
465/// A deterministic, identifier-keyed collection of schema documents.
466#[derive(Clone, Debug, Default, Eq, PartialEq)]
467pub struct SchemaDocumentSet {
468    documents: BTreeMap<DocumentId, SchemaDocument>,
469    manifest: Option<SchemaSetManifestDocument>,
470}
471
472impl SchemaDocumentSet {
473    /// Parses documents with default resource ceilings.
474    pub fn parse<I, S>(sources: I) -> Result<Self, SchemaDiagnostics>
475    where
476        I: IntoIterator<Item = (DocumentId, S)>,
477        S: Into<String>,
478    {
479        Self::parse_with_limits(sources, SchemaParseLimits::default())
480    }
481
482    /// Parses documents with explicit per-document and aggregate ceilings.
483    pub fn parse_with_limits<I, S>(
484        sources: I,
485        limits: SchemaParseLimits,
486    ) -> Result<Self, SchemaDiagnostics>
487    where
488        I: IntoIterator<Item = (DocumentId, S)>,
489        S: Into<String>,
490    {
491        let mut documents: BTreeMap<DocumentId, SchemaDocument> = BTreeMap::new();
492        let mut aggregate_bytes = 0usize;
493
494        for (id, source) in sources {
495            if documents.len() >= limits.max_documents() {
496                return Err(resource_diagnostic(
497                    "schema_document_count_limit",
498                    format!(
499                        "schema document count exceeds the limit of {}",
500                        limits.max_documents()
501                    ),
502                    None,
503                ));
504            }
505
506            let source = source.into();
507            aggregate_bytes = aggregate_bytes.checked_add(source.len()).ok_or_else(|| {
508                resource_diagnostic(
509                    "schema_aggregate_size_limit",
510                    "schema aggregate source size overflowed",
511                    None,
512                )
513            })?;
514            if aggregate_bytes > limits.max_aggregate_bytes() {
515                return Err(resource_diagnostic(
516                    "schema_aggregate_size_limit",
517                    format!(
518                        "schema aggregate source size exceeds the limit of {} bytes",
519                        limits.max_aggregate_bytes()
520                    ),
521                    None,
522                ));
523            }
524
525            if let Some(existing) = documents.get(&id) {
526                return Err(crate::yaml::diagnostic_with_related(
527                    DiagnosticCategory::InvalidContract,
528                    "duplicate_schema_document",
529                    format!("schema document identifier `{}` is duplicated", id.as_str()),
530                    existing.root().span().clone(),
531                    existing.root().span().clone(),
532                    "first document with this identifier",
533                ));
534            }
535
536            let document = SchemaDocument::parse_with_limits(id.clone(), source, limits)?;
537            documents.insert(id, document);
538        }
539
540        Ok(Self {
541            documents,
542            manifest: None,
543        })
544    }
545
546    pub(crate) fn attach_manifest(&mut self, manifest: SchemaSetManifestDocument) {
547        self.manifest = Some(manifest);
548    }
549
550    /// Returns the schema-set manifest retained by file-backed loading, if any.
551    #[must_use]
552    pub const fn manifest(&self) -> Option<&SchemaSetManifestDocument> {
553        self.manifest.as_ref()
554    }
555
556    /// Fingerprints ordered portable document paths and exact source fingerprints.
557    pub fn fingerprint(&self) -> Result<SchemaDocumentSetFingerprint, SchemaDiagnostics> {
558        let mut canonical = Vec::new();
559        canonical.extend_from_slice(
560            &u64::try_from(self.documents.len())
561                .expect("schema document count is bounded below u64::MAX")
562                .to_be_bytes(),
563        );
564        for (id, document) in &self.documents {
565            canonical.extend_from_slice(
566                &u64::try_from(id.as_str().len())
567                    .expect("document identifier length is bounded below u64::MAX")
568                    .to_be_bytes(),
569            );
570            canonical.extend_from_slice(id.as_str().as_bytes());
571            canonical.extend_from_slice(&document.fingerprint().as_fingerprint().digest().bytes());
572        }
573        SchemaDocumentSetFingerprint::compute(&canonical)
574            .map_err(|error| SchemaDiagnostics::one(SchemaDiagnostic::new(error, None)))
575    }
576
577    /// Returns the number of documents.
578    #[must_use]
579    pub fn len(&self) -> usize {
580        self.documents.len()
581    }
582
583    /// Reports whether the set has no documents.
584    #[must_use]
585    pub fn is_empty(&self) -> bool {
586        self.documents.is_empty()
587    }
588
589    /// Returns a document by stable identifier.
590    #[must_use]
591    pub fn get(&self, id: &DocumentId) -> Option<&SchemaDocument> {
592        self.documents.get(id)
593    }
594
595    /// Iterates documents in stable identifier order.
596    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&DocumentId, &SchemaDocument)> {
597        self.documents.iter()
598    }
599}
600
601pub(crate) fn resource_diagnostic(
602    code: &'static str,
603    message: impl Into<String>,
604    primary: Option<SourceSpan>,
605) -> SchemaDiagnostics {
606    let diagnostic = Diagnostic::new(
607        DiagnosticCategory::ResourceLimit,
608        DiagnosticCode::new(code).expect("static schema diagnostic code is valid"),
609        message,
610    );
611    SchemaDiagnostics::one(SchemaDiagnostic::new(diagnostic, primary))
612}