1use crate::nodes::{
15 CitationRef, FootnoteRef, Inline, PendingRef, Problematic, RefTarget, SubstitutionRef,
16};
17
18#[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
49pub 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 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 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 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 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 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 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 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 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 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 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 if self.c[self.pos] == ':'
222 && self.pre_ok()
223 && let Some((role, after)) = self.scan_role_prefix()
224 {
225 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 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 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 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 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 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 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 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 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 fn scan_raw(&mut self, end: &str) -> Option<String> {
358 let mut inner = String::new();
359 while self.pos < self.c.len() {
360 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 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 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 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 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 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 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 out.push(Inline::Cite(vec![Inline::Text(inner)]));
499 }
500 }
501 }
502
503 fn parse_interpreted(
506 &mut self,
507 role: String,
508 rstart: usize,
509 after_prefix: usize,
510 out: &mut Vec<Inline>,
511 ) {
512 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 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 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 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 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 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 if self.c.get(i + 1) == Some(&'|') {
633 return None;
634 }
635 self.pos = i + 1;
636 Some(trimmed.to_lowercase())
637 }
638
639 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 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 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 match domain.rsplit('.').next() {
690 Some(tld) if tld.len() >= 2 && tld.chars().all(|c| c.is_alphabetic()) => {}
691 _ => return None,
692 }
693 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 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 let mut extra = 0;
721 while name.ends_with('.') || name.ends_with(':') {
722 name.pop();
723 extra += 1;
724 }
725 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 for _ in 2..trailing {
741 name.push('_');
742 }
743 self.pos = end;
744 return Some((name, true));
745 }
746 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 let mut chars = after.char_indices();
767 let _ = chars.next(); 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
788fn 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 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
824fn 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}