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 parsed = self.parser.parse_inline_segment(
227            self.slice,
228            self.base,
229            self.i + delimiter.width(),
230            self.style.with(delimiter),
231            Some(delimiter),
232        );
233
234        if let Some(closed) = parsed.closed {
235            self.flush(self.i);
236            let mut children = parsed.inlines;
237            widen_span_to_delimiters(&mut children, self.base + self.i, self.base + closed.end);
238            self.out.extend(children);
239            self.i = parsed.next;
240            self.text_start = self.i;
241        } else {
242            self.parser.diagnostics.truncate(diagnostic_checkpoint);
243            if self.close.is_none() {
244                self.parser
245                    .warn_unterminated_delimiter(self.slice, self.base, self.i, delimiter);
246            }
247            self.i += delimiter.width();
248        }
249        None
250    }
251
252    fn handle_code(&mut self) {
253        if let Some(end) = find_byte(self.bytes, b'`', self.i + 1) {
254            self.flush(self.i);
255            self.out.push(Inline {
256                kind: InlineKind::Code,
257                text: self.slice[self.i + 1..end].to_owned(),
258                span: self.parser.span(self.base + self.i, self.base + end + 1),
259                label_span: None,
260            });
261            self.i = end + 1;
262            self.text_start = self.i;
263            return;
264        }
265        let mut diagnostic = self.parser.warn(
266            &codes::MOS0034,
267            "unterminated `` `code` `` run; treated as text",
268            self.base + self.i,
269            self.base + self.i + 1,
270        );
271        if let Some(insertion) = Parser::code_closing_insertion(self.slice, self.i, self.close) {
272            let insertion = self.base + insertion;
273            diagnostic = diagnostic
274                .with_suggestion(Suggestion::new(self.parser.span(insertion, insertion), "`"));
275        }
276        self.parser.diagnostics.push(diagnostic);
277        self.i += 1;
278    }
279
280    /// Whether the cursor sits at a trailing/interior `//` line comment: the
281    /// slashes must be at a whitespace boundary (slice start or a preceding
282    /// space/tab/newline) and be followed by a space or end-of-line. Code spans
283    /// and `@refs` are already consumed atomically before the cursor gets here,
284    /// so this only ever fires in the default text state.
285    fn at_inline_comment(&self) -> bool {
286        let boundary =
287            self.i == 0 || matches!(self.bytes[self.i - 1], b' ' | b'\t' | b'\n' | b'\r');
288        boundary && double_slash_comment(self.bytes, self.i, self.bytes.len())
289    }
290
291    /// Drop a `//` comment from the cursor to the end of its line: flush text up
292    /// to the comment (trimming the space/tab run before `//`). A *trailing*
293    /// comment resumes at the line's `\n` without consuming it, so the soft
294    /// break to the next line survives. A *whole-line* comment (only whitespace
295    /// before `//`) also consumes that `\n`, so the excised line leaves no blank
296    /// gap between the surrounding text lines.
297    fn handle_inline_comment(&mut self) {
298        let mut ws = self.i;
299        while ws > self.text_start && matches!(self.bytes[ws - 1], b' ' | b'\t') {
300            ws -= 1;
301        }
302        let whole_line = {
303            let mut p = self.i;
304            while p > 0 && matches!(self.bytes[p - 1], b' ' | b'\t') {
305                p -= 1;
306            }
307            p == 0 || matches!(self.bytes[p - 1], b'\n' | b'\r')
308        };
309        self.flush(ws);
310        let mut j = self.i;
311        while j < self.bytes.len() && self.bytes[j] != b'\n' {
312            j += 1;
313        }
314        if whole_line && j < self.bytes.len() {
315            j += 1;
316        }
317        self.i = j;
318        self.text_start = j;
319    }
320
321    /// Whether the cursor sits at a `/*` block-comment opener. Unlike the `//`
322    /// line comment, a block comment is recognized anywhere (matching the
323    /// editor grammar), not just at a whitespace boundary: verbatim contexts
324    /// (code spans, directive strings, raw blocks) are consumed before the
325    /// cursor reaches them, and `/*` does not occur in URLs, so no URL-safety
326    /// carve-out is needed.
327    fn at_inline_block_comment(&self) -> bool {
328        self.i + 1 < self.bytes.len()
329            && self.bytes[self.i] == b'/'
330            && self.bytes[self.i + 1] == b'*'
331    }
332
333    /// Drop a `/* … */` block comment from the cursor to its closing `*/`:
334    /// flush text up to the comment (trimming the space/tab run before `/*`,
335    /// matching [`Self::handle_inline_comment`]), scan raw bytes across newlines
336    /// for the first `*/`, then resume just past it. An unterminated `/*` is a
337    /// recoverable `MOS0050` warning and consumes to the end of the slice.
338    fn handle_inline_block_comment(&mut self) {
339        let mut ws = self.i;
340        while ws > self.text_start && matches!(self.bytes[ws - 1], b' ' | b'\t') {
341            ws -= 1;
342        }
343        self.flush(ws);
344        let mut j = self.i + 2;
345        while j + 1 < self.bytes.len() {
346            if self.bytes[j] == b'*' && self.bytes[j + 1] == b'/' {
347                self.i = j + 2;
348                self.text_start = self.i;
349                return;
350            }
351            j += 1;
352        }
353        self.parser.diagnostics.push(self.parser.warn(
354            &codes::MOS0050,
355            "unterminated `/*` block comment; consumed to end of input",
356            self.base + self.i,
357            self.base + self.bytes.len(),
358        ));
359        self.i = self.bytes.len();
360        self.text_start = self.bytes.len();
361    }
362
363    fn handle_reference(&mut self) {
364        let id_end = scan_label_chars(self.bytes, self.i + 1);
365        if id_end <= self.i + 1 {
366            self.warn_stray_at();
367            return;
368        }
369        if self.push_page_reference(id_end) {
370            return;
371        }
372        self.flush(self.i);
373        self.out.push(Inline {
374            kind: InlineKind::Reference,
375            text: self.slice[self.i + 1..id_end].to_owned(),
376            span: self.parser.span(self.base + self.i, self.base + id_end),
377            label_span: Some(self.parser.span(self.base + self.i + 1, self.base + id_end)),
378        });
379        self.i = id_end;
380        self.text_start = self.i;
381    }
382
383    fn push_page_reference(&mut self, id_end: usize) -> bool {
384        if &self.slice[self.i + 1..id_end] != "page"
385            || id_end >= self.bytes.len()
386            || self.bytes[id_end] != b'('
387        {
388            return false;
389        }
390        let label_start = id_end + 1;
391        let label_end = scan_label_chars(self.bytes, label_start);
392        if label_end <= label_start
393            || label_end >= self.bytes.len()
394            || self.bytes[label_end] != b')'
395        {
396            return false;
397        }
398        self.flush(self.i);
399        self.out.push(Inline {
400            kind: InlineKind::PageReference,
401            text: self.slice[label_start..label_end].to_owned(),
402            span: self
403                .parser
404                .span(self.base + self.i, self.base + label_end + 1),
405            label_span: Some(
406                self.parser
407                    .span(self.base + label_start, self.base + label_end),
408            ),
409        });
410        self.i = label_end + 1;
411        self.text_start = self.i;
412        true
413    }
414
415    fn warn_stray_at(&mut self) {
416        self.parser.diagnostics.push(self.parser.warn(
417            &codes::MOS0036,
418            "stray `@` is not followed by a label identifier; treated as text",
419            self.base + self.i,
420            self.base + self.i + 1,
421        ));
422        self.i += 1;
423    }
424
425    fn handle_citation(&mut self) {
426        let key_start = self.i + 2;
427        let key_end = scan_label_chars(self.bytes, key_start);
428        if key_end > key_start && key_end < self.bytes.len() && self.bytes[key_end] == b']' {
429            self.flush(self.i);
430            let end = key_end + 1;
431            self.out.push(Inline {
432                kind: InlineKind::Citation,
433                text: self.slice[key_start..key_end].to_owned(),
434                span: self.parser.span(self.base + self.i, self.base + end),
435                label_span: None,
436            });
437            self.i = end;
438            self.text_start = self.i;
439            return;
440        }
441        let recovery_end =
442            find_byte(self.bytes, b']', key_start).map_or(key_start, |close| close + 1);
443        self.parser.diagnostics.push(self.parser.warn(
444            &codes::MOS0039,
445            "malformed citation `[@…]`; expected `[@key]`; treated as text",
446            self.base + self.i,
447            self.base + recovery_end,
448        ));
449        self.i = recovery_end;
450    }
451}
452
453impl Parser<'_> {
454    /// Tokenize `slice` (whose first byte sits at `base` in `self.src`)
455    /// into inline runs. Backtick code and `@label` references are
456    /// atomic; emphasis delimiters can nest into bold+italic text runs.
457    pub(crate) fn parse_inlines(&mut self, slice: &str, base: usize) -> Vec<Inline> {
458        self.parse_inline_segment(slice, base, 0, InlineStyle::default(), None)
459            .inlines
460    }
461
462    fn parse_inline_segment(
463        &mut self,
464        slice: &str,
465        base: usize,
466        from: usize,
467        style: InlineStyle,
468        close: Option<Delimiter>,
469    ) -> ParsedSegment {
470        InlineSegmentParser::new(self, slice, base, from, style, close).parse()
471    }
472
473    /// Flush `slice[from..to]` (possibly prefixed by buffered `pending`
474    /// text from earlier escape expansions like `\-` → U+00AD) into a
475    /// single styled-text inline. The span covers the full source range
476    /// from the earliest byte that fed `pending` (or `from` when pending
477    /// is empty) through `to`, so emitted inlines whose text includes
478    /// expanded escapes still carry a span covering the original source
479    /// bytes: including the consumed `\-` markers.
480    #[allow(
481        clippy::too_many_arguments,
482        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."
483    )]
484    fn flush_styled_text_with_pending(
485        &self,
486        out: &mut Vec<Inline>,
487        slice: &str,
488        base: usize,
489        from: usize,
490        to: usize,
491        style: InlineStyle,
492        pending: &mut String,
493        pending_source_start: &mut Option<usize>,
494    ) {
495        if pending.is_empty() {
496            // Defensive: pending_source_start should always be paired
497            // with a non-empty pending. Clear it anyway so a future
498            // escape that splices into `pending` starts from a fresh
499            // state.
500            *pending_source_start = None;
501            self.flush_styled_text(out, slice, base, from, to, style);
502            return;
503        }
504        let mut text = std::mem::take(pending);
505        if from < to {
506            text.push_str(&slice[from..to]);
507        }
508        let span_from = pending_source_start.take().unwrap_or(from);
509        out.push(Inline {
510            kind: style.kind(),
511            text,
512            span: self.span(base + span_from, base + to),
513            label_span: None,
514        });
515    }
516
517    fn flush_styled_text(
518        &self,
519        out: &mut Vec<Inline>,
520        slice: &str,
521        base: usize,
522        from: usize,
523        to: usize,
524        style: InlineStyle,
525    ) {
526        if from < to {
527            out.push(Inline {
528                kind: style.kind(),
529                text: slice[from..to].to_owned(),
530                span: self.span(base + from, base + to),
531                label_span: None,
532            });
533        }
534    }
535
536    fn warn_unterminated_delimiter(
537        &mut self,
538        slice: &str,
539        base: usize,
540        i: usize,
541        delimiter: Delimiter,
542    ) {
543        let (def, message) = match delimiter {
544            Delimiter::Strong => (
545                &codes::MOS0028,
546                "unterminated `**strong**` run; treated as text",
547            ),
548            Delimiter::Emphasis => (
549                &codes::MOS0031,
550                "unterminated `*emphasis*` run; treated as text",
551            ),
552        };
553        let mut diagnostic = self.warn(def, message, base + i, base + i + delimiter.width());
554        if let Some(suggestion) = self.closing_delimiter_suggestion(slice, base, i, delimiter) {
555            diagnostic = diagnostic.with_suggestion(suggestion);
556        }
557        self.diagnostics.push(diagnostic);
558    }
559
560    fn closing_delimiter_suggestion(
561        &self,
562        slice: &str,
563        base: usize,
564        i: usize,
565        delimiter: Delimiter,
566    ) -> Option<Suggestion> {
567        let after_opener = i + delimiter.width();
568        if slice.as_bytes()[after_opener..].contains(&b'*') {
569            return None;
570        }
571        let insertion = base + slice.len();
572        Some(Suggestion::new(
573            self.span(insertion, insertion),
574            delimiter.closing_text(),
575        ))
576    }
577
578    fn code_closing_insertion(slice: &str, i: usize, close: Option<Delimiter>) -> Option<usize> {
579        let bytes = slice.as_bytes();
580        let mut cursor = i + 1;
581        while cursor < bytes.len() {
582            if bytes[cursor] == b'*' {
583                let run_len = star_run_len(bytes, cursor);
584                if close.is_some_and(|delimiter| delimiter_closes(delimiter, run_len)) {
585                    return Some(cursor);
586                }
587                return None;
588            }
589            cursor += 1;
590        }
591        Some(bytes.len())
592    }
593}
594
595fn star_run_len(bytes: &[u8], from: usize) -> usize {
596    let mut end = from;
597    while end < bytes.len() && bytes[end] == b'*' {
598        end += 1;
599    }
600    end - from
601}
602
603const fn delimiter_closes(delimiter: Delimiter, run_len: usize) -> bool {
604    match delimiter {
605        Delimiter::Strong => run_len >= 2,
606        Delimiter::Emphasis => run_len % 2 == 1,
607    }
608}
609
610fn widen_span_to_delimiters(inlines: &mut [Inline], start: usize, end: usize) {
611    if let Some(first) = inlines.first_mut() {
612        first.span.set_start(start);
613    }
614    if let Some(last) = inlines.last_mut() {
615        last.span.set_end(end);
616    }
617}