Skip to main content

brief/
inline.rs

1use crate::ast::{Inline, ShortArgs};
2use crate::diag::{Code, Diagnostic};
3use crate::shortcode::ArgValue;
4use crate::span::Span;
5use std::collections::BTreeMap;
6
7pub fn parse_inline(line: &str, base: u32) -> (Vec<Inline>, Vec<Diagnostic>) {
8    let mut p = Parser {
9        src: line,
10        base,
11        pos: 0,
12        diags: Vec::new(),
13    };
14    let nodes = p.parse_until(None);
15    (nodes, p.diags)
16}
17
18struct Parser<'a> {
19    src: &'a str,
20    base: u32,
21    pos: usize,
22    diags: Vec<Diagnostic>,
23}
24
25/// Bytes the inline scanner must stop at: escape, code span, shortcode,
26/// the four emphasis markers, and `]` (the only terminator ever passed to
27/// `parse_until`). Everything else — including UTF-8 continuation bytes,
28/// which are all >= 0x80 and never collide with these ASCII values — is
29/// skipped one byte at a time without decoding. The scanner only ever
30/// *stops* on ASCII, so `pos` always lands on a char boundary.
31const SPECIAL: [bool; 256] = {
32    let mut t = [false; 256];
33    t[b'\\' as usize] = true;
34    t[b'`' as usize] = true;
35    t[b'@' as usize] = true;
36    t[b'*' as usize] = true;
37    t[b'_' as usize] = true;
38    t[b'+' as usize] = true;
39    t[b'~' as usize] = true;
40    t[b']' as usize] = true;
41    t
42};
43
44impl<'a> Parser<'a> {
45    fn span(&self, start: usize, len: usize) -> Span {
46        Span::new(self.base as usize + start, len)
47    }
48
49    fn peek(&self) -> Option<u8> {
50        self.src.as_bytes().get(self.pos).copied()
51    }
52
53    fn parse_until(&mut self, terminator: Option<u8>) -> Vec<Inline> {
54        let mut out: Vec<Inline> = Vec::new();
55        let mut text_start = self.pos;
56        let bytes = self.src.as_bytes();
57
58        while self.pos < bytes.len() {
59            let c = bytes[self.pos];
60            if !SPECIAL[c as usize] {
61                self.pos += 1;
62                continue;
63            }
64            if Some(c) == terminator {
65                break;
66            }
67            match c {
68                b'\\' => {
69                    self.flush_text(&mut out, text_start);
70                    if let Some(esc_char) = self.src[self.pos + 1..].chars().next() {
71                        let w = esc_char.len_utf8();
72                        let s = self.span(self.pos, 1 + w);
73                        out.push(Inline::Text {
74                            value: esc_char.to_string(),
75                            span: s,
76                        });
77                        self.pos += 1 + w;
78                    } else {
79                        self.pos += 1;
80                    }
81                    text_start = self.pos;
82                }
83                b'`' => {
84                    self.flush_text(&mut out, text_start);
85                    self.parse_code(&mut out);
86                    text_start = self.pos;
87                }
88                b'@' => {
89                    self.flush_text(&mut out, text_start);
90                    if !self.try_parse_shortcode(&mut out) {
91                        out.push(Inline::Text {
92                            value: "@".to_string(),
93                            span: self.span(self.pos, 1),
94                        });
95                        self.pos += 1;
96                    }
97                    text_start = self.pos;
98                }
99                b'*' | b'_' | b'+' | b'~' if self.is_open_marker() => {
100                    self.flush_text(&mut out, text_start);
101                    self.parse_emph(&mut out, c);
102                    text_start = self.pos;
103                }
104                _ => {
105                    // A special byte that didn't start a construct (`]`
106                    // with no terminator armed, or a marker that isn't an
107                    // opener). All special bytes are ASCII, so +1 keeps
108                    // `pos` on a char boundary.
109                    self.pos += 1;
110                }
111            }
112        }
113        self.flush_text(&mut out, text_start);
114        out
115    }
116
117    fn flush_text(&self, out: &mut Vec<Inline>, start: usize) {
118        if start < self.pos {
119            let value = self.src[start..self.pos].to_string();
120            out.push(Inline::Text {
121                value,
122                span: self.span(start, self.pos - start),
123            });
124        }
125    }
126
127    fn is_open_marker(&self) -> bool {
128        is_open_marker_at(self.src.as_bytes(), self.pos)
129    }
130
131    fn is_close_marker(&self, marker: u8) -> bool {
132        let bytes = self.src.as_bytes();
133        let pos = self.pos;
134        if bytes.get(pos) != Some(&marker) {
135            return false;
136        }
137        if bytes.get(pos + 1) == Some(&marker) {
138            return false;
139        }
140        let prev = if pos == 0 { None } else { Some(bytes[pos - 1]) };
141        let next = bytes.get(pos + 1).copied();
142        let prev_ok = matches!(prev, Some(b) if b != b' ');
143        let next_ok = match next {
144            None => true,
145            Some(b' ') => true,
146            Some(b) => is_inline_sigil(b) || is_punct(b),
147        };
148        prev_ok && next_ok
149    }
150
151    fn parse_emph(&mut self, out: &mut Vec<Inline>, marker: u8) {
152        let start = self.pos;
153        self.pos += 1;
154        let inner_start = self.pos;
155        let mut content: Vec<Inline> = Vec::new();
156        let mut text_start = inner_start;
157        let mut closed = false;
158        let bytes = self.src.as_bytes();
159
160        while self.pos < bytes.len() {
161            let c = bytes[self.pos];
162            if !SPECIAL[c as usize] {
163                self.pos += 1;
164                continue;
165            }
166            if c == marker && self.is_close_marker(marker) {
167                if text_start < self.pos {
168                    content.push(Inline::Text {
169                        value: self.src[text_start..self.pos].to_string(),
170                        span: self.span(text_start, self.pos - text_start),
171                    });
172                }
173                self.pos += 1;
174                closed = true;
175                break;
176            }
177            if c == marker {
178                self.diags.push(
179                    Diagnostic::new(Code::EmphasisSameMarker, self.span(self.pos, 1))
180                        .label("inner emphasis re-uses the same marker")
181                        .help("use a different emphasis marker for the inner span"),
182                );
183                self.pos += 1;
184                continue;
185            }
186            match c {
187                b'\\' => {
188                    if text_start < self.pos {
189                        content.push(Inline::Text {
190                            value: self.src[text_start..self.pos].to_string(),
191                            span: self.span(text_start, self.pos - text_start),
192                        });
193                    }
194                    if let Some(esc_char) = self.src[self.pos + 1..].chars().next() {
195                        let w = esc_char.len_utf8();
196                        content.push(Inline::Text {
197                            value: esc_char.to_string(),
198                            span: self.span(self.pos, 1 + w),
199                        });
200                        self.pos += 1 + w;
201                    } else {
202                        self.pos += 1;
203                    }
204                    text_start = self.pos;
205                }
206                b'`' => {
207                    if text_start < self.pos {
208                        content.push(Inline::Text {
209                            value: self.src[text_start..self.pos].to_string(),
210                            span: self.span(text_start, self.pos - text_start),
211                        });
212                    }
213                    self.parse_code(&mut content);
214                    text_start = self.pos;
215                }
216                b'@' => {
217                    if text_start < self.pos {
218                        content.push(Inline::Text {
219                            value: self.src[text_start..self.pos].to_string(),
220                            span: self.span(text_start, self.pos - text_start),
221                        });
222                    }
223                    if !self.try_parse_shortcode(&mut content) {
224                        content.push(Inline::Text {
225                            value: "@".to_string(),
226                            span: self.span(self.pos, 1),
227                        });
228                        self.pos += 1;
229                    }
230                    text_start = self.pos;
231                }
232                b'*' | b'_' | b'+' | b'~' if c != marker && self.is_open_marker() => {
233                    if text_start < self.pos {
234                        content.push(Inline::Text {
235                            value: self.src[text_start..self.pos].to_string(),
236                            span: self.span(text_start, self.pos - text_start),
237                        });
238                    }
239                    self.parse_emph(&mut content, c);
240                    text_start = self.pos;
241                }
242                _ => {
243                    // Non-construct special byte (see `parse_until`); all
244                    // special bytes are ASCII so +1 stays on a boundary.
245                    self.pos += 1;
246                }
247            }
248        }
249        if !closed {
250            self.diags.push(
251                Diagnostic::new(Code::UnterminatedEmph, self.span(start, 1))
252                    .label(format!("opened with `{}`", marker as char)),
253            );
254        }
255        let span = self.span(start, self.pos - start);
256        let node = match marker {
257            b'*' => Inline::Bold { content, span },
258            b'_' => Inline::Italic { content, span },
259            b'+' => Inline::Underline { content, span },
260            b'~' => Inline::Strike { content, span },
261            _ => unreachable!(),
262        };
263        out.push(node);
264    }
265
266    fn parse_code(&mut self, out: &mut Vec<Inline>) {
267        let start = self.pos;
268        let mut ticks = 0;
269        while self.peek() == Some(b'`') && ticks < 2 {
270            self.pos += 1;
271            ticks += 1;
272        }
273        if self.peek() == Some(b'`') {
274            out.push(Inline::Text {
275                value: self.src[start..self.pos].to_string(),
276                span: self.span(start, self.pos - start),
277            });
278            return;
279        }
280        let body_start = self.pos;
281        let needle = if ticks == 1 {
282            "`".to_string()
283        } else {
284            "``".to_string()
285        };
286        let rest = &self.src[body_start..];
287        if let Some(rel) = rest.find(&needle) {
288            let body = &self.src[body_start..body_start + rel];
289            self.pos = body_start + rel + needle.len();
290            out.push(Inline::InlineCode {
291                value: body.to_string(),
292                span: self.span(start, self.pos - start),
293            });
294        } else {
295            self.diags.push(Diagnostic::new(
296                Code::UnterminatedCode,
297                self.span(start, ticks),
298            ));
299            out.push(Inline::Text {
300                value: self.src[start..self.pos].to_string(),
301                span: self.span(start, self.pos - start),
302            });
303        }
304    }
305
306    fn try_parse_shortcode(&mut self, out: &mut Vec<Inline>) -> bool {
307        let saved = self.pos;
308        if self.peek() != Some(b'@') {
309            return false;
310        }
311        let mut cursor = self.pos + 1;
312        let bytes = self.src.as_bytes();
313        if bytes
314            .get(cursor)
315            .map(|b| !b.is_ascii_alphabetic())
316            .unwrap_or(true)
317        {
318            return false;
319        }
320        let name_start = cursor;
321        while let Some(&b) = bytes.get(cursor) {
322            if b.is_ascii_alphanumeric() || b == b'-' {
323                cursor += 1;
324            } else {
325                break;
326            }
327        }
328        let name = self.src[name_start..cursor].to_string();
329        let mut args = ShortArgs::default();
330        if bytes.get(cursor) == Some(&b'(') {
331            match parse_args(self.src, &mut cursor) {
332                Ok(a) => args = a,
333                Err(d) => {
334                    self.diags.push(d.label("in inline shortcode"));
335                    self.pos = cursor;
336                    out.push(Inline::Text {
337                        value: self.src[saved..self.pos].to_string(),
338                        span: self.span(saved, self.pos - saved),
339                    });
340                    return true;
341                }
342            }
343        }
344        self.pos = cursor;
345        let mut content = None;
346        if self.peek() == Some(b'[') {
347            self.pos += 1;
348            let inner = self.parse_until(Some(b']'));
349            if self.peek() == Some(b']') {
350                self.pos += 1;
351            }
352            content = Some(inner);
353
354            // Markdown-link sugar: `@name[text](url)` -- the trailing parens
355            // hold a single raw-string positional argument. This is the only
356            // place where a URL-like value escapes the strict arg grammar.
357            // Parens inside the URL are allowed when balanced (Wikipedia
358            // disambiguation URLs); the closing delimiter is the first `)`
359            // that isn't matched by a `(` inside the URL.
360            if self.peek() == Some(b'(') {
361                self.pos += 1;
362                let url_start = self.pos;
363                let mut open_parens = 0usize;
364                while let Some(b) = self.peek() {
365                    match b {
366                        b'(' => open_parens += 1,
367                        b')' => {
368                            if open_parens == 0 {
369                                break;
370                            }
371                            open_parens -= 1;
372                        }
373                        _ => {}
374                    }
375                    self.pos += 1;
376                }
377                let url = self.src[url_start..self.pos].to_string();
378                if self.peek() == Some(b')') {
379                    self.pos += 1;
380                } else {
381                    self.diags.push(
382                        Diagnostic::new(
383                            Code::BadArgSyntax,
384                            self.span(url_start, self.pos - url_start),
385                        )
386                        .label("`(url)` is never closed")
387                        .help("percent-encode unmatched parentheses in the URL as %28 / %29"),
388                    );
389                }
390                args.positional.push(ArgValue::Str(url));
391            }
392        }
393        let span = self.span(saved, self.pos - saved);
394        out.push(Inline::Shortcode {
395            name,
396            args,
397            content,
398            span,
399        });
400        true
401    }
402}
403
404pub(crate) fn is_inline_sigil(b: u8) -> bool {
405    matches!(b, b'*' | b'_' | b'+' | b'~' | b'`' | b'@' | b'[' | b']')
406}
407
408pub(crate) fn is_punct(b: u8) -> bool {
409    matches!(
410        b,
411        b'.' | b',' | b';' | b':' | b'!' | b'?' | b')' | b'(' | b'"' | b'\'' | b'-' | b'/'
412    )
413}
414
415/// `true` if `bytes[pos]` (which must be one of `*`/`_`/`+`/`~`) would
416/// open an emphasis span at this position under Brief's flanking rules.
417/// Shared between the inline parser and the Markdown→Brief converter so
418/// the two cannot drift.
419pub(crate) fn is_open_marker_at(bytes: &[u8], pos: usize) -> bool {
420    let marker = match bytes.get(pos) {
421        Some(&b @ (b'*' | b'_' | b'+' | b'~')) => b,
422        _ => return false,
423    };
424    let prev = if pos == 0 { None } else { Some(bytes[pos - 1]) };
425    let next = bytes.get(pos + 1).copied();
426    if next == Some(marker) || prev == Some(marker) {
427        return false;
428    }
429    let prev_ok = match prev {
430        None => true,
431        Some(b' ') => true,
432        Some(b) if is_inline_sigil(b) => true,
433        Some(b) if is_punct(b) => true,
434        _ => false,
435    };
436    let next_ok = matches!(next, Some(b) if b != b' ' && b != marker);
437    prev_ok && next_ok
438}
439
440pub fn parse_args(src: &str, cursor: &mut usize) -> Result<ShortArgs, Diagnostic> {
441    let bytes = src.as_bytes();
442    if bytes.get(*cursor) != Some(&b'(') {
443        return Ok(ShortArgs::default());
444    }
445    *cursor += 1;
446    let mut args = ShortArgs::default();
447    let mut keys_seen: BTreeMap<String, ()> = BTreeMap::new();
448    skip_ws(src, cursor);
449    if bytes.get(*cursor) == Some(&b')') {
450        *cursor += 1;
451        return Ok(args);
452    }
453    loop {
454        skip_ws(src, cursor);
455        let arg_start = *cursor;
456        let saved = *cursor;
457        if let Some(name) = read_ident(src, cursor) {
458            skip_ws(src, cursor);
459            if bytes.get(*cursor) == Some(&b':') {
460                *cursor += 1;
461                skip_ws(src, cursor);
462                let v = read_value(src, cursor)
463                    .ok_or_else(|| Diagnostic::new(Code::BadArgSyntax, Span::new(*cursor, 1)))?;
464                if keys_seen.insert(name.clone(), ()).is_some() {
465                    return Err(Diagnostic::new(
466                        Code::DuplicateKwarg,
467                        Span::new(arg_start, name.len()),
468                    ));
469                }
470                args.keyword.insert(name, v);
471            } else {
472                *cursor = saved;
473                let v = read_value(src, cursor)
474                    .ok_or_else(|| Diagnostic::new(Code::BadArgSyntax, Span::new(*cursor, 1)))?;
475                args.positional.push(v);
476            }
477        } else {
478            let v = read_value(src, cursor)
479                .ok_or_else(|| Diagnostic::new(Code::BadArgSyntax, Span::new(*cursor, 1)))?;
480            args.positional.push(v);
481        }
482        skip_ws(src, cursor);
483        match bytes.get(*cursor) {
484            Some(&b',') => {
485                *cursor += 1;
486                continue;
487            }
488            Some(&b')') => {
489                *cursor += 1;
490                break;
491            }
492            _ => return Err(Diagnostic::new(Code::BadArgSyntax, Span::new(*cursor, 1))),
493        }
494    }
495    Ok(args)
496}
497
498fn skip_ws(src: &str, cursor: &mut usize) {
499    while src.as_bytes().get(*cursor) == Some(&b' ') {
500        *cursor += 1;
501    }
502}
503
504fn read_ident(src: &str, cursor: &mut usize) -> Option<String> {
505    let bytes = src.as_bytes();
506    let start = *cursor;
507    let first = *bytes.get(start)?;
508    if !first.is_ascii_alphabetic() {
509        return None;
510    }
511    let mut end = start + 1;
512    while let Some(&b) = bytes.get(end) {
513        if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
514            end += 1;
515        } else {
516            break;
517        }
518    }
519    *cursor = end;
520    Some(src[start..end].to_string())
521}
522
523fn read_value(src: &str, cursor: &mut usize) -> Option<ArgValue> {
524    skip_ws(src, cursor);
525    let bytes = src.as_bytes();
526    let start = *cursor;
527    match bytes.get(start)? {
528        b'"' => {
529            *cursor += 1;
530            let mut s = String::new();
531            while *cursor < bytes.len() {
532                let b = bytes[*cursor];
533                if b == b'"' {
534                    *cursor += 1;
535                    return Some(ArgValue::Str(s));
536                }
537                if b == b'\\' {
538                    if let Some(c) = src[*cursor + 1..].chars().next() {
539                        s.push(c);
540                        *cursor += 1 + c.len_utf8();
541                        continue;
542                    }
543                    // dangling backslash at EOF: treat as literal
544                    s.push('\\');
545                    *cursor += 1;
546                    continue;
547                }
548                // Take one full char so cursor stays UTF-8 aligned and the
549                // produced string preserves multibyte content correctly.
550                let c = src[*cursor..].chars().next().expect("cursor < len");
551                s.push(c);
552                *cursor += c.len_utf8();
553            }
554            None
555        }
556        b'[' => {
557            *cursor += 1;
558            let mut arr: Vec<ArgValue> = Vec::new();
559            skip_ws(src, cursor);
560            if bytes.get(*cursor) == Some(&b']') {
561                *cursor += 1;
562                return Some(ArgValue::Array(arr));
563            }
564            loop {
565                let v = read_value(src, cursor)?;
566                arr.push(v);
567                skip_ws(src, cursor);
568                match bytes.get(*cursor) {
569                    Some(&b',') => {
570                        *cursor += 1;
571                        skip_ws(src, cursor);
572                    }
573                    Some(&b']') => {
574                        *cursor += 1;
575                        return Some(ArgValue::Array(arr));
576                    }
577                    _ => return None,
578                }
579            }
580        }
581        c if c.is_ascii_digit() || *c == b'-' => {
582            let mut end = start;
583            if bytes[end] == b'-' {
584                end += 1;
585            }
586            while let Some(&b) = bytes.get(end) {
587                if b.is_ascii_digit() {
588                    end += 1;
589                } else {
590                    break;
591                }
592            }
593            let n: i64 = src[start..end].parse().ok()?;
594            *cursor = end;
595            Some(ArgValue::Int(n))
596        }
597        c if c.is_ascii_alphabetic() => {
598            let id = read_ident(src, cursor)?;
599            Some(ArgValue::Ident(id))
600        }
601        _ => None,
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn parse(s: &str) -> (Vec<Inline>, Vec<Diagnostic>) {
610        parse_inline(s, 0)
611    }
612
613    #[test]
614    fn plain_text() {
615        let (n, d) = parse("hello world");
616        assert!(d.is_empty());
617        assert_eq!(n.len(), 1);
618        if let Inline::Text { value, .. } = &n[0] {
619            assert_eq!(value, "hello world");
620        } else {
621            panic!();
622        }
623    }
624
625    #[test]
626    fn bold() {
627        let (n, d) = parse("a *bold* b");
628        assert!(d.is_empty(), "{:?}", d);
629        assert!(matches!(n[1], Inline::Bold { .. }));
630    }
631
632    #[test]
633    fn snake_case_is_literal() {
634        let (n, d) = parse("snake_case_name");
635        assert!(d.is_empty());
636        assert_eq!(n.len(), 1);
637        assert!(matches!(n[0], Inline::Text { .. }));
638    }
639
640    #[test]
641    fn nested_same_marker_errors() {
642        let (_, d) = parse("*outer *inner* outer*");
643        assert!(d.iter().any(|x| x.code == Code::EmphasisSameMarker));
644    }
645
646    #[test]
647    fn link_url_with_balanced_parens() {
648        let (n, d) =
649            parse("@link[rust](https://en.wikipedia.org/wiki/Rust_(programming_language)) x");
650        assert!(d.is_empty(), "{:?}", d);
651        if let Inline::Shortcode { args, .. } = &n[0] {
652            assert_eq!(
653                args.positional[0].as_str(),
654                Some("https://en.wikipedia.org/wiki/Rust_(programming_language)")
655            );
656        } else {
657            panic!("expected shortcode, got {:?}", n[0]);
658        }
659        if let Inline::Text { value, .. } = &n[1] {
660            assert_eq!(value, " x");
661        } else {
662            panic!("tail must survive as text");
663        }
664    }
665
666    #[test]
667    fn link_url_unterminated_paren_is_b0406() {
668        let (_, d) = parse("@link[x](https://e.com/a(b now");
669        assert!(d.iter().any(|x| x.code == Code::BadArgSyntax), "{:?}", d);
670    }
671
672    #[test]
673    fn inline_code() {
674        let (n, d) = parse("use `printf` here");
675        assert!(d.is_empty());
676        assert!(matches!(n[1], Inline::InlineCode { .. }));
677    }
678
679    #[test]
680    fn double_backtick_code_with_backtick() {
681        let (n, d) = parse("``a ` b``");
682        assert!(d.is_empty());
683        if let Inline::InlineCode { value, .. } = &n[0] {
684            assert_eq!(value, "a ` b");
685        } else {
686            panic!();
687        }
688    }
689
690    #[test]
691    fn shortcode_inline() {
692        let (n, d) = parse("see @link[here](https://x)");
693        assert!(d.is_empty(), "{:?}", d);
694        assert!(matches!(n.last().unwrap(), Inline::Shortcode { .. }));
695    }
696
697    #[test]
698    fn escape_emphasis() {
699        let (n, d) = parse(r"\*literal\*");
700        assert!(d.is_empty());
701        let joined: String = n
702            .iter()
703            .filter_map(|x| {
704                if let Inline::Text { value, .. } = x {
705                    Some(value.clone())
706                } else {
707                    None
708                }
709            })
710            .collect();
711        assert_eq!(joined, "*literal*");
712    }
713
714    #[test]
715    fn double_marker_not_emphasis() {
716        let (n, _d) = parse("**no**");
717        assert!(!matches!(n[0], Inline::Bold { .. }));
718    }
719
720    #[test]
721    fn escape_before_multibyte_char() {
722        // Regression: escaping `\é` used to advance two bytes past `\` and
723        // land mid-codepoint, panicking the next slice. The escape must
724        // consume the full UTF-8 char.
725        let (n, d) = parse("a \\é b");
726        assert!(d.is_empty(), "{:?}", d);
727        let joined: String = n
728            .iter()
729            .filter_map(|x| {
730                if let Inline::Text { value, .. } = x {
731                    Some(value.clone())
732                } else {
733                    None
734                }
735            })
736            .collect();
737        assert_eq!(joined, "a é b");
738    }
739
740    #[test]
741    fn multibyte_text_then_emph() {
742        // Regression: the byte-walking text scanner must land on a char
743        // boundary before any sigil, even when text is multibyte.
744        let (n, d) = parse("日本 *bold*");
745        assert!(d.is_empty(), "{:?}", d);
746        assert!(matches!(n.last().unwrap(), Inline::Bold { .. }));
747    }
748
749    #[test]
750    fn arg_string_preserves_multibyte() {
751        // Regression: read_value used to push raw bytes as Latin-1 chars,
752        // corrupting multibyte content inside a string argument.
753        let mut cursor = 0usize;
754        let s = "(label: \"日本 🦀\")";
755        let args = parse_args(s, &mut cursor).unwrap();
756        if let ArgValue::Str(v) = args.keyword.get("label").unwrap() {
757            assert_eq!(v, "日本 🦀");
758        } else {
759            panic!();
760        }
761    }
762
763    #[test]
764    fn escape_at_end_of_input() {
765        // A trailing `\\` with nothing to escape must not panic.
766        let (_, _d) = parse("trailing\\");
767    }
768}