Skip to main content

mos_parse/
inline.rs

1use crate::parser::Parser;
2use crate::support::{double_slash_comment, find_byte, scan_label_chars};
3use mos_core::{Suggestion, codes};
4
5use crate::{Inline, InlineKind};
6
7#[derive(Clone, Copy, Debug, Default)]
8enum InlineStyle {
9    #[default]
10    Plain,
11    Emphasis,
12    Strong,
13    BoldItalic,
14}
15
16impl InlineStyle {
17    const fn with(self, delimiter: Delimiter) -> Self {
18        match delimiter {
19            Delimiter::Strong => self.with_strong(),
20            Delimiter::Emphasis => self.with_emphasis(),
21        }
22    }
23
24    const fn with_strong(self) -> Self {
25        match self {
26            Self::Plain => Self::Strong,
27            Self::Emphasis | Self::Strong | Self::BoldItalic => Self::BoldItalic,
28        }
29    }
30
31    const fn with_emphasis(self) -> Self {
32        match self {
33            Self::Plain => Self::Emphasis,
34            Self::Strong | Self::Emphasis | Self::BoldItalic => Self::BoldItalic,
35        }
36    }
37
38    const fn kind(self) -> InlineKind {
39        match self {
40            Self::Plain => InlineKind::Text,
41            Self::Emphasis => InlineKind::Emphasis,
42            Self::Strong => InlineKind::Strong,
43            Self::BoldItalic => InlineKind::BoldItalic,
44        }
45    }
46}
47
48#[derive(Clone, Copy, Debug)]
49enum Delimiter {
50    Emphasis,
51    Strong,
52}
53
54impl Delimiter {
55    const fn width(self) -> usize {
56        match self {
57            Self::Emphasis => 1,
58            Self::Strong => 2,
59        }
60    }
61
62    const fn closing_text(self) -> &'static str {
63        match self {
64            Self::Emphasis => "*",
65            Self::Strong => "**",
66        }
67    }
68}
69
70struct ParsedSegment {
71    inlines: Vec<Inline>,
72    next: usize,
73    closed: Option<ClosedDelimiter>,
74}
75
76struct ClosedDelimiter {
77    end: usize,
78}
79
80struct InlineSegmentParser<'parser, 'slice, 'src> {
81    parser: &'parser mut Parser<'src>,
82    slice: &'slice str,
83    bytes: &'slice [u8],
84    base: usize,
85    out: Vec<Inline>,
86    pending: String,
87    pending_source_start: Option<usize>,
88    i: usize,
89    text_start: usize,
90    style: InlineStyle,
91    close: Option<Delimiter>,
92}
93
94impl<'parser, 'slice, 'src> InlineSegmentParser<'parser, 'slice, 'src> {
95    const fn new(
96        parser: &'parser mut Parser<'src>,
97        slice: &'slice str,
98        base: usize,
99        from: usize,
100        style: InlineStyle,
101        close: Option<Delimiter>,
102    ) -> Self {
103        Self {
104            parser,
105            slice,
106            bytes: slice.as_bytes(),
107            base,
108            out: Vec::new(),
109            pending: String::new(),
110            pending_source_start: None,
111            i: from,
112            text_start: from,
113            style,
114            close,
115        }
116    }
117
118    fn parse(mut self) -> ParsedSegment {
119        while self.i < self.bytes.len() {
120            match self.bytes[self.i] {
121                b'\\' => self.handle_backslash(),
122                b'*' => {
123                    if let Some(segment) = self.handle_star() {
124                        return segment;
125                    }
126                }
127                b'`' => self.handle_code(),
128                b'@' => self.handle_reference(),
129                b'/' if self.at_inline_block_comment() => self.handle_inline_block_comment(),
130                b'/' if self.at_inline_comment() => self.handle_inline_comment(),
131                b'[' if self.i + 1 < self.bytes.len() && self.bytes[self.i + 1] == b'@' => {
132                    self.handle_citation();
133                }
134                _ => self.i += 1,
135            }
136        }
137        self.flush(self.bytes.len());
138        ParsedSegment {
139            inlines: self.out,
140            next: self.bytes.len(),
141            closed: None,
142        }
143    }
144
145    fn flush(&mut self, to: usize) {
146        self.parser.flush_styled_text_with_pending(
147            &mut self.out,
148            self.slice,
149            self.base,
150            self.text_start,
151            to,
152            self.style,
153            &mut self.pending,
154            &mut self.pending_source_start,
155        );
156    }
157
158    fn push_pending_escape(&mut self, text: char, width: usize) {
159        if self.pending_source_start.is_none() {
160            self.pending_source_start = Some(self.text_start);
161        }
162        self.pending.push_str(&self.slice[self.text_start..self.i]);
163        self.pending.push(text);
164        self.i += width;
165        self.text_start = self.i;
166    }
167
168    fn handle_backslash(&mut self) {
169        if self.i + 1 < self.bytes.len() && self.bytes[self.i + 1] == b'\\' {
170            self.flush(self.i);
171            self.out.push(Inline {
172                kind: InlineKind::HardBreak,
173                text: String::new(),
174                span: self.parser.span(self.base + self.i, self.base + self.i + 2),
175                label_span: None,
176            });
177            self.i += 2;
178            self.text_start = self.i;
179            return;
180        }
181        if self.i + 1 < self.bytes.len() && self.bytes[self.i + 1] == b'-' {
182            self.push_pending_escape('\u{AD}', 2);
183            return;
184        }
185        if self.i + 1 < self.bytes.len() && self.bytes[self.i + 1] == b'<' {
186            self.push_pending_escape('<', 2);
187            return;
188        }
189        if self.i + 1 < self.bytes.len() && self.bytes[self.i + 1] == b'*' {
190            self.push_pending_escape('*', 2);
191            return;
192        }
193        if self.i + 1 >= self.bytes.len() {
194            self.parser.diagnostics.push(self.parser.warn(
195                &codes::MOS0038,
196                "lone trailing `\\` is not a recognized escape; treated as literal text",
197                self.base + self.i,
198                self.base + self.i + 1,
199            ));
200        }
201        self.i += 1;
202    }
203
204    fn handle_star(&mut self) -> Option<ParsedSegment> {
205        let run_len = star_run_len(self.bytes, self.i);
206        if let Some(delimiter) = self.close
207            && delimiter_closes(delimiter, run_len)
208        {
209            self.flush(self.i);
210            let width = delimiter.width();
211            return Some(ParsedSegment {
212                inlines: std::mem::take(&mut self.out),
213                next: self.i + width,
214                closed: Some(ClosedDelimiter {
215                    end: self.i + width,
216                }),
217            });
218        }
219
220        let delimiter = if run_len >= 2 {
221            Delimiter::Strong
222        } else {
223            Delimiter::Emphasis
224        };
225        let diagnostic_checkpoint = self.parser.diagnostics.len();
226        let citation_checkpoint = self.parser.citation_spans.len();
227        let parsed = self.parser.parse_inline_segment(
228            self.slice,
229            self.base,
230            self.i + delimiter.width(),
231            self.style.with(delimiter),
232            Some(delimiter),
233        );
234
235        if let Some(closed) = parsed.closed {
236            self.flush(self.i);
237            let mut children = parsed.inlines;
238            widen_span_to_delimiters(&mut children, self.base + self.i, self.base + closed.end);
239            self.out.extend(children);
240            self.i = parsed.next;
241            self.text_start = self.i;
242        } else {
243            self.parser.diagnostics.truncate(diagnostic_checkpoint);
244            self.parser.citation_spans.truncate(citation_checkpoint);
245            if self.close.is_none() {
246                self.parser
247                    .warn_unterminated_delimiter(self.slice, self.base, self.i, delimiter);
248            }
249            self.i += delimiter.width();
250        }
251        None
252    }
253
254    fn handle_code(&mut self) {
255        if let Some(end) = find_byte(self.bytes, b'`', self.i + 1) {
256            self.flush(self.i);
257            self.out.push(Inline {
258                kind: InlineKind::Code,
259                text: self.slice[self.i + 1..end].to_owned(),
260                span: self.parser.span(self.base + self.i, self.base + end + 1),
261                label_span: None,
262            });
263            self.i = end + 1;
264            self.text_start = self.i;
265            return;
266        }
267        let mut diagnostic = self.parser.warn(
268            &codes::MOS0034,
269            "unterminated `` `code` `` run; treated as text",
270            self.base + self.i,
271            self.base + self.i + 1,
272        );
273        if let Some(insertion) = Parser::code_closing_insertion(self.slice, self.i, self.close) {
274            let insertion = self.base + insertion;
275            diagnostic = diagnostic
276                .with_suggestion(Suggestion::new(self.parser.span(insertion, insertion), "`"));
277        }
278        self.parser.diagnostics.push(diagnostic);
279        self.i += 1;
280    }
281
282    /// Whether the cursor sits at a trailing/interior `//` line comment: the
283    /// slashes must be at a whitespace boundary (slice start or a preceding
284    /// space/tab/newline) and be followed by a space or end-of-line. Code spans
285    /// and `@refs` are already consumed atomically before the cursor gets here,
286    /// so this only ever fires in the default text state.
287    fn at_inline_comment(&self) -> bool {
288        let boundary =
289            self.i == 0 || matches!(self.bytes[self.i - 1], b' ' | b'\t' | b'\n' | b'\r');
290        boundary && double_slash_comment(self.bytes, self.i, self.bytes.len())
291    }
292
293    /// Drop a `//` comment from the cursor to the end of its line: flush text up
294    /// to the comment (trimming the space/tab run before `//`). A *trailing*
295    /// comment resumes at the line's `\n` without consuming it, so the soft
296    /// break to the next line survives. A *whole-line* comment (only whitespace
297    /// before `//`) also consumes that `\n`, so the excised line leaves no blank
298    /// gap between the surrounding text lines.
299    fn handle_inline_comment(&mut self) {
300        let mut ws = self.i;
301        while ws > self.text_start && matches!(self.bytes[ws - 1], b' ' | b'\t') {
302            ws -= 1;
303        }
304        let whole_line = {
305            let mut p = self.i;
306            while p > 0 && matches!(self.bytes[p - 1], b' ' | b'\t') {
307                p -= 1;
308            }
309            p == 0 || matches!(self.bytes[p - 1], b'\n' | b'\r')
310        };
311        self.flush(ws);
312        let mut j = self.i;
313        while j < self.bytes.len() && self.bytes[j] != b'\n' {
314            j += 1;
315        }
316        if whole_line && j < self.bytes.len() {
317            j += 1;
318        }
319        self.i = j;
320        self.text_start = j;
321    }
322
323    /// Whether the cursor sits at a `/*` block-comment opener. Unlike the `//`
324    /// line comment, a block comment is recognized anywhere (matching the
325    /// editor grammar), not just at a whitespace boundary: verbatim contexts
326    /// (code spans, directive strings, raw blocks) are consumed before the
327    /// cursor reaches them, and `/*` does not occur in URLs, so no URL-safety
328    /// carve-out is needed.
329    fn at_inline_block_comment(&self) -> bool {
330        self.i + 1 < self.bytes.len()
331            && self.bytes[self.i] == b'/'
332            && self.bytes[self.i + 1] == b'*'
333    }
334
335    /// Drop a `/* … */` block comment from the cursor to its closing `*/`:
336    /// flush text up to the comment (trimming the space/tab run before `/*`,
337    /// matching [`Self::handle_inline_comment`]), scan raw bytes across newlines
338    /// for the first `*/`, then resume just past it. An unterminated `/*` is a
339    /// recoverable `MOS0050` warning and consumes to the end of the slice.
340    fn handle_inline_block_comment(&mut self) {
341        let mut ws = self.i;
342        while ws > self.text_start && matches!(self.bytes[ws - 1], b' ' | b'\t') {
343            ws -= 1;
344        }
345        self.flush(ws);
346        let mut j = self.i + 2;
347        while j + 1 < self.bytes.len() {
348            if self.bytes[j] == b'*' && self.bytes[j + 1] == b'/' {
349                self.i = j + 2;
350                self.text_start = self.i;
351                return;
352            }
353            j += 1;
354        }
355        self.parser.diagnostics.push(self.parser.warn(
356            &codes::MOS0050,
357            "unterminated `/*` block comment; consumed to end of input",
358            self.base + self.i,
359            self.base + self.bytes.len(),
360        ));
361        self.i = self.bytes.len();
362        self.text_start = self.bytes.len();
363    }
364
365    fn handle_reference(&mut self) {
366        let id_end = scan_label_chars(self.bytes, self.i + 1);
367        if id_end <= self.i + 1 {
368            self.warn_stray_at();
369            return;
370        }
371        if self.push_page_reference(id_end) {
372            return;
373        }
374        self.flush(self.i);
375        self.out.push(Inline {
376            kind: InlineKind::Reference,
377            text: self.slice[self.i + 1..id_end].to_owned(),
378            span: self.parser.span(self.base + self.i, self.base + id_end),
379            label_span: Some(self.parser.span(self.base + self.i + 1, self.base + id_end)),
380        });
381        self.i = id_end;
382        self.text_start = self.i;
383    }
384
385    fn push_page_reference(&mut self, id_end: usize) -> bool {
386        if &self.slice[self.i + 1..id_end] != "page"
387            || id_end >= self.bytes.len()
388            || self.bytes[id_end] != b'('
389        {
390            return false;
391        }
392        let label_start = id_end + 1;
393        let label_end = scan_label_chars(self.bytes, label_start);
394        if label_end <= label_start
395            || label_end >= self.bytes.len()
396            || self.bytes[label_end] != b')'
397        {
398            return false;
399        }
400        self.flush(self.i);
401        self.out.push(Inline {
402            kind: InlineKind::PageReference,
403            text: self.slice[label_start..label_end].to_owned(),
404            span: self
405                .parser
406                .span(self.base + self.i, self.base + label_end + 1),
407            label_span: Some(
408                self.parser
409                    .span(self.base + label_start, self.base + label_end),
410            ),
411        });
412        self.i = label_end + 1;
413        self.text_start = self.i;
414        true
415    }
416
417    fn warn_stray_at(&mut self) {
418        self.parser.diagnostics.push(self.parser.warn(
419            &codes::MOS0036,
420            "stray `@` is not followed by a label identifier; treated as text",
421            self.base + self.i,
422            self.base + self.i + 1,
423        ));
424        self.i += 1;
425    }
426
427    fn handle_citation(&mut self) {
428        let key_start = self.i + 2;
429        let key_end = scan_label_chars(self.bytes, key_start);
430        if key_end > key_start && key_end < self.bytes.len() && self.bytes[key_end] == b']' {
431            self.parser
432                .citation_spans
433                .push(self.base + self.i..self.base + key_end);
434            self.flush(self.i);
435            let end = key_end + 1;
436            self.out.push(Inline {
437                kind: InlineKind::Citation,
438                text: self.slice[key_start..key_end].to_owned(),
439                span: self.parser.span(self.base + self.i, self.base + end),
440                label_span: None,
441            });
442            self.i = end;
443            self.text_start = self.i;
444            return;
445        }
446        let close = find_byte(self.bytes, b']', key_start);
447        // Preserve the whole malformed body so editor consumers cannot
448        // mistake its valid leading characters for a complete key token.
449        // A later `[@` starts another citation: its closer must not prevent
450        // completing this unfinished prefix.
451        let citation_end = close
452            .filter(|&end| !self.slice[key_start..end].contains("[@"))
453            .unwrap_or(key_end);
454        self.parser
455            .citation_spans
456            .push(self.base + self.i..self.base + citation_end);
457        let recovery_end = close.map_or(key_start, |close| close + 1);
458        self.parser.diagnostics.push(self.parser.warn(
459            &codes::MOS0039,
460            "malformed citation `[@…]`; expected `[@key]`; treated as text",
461            self.base + self.i,
462            self.base + recovery_end,
463        ));
464        self.i = recovery_end;
465    }
466}
467
468impl Parser<'_> {
469    /// Tokenize `slice` (whose first byte sits at `base` in `self.src`)
470    /// into inline runs. Backtick code and `@label` references are
471    /// atomic; emphasis delimiters can nest into bold+italic text runs.
472    pub(crate) fn parse_inlines(&mut self, slice: &str, base: usize) -> Vec<Inline> {
473        self.parse_inline_segment(slice, base, 0, InlineStyle::default(), None)
474            .inlines
475    }
476
477    fn parse_inline_segment(
478        &mut self,
479        slice: &str,
480        base: usize,
481        from: usize,
482        style: InlineStyle,
483        close: Option<Delimiter>,
484    ) -> ParsedSegment {
485        InlineSegmentParser::new(self, slice, base, from, style, close).parse()
486    }
487
488    /// Flush `slice[from..to]` (possibly prefixed by buffered `pending`
489    /// text from earlier escape expansions like `\-` → U+00AD) into a
490    /// single styled-text inline. The span covers the full source range
491    /// from the earliest byte that fed `pending` (or `from` when pending
492    /// is empty) through `to`, so emitted inlines whose text includes
493    /// expanded escapes still carry a span covering the original source
494    /// bytes: including the consumed `\-` markers.
495    #[allow(
496        clippy::too_many_arguments,
497        reason = "transitional: extends the existing `flush_styled_text` (7-arg) with a buffered-text channel and a pending-source-start tracker for escape expansion. Bundling the slice/base/style triple into a context struct would churn every call site in `parse_inline_segment` for no net clarity."
498    )]
499    fn flush_styled_text_with_pending(
500        &self,
501        out: &mut Vec<Inline>,
502        slice: &str,
503        base: usize,
504        from: usize,
505        to: usize,
506        style: InlineStyle,
507        pending: &mut String,
508        pending_source_start: &mut Option<usize>,
509    ) {
510        if pending.is_empty() {
511            // Defensive: pending_source_start should always be paired
512            // with a non-empty pending. Clear it anyway so a future
513            // escape that splices into `pending` starts from a fresh
514            // state.
515            *pending_source_start = None;
516            self.flush_styled_text(out, slice, base, from, to, style);
517            return;
518        }
519        let mut text = std::mem::take(pending);
520        if from < to {
521            text.push_str(&slice[from..to]);
522        }
523        let span_from = pending_source_start.take().unwrap_or(from);
524        out.push(Inline {
525            kind: style.kind(),
526            text,
527            span: self.span(base + span_from, base + to),
528            label_span: None,
529        });
530    }
531
532    fn flush_styled_text(
533        &self,
534        out: &mut Vec<Inline>,
535        slice: &str,
536        base: usize,
537        from: usize,
538        to: usize,
539        style: InlineStyle,
540    ) {
541        if from < to {
542            out.push(Inline {
543                kind: style.kind(),
544                text: slice[from..to].to_owned(),
545                span: self.span(base + from, base + to),
546                label_span: None,
547            });
548        }
549    }
550
551    fn warn_unterminated_delimiter(
552        &mut self,
553        slice: &str,
554        base: usize,
555        i: usize,
556        delimiter: Delimiter,
557    ) {
558        let (def, message) = match delimiter {
559            Delimiter::Strong => (
560                &codes::MOS0028,
561                "unterminated `**strong**` run; treated as text",
562            ),
563            Delimiter::Emphasis => (
564                &codes::MOS0031,
565                "unterminated `*emphasis*` run; treated as text",
566            ),
567        };
568        let mut diagnostic = self.warn(def, message, base + i, base + i + delimiter.width());
569        if let Some(suggestion) = self.closing_delimiter_suggestion(slice, base, i, delimiter) {
570            diagnostic = diagnostic.with_suggestion(suggestion);
571        }
572        self.diagnostics.push(diagnostic);
573    }
574
575    fn closing_delimiter_suggestion(
576        &self,
577        slice: &str,
578        base: usize,
579        i: usize,
580        delimiter: Delimiter,
581    ) -> Option<Suggestion> {
582        let after_opener = i + delimiter.width();
583        if slice.as_bytes()[after_opener..].contains(&b'*') {
584            return None;
585        }
586        let insertion = base + slice.len();
587        Some(Suggestion::new(
588            self.span(insertion, insertion),
589            delimiter.closing_text(),
590        ))
591    }
592
593    fn code_closing_insertion(slice: &str, i: usize, close: Option<Delimiter>) -> Option<usize> {
594        let bytes = slice.as_bytes();
595        let mut cursor = i + 1;
596        while cursor < bytes.len() {
597            if bytes[cursor] == b'*' {
598                let run_len = star_run_len(bytes, cursor);
599                if close.is_some_and(|delimiter| delimiter_closes(delimiter, run_len)) {
600                    return Some(cursor);
601                }
602                return None;
603            }
604            cursor += 1;
605        }
606        Some(bytes.len())
607    }
608}
609
610fn star_run_len(bytes: &[u8], from: usize) -> usize {
611    let mut end = from;
612    while end < bytes.len() && bytes[end] == b'*' {
613        end += 1;
614    }
615    end - from
616}
617
618const fn delimiter_closes(delimiter: Delimiter, run_len: usize) -> bool {
619    match delimiter {
620        Delimiter::Strong => run_len >= 2,
621        Delimiter::Emphasis => run_len % 2 == 1,
622    }
623}
624
625fn widen_span_to_delimiters(inlines: &mut [Inline], start: usize, end: usize) {
626    if let Some(first) = inlines.first_mut() {
627        first.span.set_start(start);
628    }
629    if let Some(last) = inlines.last_mut() {
630        last.span.set_end(end);
631    }
632}