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