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 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 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 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 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 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 #[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 *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}