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}