Skip to main content

mos_core/
lib.rs

1//! Core types for the Mosaic typesetting engine.
2//!
3//! Implements the document model (manifest §5) and diagnostics surface
4//! (manifest §31). Every other crate depends on this one; nothing here
5//! depends on parsing, layout, or backends.
6
7#![doc(
8    html_logo_url = "https://mosaic.kjanat.dev/assets/A4.svg",
9    html_favicon_url = "https://mosaic.kjanat.dev/assets/A4.svg"
10)]
11
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15
16pub mod codes;
17mod sink;
18
19pub use codes::{DiagnosticCategory, DiagnosticCode, DiagnosticDef};
20pub use sink::{CollectingSink, DiagnosticAbort, DiagnosticResult, DiagnosticSink};
21
22/// Stable identifier for a document node.
23///
24/// Per manifest §5.1, IDs should ideally be derived from
25/// `hash(file path + syntactic position + explicit label + local structure)`
26/// rather than parse order. The MVP 0 lowerer (`mos-eval`) hands out
27/// monotonic IDs through `Document::alloc`; the hash-based derivation is
28/// deferred to MVP 5 when stable IDs become observable through the cache.
29///
30/// # Examples
31///
32/// ```
33/// use mos_core::NodeId;
34///
35/// let root = NodeId(0);
36///
37/// assert_eq!(root.0, 0);
38/// ```
39#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
40pub struct NodeId(pub u64);
41
42/// Opaque content / dependency hash.
43///
44/// # Examples
45///
46/// ```
47/// use mos_core::ContentHash;
48///
49/// let hash = ContentHash::default();
50///
51/// assert_eq!(hash.0, 0);
52/// ```
53#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
54pub struct ContentHash(pub u128);
55
56/// Identifier for a resolved style bundle.
57///
58/// # Examples
59///
60/// ```
61/// use mos_core::StyleId;
62///
63/// let style = StyleId::default();
64///
65/// assert_eq!(style.0, 0);
66/// ```
67#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
68pub struct StyleId(pub u32);
69
70/// The kinds of nodes Mosaic recognises (manifest §5.1).
71///
72/// # Examples
73///
74/// ```
75/// use mos_core::NodeKind;
76///
77/// let kind = NodeKind::Paragraph;
78///
79/// assert_eq!(kind, NodeKind::Paragraph);
80/// ```
81#[derive(Copy, Clone, Eq, PartialEq, Debug)]
82pub enum NodeKind {
83    Document,
84    Section,
85    Paragraph,
86    Text,
87    Emphasis,
88    Strong,
89    BoldItalic,
90    Math,
91    Equation,
92    /// A captioned container — an image plus a caption paragraph, laid
93    /// out together with the caption beneath. Cross-references via
94    /// `@fig:foo` will target this kind once MVP 3 lands.
95    Figure,
96    /// A raster image (PNG / JPEG in MVP 1.5). The decoded pixel data
97    /// and natural dimensions live on the node's attributes; see the
98    /// `mos-eval` resolver for the exact attribute names.
99    Image,
100    Table,
101    Citation,
102    Reference,
103    Theorem,
104    Footnote,
105    Bibliography,
106    Raw,
107    /// A bullet or numbered list. The `ordered` attribute distinguishes
108    /// the two kinds and child nodes are [`NodeKind::ListItem`]s.
109    List,
110    /// One entry inside a [`NodeKind::List`]. Inline children carry the
111    /// item's text; nested [`NodeKind::List`] children describe deeper
112    /// levels.
113    ListItem,
114    /// `\\` — a forced line break inside a paragraph. Carries no
115    /// attributes; layout consumes it as a `WordItem::HardBreak`
116    /// sentinel in the inline word stream. A blank-line paragraph
117    /// break is **not** the same node — it ends the paragraph and
118    /// triggers paragraph-spacing leading, whereas `HardBreak` keeps
119    /// the same paragraph and applies normal inter-line leading.
120    HardBreak,
121}
122
123/// A semantic document node (manifest §5.1).
124///
125/// # Examples
126///
127/// ```
128/// use std::path::PathBuf;
129///
130/// use mos_core::{AttrMap, ContentHash, Node, NodeId, NodeKind, SourceSpan, StyleId};
131///
132/// let file = PathBuf::from("main.mos");
133/// let node = Node {
134///     id: NodeId(1),
135///     kind: NodeKind::Paragraph,
136///     span: SourceSpan::placeholder(file),
137///     content_hash: ContentHash::default(),
138///     style_id: StyleId::default(),
139///     children: Vec::new(),
140///     attributes: AttrMap::new(),
141/// };
142///
143/// assert_eq!(node.kind, NodeKind::Paragraph);
144/// ```
145#[derive(Clone, Debug)]
146pub struct Node {
147    pub id: NodeId,
148    pub kind: NodeKind,
149    pub span: SourceSpan,
150    pub content_hash: ContentHash,
151    pub style_id: StyleId,
152    pub children: Vec<NodeId>,
153    pub attributes: AttrMap,
154}
155
156/// Attribute map carried on each node. Keys are interned strings in a
157/// later iteration; for now plain `String` keys are fine for the stub.
158pub type AttrMap = BTreeMap<String, AttrValue>;
159
160/// Attribute value carried on a semantic [`Node`].
161///
162/// # Examples
163///
164/// ```
165/// use mos_core::AttrValue;
166///
167/// let value = AttrValue::Str("intro".to_owned());
168///
169/// assert_eq!(value, AttrValue::Str("intro".to_owned()));
170/// ```
171#[derive(Clone, Debug, PartialEq)]
172pub enum AttrValue {
173    Bool(bool),
174    Int(i64),
175    Float(f64),
176    Str(String),
177    List(Vec<Self>),
178    /// A length already resolved to PDF points. The parser carries
179    /// unit-tagged literals (`mm`, `pt`, `em`); the lowerer converts
180    /// them to a single canonical scalar so layout never has to know
181    /// about units.
182    Length(f64),
183    /// Opaque binary payload — currently used to carry decoded raster
184    /// image pixels (RGB8) onto an [`NodeKind::Image`] node so the PDF
185    /// backend can emit them as an Image `XObject` without re-reading the
186    /// source file.
187    ///
188    /// Stored as `Arc<[u8]>` so a node carrying decoded pixels is cheap
189    /// to clone (e.g. across cache boundaries or when the same image
190    /// would otherwise be duplicated through the document graph). The
191    /// layout engine still dedups by resolved path, so most documents
192    /// hold one buffer per image regardless; the `Arc` is insurance
193    /// against accidental copies on the eval → layout boundary.
194    Bytes(Arc<[u8]>),
195}
196
197/// A byte-range location in a source file (manifest §6 stage 1).
198///
199/// # Examples
200///
201/// ```
202/// use std::path::PathBuf;
203///
204/// use mos_core::SourceSpan;
205///
206/// let span = SourceSpan::new(PathBuf::from("main.mos"), 2, 8);
207///
208/// assert_eq!(span.start, 2);
209/// ```
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct SourceSpan {
212    pub file: PathBuf,
213    pub start: usize,
214    pub end: usize,
215}
216
217impl SourceSpan {
218    /// Construct a span covering `start..end` in `file`.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use std::path::PathBuf;
224    ///
225    /// use mos_core::SourceSpan;
226    ///
227    /// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 9);
228    ///
229    /// assert_eq!(span.end, 9);
230    /// ```
231    #[must_use]
232    pub fn new(file: PathBuf, start: usize, end: usize) -> Self {
233        Self { file, start, end }
234    }
235
236    /// A zero-length placeholder span anchored at the start of `file`.
237    ///
238    /// # Examples
239    ///
240    /// ```
241    /// use std::path::PathBuf;
242    ///
243    /// use mos_core::SourceSpan;
244    ///
245    /// let span = SourceSpan::placeholder(PathBuf::from("main.mos"));
246    ///
247    /// assert_eq!((span.start, span.end), (0, 0));
248    /// ```
249    #[must_use]
250    pub fn placeholder(file: PathBuf) -> Self {
251        Self {
252            file,
253            start: 0,
254            end: 0,
255        }
256    }
257}
258
259/// Diagnostic severity (manifest §31).
260///
261/// Three runtime severities. `Error` marks a *failing* diagnostic (the CLI
262/// exits non-zero at the next phase barrier) — it does **not** mean "abort
263/// the phase right now". `Notice` is informational and non-failing
264/// (substitutions, auto-decisions). Sub-message kinds (`note`/`help`/
265/// `hint`) live on [`DiagnosticAnnotation`], never here.
266///
267/// # Examples
268///
269/// ```
270/// use mos_core::Severity;
271///
272/// assert_ne!(Severity::Error, Severity::Notice);
273/// ```
274#[derive(Copy, Clone, Eq, PartialEq, Debug)]
275pub enum Severity {
276    /// Failing diagnostic; non-zero exit at the next phase barrier.
277    Error,
278    /// Surfaced, but the build continues.
279    Warning,
280    /// Informational only; the build continues.
281    Notice,
282}
283
284/// A sub-message attached to a [`Diagnostic`].
285///
286/// The diagnostic's *primary* span lives on [`Diagnostic::span`]; these are
287/// only secondary spans (`Related`) and textual rows. There is intentionally
288/// no `Primary` variant — that would be a second home for the primary span.
289///
290/// # Examples
291///
292/// ```
293/// use mos_core::DiagnosticAnnotation;
294///
295/// let help = DiagnosticAnnotation::Help("try `#set text(...)`".to_owned());
296/// assert!(matches!(help, DiagnosticAnnotation::Help(_)));
297/// ```
298#[derive(Clone, Debug)]
299pub enum DiagnosticAnnotation {
300    /// Another source location that helps explain the primary cause
301    /// (e.g. the first declaration of a duplicated label).
302    Related {
303        /// Where the related span points.
304        span: SourceSpan,
305        /// What that location contributes.
306        message: String,
307    },
308    /// Attached explanation, rendered as `note:`.
309    Note(String),
310    /// Attached suggestion, rendered as `help:`.
311    Help(String),
312    /// Attached hint, rendered as `hint:`.
313    Hint(String),
314}
315
316/// A machine-actionable fix for a [`Diagnostic`].
317///
318/// A `Suggestion` says "replace the bytes at this [`SourceSpan`] with this
319/// text" — it is structured data a tool can apply automatically, as opposed
320/// to the prose advice carried by [`DiagnosticAnnotation::Help`]. Backends
321/// consume it without re-parsing: the CLI can print a fix-it diff and an LSP
322/// can surface it as a code action keyed on the same span.
323///
324/// Two edge cases fall out of the replace-the-span model and are intentional:
325///
326/// - an empty `replacement` **deletes** the bytes covered by `span`;
327/// - a zero-length `span` (`start == end`) **inserts** `replacement` at that
328///   offset without removing anything.
329///
330/// # Examples
331///
332/// ```
333/// use std::path::PathBuf;
334///
335/// use mos_core::{SourceSpan, Suggestion};
336///
337/// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
338/// let fix = Suggestion::new(span, "@intro");
339///
340/// assert_eq!(fix.replacement, "@intro");
341/// ```
342#[derive(Clone, Debug, Eq, PartialEq)]
343pub struct Suggestion {
344    /// The source range the fix replaces. A zero-length span
345    /// (`start == end`) marks a pure insertion point.
346    pub span: SourceSpan,
347    /// The text to substitute for the bytes covered by `span`. An empty
348    /// string deletes that range.
349    pub replacement: String,
350}
351
352impl Suggestion {
353    /// Construct a suggestion replacing `span` with `replacement`.
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use std::path::PathBuf;
359    ///
360    /// use mos_core::{SourceSpan, Suggestion};
361    ///
362    /// let span = SourceSpan::new(PathBuf::from("main.mos"), 0, 3);
363    /// let fix = Suggestion::new(span, "set".to_owned());
364    ///
365    /// assert_eq!(fix.span.start, 0);
366    /// ```
367    #[must_use]
368    pub fn new(span: SourceSpan, replacement: impl Into<String>) -> Self {
369        Self {
370            span,
371            replacement: replacement.into(),
372        }
373    }
374}
375
376/// A user-facing diagnostic (manifest §16, §31).
377///
378/// Identity and default severity come from a `'static` [`DiagnosticDef`] in
379/// [`codes`]; the instance carries the *resolved* severity (today always the
380/// def's default, later a config override) so rendering never has to consult
381/// the def. Fields are private — construct via [`Diagnostic::simple`] or
382/// [`Diagnostic::new`].
383///
384/// # Examples
385///
386/// ```
387/// use mos_core::{Diagnostic, Severity, codes};
388///
389/// let diagnostic = Diagnostic::simple(&codes::MOS0010, None, "boom");
390///
391/// assert_eq!(diagnostic.severity(), Severity::Error);
392/// assert_eq!(diagnostic.def().code().to_string(), "MOS0010");
393/// ```
394#[derive(Clone, Debug)]
395pub struct Diagnostic {
396    def: &'static DiagnosticDef,
397    severity: Severity,
398    span: Option<SourceSpan>,
399    message: String,
400    annotations: Vec<DiagnosticAnnotation>,
401    suggestions: Vec<Suggestion>,
402}
403
404impl Diagnostic {
405    /// Full constructor: the caller supplies the resolved severity. The
406    /// future config resolver uses this; nothing has to crack open the
407    /// struct.
408    ///
409    /// # Examples
410    ///
411    /// ```
412    /// use mos_core::{Diagnostic, Severity, codes};
413    ///
414    /// // Promote a warning-by-default code to an error.
415    /// let d = Diagnostic::new(&codes::MOS0028, Severity::Error, None, "promoted");
416    /// assert_eq!(d.severity(), Severity::Error);
417    /// ```
418    pub fn new(
419        def: &'static DiagnosticDef,
420        severity: Severity,
421        span: Option<SourceSpan>,
422        message: impl Into<String>,
423    ) -> Self {
424        Self {
425            def,
426            severity,
427            span,
428            message: message.into(),
429            annotations: Vec::new(),
430            suggestions: Vec::new(),
431        }
432    }
433
434    /// Convenience: severity defaults to `def.default_severity()`.
435    ///
436    /// # Examples
437    ///
438    /// ```
439    /// use mos_core::{Diagnostic, Severity, codes};
440    ///
441    /// let d = Diagnostic::simple(&codes::MOS0018, None, "substituted Noto Sans");
442    /// assert_eq!(d.severity(), Severity::Notice);
443    /// ```
444    pub fn simple(
445        def: &'static DiagnosticDef,
446        span: Option<SourceSpan>,
447        message: impl Into<String>,
448    ) -> Self {
449        Self::new(def, def.default_severity(), span, message)
450    }
451
452    /// Attach a sub-message annotation, builder-style.
453    ///
454    /// # Examples
455    ///
456    /// ```
457    /// use mos_core::{Diagnostic, DiagnosticAnnotation, codes};
458    ///
459    /// let d = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
460    ///     .with_annotation(DiagnosticAnnotation::Help("did you mean `@intro`?".to_owned()));
461    /// assert_eq!(d.annotations().len(), 1);
462    /// ```
463    #[must_use]
464    pub fn with_annotation(mut self, annotation: DiagnosticAnnotation) -> Self {
465        self.annotations.push(annotation);
466        self
467    }
468
469    /// Attach a machine-actionable [`Suggestion`], builder-style.
470    ///
471    /// # Examples
472    ///
473    /// ```
474    /// use std::path::PathBuf;
475    ///
476    /// use mos_core::{Diagnostic, SourceSpan, Suggestion, codes};
477    ///
478    /// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
479    /// let d = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
480    ///     .with_suggestion(Suggestion::new(span, "@intro"));
481    /// assert_eq!(d.suggestions().len(), 1);
482    /// ```
483    #[must_use]
484    pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
485        self.suggestions.push(suggestion);
486        self
487    }
488
489    /// Attach a span, builder-style.
490    ///
491    /// # Examples
492    ///
493    /// ```
494    /// use std::path::PathBuf;
495    ///
496    /// use mos_core::{Diagnostic, SourceSpan, codes};
497    ///
498    /// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
499    /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
500    ///     .with_span(span.clone());
501    ///
502    /// assert_eq!(diagnostic.span(), Some(&span));
503    /// ```
504    #[must_use]
505    pub fn with_span(mut self, span: SourceSpan) -> Self {
506        self.span = Some(span);
507        self
508    }
509
510    /// The registry definition behind this diagnostic.
511    ///
512    /// # Examples
513    ///
514    /// ```
515    /// use mos_core::{Diagnostic, codes};
516    ///
517    /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
518    ///
519    /// assert_eq!(diagnostic.def().code(), codes::MOS0033.code());
520    /// ```
521    #[must_use]
522    pub fn def(&self) -> &'static DiagnosticDef {
523        self.def
524    }
525
526    /// The resolved severity carried by this instance.
527    ///
528    /// # Examples
529    ///
530    /// ```
531    /// use mos_core::{Diagnostic, Severity, codes};
532    ///
533    /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
534    ///
535    /// assert_eq!(diagnostic.severity(), Severity::Error);
536    /// ```
537    #[must_use]
538    pub fn severity(&self) -> Severity {
539        self.severity
540    }
541
542    /// The primary span, if any.
543    ///
544    /// # Examples
545    ///
546    /// ```
547    /// use mos_core::{Diagnostic, codes};
548    ///
549    /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
550    ///
551    /// assert!(diagnostic.span().is_none());
552    /// ```
553    #[must_use]
554    pub fn span(&self) -> Option<&SourceSpan> {
555        self.span.as_ref()
556    }
557
558    /// The primary message.
559    ///
560    /// # Examples
561    ///
562    /// ```
563    /// use mos_core::{Diagnostic, codes};
564    ///
565    /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
566    ///
567    /// assert_eq!(diagnostic.message(), "unknown label");
568    /// ```
569    #[must_use]
570    pub fn message(&self) -> &str {
571        &self.message
572    }
573
574    /// The attached sub-message annotations, in attach order.
575    ///
576    /// # Examples
577    ///
578    /// ```
579    /// use mos_core::{Diagnostic, DiagnosticAnnotation, codes};
580    ///
581    /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
582    ///     .with_annotation(DiagnosticAnnotation::Help("declare `<intro>` first".to_owned()));
583    ///
584    /// assert_eq!(diagnostic.annotations().len(), 1);
585    /// ```
586    #[must_use]
587    pub fn annotations(&self) -> &[DiagnosticAnnotation] {
588        &self.annotations
589    }
590
591    /// The attached machine-actionable suggestions, in attach order.
592    ///
593    /// # Examples
594    ///
595    /// ```
596    /// use std::path::PathBuf;
597    ///
598    /// use mos_core::{Diagnostic, SourceSpan, Suggestion, codes};
599    ///
600    /// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
601    /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
602    ///     .with_suggestion(Suggestion::new(span, "@intro"));
603    ///
604    /// assert_eq!(diagnostic.suggestions().len(), 1);
605    /// ```
606    #[must_use]
607    pub fn suggestions(&self) -> &[Suggestion] {
608        &self.suggestions
609    }
610}
611
612impl std::fmt::Display for Diagnostic {
613    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
614        write!(f, "[{}] {}", self.def.code(), self.message)
615    }
616}
617
618/// Convert a byte offset into a 1-based `(line, column)` pair.
619///
620/// `src` is treated as UTF-8; columns are counted in *Unicode scalar
621/// values* (i.e. `char`s), not bytes, so a span pointing at the byte
622/// after `µ` reports column 2 rather than 3. Both the returned line
623/// and column are at least 1, and offsets past the end of `src` are
624/// clamped to the end. Offsets that fall in the middle of a UTF-8
625/// code-point round down to the start of that code-point.
626///
627/// # Examples
628///
629/// ```
630/// use mos_core::linecol;
631///
632/// assert_eq!(linecol("a\nb", 2), (2, 1));
633/// ```
634#[must_use]
635pub fn linecol(src: &str, byte_offset: usize) -> (usize, usize) {
636    let mut clamped = byte_offset.min(src.len());
637    while clamped > 0 && !src.is_char_boundary(clamped) {
638        clamped -= 1;
639    }
640    let mut line = 1_usize;
641    let mut line_start = 0_usize;
642    for (i, b) in src.as_bytes().iter().enumerate().take(clamped) {
643        if *b == b'\n' {
644            line += 1;
645            line_start = i + 1;
646        }
647    }
648    let column = src[line_start..clamped].chars().count() + 1;
649    (line, column)
650}
651
652impl std::error::Error for Diagnostic {}
653
654/// Convenience top-level error type for crates that want a single
655/// `Result` alias without inventing their own.
656///
657/// # Examples
658///
659/// ```
660/// use mos_core::CoreError;
661///
662/// let err = CoreError::Unimplemented("cache");
663///
664/// assert_eq!(err.to_string(), "not yet implemented: cache");
665/// ```
666#[derive(thiserror::Error, Debug)]
667pub enum CoreError {
668    #[error("not yet implemented: {0}")]
669    Unimplemented(&'static str),
670
671    #[error(transparent)]
672    Diagnostic(Box<Diagnostic>),
673}
674
675pub type Result<T> = std::result::Result<T, CoreError>;
676
677/// The lowered semantic document graph (manifest §5, §6 stage 2).
678///
679/// Owns every [`Node`] and exposes them through their stable [`NodeId`].
680/// MVP 0 stores nodes in insertion order; the manifest §5.1 hash-derived
681/// IDs land alongside the cache work in MVP 5.
682///
683/// # Examples
684///
685/// ```
686/// use std::path::PathBuf;
687///
688/// use mos_core::{Document, NodeId};
689///
690/// let doc = Document::new(PathBuf::from("main.mos"));
691///
692/// assert_eq!(doc.root, NodeId(0));
693/// ```
694#[derive(Debug)]
695pub struct Document {
696    pub root: NodeId,
697    pub file: PathBuf,
698    nodes: BTreeMap<NodeId, Node>,
699    next_id: u64,
700}
701
702impl Document {
703    /// Create an empty document rooted at `file`. Allocates the
704    /// `Document` root node (`NodeId(0)`) eagerly so callers can append
705    /// children to it immediately.
706    ///
707    /// # Examples
708    ///
709    /// ```
710    /// use std::path::PathBuf;
711    ///
712    /// use mos_core::Document;
713    ///
714    /// let doc = Document::new(PathBuf::from("main.mos"));
715    ///
716    /// assert_eq!(doc.len(), 1);
717    /// ```
718    #[must_use]
719    pub fn new(file: PathBuf) -> Self {
720        let root_id = NodeId(0);
721        let root_node = Node {
722            id: root_id,
723            kind: NodeKind::Document,
724            span: SourceSpan::placeholder(file.clone()),
725            content_hash: ContentHash::default(),
726            style_id: StyleId::default(),
727            children: Vec::new(),
728            attributes: AttrMap::new(),
729        };
730        let mut nodes = BTreeMap::new();
731        nodes.insert(root_id, root_node);
732        Self {
733            root: root_id,
734            file,
735            nodes,
736            next_id: 1,
737        }
738    }
739
740    /// Allocate `node` in the arena and return its assigned [`NodeId`].
741    /// The `id` field on the input is overwritten with the fresh ID.
742    ///
743    /// # Examples
744    ///
745    /// ```
746    /// use std::path::PathBuf;
747    ///
748    /// use mos_core::{AttrMap, ContentHash, Document, Node, NodeId, NodeKind, SourceSpan, StyleId};
749    ///
750    /// let file = PathBuf::from("main.mos");
751    /// let mut doc = Document::new(file.clone());
752    /// let id = doc.alloc(Node {
753    ///     id: NodeId::default(),
754    ///     kind: NodeKind::Paragraph,
755    ///     span: SourceSpan::placeholder(file),
756    ///     content_hash: ContentHash::default(),
757    ///     style_id: StyleId::default(),
758    ///     children: Vec::new(),
759    ///     attributes: AttrMap::new(),
760    /// });
761    ///
762    /// assert_eq!(id, NodeId(1));
763    /// ```
764    pub fn alloc(&mut self, mut node: Node) -> NodeId {
765        let id = NodeId(self.next_id);
766        self.next_id += 1;
767        node.id = id;
768        self.nodes.insert(id, node);
769        id
770    }
771
772    /// Allocate `node` as a child of `parent` and return its [`NodeId`].
773    ///
774    /// # Panics
775    ///
776    /// Panics if `parent` is not a node already allocated by this
777    /// `Document`. Silently producing detached nodes would hide lowerer
778    /// bugs in release builds, so this is intentionally a release-time
779    /// assertion rather than a `debug_assert!`.
780    ///
781    /// # Examples
782    ///
783    /// ```
784    /// use std::path::PathBuf;
785    ///
786    /// use mos_core::{AttrMap, ContentHash, Document, Node, NodeId, NodeKind, SourceSpan, StyleId};
787    ///
788    /// let file = PathBuf::from("main.mos");
789    /// let mut doc = Document::new(file.clone());
790    /// let child = doc.alloc_child(doc.root, Node {
791    ///     id: NodeId::default(),
792    ///     kind: NodeKind::Paragraph,
793    ///     span: SourceSpan::placeholder(file),
794    ///     content_hash: ContentHash::default(),
795    ///     style_id: StyleId::default(),
796    ///     children: Vec::new(),
797    ///     attributes: AttrMap::new(),
798    /// });
799    ///
800    /// assert_eq!(doc.get(doc.root).map(|node| node.children.as_slice()), Some(&[child][..]));
801    /// ```
802    pub fn alloc_child(&mut self, parent: NodeId, node: Node) -> NodeId {
803        assert!(
804            self.nodes.contains_key(&parent),
805            "Document::alloc_child: unknown parent {parent:?}"
806        );
807        let child_id = self.alloc(node);
808        // Safe to index: we just verified the key exists, and `alloc`
809        // doesn't remove existing entries.
810        if let Some(parent_node) = self.nodes.get_mut(&parent) {
811            parent_node.children.push(child_id);
812        }
813        child_id
814    }
815
816    /// Get a node by id.
817    ///
818    /// # Examples
819    ///
820    /// ```
821    /// use std::path::PathBuf;
822    ///
823    /// use mos_core::{Document, NodeKind};
824    ///
825    /// let doc = Document::new(PathBuf::from("main.mos"));
826    ///
827    /// assert_eq!(doc.get(doc.root).map(|node| node.kind), Some(NodeKind::Document));
828    /// ```
829    #[must_use]
830    pub fn get(&self, id: NodeId) -> Option<&Node> {
831        self.nodes.get(&id)
832    }
833
834    /// Mutable accessor for a single node. Used by the resolver
835    /// (manifest §6 stage 3) to back-patch attributes like `number`
836    /// onto sections and `text` onto `@label` references.
837    ///
838    /// # Examples
839    ///
840    /// ```
841    /// use std::path::PathBuf;
842    ///
843    /// use mos_core::{AttrValue, Document};
844    ///
845    /// let mut doc = Document::new(PathBuf::from("main.mos"));
846    /// if let Some(root) = doc.get_mut(doc.root) {
847    ///     root.attributes.insert("title".to_owned(), AttrValue::Str("Demo".to_owned()));
848    /// }
849    ///
850    /// assert!(doc.get(doc.root).is_some_and(|node| node.attributes.contains_key("title")));
851    /// ```
852    #[must_use]
853    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Node> {
854        self.nodes.get_mut(&id)
855    }
856
857    /// Iterate over every node in the arena in insertion order.
858    ///
859    /// # Examples
860    ///
861    /// ```
862    /// use std::path::PathBuf;
863    ///
864    /// use mos_core::{Document, NodeKind};
865    ///
866    /// let doc = Document::new(PathBuf::from("main.mos"));
867    /// let kinds: Vec<NodeKind> = doc.nodes().map(|node| node.kind).collect();
868    ///
869    /// assert_eq!(kinds, vec![NodeKind::Document]);
870    /// ```
871    pub fn nodes(&self) -> impl Iterator<Item = &Node> {
872        self.nodes.values()
873    }
874
875    /// Total number of nodes including the document root.
876    ///
877    /// # Examples
878    ///
879    /// ```
880    /// use std::path::PathBuf;
881    ///
882    /// use mos_core::Document;
883    ///
884    /// let doc = Document::new(PathBuf::from("main.mos"));
885    ///
886    /// assert_eq!(doc.len(), 1);
887    /// ```
888    #[must_use]
889    pub fn len(&self) -> usize {
890        self.nodes.len()
891    }
892
893    /// Return whether the document has no semantic content beyond the root.
894    ///
895    /// # Examples
896    ///
897    /// ```
898    /// use std::path::PathBuf;
899    ///
900    /// use mos_core::Document;
901    ///
902    /// let doc = Document::new(PathBuf::from("main.mos"));
903    ///
904    /// assert!(doc.is_empty());
905    /// ```
906    #[must_use]
907    pub fn is_empty(&self) -> bool {
908        // The root always exists, so `Document` is never truly empty;
909        // expose the conventional method anyway for clippy compliance.
910        self.len() <= 1
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917
918    #[test]
919    fn linecol_handles_ascii_offsets() {
920        let src = "ab\ncd\nef";
921        assert_eq!(linecol(src, 0), (1, 1));
922        assert_eq!(linecol(src, 1), (1, 2));
923        assert_eq!(linecol(src, 2), (1, 3));
924        assert_eq!(linecol(src, 3), (2, 1));
925        assert_eq!(linecol(src, 6), (3, 1));
926        assert_eq!(linecol(src, 7), (3, 2));
927        // Past the end clamps.
928        assert_eq!(linecol(src, 9999), (3, 3));
929    }
930
931    #[test]
932    fn linecol_counts_chars_not_bytes() {
933        // `µ` is 2 bytes in UTF-8, `字` is 3 bytes. The column for the
934        // byte after them should still be 2, not 3 / 4.
935        let src = "µx\n字y\n";
936        assert_eq!(linecol(src, 0), (1, 1));
937        assert_eq!(linecol(src, 2), (1, 2)); // after `µ`
938        assert_eq!(linecol(src, 3), (1, 3)); // after `µx`
939        assert_eq!(linecol(src, 4), (2, 1)); // start of line 2
940        assert_eq!(linecol(src, 7), (2, 2)); // after `字`
941    }
942
943    #[test]
944    fn linecol_offsets_inside_codepoints_round_down() {
945        // Pointing at the second byte of `µ` should still report
946        // column 1 of line 1, not panic.
947        let src = "µ";
948        assert_eq!(linecol(src, 1), (1, 1));
949    }
950
951    #[test]
952    #[should_panic(expected = "unknown parent")]
953    fn alloc_child_panics_on_unknown_parent() {
954        let mut doc = Document::new(PathBuf::from("test.mos"));
955        // `NodeId(9999)` was never allocated by `doc`; the call must
956        // abort instead of leaking a detached node.
957        doc.alloc_child(
958            NodeId(9999),
959            Node {
960                id: NodeId::default(),
961                kind: NodeKind::Text,
962                span: SourceSpan::placeholder(PathBuf::from("test.mos")),
963                content_hash: ContentHash::default(),
964                style_id: StyleId::default(),
965                children: Vec::new(),
966                attributes: AttrMap::new(),
967            },
968        );
969    }
970
971    #[test]
972    fn document_alloc_and_traverse() {
973        let mut doc = Document::new(PathBuf::from("test.mos"));
974        let para = doc.alloc_child(
975            doc.root,
976            Node {
977                id: NodeId::default(),
978                kind: NodeKind::Paragraph,
979                span: SourceSpan::placeholder(PathBuf::from("test.mos")),
980                content_hash: ContentHash::default(),
981                style_id: StyleId::default(),
982                children: Vec::new(),
983                attributes: AttrMap::new(),
984            },
985        );
986        doc.alloc_child(
987            para,
988            Node {
989                id: NodeId::default(),
990                kind: NodeKind::Text,
991                span: SourceSpan::placeholder(PathBuf::from("test.mos")),
992                content_hash: ContentHash::default(),
993                style_id: StyleId::default(),
994                children: Vec::new(),
995                attributes: AttrMap::new(),
996            },
997        );
998        assert_eq!(doc.len(), 3);
999        assert_eq!(doc.get(doc.root).unwrap().children.len(), 1);
1000        assert_eq!(doc.get(para).unwrap().children.len(), 1);
1001    }
1002
1003    #[test]
1004    fn suggestion_new_sets_span_and_replacement() {
1005        let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
1006        let suggestion = Suggestion::new(span.clone(), "@intro");
1007        assert_eq!(suggestion.span, span);
1008        assert_eq!(suggestion.replacement, "@intro");
1009    }
1010
1011    #[test]
1012    fn diagnostic_has_no_suggestions_by_default() {
1013        let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
1014        assert!(diagnostic.suggestions().is_empty());
1015    }
1016
1017    #[test]
1018    fn with_suggestion_accumulates_in_order() {
1019        let first = Suggestion::new(SourceSpan::new(PathBuf::from("main.mos"), 4, 10), "@intro");
1020        let second = Suggestion::new(
1021            SourceSpan::new(PathBuf::from("other.mos"), 12, 15),
1022            "@summary",
1023        );
1024        let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
1025            .with_suggestion(first)
1026            .with_suggestion(second);
1027
1028        let suggestions = diagnostic.suggestions();
1029        assert_eq!(suggestions.len(), 2);
1030
1031        assert_eq!(suggestions[0].span.file, PathBuf::from("main.mos"));
1032        assert_eq!(suggestions[0].span.start, 4);
1033        assert_eq!(suggestions[0].span.end, 10);
1034        assert_eq!(suggestions[0].replacement, "@intro");
1035
1036        assert_eq!(suggestions[1].span.file, PathBuf::from("other.mos"));
1037        assert_eq!(suggestions[1].span.start, 12);
1038        assert_eq!(suggestions[1].span.end, 15);
1039        assert_eq!(suggestions[1].replacement, "@summary");
1040    }
1041
1042    #[test]
1043    fn suggestion_new_accepts_str_and_owned_string() {
1044        let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
1045        let from_str = Suggestion::new(span.clone(), "@intro");
1046        let from_string = Suggestion::new(span, String::from("@intro"));
1047        assert_eq!(from_str, from_string);
1048    }
1049
1050    #[test]
1051    fn suggestion_clone_and_equality() {
1052        let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
1053        let suggestion = Suggestion::new(span.clone(), "@intro");
1054
1055        // A clone equals its original.
1056        assert_eq!(suggestion.clone(), suggestion);
1057        // Built independently from the same parts => equal.
1058        assert_eq!(Suggestion::new(span.clone(), "@intro"), suggestion);
1059        // Differing replacement text => unequal.
1060        assert_ne!(Suggestion::new(span, "@outro"), suggestion);
1061        // Differing span => unequal.
1062        let wider = SourceSpan::new(PathBuf::from("main.mos"), 4, 11);
1063        assert_ne!(Suggestion::new(wider, "@intro"), suggestion);
1064    }
1065
1066    #[test]
1067    fn suggestion_empty_replacement_encodes_deletion() {
1068        let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
1069        let deletion = Suggestion::new(span, "");
1070        assert!(deletion.replacement.is_empty());
1071        // A deletion still covers a real, non-empty range.
1072        assert!(deletion.span.start < deletion.span.end);
1073    }
1074
1075    #[test]
1076    fn suggestion_zero_length_span_encodes_insertion() {
1077        let point = SourceSpan::new(PathBuf::from("main.mos"), 7, 7);
1078        let insertion = Suggestion::new(point, "@intro");
1079        assert_eq!(insertion.span.start, insertion.span.end);
1080        assert_eq!(insertion.replacement, "@intro");
1081    }
1082
1083    #[test]
1084    fn suggestions_and_annotations_are_independent_channels() {
1085        let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
1086
1087        // A suggestion does not leak into the annotation channel.
1088        let with_fix = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
1089            .with_suggestion(Suggestion::new(span.clone(), "@intro"));
1090        assert_eq!(with_fix.suggestions().len(), 1);
1091        assert!(with_fix.annotations().is_empty());
1092
1093        // Prose help does not leak into the suggestion channel.
1094        let with_help = Diagnostic::simple(&codes::MOS0033, None, "unknown label").with_annotation(
1095            DiagnosticAnnotation::Help("did you mean `@intro`?".to_owned()),
1096        );
1097        assert_eq!(with_help.annotations().len(), 1);
1098        assert!(with_help.suggestions().is_empty());
1099
1100        // Both channels populate independently and keep their own payloads.
1101        let with_both = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
1102            .with_annotation(DiagnosticAnnotation::Help(
1103                "did you mean `@intro`?".to_owned(),
1104            ))
1105            .with_suggestion(Suggestion::new(span, "@intro"));
1106        assert_eq!(with_both.suggestions().len(), 1);
1107        assert_eq!(with_both.annotations().len(), 1);
1108        assert_eq!(with_both.suggestions()[0].replacement, "@intro");
1109
1110        // The existing Help annotation is carried through unchanged.
1111        let help_text = match &with_both.annotations()[0] {
1112            DiagnosticAnnotation::Help(text) => Some(text.as_str()),
1113            _ => None,
1114        };
1115        assert_eq!(help_text, Some("did you mean `@intro`?"));
1116    }
1117}