mos_parse/syntax.rs
1use std::path::PathBuf;
2
3use mos_core::{Diagnostic, Severity, SourceSpan};
4
5/// Concrete syntax tree for a single `.mos` source file.
6#[derive(Debug, Clone)]
7pub struct SyntaxTree {
8 pub file: PathBuf,
9 pub items: Vec<Item>,
10 /// Byte ranges from `[@` through the contents, excluding `]`, for citations
11 /// encountered in markup, including unfinished or malformed citations.
12 /// Code, raw blocks, directive values, and comments contribute no ranges.
13 pub citation_spans: Vec<std::ops::Range<usize>>,
14}
15
16/// Top-level construct in a `.mos` file.
17#[derive(Debug, Clone)]
18pub enum Item {
19 /// `= Title`, `== Subtitle`, `=== Subsubtitle`. A trailing
20 /// `<label>` token after the title attaches to this heading.
21 Heading {
22 level: u8,
23 inlines: Vec<Inline>,
24 label: Option<String>,
25 label_span: Option<SourceSpan>,
26 span: SourceSpan,
27 },
28 /// One or more consecutive non-blank lines that are not a heading
29 /// and not a `#set` block. A leading `<label>` token (possibly
30 /// preceded by ASCII whitespace) attaches to this paragraph.
31 Paragraph {
32 inlines: Vec<Inline>,
33 label: Option<String>,
34 label_span: Option<SourceSpan>,
35 span: SourceSpan,
36 },
37 /// `#set name(...)`, `#image(...)`, `#figure(...)`. The body is
38 /// lexed into typed `(key, value)` args; semantic validation
39 /// (known target/key, type coercion, sanity floors) happens in
40 /// the lowerer. `kind` distinguishes the `#set`-style configuration
41 /// directive from standalone calls like `#image` and `#figure`,
42 /// which the lowerer dispatches to dedicated paths.
43 Set {
44 kind: DirectiveKind,
45 name: String,
46 args: Vec<SetArg>,
47 span: SourceSpan,
48 },
49 /// Raw preformatted text or code block. Both forms preserve their
50 /// long-bracket body as text; the kind leaves room for later styling
51 /// or language-aware code rendering.
52 RawBlock {
53 kind: RawBlockKind,
54 args: Vec<SetArg>,
55 text: String,
56 label: Option<String>,
57 label_span: Option<SourceSpan>,
58 span: SourceSpan,
59 },
60 /// A bullet (`- `) or numbered (`\d+\. `) list. Sibling items at
61 /// the same indent are grouped under one list; deeper indents
62 /// become nested lists hanging off the most recent item. Numbered
63 /// lists always renumber from 1 in MVP: explicit `start: N` is
64 /// deferred.
65 List {
66 ordered: bool,
67 items: Vec<ListItem>,
68 span: SourceSpan,
69 },
70 /// A `/** … */` documentation comment. Unlike `//` line and `/*` block
71 /// comments (both dropped), a doc comment is preserved so the lowerer can
72 /// attach its `text` as a `doc` attribute on the semantic node it
73 /// precedes (a heading, or a labelled block), which the LSP surfaces on
74 /// hover. `text` is the cleaned inner body, fences removed.
75 DocComment { text: String, span: SourceSpan },
76}
77
78/// One entry inside an [`Item::List`].
79///
80/// `blocks` preserves source order between item paragraphs and nested lists;
81/// nested lists live only there. `inlines` mirrors the first paragraph for
82/// older consumers.
83#[derive(Debug, Clone)]
84pub struct ListItem {
85 pub inlines: Vec<Inline>,
86 pub blocks: Vec<ListItemBlock>,
87 pub span: SourceSpan,
88}
89
90/// Ordered content inside a [`ListItem`].
91#[derive(Debug, Clone)]
92pub enum ListItemBlock {
93 Paragraph {
94 inlines: Vec<Inline>,
95 span: SourceSpan,
96 },
97 List {
98 ordered: bool,
99 items: Vec<ListItem>,
100 span: SourceSpan,
101 },
102}
103
104/// Tag for the directive shapes [`Item::Set`] can represent.
105///
106/// Distinguishes `#set <target>(...)` from standalone `#image(...)`,
107/// `#figure(...)`, and `#bibliography(...)` calls so the lowerer does not
108/// infer semantics from [`Item::Set::name`].
109#[derive(Debug, Clone, Copy, Eq, PartialEq)]
110pub enum DirectiveKind {
111 /// `#set <name>(...)`: sets defaults on a style target.
112 Set,
113 /// `#image("path", ...)`: raster image directive.
114 Image,
115 /// `#figure(image: ..., caption: ...)`: captioned image container.
116 Figure,
117 /// `#bibliography("refs.bib")`: declares a bibliography source
118 /// database. The lowerer records the (source-relative) path so a
119 /// later BibTeX-parsing slice can read it; citation resolution and
120 /// rendering are not part of this directive.
121 Bibliography,
122}
123
124#[derive(Debug, Clone, Copy, Eq, PartialEq)]
125pub enum RawBlockKind {
126 Pre,
127 Code,
128}
129
130/// Borrowed view of an [`Item::RawBlock`] payload.
131#[derive(Debug, Clone, Copy)]
132pub struct RawBlockView<'a> {
133 pub kind: RawBlockKind,
134 pub args: &'a [SetArg],
135 pub text: &'a str,
136 pub label: Option<&'a str>,
137 pub label_span: Option<&'a SourceSpan>,
138 pub span: &'a SourceSpan,
139}
140
141/// One argument inside a directive body: either a `key: value`
142/// pair (the only form `#set` accepts) or a positional value (a
143/// leading string literal allowed on `#image(...)` / `#figure(...)`).
144///
145/// This used to be a struct with an empty-string `key` standing in
146/// for "positional," but that sentinel was a brittle public contract:
147/// any consumer that forgot the special-case would silently treat a
148/// positional path as a named arg called `""`. The enum form makes
149/// the two shapes explicit so the compiler can enforce exhaustive
150/// matches.
151#[derive(Debug, Clone)]
152pub enum SetArg {
153 /// A `key: value` argument. `key_span` covers the identifier
154 /// before the colon; `value_span` covers the literal.
155 Named {
156 key: String,
157 value: SetValue,
158 key_span: SourceSpan,
159 value_span: SourceSpan,
160 },
161 /// A leading positional value. The parser currently only accepts
162 /// string literals here (used for `#image("path.png")`); other
163 /// literal kinds in a positional slot would surface as a parse
164 /// error rather than land in this variant.
165 Positional {
166 value: SetValue,
167 value_span: SourceSpan,
168 },
169}
170
171impl SetArg {
172 /// Borrow the value carried by this argument, regardless of shape.
173 #[must_use]
174 pub const fn value(&self) -> &SetValue {
175 match self {
176 Self::Named { value, .. } | Self::Positional { value, .. } => value,
177 }
178 }
179
180 /// The span covering the argument's value literal.
181 #[must_use]
182 pub const fn value_span(&self) -> &SourceSpan {
183 match self {
184 Self::Named { value_span, .. } | Self::Positional { value_span, .. } => value_span,
185 }
186 }
187
188 /// The key identifier for [`Self::Named`]; `None` for
189 /// [`Self::Positional`].
190 #[must_use]
191 pub const fn key(&self) -> Option<&str> {
192 match self {
193 Self::Named { key, .. } => Some(key.as_str()),
194 Self::Positional { .. } => None,
195 }
196 }
197
198 /// The span covering the key identifier, for [`Self::Named`].
199 /// `None` for [`Self::Positional`].
200 #[must_use]
201 pub const fn key_span(&self) -> Option<&SourceSpan> {
202 match self {
203 Self::Named { key_span, .. } => Some(key_span),
204 Self::Positional { .. } => None,
205 }
206 }
207}
208
209/// Literal values recognised inside a `#set` body. Full expression
210/// evaluation (`#let`, function calls, `if`) is deferred to MVP 5; this
211/// covers what the manifest examples actually use.
212#[derive(Debug, Clone, PartialEq)]
213pub enum SetValue {
214 Str(String),
215 Int(i64),
216 Float(f64),
217 Length(f64, LengthUnit),
218 Ident(String),
219}
220
221#[derive(Debug, Clone, Copy, Eq, PartialEq)]
222pub enum LengthUnit {
223 Mm,
224 Pt,
225 Em,
226}
227
228/// Inline run produced by the markup tokenizer.
229#[derive(Debug, Clone)]
230pub struct Inline {
231 pub kind: InlineKind,
232 pub text: String,
233 pub span: SourceSpan,
234 /// For [`InlineKind::Reference`] / [`InlineKind::PageReference`], the
235 /// source span of the label *identifier* alone; the `intro` in `@intro`
236 /// or `@page(intro)`, excluding the `@` sigil and the `@page(`…`)`
237 /// wrapper. The lowerer stamps it as the node's `label_span` so editor
238 /// features (rename) read the identifier range directly instead of
239 /// re-deriving it from [`Self::span`] geometry. `None` for every other
240 /// inline kind.
241 pub label_span: Option<SourceSpan>,
242}
243
244#[derive(Debug, Clone, Copy, Eq, PartialEq)]
245pub enum InlineKind {
246 Text,
247 Emphasis,
248 Strong,
249 BoldItalic,
250 Code,
251 /// `@label`: a cross-reference to a labelled block. The
252 /// [`Inline::text`] payload is the bare label name (no leading
253 /// `@`); the resolver rewrites it to the target's resolved text.
254 Reference,
255 /// `@page(label)`: a reference to the printed *page number* of a
256 /// labelled target. The [`Inline::text`] payload is the bare label name
257 /// (the `page(` wrapper and `)` stripped). Distinct from
258 /// [`Reference`](Self::Reference), which resolves to the target's section
259 /// or figure number; a page reference resolves to where the target lands,
260 /// which is only known after layout. Resolution runs through the
261 /// resolve↔layout fixpoint (issue #72); this slice parses and models the
262 /// reference but leaves it unresolved (placeholder text).
263 PageReference,
264 /// `[@key]`: a citation to a bibliography entry. The
265 /// [`Inline::text`] payload is the bare citation key (no leading
266 /// `[@` or trailing `]`); bibliography loading and rendering are
267 /// future work tracked under MVP 4. The key alphabet matches the
268 /// label alphabet (`[A-Za-z0-9_:.-]`); a single key per
269 /// `[@…]` group is the only form recognised in this slice: list
270 /// forms like `[@a; @b]` and prefix/suffix bodies are deferred.
271 Citation,
272 /// `\\`: a forced line break inside a paragraph. The line
273 /// breaks here without the extra leading a blank-line paragraph
274 /// break would give. Carries no text payload. The shorthand for
275 /// a soft hyphen `\-` lowers to a literal U+00AD inside a
276 /// surrounding [`InlineKind::Text`] run, not to a separate variant.
277 HardBreak,
278}
279
280impl Item {
281 /// Borrow the heading payload if `self` is [`Item::Heading`].
282 #[must_use]
283 pub fn as_heading(&self) -> Option<(u8, &[Inline], &SourceSpan)> {
284 if let Self::Heading {
285 level,
286 inlines,
287 span,
288 ..
289 } = self
290 {
291 Some((*level, inlines, span))
292 } else {
293 None
294 }
295 }
296
297 /// Borrow the paragraph payload if `self` is [`Item::Paragraph`].
298 #[must_use]
299 pub fn as_paragraph(&self) -> Option<(&[Inline], &SourceSpan)> {
300 if let Self::Paragraph { inlines, span, .. } = self {
301 Some((inlines, span))
302 } else {
303 None
304 }
305 }
306
307 /// Borrow the directive payload if `self` is [`Item::Set`].
308 ///
309 /// The returned tuple is `(name, args, span)`; the caller can also
310 /// reach [`DirectiveKind`] via [`Self::directive_kind`]. The
311 /// accessor name is retained for back-compat; every existing
312 /// caller pre-dates the `#image`/`#figure` directives and only
313 /// looks at name/args/span.
314 #[must_use]
315 pub const fn as_set(&self) -> Option<(&str, &[SetArg], &SourceSpan)> {
316 if let Self::Set {
317 name, args, span, ..
318 } = self
319 {
320 Some((name.as_str(), args.as_slice(), span))
321 } else {
322 None
323 }
324 }
325
326 /// Borrow the raw block payload if `self` is [`Item::RawBlock`].
327 #[must_use]
328 pub fn as_raw_block(&self) -> Option<RawBlockView<'_>> {
329 if let Self::RawBlock {
330 kind,
331 args,
332 text,
333 label,
334 label_span,
335 span,
336 } = self
337 {
338 Some(RawBlockView {
339 kind: *kind,
340 args: args.as_slice(),
341 text: text.as_str(),
342 label: label.as_deref(),
343 label_span: label_span.as_ref(),
344 span,
345 })
346 } else {
347 None
348 }
349 }
350
351 /// Borrow the [`DirectiveKind`] tag if `self` is [`Item::Set`].
352 #[must_use]
353 pub const fn directive_kind(&self) -> Option<DirectiveKind> {
354 if let Self::Set { kind, .. } = self {
355 Some(*kind)
356 } else {
357 None
358 }
359 }
360
361 /// Borrow the list payload if `self` is [`Item::List`]. The
362 /// returned tuple is `(ordered, items, span)`.
363 #[must_use]
364 pub const fn as_list(&self) -> Option<(bool, &[ListItem], &SourceSpan)> {
365 if let Self::List {
366 ordered,
367 items,
368 span,
369 } = self
370 {
371 Some((*ordered, items.as_slice(), span))
372 } else {
373 None
374 }
375 }
376
377 /// Borrow the `/** … */` doc-comment payload if `self` is
378 /// [`Item::DocComment`]. The returned tuple is `(cleaned text, span)`.
379 #[must_use]
380 pub fn as_doc_comment(&self) -> Option<(&str, &SourceSpan)> {
381 if let Self::DocComment { text, span } = self {
382 Some((text.as_str(), span))
383 } else {
384 None
385 }
386 }
387
388 /// Borrow the explicit `<label>` attached to this block, if any.
389 /// Returns `None` for [`Item::Set`] and [`Item::List`] (label
390 /// syntax is not yet defined on those blocks).
391 #[must_use]
392 pub fn label(&self) -> Option<&str> {
393 match self {
394 Self::Heading { label, .. }
395 | Self::Paragraph { label, .. }
396 | Self::RawBlock { label, .. } => label.as_deref(),
397 Self::Set { .. } | Self::List { .. } | Self::DocComment { .. } => None,
398 }
399 }
400
401 /// Borrow the source span covering only the label token text, if any.
402 /// The delimiters (`<`, `>`, or directive string quotes) are excluded so a
403 /// structured suggestion can replace just the label bytes.
404 #[must_use]
405 pub const fn label_span(&self) -> Option<&SourceSpan> {
406 match self {
407 Self::Heading { label_span, .. }
408 | Self::Paragraph { label_span, .. }
409 | Self::RawBlock { label_span, .. } => label_span.as_ref(),
410 Self::Set { .. } | Self::List { .. } | Self::DocComment { .. } => None,
411 }
412 }
413}
414
415/// Output of [`crate::parse`]. Diagnostics may include warnings even
416/// when the tree is structurally usable; callers decide what to do per
417/// [`ParseResult::has_errors`].
418#[derive(Debug)]
419pub struct ParseResult {
420 pub tree: SyntaxTree,
421 pub diagnostics: Vec<Diagnostic>,
422}
423
424impl ParseResult {
425 #[must_use]
426 pub fn has_errors(&self) -> bool {
427 self.diagnostics
428 .iter()
429 .any(|d| d.severity() == Severity::Error)
430 }
431}