Skip to main content

mos_parse/
block.rs

1use mos_core::{DiagnosticAnnotation, Suggestion, codes};
2
3use crate::Item;
4use crate::parser::Parser;
5use crate::support::{
6    locate_label, strip_leading_label, strip_trailing_label, trailing_block_comment_start,
7    trailing_line_comment_start,
8};
9
10impl Parser<'_> {
11    pub(crate) fn parse_heading(&mut self) {
12        let (line_start, content_end, line_end) = self.current_line_bounds();
13        let bytes = self.src.as_bytes();
14        let mut level: u8 = 0;
15        let mut i = line_start;
16        while i < content_end && bytes[i] == b'=' {
17            level = level.saturating_add(1);
18            i += 1;
19        }
20        if i >= content_end || !bytes[i].is_ascii_whitespace() {
21            self.parse_paragraph();
22            return;
23        }
24        while i < content_end && (bytes[i] == b' ' || bytes[i] == b'\t') {
25            i += 1;
26        }
27        // Excise a trailing `// ` or `/* … */` comment before label/inline
28        // parsing, so a heading like `= Title <lbl> // note` (or `/* note */`)
29        // still attaches `<lbl>` (the comment would otherwise sit after the
30        // label and hide it from `strip_trailing_label`, spuriously tripping
31        // MOS0048). A non-trailing block comment is left in place and stripped
32        // later by the inline scanner.
33        let content_end =
34            trailing_block_comment_start(bytes, i, content_end).unwrap_or(content_end);
35        let content_end = trailing_line_comment_start(bytes, i, content_end).unwrap_or(content_end);
36        let (text_end, parsed_label) = strip_trailing_label(self.src, i, content_end);
37        if parsed_label.is_none() {
38            self.flag_misplaced_heading_label(i, content_end);
39        }
40        let label_span = parsed_label
41            .as_ref()
42            .map(|label| self.span(label.start, label.end));
43        let label = parsed_label.map(|label| label.text);
44        let content = &self.src[i..text_end];
45        let inlines = self.parse_inlines(content, i);
46        let span = self.span(line_start, content_end);
47        self.items.push(Item::Heading {
48            level,
49            inlines,
50            label,
51            label_span,
52            span,
53        });
54        self.pos = line_end;
55    }
56
57    /// Emit `MOS0048` when a heading carries a `<label>` token that is not the
58    /// trailing element, so [`strip_trailing_label`] left it unrecognised and
59    /// it would otherwise be swallowed into the heading text. The attached
60    /// suggestion moves the label to the end of the line, where it registers as
61    /// a real declaration.
62    fn flag_misplaced_heading_label(&mut self, start: usize, content_end: usize) {
63        let Some((label, after)) = locate_label(self.src, start, content_end) else {
64            return;
65        };
66        // Only a label with real content after it is misplaced; trailing
67        // whitespace alone is harmless and not the author's mistake.
68        let trailing = self.src[after..content_end].trim();
69        if trailing.is_empty() {
70            return;
71        }
72        let Some(label_open) = label.start.checked_sub(1).filter(|open| *open >= start) else {
73            return;
74        };
75        let before = self.src[start..label_open].trim();
76        let mut fixed = String::new();
77        for part in [before, trailing] {
78            if part.is_empty() {
79                continue;
80            }
81            if !fixed.is_empty() {
82                fixed.push(' ');
83            }
84            fixed.push_str(part);
85        }
86        if !fixed.is_empty() {
87            fixed.push(' ');
88        }
89        fixed.push('<');
90        fixed.push_str(&label.text);
91        fixed.push('>');
92        let message = format!(
93            "heading label `<{}>` must be the last element on the line",
94            label.text
95        );
96        let diagnostic = self
97            .warn(&codes::MOS0048, &message, start, content_end)
98            .with_suggestion(Suggestion::new(self.span(start, content_end), fixed))
99            .with_suggestion(Suggestion::new(self.span(label_open, label.start), "\\<"))
100            .with_annotation(DiagnosticAnnotation::Hint(format!(
101                "if `<{label}>` is literal text (e.g. an HTML tag), escape the `<` as `\\<{label}>`",
102                label = label.text
103            )));
104        self.diagnostics.push(diagnostic);
105    }
106
107    pub(crate) fn parse_paragraph(&mut self) {
108        let bytes = self.src.as_bytes();
109        let para_start = self.pos;
110        let mut para_end = self.pos;
111        let mut text_start: Option<usize> = None;
112        loop {
113            if self.pos >= bytes.len() || self.at_blank_line() {
114                break;
115            }
116            if self.starts_with("=") && self.heading_level_of_current_line().is_some() {
117                break;
118            }
119            if self.at_directive_keyword().is_some() || self.at_list_marker() {
120                break;
121            }
122            // A line opening a `/* */` block comment ends the paragraph so
123            // `run()` can consume it (possibly across lines). Whole-line `//`
124            // comments are intentionally NOT a break: the inline scanner excises
125            // them so surrounding text stays one paragraph.
126            if self.at_block_comment() {
127                break;
128            }
129            let (line_start, content_end, line_end) = self.current_line_bounds();
130            if text_start.is_none() {
131                text_start = Some(line_start);
132            }
133            para_end = content_end;
134            self.pos = line_end;
135        }
136        if let Some(start) = text_start {
137            let (body_start, parsed_label) = strip_leading_label(self.src, start, para_end);
138            let label_span = parsed_label
139                .as_ref()
140                .map(|label| self.span(label.start, label.end));
141            let label = parsed_label.map(|label| label.text);
142            let slice = &self.src[body_start..para_end];
143            let mut inlines = self.parse_inlines(slice, body_start);
144            for inline in &mut inlines {
145                if inline.text.contains("\r\n") {
146                    inline.text = inline.text.replace("\r\n", "\n");
147                }
148            }
149            let span = self.span(para_start, para_end);
150            self.items.push(Item::Paragraph {
151                inlines,
152                label,
153                label_span,
154                span,
155            });
156        }
157    }
158
159    /// Returns `Some(level)` if the current line is a well-formed
160    /// heading of `=`+ followed by ASCII whitespace.
161    fn heading_level_of_current_line(&self) -> Option<u8> {
162        let (start, content_end, _) = self.current_line_bounds();
163        let bytes = self.src.as_bytes();
164        let mut i = start;
165        let mut level: u8 = 0;
166        while i < content_end && bytes[i] == b'=' {
167            level = level.saturating_add(1);
168            i += 1;
169        }
170        if level == 0 {
171            return None;
172        }
173        if i < content_end && bytes[i].is_ascii_whitespace() {
174            Some(level)
175        } else {
176            None
177        }
178    }
179}