Skip to main content

boxferry_quadlet/
source.rs

1//! Explicit input bundle for one native Quadlet document set.
2
3use std::{collections::BTreeMap, error::Error, fmt};
4
5use boxferry_engine::{NativeFinding, NativeFindingLabel, NativeFindingLabelKind, Severity};
6use boxferry_model::{Identifier, ModelError, SourceId};
7use quadlet_lens::{
8    diagnostic::{Diagnostic as NativeDiagnostic, Severity as NativeSeverity},
9    model::{
10        DocumentSetError, NamedQuadletDocument, QuadletDocument, QuadletDocumentSet, TypedModelError, UnitFileName,
11    },
12    source::{SourceId as QuadletSourceId, SourceSpan as QuadletSourceSpan},
13};
14
15/// Origin of one native diagnostic retained by [`QuadletSource::parse`].
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum QuadletParseDiagnosticOrigin {
19    /// `QuadletLens` syntax parsing.
20    Syntax,
21    /// `QuadletLens` typed-model validation.
22    Model,
23    /// `QuadletLens` cross-document validation.
24    DocumentSet,
25}
26
27/// Severity of one native diagnostic retained by [`QuadletSource::parse`].
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29#[non_exhaustive]
30pub enum QuadletParseDiagnosticSeverity {
31    /// The native input cannot cross the parse boundary.
32    Error,
33    /// The native input remains usable but requires attention.
34    Warning,
35    /// Informational native context.
36    Note,
37}
38
39/// One labelled byte range on a [`QuadletParseDiagnostic`].
40///
41/// This DTO deliberately retains only the numeric source identity, half-open byte offsets, and
42/// static native label message. It never retains source text or a source name.
43#[derive(Clone, Debug, Eq, PartialEq)]
44#[non_exhaustive]
45pub struct QuadletParseDiagnosticLabel {
46    source_id: u32,
47    start: usize,
48    end: usize,
49    message: &'static str,
50}
51
52impl QuadletParseDiagnosticLabel {
53    /// Returns the caller-owned numeric source identity.
54    #[must_use]
55    pub const fn source_id(&self) -> u32 {
56        self.source_id
57    }
58
59    /// Returns the inclusive byte offset of this label.
60    #[must_use]
61    pub const fn start(&self) -> usize {
62        self.start
63    }
64
65    /// Returns the exclusive byte offset of this label.
66    #[must_use]
67    pub const fn end(&self) -> usize {
68        self.end
69    }
70
71    /// Returns the static, value-free native label message.
72    #[must_use]
73    pub const fn message(&self) -> &'static str {
74        self.message
75    }
76}
77
78/// One recoverable `QuadletLens` diagnostic retained without source contents.
79#[derive(Clone, Debug, Eq, PartialEq)]
80#[non_exhaustive]
81pub struct QuadletParseDiagnostic {
82    code: &'static str,
83    severity: QuadletParseDiagnosticSeverity,
84    summary: &'static str,
85    origin: QuadletParseDiagnosticOrigin,
86    labels: Vec<QuadletParseDiagnosticLabel>,
87}
88
89impl QuadletParseDiagnostic {
90    /// Returns the stable native diagnostic code.
91    #[must_use]
92    pub const fn code(&self) -> &'static str {
93        self.code
94    }
95
96    /// Returns the native diagnostic severity.
97    #[must_use]
98    pub const fn severity(&self) -> QuadletParseDiagnosticSeverity {
99        self.severity
100    }
101
102    /// Returns the static, value-free native diagnostic summary.
103    #[must_use]
104    pub const fn summary(&self) -> &'static str {
105        self.summary
106    }
107
108    /// Returns the native diagnostic layer that produced this diagnostic.
109    #[must_use]
110    pub const fn origin(&self) -> QuadletParseDiagnosticOrigin {
111        self.origin
112    }
113
114    /// Returns every labelled native source span in native order.
115    #[must_use]
116    pub fn labels(&self) -> &[QuadletParseDiagnosticLabel] {
117        &self.labels
118    }
119
120    /// Converts this diagnostic into the shared protected finding envelope.
121    #[must_use]
122    pub fn native_finding(&self) -> NativeFinding {
123        native_finding(self)
124    }
125}
126
127/// Stage that produced a non-recoverable parse failure.
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129#[non_exhaustive]
130pub enum QuadletParseFailureStage {
131    /// Native unit filename validation.
132    Filename,
133    /// Native typed-model construction.
134    TypedModel,
135    /// Native named-document metadata construction.
136    DocumentMetadata,
137    /// Native document-set construction.
138    DocumentSet,
139    /// `BoxFerry` neutral source construction.
140    NeutralModel,
141}
142
143/// A non-recoverable failure that prevented a parse result.
144///
145/// The failure retains structured stage and location metadata only. It never stores a filename,
146/// source text, native display rendering, or model value.
147#[derive(Clone, Debug, Eq, PartialEq)]
148#[non_exhaustive]
149pub struct QuadletParseFailure {
150    stage: QuadletParseFailureStage,
151    input_index: Option<usize>,
152    source_id: Option<u32>,
153    span: Option<QuadletParseDiagnosticLabel>,
154    summary: &'static str,
155}
156
157impl QuadletParseFailure {
158    /// Returns the stage that could not recover.
159    #[must_use]
160    pub const fn stage(&self) -> QuadletParseFailureStage {
161        self.stage
162    }
163
164    /// Returns the caller-order index of the input when known.
165    #[must_use]
166    pub const fn input_index(&self) -> Option<usize> {
167        self.input_index
168    }
169
170    /// Returns the caller-owned numeric source identity when known.
171    #[must_use]
172    pub const fn source_id(&self) -> Option<u32> {
173        self.source_id
174    }
175
176    /// Returns the parser-owned span when the failure has one.
177    #[must_use]
178    pub const fn span(&self) -> Option<&QuadletParseDiagnosticLabel> {
179        self.span.as_ref()
180    }
181
182    /// Returns a static, value-free failure summary.
183    #[must_use]
184    pub const fn summary(&self) -> &'static str {
185        self.summary
186    }
187}
188
189/// Successful native parse with every recoverable diagnostic retained.
190#[derive(Clone, Debug, Eq, PartialEq)]
191#[non_exhaustive]
192pub struct QuadletParseResult {
193    source: QuadletSource,
194    diagnostics: Vec<QuadletParseDiagnostic>,
195}
196
197impl QuadletParseResult {
198    /// Returns the native source boundary, including a recoverably incomplete document graph.
199    #[must_use]
200    pub const fn source(&self) -> &QuadletSource {
201        &self.source
202    }
203
204    /// Returns native syntax, model, and document-set diagnostics in collection order.
205    #[must_use]
206    pub fn diagnostics(&self) -> &[QuadletParseDiagnostic] {
207        &self.diagnostics
208    }
209
210    /// Consumes this result and returns the valid source boundary.
211    #[must_use]
212    pub fn into_source(self) -> QuadletSource {
213        self.source
214    }
215}
216
217/// Quadlet parse failure with recoverable diagnostics retained.
218#[derive(Clone, Debug, Eq, PartialEq)]
219#[non_exhaustive]
220pub struct QuadletParseError {
221    diagnostics: Vec<QuadletParseDiagnostic>,
222    failures: Vec<QuadletParseFailure>,
223}
224
225impl QuadletParseError {
226    /// Returns every recoverable native diagnostic collected before the boundary failed.
227    #[must_use]
228    pub fn diagnostics(&self) -> &[QuadletParseDiagnostic] {
229        &self.diagnostics
230    }
231
232    /// Returns non-recoverable native or neutral-model failures in collection order.
233    #[must_use]
234    pub fn failures(&self) -> &[QuadletParseFailure] {
235        &self.failures
236    }
237}
238
239impl fmt::Display for QuadletParseError {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        formatter.write_str("Quadlet parse failed")
242    }
243}
244
245impl Error for QuadletParseError {}
246
247/// One caller-supplied Quadlet unit file held entirely in memory.
248#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct QuadletDocumentInput {
250    name: String,
251    source_id: QuadletSourceId,
252    text: String,
253}
254
255impl QuadletDocumentInput {
256    /// Creates an input without reading or validating an external path.
257    #[must_use]
258    pub fn new(name: impl Into<String>, source_id: QuadletSourceId, text: impl Into<String>) -> Self {
259        Self {
260            name: name.into(),
261            source_id,
262            text: text.into(),
263        }
264    }
265
266    /// Returns the caller-supplied unit-file basename.
267    #[must_use]
268    pub fn name(&self) -> &str {
269        &self.name
270    }
271
272    /// Returns the caller-owned native source identity.
273    #[must_use]
274    pub const fn source_id(&self) -> QuadletSourceId {
275        self.source_id
276    }
277
278    /// Returns the complete authored unit text.
279    #[must_use]
280    pub fn text(&self) -> &str {
281        &self.text
282    }
283}
284
285/// A named Quadlet document set and caller-owned source identities.
286#[derive(Clone, Debug, Eq, PartialEq)]
287pub struct QuadletSource {
288    application_name: Identifier,
289    documents: QuadletDocumentSet,
290    source_ids: BTreeMap<QuadletSourceId, SourceId>,
291    fallback_source_id: SourceId,
292    native_findings: Vec<NativeFinding>,
293}
294
295impl QuadletSource {
296    /// Parses explicit in-memory unit files while retaining every recoverable native diagnostic.
297    ///
298    /// Diagnostics are collected in caller input order: syntax diagnostics followed by typed-model
299    /// diagnostics for each input, then document-set diagnostics. Recoverable document-set
300    /// diagnostics retain an incomplete graph in the successful result. Syntax/model errors and
301    /// non-recoverable metadata failures return [`QuadletParseError`].
302    ///
303    /// The parse DTOs contain static diagnostic messages, numeric source identities, and byte
304    /// spans only; source text, filenames, protected values, and native terminal renderings are
305    /// deliberately excluded.
306    ///
307    /// # Errors
308    ///
309    /// Returns [`QuadletParseError`] when a syntax/model error-severity diagnostic or a
310    /// non-recoverable native or neutral-model construction failure prevents a usable source.
311    pub fn parse(
312        application_name: Identifier,
313        inputs: impl IntoIterator<Item = QuadletDocumentInput>,
314    ) -> Result<QuadletParseResult, QuadletParseError> {
315        let mut documents = Vec::new();
316        let mut source_names = Vec::new();
317        let mut diagnostics = Vec::new();
318        let mut failures = Vec::new();
319
320        for (input_index, input) in inputs.into_iter().enumerate() {
321            let source_id = input.source_id;
322            let filename = match UnitFileName::new(input.name.clone()) {
323                Ok(filename) => filename,
324                Err(error) => {
325                    failures.push(document_metadata_failure(
326                        QuadletParseFailureStage::Filename,
327                        Some(input_index),
328                        Some(source_id.get()),
329                        None,
330                        &error,
331                    ));
332                    continue;
333                }
334            };
335            let parsed = match QuadletDocument::parse(filename.unit_type(), source_id, input.text) {
336                Ok(parsed) => parsed,
337                Err(error) => {
338                    failures.push(typed_model_failure(input_index, source_id, error));
339                    continue;
340                }
341            };
342            diagnostics.extend(copy_diagnostics(
343                QuadletParseDiagnosticOrigin::Syntax,
344                parsed.syntax().diagnostics(),
345            ));
346            diagnostics.extend(copy_diagnostics(
347                QuadletParseDiagnosticOrigin::Model,
348                parsed.model_diagnostics(),
349            ));
350            match NamedQuadletDocument::new(filename.as_str(), parsed.document().clone()) {
351                Ok(document) => {
352                    source_names.push((source_id, filename.as_str().to_owned()));
353                    documents.push(document);
354                }
355                Err(error) => failures.push(document_metadata_failure(
356                    QuadletParseFailureStage::DocumentMetadata,
357                    Some(input_index),
358                    Some(source_id.get()),
359                    None,
360                    &error,
361                )),
362            }
363        }
364
365        let documents = match QuadletDocumentSet::new(documents) {
366            Ok(documents) => {
367                diagnostics.extend(copy_diagnostics(
368                    QuadletParseDiagnosticOrigin::DocumentSet,
369                    documents.diagnostics(),
370                ));
371                Some(documents)
372            }
373            Err(error) => {
374                failures.push(document_set_failure(&error));
375                None
376            }
377        };
378
379        if diagnostics.iter().any(|diagnostic| {
380            diagnostic.severity() == QuadletParseDiagnosticSeverity::Error
381                && !matches!(diagnostic.origin(), QuadletParseDiagnosticOrigin::DocumentSet)
382        }) || !failures.is_empty()
383        {
384            return Err(QuadletParseError { diagnostics, failures });
385        }
386
387        let Some(documents) = documents else {
388            return Err(QuadletParseError {
389                diagnostics,
390                failures: vec![QuadletParseFailure {
391                    stage: QuadletParseFailureStage::DocumentSet,
392                    input_index: None,
393                    source_id: None,
394                    span: None,
395                    summary: "Quadlet document set could not be constructed",
396                }],
397            });
398        };
399        let mut source = match Self::from_validated_documents(application_name, documents) {
400            Ok(source) => source,
401            Err(error) => {
402                failures.push(neutral_model_failure(error));
403                return Err(QuadletParseError { diagnostics, failures });
404            }
405        };
406        for (native, name) in source_names {
407            let neutral = match SourceId::new(name) {
408                Ok(neutral) => neutral,
409                Err(error) => {
410                    failures.push(neutral_model_failure(error));
411                    return Err(QuadletParseError { diagnostics, failures });
412                }
413            };
414            source = source.with_source_id(native, neutral);
415        }
416        source.native_findings = diagnostics.iter().map(native_finding).collect();
417        Ok(QuadletParseResult { source, diagnostics })
418    }
419
420    /// Creates an explicit source without reading a Quadlet search path or installed units.
421    ///
422    /// Every document initially receives the stable neutral identity
423    /// `quadlet-source-<numeric-id>`. Call [`Self::with_source_id`] to replace it with a
424    /// caller-owned path, URI, or other display identity.
425    ///
426    /// # Errors
427    ///
428    /// Returns [`ModelError`] if a generated source identity violates the neutral-model
429    /// invariant. The current generated spelling is always valid.
430    pub fn from_validated_documents(
431        application_name: Identifier,
432        documents: QuadletDocumentSet,
433    ) -> Result<Self, ModelError> {
434        let fallback_source_id = SourceId::new("quadlet-source")?;
435        let native_findings = copy_diagnostics(QuadletParseDiagnosticOrigin::DocumentSet, documents.diagnostics())
436            .iter()
437            .map(native_finding)
438            .collect();
439        let source_ids = documents
440            .documents()
441            .iter()
442            .map(|document| document.document().source_id())
443            .map(|source_id| {
444                SourceId::new(format!("quadlet-source-{}", source_id.get())).map(|neutral| (source_id, neutral))
445            })
446            .collect::<Result<_, _>>()?;
447        Ok(Self {
448            application_name,
449            documents,
450            source_ids,
451            fallback_source_id,
452            native_findings,
453        })
454    }
455
456    /// Assigns a caller-owned neutral identity to one Quadlet source document.
457    #[must_use]
458    pub fn with_source_id(mut self, quadlet: QuadletSourceId, neutral: SourceId) -> Self {
459        self.source_ids.insert(quadlet, neutral);
460        self
461    }
462
463    /// Returns the caller-selected neutral application name.
464    #[must_use]
465    pub const fn application_name(&self) -> &Identifier {
466        &self.application_name
467    }
468
469    /// Returns the complete named native document set.
470    #[must_use]
471    pub const fn documents(&self) -> &QuadletDocumentSet {
472        &self.documents
473    }
474
475    /// Resolves a Quadlet source identity into its neutral-model identity.
476    #[must_use]
477    pub fn source_id(&self, quadlet: QuadletSourceId) -> &SourceId {
478        self.source_ids.get(&quadlet).unwrap_or(&self.fallback_source_id)
479    }
480
481    /// Returns every recoverable native finding retained by the source boundary.
482    ///
483    /// The findings contain stable native codes and value-free source metadata, but never source
484    /// contents or filenames. Importing this source forwards the same findings to embedded callers.
485    #[must_use]
486    pub fn native_findings(&self) -> &[NativeFinding] {
487        &self.native_findings
488    }
489}
490
491fn native_finding(diagnostic: &QuadletParseDiagnostic) -> NativeFinding {
492    let mut finding = NativeFinding::new(
493        "quadlet",
494        "quadlet-lens",
495        diagnostic.code(),
496        match diagnostic.origin() {
497            QuadletParseDiagnosticOrigin::Syntax => "syntax",
498            QuadletParseDiagnosticOrigin::Model => "model",
499            QuadletParseDiagnosticOrigin::DocumentSet => "document-set",
500        },
501        match diagnostic.severity() {
502            QuadletParseDiagnosticSeverity::Error => Severity::Error,
503            QuadletParseDiagnosticSeverity::Warning => Severity::Warning,
504            QuadletParseDiagnosticSeverity::Note => Severity::Note,
505        },
506        diagnostic.summary(),
507    );
508    for label in diagnostic.labels() {
509        finding = finding.with_label(NativeFindingLabel::new(
510            NativeFindingLabelKind::Primary,
511            label.source_id(),
512            label.start(),
513            label.end(),
514            label.message(),
515        ));
516    }
517    finding
518}
519
520fn copy_diagnostics(
521    origin: QuadletParseDiagnosticOrigin,
522    diagnostics: &[NativeDiagnostic],
523) -> Vec<QuadletParseDiagnostic> {
524    diagnostics
525        .iter()
526        .map(|diagnostic| QuadletParseDiagnostic {
527            code: diagnostic.code().as_str(),
528            severity: match diagnostic.severity() {
529                NativeSeverity::Error => QuadletParseDiagnosticSeverity::Error,
530                NativeSeverity::Warning => QuadletParseDiagnosticSeverity::Warning,
531                NativeSeverity::Note => QuadletParseDiagnosticSeverity::Note,
532            },
533            summary: diagnostic.summary(),
534            origin,
535            labels: diagnostic.labels().iter().map(diagnostic_label).collect(),
536        })
537        .collect()
538}
539
540fn diagnostic_label(label: &quadlet_lens::diagnostic::Label) -> QuadletParseDiagnosticLabel {
541    span_label(label.span(), label.message())
542}
543
544fn span_label(span: QuadletSourceSpan, message: &'static str) -> QuadletParseDiagnosticLabel {
545    QuadletParseDiagnosticLabel {
546        source_id: span.source_id().get(),
547        start: span.start(),
548        end: span.end(),
549        message,
550    }
551}
552
553fn typed_model_failure(input_index: usize, source_id: QuadletSourceId, error: TypedModelError) -> QuadletParseFailure {
554    match error {
555        TypedModelError::InvalidSourceSpan(span) => QuadletParseFailure {
556            stage: QuadletParseFailureStage::TypedModel,
557            input_index: Some(input_index),
558            source_id: Some(source_id.get()),
559            span: Some(span_label(span, "native typed-model span could not be resolved")),
560            summary: "Quadlet typed-model construction failed",
561        },
562        _ => QuadletParseFailure {
563            stage: QuadletParseFailureStage::TypedModel,
564            input_index: Some(input_index),
565            source_id: Some(source_id.get()),
566            span: None,
567            summary: "Quadlet typed-model construction failed",
568        },
569    }
570}
571
572fn document_metadata_failure(
573    stage: QuadletParseFailureStage,
574    input_index: Option<usize>,
575    source_id: Option<u32>,
576    span: Option<QuadletParseDiagnosticLabel>,
577    error: &DocumentSetError,
578) -> QuadletParseFailure {
579    let summary = match error {
580        DocumentSetError::InvalidUnitFileName(_) => "Quadlet unit filename is invalid",
581        DocumentSetError::UnsupportedUnitFileExtension(_) => "Quadlet unit filename has an unsupported extension",
582        DocumentSetError::UnitTypeMismatch { .. } => "Quadlet unit filename and typed document kind differ",
583        DocumentSetError::DuplicateSourceId(_) => "Quadlet named document has a duplicate source identity",
584        _ => "Quadlet native document metadata could not be constructed",
585    };
586    QuadletParseFailure {
587        stage,
588        input_index,
589        source_id,
590        span,
591        summary,
592    }
593}
594
595fn document_set_failure(error: &DocumentSetError) -> QuadletParseFailure {
596    let source_id = match error {
597        DocumentSetError::DuplicateSourceId(source_id) => Some(source_id.get()),
598        _ => None,
599    };
600    let summary = match error {
601        DocumentSetError::InvalidUnitFileName(_) => "Quadlet document set contains an invalid unit filename",
602        DocumentSetError::UnsupportedUnitFileExtension(_) => {
603            "Quadlet document set contains an unsupported unit extension"
604        }
605        DocumentSetError::UnitTypeMismatch { .. } => "Quadlet document set has mismatched document metadata",
606        DocumentSetError::DuplicateSourceId(_) => "Quadlet document set has duplicate source identities",
607        _ => "Quadlet document set could not be constructed",
608    };
609    QuadletParseFailure {
610        stage: QuadletParseFailureStage::DocumentSet,
611        input_index: None,
612        source_id,
613        span: None,
614        summary,
615    }
616}
617
618fn neutral_model_failure(_error: ModelError) -> QuadletParseFailure {
619    QuadletParseFailure {
620        stage: QuadletParseFailureStage::NeutralModel,
621        input_index: None,
622        source_id: None,
623        span: None,
624        summary: "BoxFerry neutral Quadlet source construction failed",
625    }
626}