Skip to main content

docutils_core/
inline.rs

1//! Inline markup parser: mirrors `docutils.parsers.rst.states.Body` inline
2//! processing (`patterns.py` start-strings, `inliner.py`)
3//! with a pragmatic single-pass scanner.
4//!
5//! Supported: `*emphasis*`, `**strong**`, ``` ``literal`` ```, interpreted
6//! text with roles (`:emphasis:`, `:strong:`, `:literal:`, `:code:`,
7//! `:title-reference:`, `:pep-reference:`, `:rfc-reference:`, suffix form),
8//! phrase/simple/bare hyperlink references (`name`_, `` `text`_ ``,
9//! `` `text <target>`_ ``, anonymous `__`), standalone URIs and e-mail,
10//! footnote/citation references (`[1]_`, `[#x]_`, `[#]_`, `[*]_`, `[CIT]_`),
11//! substitution references (`|name|`), inline internal targets (`_`x``),
12//! backslash escapes.
13
14use crate::nodes::{
15    CitationRef, FootnoteRef, Inline, PendingRef, Problematic, RefTarget, SubstitutionRef,
16};
17
18/// One inline-level problem; the block layer turns these into
19/// `SystemMessage`s with line numbers.
20#[derive(Debug, Clone, PartialEq)]
21pub struct InlineError {
22    pub level: u8,
23    pub message: String,
24}
25
26impl InlineError {
27    fn error(message: impl Into<String>) -> Self {
28        Self {
29            level: 3,
30            message: message.into(),
31        }
32    }
33
34    fn warning(message: impl Into<String>) -> Self {
35        Self {
36            level: 2,
37            message: message.into(),
38        }
39    }
40
41    fn info(message: impl Into<String>) -> Self {
42        Self {
43            level: 1,
44            message: message.into(),
45        }
46    }
47}
48
49/// Parse inline markup in `text`.
50pub fn parse_inline(text: &str) -> (Vec<Inline>, Vec<InlineError>) {
51    let mut p = Parser::new(text);
52    let nodes = p.parse_top();
53    (nodes, p.errors)
54}
55
56const URISCHEMES: &[&str] = &["http://", "https://", "ftp://", "mailto:"];
57
58struct Parser {
59    c: Vec<char>,
60    pos: usize,
61    errors: Vec<InlineError>,
62}
63
64impl Parser {
65    fn new(src: &str) -> Self {
66        Self {
67            c: src.chars().collect(),
68            pos: 0,
69            errors: Vec::new(),
70        }
71    }
72
73    fn rest(&self) -> String {
74        self.c[self.pos..].iter().collect()
75    }
76
77    fn starts_with(&self, s: &str) -> bool {
78        self.rest().starts_with(s)
79    }
80
81    /// Start-string precondition: start of input or non-alphanumeric before.
82    fn pre_ok(&self) -> bool {
83        if self.pos == 0 {
84            return true;
85        }
86        let prev = self.c[self.pos - 1];
87        !(prev.is_alphanumeric() || prev == '_')
88    }
89
90    /// End-string postcondition after consuming `len` chars: end of input
91    /// or non-alphanumeric ahead.
92    fn post_ok(&self, len: usize) -> bool {
93        match self.c.get(self.pos + len) {
94            None => true,
95            Some(&c) => !(c.is_alphanumeric() || c == '_'),
96        }
97    }
98
99    fn parse_top(&mut self) -> Vec<Inline> {
100        let mut out: Vec<Inline> = Vec::new();
101        let mut buf = String::new();
102        while self.pos < self.c.len() {
103            if self.try_construct(&mut out, &mut buf) {
104                continue;
105            }
106            buf.push(self.c[self.pos]);
107            self.pos += 1;
108        }
109        if !buf.is_empty() {
110            out.push(Inline::Text(std::mem::take(&mut buf)));
111        }
112        out
113    }
114
115    /// Try one inline construct at the cursor. Returns true if handled
116    /// (cursor advanced, possibly pushing nodes and flushing `buf`).
117    fn try_construct(&mut self, out: &mut Vec<Inline>, buf: &mut String) -> bool {
118        let flush = |out: &mut Vec<Inline>, buf: &mut String| {
119            if !buf.is_empty() {
120                out.push(Inline::Text(std::mem::take(buf)));
121            }
122        };
123
124        // Backslash escape: `\x` -> literal `x` for non-alphanumeric x,
125        // else keep both (mirrors docutils escaping closely enough).
126        if self.c[self.pos] == '\\' {
127            if let Some(&next) = self.c.get(self.pos + 1) {
128                if next.is_alphanumeric() {
129                    return false;
130                }
131                flush(out, buf);
132                out.push(Inline::Text(next.to_string()));
133                self.pos += 2;
134                return true;
135            }
136            return false;
137        }
138
139        // Literal ``...``.
140        if self.starts_with("``") && self.pre_ok() {
141            if let Some(inner) = self.scan_double_backquote() {
142                flush(out, buf);
143                out.push(Inline::Literal(inner));
144                return true;
145            }
146            // No closer: one warning, opener becomes problematic.
147            self.errors.push(InlineError::warning(
148                "Inline literal start-string without end-string.",
149            ));
150            flush(out, buf);
151            out.push(Inline::Problematic(Problematic {
152                text: "``".into(),
153                id: None,
154            }));
155            self.pos += 2;
156            return true;
157        }
158
159        // Strong `**...**` (content is raw: the oracle does not nest
160        // inline markup inside strong/emphasis).
161        if self.starts_with("**") && self.pre_ok() && !self.prev_ws_ahead(2) {
162            let save = self.pos;
163            self.pos += 2;
164            match self.scan_raw("**") {
165                Some(inner) if !inner.is_empty() => {
166                    flush(out, buf);
167                    out.push(Inline::Strong(vec![Inline::Text(inner)]));
168                    return true;
169                }
170                Some(_) => {
171                    self.pos = save;
172                    return false;
173                }
174                None => {
175                    self.errors.push(InlineError::warning(
176                        "Inline strong start-string without end-string.",
177                    ));
178                    // One warning: the opener becomes a problematic node.
179                    flush(out, buf);
180                    out.push(Inline::Problematic(Problematic {
181                        text: "**".into(),
182                        id: None,
183                    }));
184                    self.pos = save + 2;
185                    return true;
186                }
187            }
188        }
189
190        // Emphasis `*...*` (content is raw, see above).
191        if self.c[self.pos] == '*' && self.pre_ok() && !self.prev_ws_ahead(1) {
192            let save = self.pos;
193            self.pos += 1;
194            match self.scan_raw("*") {
195                Some(inner) if !inner.is_empty() => {
196                    flush(out, buf);
197                    out.push(Inline::Emphasis(vec![Inline::Text(inner)]));
198                    return true;
199                }
200                Some(_) => {
201                    self.pos = save;
202                    return false;
203                }
204                None => {
205                    self.errors.push(InlineError::warning(
206                        "Inline emphasis start-string without end-string.",
207                    ));
208                    // One warning: the opener becomes a problematic node.
209                    flush(out, buf);
210                    out.push(Inline::Problematic(Problematic {
211                        text: "*".into(),
212                        id: None,
213                    }));
214                    self.pos = save + 1;
215                    return true;
216                }
217            }
218        }
219
220        // Role prefix `:role:` + backquote.
221        if self.c[self.pos] == ':'
222            && self.pre_ok()
223            && let Some((role, after)) = self.scan_role_prefix()
224        {
225            // Must be immediately followed by a backquote.
226            if after < self.c.len() && self.c[after] == '`' {
227                let rstart = self.pos;
228                flush(out, buf);
229                self.parse_interpreted(role, rstart, after, out);
230                return true;
231            }
232        }
233
234        // Backquote: interpreted text or reference forms.
235        if self.c[self.pos] == '`' && self.pre_ok() && !self.starts_with("``") {
236            flush(out, buf);
237            self.parse_backquote(out);
238            return true;
239        }
240
241        // Inline internal target `_`...``.
242        if self.starts_with("_`") {
243            let save = self.pos;
244            self.pos += 2;
245            let mut inner = String::new();
246            let mut closed = false;
247            while self.pos < self.c.len() {
248                if self.c[self.pos] == '`' {
249                    closed = true;
250                    self.pos += 1;
251                    break;
252                }
253                inner.push(self.c[self.pos]);
254                self.pos += 1;
255            }
256            if closed {
257                flush(out, buf);
258                out.push(Inline::InlineTarget(inner));
259                return true;
260            }
261            self.pos = save;
262            return false;
263        }
264
265        // Footnote / citation reference `[label]_`.
266        if self.c[self.pos] == '[' {
267            if let Some(node) = self.try_bracket_ref() {
268                flush(out, buf);
269                out.push(node);
270                return true;
271            }
272            return false;
273        }
274
275        // Substitution reference `|name|`.
276        if self.c[self.pos] == '|' && self.pre_ok() {
277            let start = self.pos;
278            if let Some(name) = self.scan_substitution() {
279                let raw: String = self.c[start..self.pos].iter().collect();
280                flush(out, buf);
281                out.push(Inline::SubstitutionRef(SubstitutionRef { name, raw }));
282                return true;
283            }
284            return false;
285        }
286        // Standalone URI.
287        for scheme in URISCHEMES {
288            if self.starts_with(scheme) {
289                flush(out, buf);
290                let uri = self.scan_uri();
291                out.push(Inline::Reference(
292                    RefTarget::External(uri.clone()),
293                    vec![Inline::Text(uri)],
294                ));
295                return true;
296            }
297        }
298
299        // E-mail address.
300        if self.pre_ok()
301            && self.c[self.pos].is_alphanumeric()
302            && let Some(addr) = self.scan_email()
303        {
304            flush(out, buf);
305            let uri = format!("mailto:{addr}");
306            out.push(Inline::Reference(
307                RefTarget::External(uri),
308                vec![Inline::Text(addr)],
309            ));
310            return true;
311        }
312
313        // Bare hyperlink reference `name`_ / `name`__.
314        if self.pre_ok() && is_name_start(self.c[self.pos]) {
315            let start = self.pos;
316            if let Some((name, anonymous)) = self.scan_bare_ref() {
317                let raw: String = self.c[start..self.pos].iter().collect();
318                flush(out, buf);
319                out.push(Inline::PendingRef(PendingRef {
320                    text: vec![Inline::Text(name.clone())],
321                    target: name,
322                    anonymous,
323                    raw,
324                }));
325                return true;
326            }
327        }
328
329        false
330    }
331
332    fn prev_ws_ahead(&self, skip: usize) -> bool {
333        matches!(self.c.get(self.pos + skip), Some(&c) if c.is_whitespace())
334            || self.c.get(self.pos + skip).is_none()
335    }
336
337    /// Scan ``...`` literal starting at the cursor (which is on the first
338    /// backquote of a pair). Advances past the closer on success.
339    fn scan_double_backquote(&mut self) -> Option<String> {
340        let mut i = self.pos + 2;
341        let mut inner = String::new();
342        while i < self.c.len() {
343            if self.c[i] == '`' && self.c.get(i + 1) == Some(&'`') {
344                self.pos = i + 2;
345                return Some(inner);
346            }
347            inner.push(self.c[i]);
348            i += 1;
349        }
350        None
351    }
352
353    /// Scan raw nested content until `end` (no inline parsing inside,
354    /// matching the oracle). Returns the raw inner text, or `None` at EOF.
355    /// Backslash escapes collapse as at top level. A `*` closer must not be
356    /// followed by another `*` (that pair belongs to a strong marker).
357    fn scan_raw(&mut self, end: &str) -> Option<String> {
358        let mut inner = String::new();
359        while self.pos < self.c.len() {
360            // Postcondition: emphasis/strong closers need a non-word char
361            // ahead; a backquote closer only needs a non-alphanumeric
362            // (`_`/`__`/`:role:` suffixes must match).
363            let post = if end == "`" {
364                match self.c.get(self.pos + end.len()) {
365                    None => true,
366                    Some(&c) => !c.is_alphanumeric(),
367                }
368            } else {
369                self.post_ok(end.len())
370            };
371            if self.starts_with(end) && post {
372                if end == "*" && self.starts_with("**") {
373                    // Part of a strong marker: literal text.
374                    inner.push(self.c[self.pos]);
375                    self.pos += 1;
376                    continue;
377                }
378                self.pos += end.len();
379                return Some(inner);
380            }
381            if self.c[self.pos] == '\\'
382                && let Some(&next) = self.c.get(self.pos + 1)
383                && !next.is_alphanumeric()
384            {
385                inner.push(next);
386                self.pos += 2;
387                continue;
388            }
389            inner.push(self.c[self.pos]);
390            self.pos += 1;
391        }
392        None
393    }
394
395    /// Scan `:role:` at the cursor; returns (role, position after prefix).
396    fn scan_role_prefix(&self) -> Option<(String, usize)> {
397        let mut i = self.pos + 1;
398        let mut role = String::new();
399        while i < self.c.len() {
400            let ch = self.c[i];
401            if ch == ':' {
402                if role.is_empty() {
403                    return None;
404                }
405                // Role names: alphanumerics plus `_-+.`.
406                if role
407                    .chars()
408                    .all(|c| c.is_alphanumeric() || "-_+.".contains(c))
409                {
410                    return Some((role, i + 1));
411                }
412                return None;
413            }
414            if ch.is_whitespace() || ch == '`' {
415                return None;
416            }
417            role.push(ch);
418            i += 1;
419        }
420        None
421    }
422
423    /// Parse a backquote construct: the cursor is on the opening backquote.
424    /// Content is raw (the oracle nests no inline markup inside interpreted
425    /// text or phrase references); the target split still applies.
426    fn parse_backquote(&mut self, out: &mut Vec<Inline>) {
427        let start = self.pos;
428        self.pos += 1;
429        let inner = match self.scan_raw("`") {
430            Some(s) => s,
431            None => {
432                self.errors.push(InlineError::warning(
433                    "Inline interpreted text or phrase reference start-string without end-string.",
434                ));
435                out.push(Inline::Problematic(Problematic {
436                    text: "`".into(),
437                    id: None,
438                }));
439                self.pos = start + 1;
440                return;
441            }
442        };
443        // Suffix after the closer: `__`, `_`, `:role:`, or nothing.
444        let after: String = self.c[self.pos..].iter().collect();
445        let (suffix_kind, suffix_len) = if after.starts_with("__") {
446            (Suffix::Anonymous, 2)
447        } else if after.starts_with('_') {
448            (Suffix::Ref, 1)
449        } else if after.starts_with(':') {
450            match scan_suffix_role(&after) {
451                Some((role, len)) => (Suffix::Role(role), len),
452                None => (Suffix::None, 0),
453            }
454        } else {
455            (Suffix::None, 0)
456        };
457        let end = self.pos + suffix_len;
458        let raw: String = self.c[start..end].iter().collect();
459        self.pos = end;
460
461        // Embedded-target form `` `text <target>`_ ``.
462        if matches!(suffix_kind, Suffix::Ref | Suffix::Anonymous)
463            && let Some((text, target)) = split_embedded(&inner)
464        {
465            let anon = matches!(suffix_kind, Suffix::Anonymous);
466            let text = vec![Inline::Text(text)];
467            if is_uri_target(&target) {
468                let uri = if looks_like_email(&target) && !target.contains("://") {
469                    format!("mailto:{target}")
470                } else {
471                    target
472                };
473                out.push(Inline::Reference(RefTarget::External(uri), text));
474            } else {
475                out.push(Inline::PendingRef(PendingRef {
476                    text,
477                    target,
478                    anonymous: anon,
479                    raw,
480                }));
481            }
482            return;
483        }
484
485        match suffix_kind {
486            Suffix::Ref | Suffix::Anonymous => {
487                let anon = matches!(suffix_kind, Suffix::Anonymous);
488                out.push(Inline::PendingRef(PendingRef {
489                    text: vec![Inline::Text(inner.clone())],
490                    target: inner,
491                    anonymous: anon,
492                    raw,
493                }));
494            }
495            Suffix::Role(role) => self.apply_role(&role, &inner, raw, out),
496            Suffix::None => {
497                // Bare interpreted text: title-reference -> <cite>.
498                out.push(Inline::Cite(vec![Inline::Text(inner)]));
499            }
500        }
501    }
502
503    /// Parse `:role:`text``: `rstart` is the `:` position, `after_prefix`
504    /// the position right after `:role:` (on the opening backquote).
505    fn parse_interpreted(
506        &mut self,
507        role: String,
508        rstart: usize,
509        after_prefix: usize,
510        out: &mut Vec<Inline>,
511    ) {
512        // Cursor moves past the opening backquote; content is raw.
513        self.pos = after_prefix + 1;
514        match self.scan_raw("`") {
515            Some(inner) => {
516                let raw: String = self.c[rstart..self.pos].iter().collect();
517                self.apply_role(&role, &inner, raw, out);
518            }
519            None => {
520                self.errors.push(InlineError::warning(
521                    "Inline interpreted text or phrase reference start-string without end-string.",
522                ));
523                // Emit the `:role:` opener literally, continue after it.
524                let lit: String = self.c[rstart..after_prefix + 1].iter().collect();
525                out.push(Inline::Text(lit));
526                self.pos = after_prefix + 1;
527            }
528        }
529    }
530
531    fn apply_role(&mut self, role: &str, inner: &str, raw: String, out: &mut Vec<Inline>) {
532        let r = role.to_lowercase();
533        // Content is raw (no nesting, matching the oracle).
534        let text = vec![Inline::Text(inner.to_string())];
535        match r.as_str() {
536            "emphasis" => out.push(Inline::Emphasis(text)),
537            "strong" => out.push(Inline::Strong(text)),
538            "literal" => out.push(Inline::Literal(inner.to_string())),
539            "code" => out.push(Inline::Code(inner.to_string())),
540            "title-reference" | "title" => out.push(Inline::Cite(text)),
541            "pep-reference" | "pep" => {
542                let num = inner.trim();
543                out.push(Inline::Reference(
544                    RefTarget::External(format!(
545                        "https://peps.python.org/pep-{}/",
546                        num.trim_start_matches("pep-").trim_start_matches("PEP-")
547                    )),
548                    vec![Inline::Text(format!("PEP {num}"))],
549                ));
550            }
551            "rfc-reference" | "rfc" => {
552                let num = inner.trim();
553                out.push(Inline::Reference(
554                    RefTarget::External(format!(
555                        "https://www.rfc-editor.org/rfc/rfc{}.txt",
556                        num.trim_start_matches("rfc-").trim_start_matches("RFC-")
557                    )),
558                    vec![Inline::Text(format!("RFC {num}"))],
559                ));
560            }
561            _ => {
562                self.errors.push(InlineError::info(format!(
563                    "No role entry for \"{role}\" in module \"docutils.parsers.rst.languages.en\".\nTrying \"{role}\" as canonical role name."
564                )));
565                self.errors.push(InlineError::error(format!(
566                    "Unknown interpreted text role \"{role}\"."
567                )));
568                out.push(Inline::Problematic(crate::nodes::Problematic {
569                    text: raw,
570                    id: None,
571                }));
572            }
573        }
574    }
575
576    /// Try `[label]_` / `[label]__` at the cursor.
577    fn try_bracket_ref(&mut self) -> Option<Inline> {
578        let mut i = self.pos + 1;
579        let mut label = String::new();
580        while i < self.c.len() && self.c[i] != ']' {
581            label.push(self.c[i]);
582            i += 1;
583        }
584        if i >= self.c.len() || label.trim().is_empty() {
585            return None;
586        }
587        // Labels are single-line in practice; allow wrapped spaces by
588        // collapsing whitespace (matches docutils for footnotes).
589        let rest: String = self.c[i + 1..].iter().collect();
590        let anon = rest.starts_with("__");
591        let named = !anon && rest.starts_with('_');
592        if !anon && !named {
593            return None;
594        }
595        let adv = if anon { i + 3 } else { i + 2 };
596        let norm: String = label.split_whitespace().collect::<Vec<_>>().join(" ");
597        let raw: String = self.c[self.pos..adv].iter().collect();
598        let node = if norm == "*" || norm == "#" || norm.starts_with('#') || is_digits(&norm) {
599            Inline::FootnoteRef(FootnoteRef {
600                auto: norm == "*" || norm.starts_with('#'),
601                symbol: norm == "*",
602                label: norm,
603                raw,
604            })
605        } else {
606            Inline::CitationRef(CitationRef { label: norm, raw })
607        };
608        self.pos = adv;
609        Some(node)
610    }
611
612    /// Try `|name|` at the cursor (cursor on opening `|`).
613    fn scan_substitution(&mut self) -> Option<String> {
614        let mut i = self.pos + 1;
615        let mut name = String::new();
616        while i < self.c.len() && self.c[i] != '|' {
617            if self.c[i] == '\n' {
618                return None;
619            }
620            name.push(self.c[i]);
621            i += 1;
622        }
623        if i >= self.c.len() {
624            return None;
625        }
626        let trimmed = name.trim();
627        if trimmed.is_empty() || trimmed.len() > 64 {
628            return None;
629        }
630        // Must not look like an empty table cell artifact: require the
631        // closer followed by non-`|` (a `||` is literal-line markup).
632        if self.c.get(i + 1) == Some(&'|') {
633            return None;
634        }
635        self.pos = i + 1;
636        Some(trimmed.to_lowercase())
637    }
638
639    /// Scan a standalone URI starting at the cursor. Caller checked scheme.
640    fn scan_uri(&mut self) -> String {
641        let mut end = self.pos;
642        while end < self.c.len() && is_uri_char(self.c[end]) {
643            end += 1;
644        }
645        let mut uri: String = self.c[self.pos..end].iter().collect();
646        // Strip trailing punctuation the engine would not include.
647        let mut stripped = 0;
648        while let Some(last) = uri.chars().last() {
649            let unbalanced_paren = last == ')'
650                && uri.chars().filter(|&c| c == '(').count()
651                    < uri.chars().filter(|&c| c == ')').count();
652            if ".,;:!?".contains(last)
653                || last == '\''
654                || last == '"'
655                || last == '>'
656                || unbalanced_paren
657            {
658                uri.pop();
659                stripped += 1;
660            } else {
661                break;
662            }
663        }
664        self.pos = end - stripped;
665        uri
666    }
667
668    /// Try an e-mail address at the cursor (cursor on alnum, pre_ok checked).
669    fn scan_email(&mut self) -> Option<String> {
670        let mut i = self.pos;
671        while i < self.c.len() && (self.c[i].is_alphanumeric() || "._%+-".contains(self.c[i])) {
672            i += 1;
673        }
674        if i == self.pos || self.c.get(i) != Some(&'@') {
675            return None;
676        }
677        let local: String = self.c[self.pos..i].iter().collect();
678        let mut j = i + 1;
679        while j < self.c.len()
680            && (self.c[j].is_alphanumeric() || self.c[j] == '.' || self.c[j] == '-')
681        {
682            j += 1;
683        }
684        let domain: String = self.c[i + 1..j].iter().collect();
685        if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
686            return None;
687        }
688        // TLD sanity: last dot-segment alpha, len >= 2.
689        match domain.rsplit('.').next() {
690            Some(tld) if tld.len() >= 2 && tld.chars().all(|c| c.is_alphabetic()) => {}
691            _ => return None,
692        }
693        // Postcondition: next char must not extend the address.
694        if let Some(&next) = self.c.get(j)
695            && next.is_alphanumeric()
696        {
697            return None;
698        }
699        self.pos = j;
700        Some(format!("{local}@{domain}"))
701    }
702
703    /// Try `name`_ / `name`__ at a name start. Returns (name, anonymous).
704    ///
705    /// `_` is a legal name character, so the greedy scan may swallow the
706    /// reference marker: trailing underscores are split back off here
707    /// (`some_name_` -> name `some_name`; `a__` -> anonymous `a`).
708    fn scan_bare_ref(&mut self) -> Option<(String, bool)> {
709        let mut i = self.pos;
710        let mut name = String::new();
711        while i < self.c.len() && is_name_char(self.c[i]) {
712            name.push(self.c[i]);
713            i += 1;
714        }
715        if name.is_empty() {
716            return None;
717        }
718        // The greedy scan may have swallowed sentence punctuation after the
719        // marker (`a__.`); give `.`/`:` back first, then split the marker.
720        let mut extra = 0;
721        while name.ends_with('.') || name.ends_with(':') {
722            name.pop();
723            extra += 1;
724        }
725        // Split trailing underscores back off as the reference marker.
726        let mut trailing = 0;
727        while name.ends_with('_') {
728            name.pop();
729            trailing += 1;
730        }
731        if name.is_empty() || trailing == 0 {
732            return None;
733        }
734        let end = i - extra;
735        let rest: String = self.c[end..].iter().collect();
736        if trailing >= 2 {
737            // `__` anonymous marker (a third `_` stays in the name... but
738            // the greedy scan put all trailing `_` in `trailing`; only two
739            // belong to the marker).
740            for _ in 2..trailing {
741                name.push('_');
742            }
743            self.pos = end;
744            return Some((name, true));
745        }
746        // Single `_`: the char after it must not continue the name
747        // (else this is an intra-word underscore, not a reference).
748        match rest.chars().next() {
749            Some(c) if c.is_alphanumeric() || c == '_' => return None,
750            _ => {}
751        }
752        self.pos = end;
753        Some((name, false))
754    }
755}
756
757enum Suffix {
758    Ref,
759    Anonymous,
760    Role(String),
761    None,
762}
763
764fn scan_suffix_role(after: &str) -> Option<(String, usize)> {
765    // `after` starts with `:`; match `:role:`.
766    let mut chars = after.char_indices();
767    let _ = chars.next(); // leading ':'
768    let mut role = String::new();
769    for (idx, ch) in chars {
770        if ch == ':' {
771            if role.is_empty()
772                || !role
773                    .chars()
774                    .all(|c| c.is_alphanumeric() || "-_+.".contains(c))
775            {
776                return None;
777            }
778            return Some((role, idx + 1));
779        }
780        if ch.is_whitespace() || ch == '`' {
781            return None;
782        }
783        role.push(ch);
784    }
785    None
786}
787
788/// Split `` text <target> `` embedded form. Returns (text, target).
789fn split_embedded(inner: &str) -> Option<(String, String)> {
790    let inner = inner.trim_end();
791    if inner.len() < 2 || !inner.ends_with('>') {
792        return None;
793    }
794    // Find the last ` <` such that the remainder is `<...>` with no spaces
795    // right after `<`... docutils is lenient; keep pragmatic.
796    let mut idx: Option<usize> = None;
797    let bytes = inner.as_bytes();
798    let mut i = inner.len() - 1;
799    while i > 0 {
800        if bytes[i] == b'<' && bytes[i - 1] == b' ' {
801            idx = Some(i);
802            break;
803        }
804        i -= 1;
805    }
806    let at = idx?;
807    let text = inner[..at].trim_end().to_string();
808    let target = inner[at + 1..inner.len() - 1].trim().to_string();
809    if text.is_empty() || target.is_empty() {
810        return None;
811    }
812    Some((text, target))
813}
814
815fn is_uri_target(t: &str) -> bool {
816    let lower = t.to_lowercase();
817    URISCHEMES.iter().any(|s| lower.starts_with(s))
818        || t.contains("://")
819        || t.starts_with("mailto:")
820        || t.starts_with('#')
821        || looks_like_email(t)
822}
823
824/// Bare `user@host.tld` without a scheme (for embedded `text <addr>` refs,
825/// which the oracle links as `mailto:`).
826fn looks_like_email(t: &str) -> bool {
827    let Some((local, domain)) = t.split_once('@') else {
828        return false;
829    };
830    !local.is_empty()
831        && local
832            .chars()
833            .all(|c| c.is_alphanumeric() || "._%+-".contains(c))
834        && domain.contains('.')
835        && !domain.starts_with('.')
836        && !domain.ends_with('.')
837        && domain
838            .rsplit('.')
839            .next()
840            .map(|tld| tld.len() >= 2 && tld.chars().all(|c| c.is_alphabetic()))
841            .unwrap_or(false)
842}
843
844fn is_digits(s: &str) -> bool {
845    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
846}
847
848fn is_name_start(c: char) -> bool {
849    c.is_alphanumeric()
850}
851
852fn is_name_char(c: char) -> bool {
853    c.is_alphanumeric() || "._+-:".contains(c)
854}
855
856fn is_uri_char(c: char) -> bool {
857    c.is_alphanumeric() || "-._~:/?#[]@!$&'()*+,;=%".contains(c)
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    fn inl(s: &str) -> Vec<Inline> {
865        parse_inline(s).0
866    }
867
868    #[test]
869    fn emphasis_and_strong() {
870        assert_eq!(
871            inl("a *b* c"),
872            vec![
873                Inline::Text("a ".into()),
874                Inline::Emphasis(vec![Inline::Text("b".into())]),
875                Inline::Text(" c".into()),
876            ]
877        );
878        assert_eq!(
879            inl("a **b** c")[1],
880            Inline::Strong(vec![Inline::Text("b".into())])
881        );
882    }
883
884    #[test]
885    fn literal() {
886        assert_eq!(inl("a ``b`` c")[1], Inline::Literal("b".into()));
887    }
888
889    #[test]
890    fn phrase_reference() {
891        let nodes = inl("`text <https://example.com>`_");
892        assert_eq!(
893            nodes.as_slice(),
894            [Inline::Reference(
895                RefTarget::External("https://example.com".into()),
896                vec![Inline::Text("text".into())]
897            )]
898        );
899    }
900
901    #[test]
902    fn footnote_and_citation_refs() {
903        assert!(matches!(inl("[1]_")[0], Inline::FootnoteRef(_)));
904        assert!(matches!(inl("[#name]_")[0], Inline::FootnoteRef(_)));
905        assert_eq!(
906            inl("[CIT]_")[0],
907            Inline::CitationRef(crate::nodes::CitationRef {
908                label: "CIT".into(),
909                raw: "[CIT]_".into(),
910            })
911        );
912    }
913
914    #[test]
915    fn substitution_ref() {
916        assert_eq!(
917            inl("|release|")[0],
918            Inline::SubstitutionRef(crate::nodes::SubstitutionRef {
919                name: "release".into(),
920                raw: "|release|".into(),
921            })
922        );
923    }
924
925    #[test]
926    fn standalone_uri() {
927        let nodes = inl("see https://example.com/x.");
928        assert_eq!(nodes.len(), 3);
929        assert_eq!(
930            nodes[1],
931            Inline::Reference(
932                RefTarget::External("https://example.com/x".into()),
933                vec![Inline::Text("https://example.com/x".into())]
934            )
935        );
936        assert_eq!(nodes[2], Inline::Text(".".into()));
937    }
938}