1use std::{
101 cell::RefCell,
102 path::{Path, PathBuf},
103 rc::Rc,
104 string::ToString,
105};
106
107use tracing::instrument;
108
109mod blocks;
110mod constants;
111mod document_attribute;
112mod error;
113pub(crate) mod grammar;
114mod model;
115mod options;
116mod parsed;
117mod preprocessor;
118mod safe_mode;
119mod warning;
120
121pub(crate) use grammar::{InlinePreprocessorParserState, ProcessedContent, inline_preprocessing};
122use preprocessor::Preprocessor;
123
124pub use error::{Error, SourceLocation};
125pub use grammar::parse_text_for_quotes;
126pub use model::{
127 Admonition, AdmonitionVariant, Anchor, AttributeName, AttributeValue, Attribution, Audio,
128 Author, Autolink, Block, BlockMetadata, Bold, Button, CalloutList, CalloutListItem, CalloutRef,
129 CalloutRefKind, Caption, CaptionKind, CiteTitle, ColumnFormat, ColumnStyle, ColumnWidth,
130 Comment, CommentKind, CrossReference, CurvedApostrophe, CurvedQuotation, DelimitedBlock,
131 DelimitedBlockType, DescriptionList, DescriptionListItem, DiscreteHeader, Document,
132 DocumentAttribute, DocumentAttributeAssignment, DocumentAttributeValue, DocumentAttributes,
133 ElementAttributes, Footnote, Form, HEADER, Header, Highlight, HorizontalAlignment, ICON_SIZES,
134 Icon, Image, IndexTerm, IndexTermKind, IndexTermRelationship, InlineMacro, InlineNode, Italic,
135 Keyboard, LineBreak, Link, ListItem, ListItemCheckedStatus, Location, MAX_SECTION_LEVELS,
136 MAX_TOC_LEVELS, Mailto, Menu, Monospace, NORMAL, OrderedList, PageBreak, Paragraph, Pass,
137 PassthroughKind, Plain, Position, Raw, Reference, Role, Section, SectionKind, Source,
138 SourceUrl, StandaloneCurvedApostrophe, Stem, StemContent, StemNotation, Subscript,
139 Substitution, Subtitle, Superscript, Table, TableColumn, TableFrame, TableGrid,
140 TableOfContents, TablePresentation, TableRow, TableStripes, ThematicBreak, Title, TocEntry,
141 UNNUMBERED_SECTION_STYLES, UnorderedList, Url, VERBATIM, Verbatim, VerticalAlignment, Video,
142 XrefCaptionLabel, XrefSignifier, XrefStyle, strip_quotes, substitute, substitute_attributes,
143};
144#[cfg(feature = "pre-spec-subs")]
145pub use model::{SubstitutionOp, SubstitutionSpec};
146pub use options::{Options, OptionsBuilder, SafeMode};
147pub use parsed::{OwnedSource, ParseInlineResult, ParseResult};
148pub use warning::{Warning, WarningKind};
149
150#[derive(Debug)]
192pub struct Parser<'input> {
193 input: &'input str,
194 options: Options<'input>,
195}
196
197impl<'input> Parser<'input> {
198 #[must_use]
212 pub fn new(input: &'input str) -> Self {
213 Self {
214 input,
215 options: Options::default(),
216 }
217 }
218
219 #[must_use]
237 pub fn with_options(mut self, options: Options<'input>) -> Self {
238 self.options = options;
239 self
240 }
241
242 pub fn parse(self) -> Result<ParseResult, Error> {
257 parse(self.input, &self.options)
258 }
259
260 pub fn parse_inline(self) -> Result<ParseInlineResult, Error> {
278 parse_inline(self.input, &self.options)
279 }
280}
281
282#[instrument(skip(reader))]
305pub fn parse_from_reader<R: std::io::Read>(
306 reader: R,
307 options: &Options<'_>,
308) -> Result<ParseResult, Error> {
309 let mut options = options
310 .clone()
311 .prepare_for_parse(document_attribute::InputKind::Reader);
312 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
315 let result = {
316 let _span = tracing::info_span!("preprocess").entered();
317 Preprocessor::process_reader(reader, &options, Rc::clone(&warnings_handle))?
318 };
319 if let Some(front_matter) = result.front_matter {
320 options
321 .document_attributes
322 .set_text("front-matter".into(), front_matter.into());
323 }
324 let text: Box<str> = result.text.into_owned().into_boxed_str();
325 let _span = tracing::info_span!("grammar_parse", input_len = text.len()).entered();
326 parse_input(
327 text,
328 options,
329 None,
330 result.leveloffset_ranges,
331 result.source_ranges,
332 result.included_files,
333 warnings_handle,
334 )
335}
336
337#[instrument]
358pub fn parse(input: &str, options: &Options<'_>) -> Result<ParseResult, Error> {
359 let mut options = options
360 .clone()
361 .prepare_for_parse(document_attribute::InputKind::String);
362 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
363 let result = {
364 let _span = tracing::info_span!("preprocess").entered();
365 Preprocessor::process(input, &options, Rc::clone(&warnings_handle))?
366 };
367 if let Some(front_matter) = result.front_matter {
368 options
369 .document_attributes
370 .set_text("front-matter".into(), front_matter.into());
371 }
372 let text: Box<str> = result.text.into_owned().into_boxed_str();
373 let _span = tracing::info_span!("grammar_parse", input_len = text.len()).entered();
374 parse_input(
375 text,
376 options,
377 None,
378 result.leveloffset_ranges,
379 result.source_ranges,
380 result.included_files,
381 warnings_handle,
382 )
383}
384
385#[instrument(skip(file_path))]
407pub fn parse_file<P: AsRef<Path>>(
408 file_path: P,
409 options: &Options<'_>,
410) -> Result<ParseResult, Error> {
411 let mut options = options
412 .clone()
413 .prepare_for_parse(document_attribute::InputKind::File(file_path.as_ref()));
414 let path = file_path.as_ref().to_path_buf();
415 let raw = preprocessor::read_and_decode_file(file_path.as_ref(), None)?;
416 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
417 let result = {
418 let _span = tracing::info_span!("preprocess").entered();
419 Preprocessor::process_with_file(
420 &raw,
421 file_path.as_ref(),
422 &options,
423 Rc::clone(&warnings_handle),
424 )?
425 };
426 if let Some(front_matter) = result.front_matter {
427 options
428 .document_attributes
429 .set_text("front-matter".into(), front_matter.into());
430 }
431 let text: Box<str> = result.text.into_owned().into_boxed_str();
432 let _span = tracing::info_span!("grammar_parse", input_len = text.len()).entered();
433 parse_input(
434 text,
435 options,
436 Some(path),
437 result.leveloffset_ranges,
438 result.source_ranges,
439 result.included_files,
440 warnings_handle,
441 )
442}
443
444fn peg_error_to_source_location(
447 error: &peg::error::ParseError<peg::str::LineCol>,
448 state: &grammar::ParserState,
449) -> SourceLocation {
450 let offset = error.location.offset;
451 if let Some(range) = model::SourceRange::find_containing(&state.source_ranges, offset) {
452 SourceLocation {
453 file: range.file.clone(),
454 location: crate::Location::point(Position::new(
455 state.line_map.source_line(range, state.input, offset),
456 u32::try_from(error.location.column).unwrap_or(u32::MAX),
457 )),
458 }
459 } else {
460 SourceLocation {
461 file: state.current_file.as_deref().cloned(),
462 location: crate::Location::point(Position::from_line_col(
463 error.location.line,
464 error.location.column,
465 )),
466 }
467 }
468}
469
470#[instrument(skip_all)]
471fn parse_input(
472 input: Box<str>,
473 options: Options<'_>,
474 file_path: Option<PathBuf>,
475 leveloffset_ranges: Vec<model::LeveloffsetRange>,
476 source_ranges: Vec<model::SourceRange>,
477 included_files: std::collections::HashSet<String>,
478 warnings_handle: Rc<RefCell<Vec<Warning>>>,
479) -> Result<ParseResult, Error> {
480 tracing::trace!(?input, "post preprocessor");
481 let owner = parsed::OwnedInput::new(input);
488 let options_owned = options.into_static();
489 let warnings_for_state = Rc::clone(&warnings_handle);
496
497 let source_files = parsed::SourceFiles::new(file_path.clone(), &source_ranges);
498 ParseResult::try_new(owner, warnings_handle, source_files, move |owner| {
499 let mut state = grammar::ParserState::new(&owner.source, &owner.arena);
500 state.document_attributes = Rc::new(options_owned.document_attributes.clone());
501 state.options = Rc::new(options_owned);
502 state.initialize_hardbreaks();
503 state.current_file = file_path.map(std::sync::Arc::new);
504 state.leveloffset_ranges = leveloffset_ranges;
505 state.source_ranges = source_ranges;
506 state.included_files = included_files;
507 state.warnings = warnings_for_state;
508 let result = match grammar::document_parser::document(&owner.source, &mut state) {
509 Ok(Ok(mut doc)) => {
510 grammar::remap_document_to_source(
514 &mut doc,
515 &state.source_ranges,
516 &owner.source,
517 &state.line_map,
518 );
519 Ok(doc)
520 }
521 Ok(Err(e)) => Err(e),
522 Err(error) => {
523 tracing::error!(?error, "error parsing document content");
524 let source_location = peg_error_to_source_location(&error, &state);
525 Err(Error::Parse(Box::new(source_location), error.to_string()))
526 }
527 };
528 state.emit_warnings();
529 result
530 })
531}
532
533#[instrument]
554pub fn parse_inline(input: &str, options: &Options<'_>) -> Result<ParseInlineResult, Error> {
555 tracing::trace!(?input, "post preprocessor");
556 let owner = parsed::OwnedInput::new(input.into());
557 let options_owned = options
558 .clone()
559 .prepare_for_parse(document_attribute::InputKind::String)
560 .into_static();
561 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
562 let warnings_for_state = Rc::clone(&warnings_handle);
563
564 ParseInlineResult::try_new(owner, warnings_handle, move |owner| {
565 let mut state = grammar::ParserState::new(&owner.source, &owner.arena);
566 state.scope = grammar::ParserScope::Inline;
567 state.document_attributes = Rc::new(options_owned.document_attributes.clone());
568 state.options = Rc::new(options_owned);
569 state.initialize_hardbreaks();
570 state
571 .inline_ctx
572 .rules
573 .set(grammar::InlineRules::HARD_BREAKS, state.hardbreaks);
574 state.warnings = warnings_for_state;
575 let result = match grammar::inline_parser::inlines(&owner.source, &mut state) {
576 Ok(mut inlines) => {
577 grammar::remap_inlines_to_source(
578 &mut inlines,
579 &state.source_ranges,
580 &owner.source,
581 &state.line_map,
582 );
583 state.scope = grammar::ParserScope::Document;
586 for inline in &mut inlines {
587 grammar::walk_inline_nodes_mut(inline, &mut |node| {
588 if let InlineNode::Macro(InlineMacro::Footnote(note)) = node {
589 state.finalize_footnote(note);
590 }
591 });
592 }
593 Ok(inlines)
594 }
595 Err(error) => {
596 tracing::error!(?error, "error parsing inline content");
597 Err(Error::Parse(
598 Box::new(peg_error_to_source_location(&error, &state)),
599 error.to_string(),
600 ))
601 }
602 };
603 state.emit_warnings();
604 result
605 })
606}
607
608#[cfg(test)]
609mod proptests;
610
611#[cfg(test)]
612#[allow(clippy::unwrap_used)]
613#[allow(clippy::panic)]
614#[allow(clippy::expect_used)]
615mod tests {
616 use std::{error::Error as StdError, fs, path::PathBuf};
617
618 use pretty_assertions::assert_eq;
619
620 use super::*;
621
622 fn read_file_contents_with_extension(path: &PathBuf, ext: &str) -> Result<String, Error> {
623 let test_file_path = path.with_extension(ext);
624 let file_contents = fs::read_to_string(&test_file_path).inspect_err(
625 |e| tracing::warn!(?path, ?test_file_path, error = %e, "test file not found"),
626 )?;
627 Ok(file_contents)
628 }
629
630 #[test]
631 fn table_comments_leave_hidden_footnotes_and_targets_unregistered() -> Result<(), Error> {
632 let parsed = parse_file(
633 "fixtures/tests/table_comment_directives.adoc",
634 &Options::default(),
635 )?;
636 assert_eq!(parsed.document().footnotes, []);
637 assert!(!parsed.document().references.contains_key("hidden-table"));
638 assert!(parsed.warnings().is_empty(), "{:?}", parsed.warnings());
639 Ok(())
640 }
641
642 #[test]
643 fn header_comments_do_not_assign_attributes_or_register_macros() -> Result<(), Error> {
644 let parsed = parse_file(
645 "fixtures/tests/header_comment_attributes.adoc",
646 &Options::default(),
647 )?;
648 let doc = parsed.document();
649 assert_eq!(doc.attributes.text("kept"), Some("original"));
650 assert_eq!(doc.attributes.text("author"), Some("Correct Author"));
651 assert_eq!(doc.attributes.text("revnumber"), Some("1.0"));
652 assert_eq!(doc.attributes.text("active-header"), Some("active"));
653 for name in [
654 "source-highlighter",
655 "hidden-before",
656 "hidden-author",
657 "hidden-revision",
658 "hidden-header",
659 "hidden-body",
660 ] {
661 assert!(
662 !doc.attributes.contains_key(name),
663 "comment assigned {name}"
664 );
665 }
666 assert_eq!(doc.footnotes, []);
667 assert!(!doc.references.contains_key("hidden"));
668 assert!(parsed.warnings().is_empty(), "{:?}", parsed.warnings());
669 Ok(())
670 }
671
672 #[test]
673 fn header_comment_unsets_preserve_caller_values() -> Result<(), Error> {
674 let input = "= T\n////\n:kept!:\n:tabsize: invalid\n////\n\n{kept}\n";
675 for builder in [
676 Options::builder().with_attribute("kept", "caller"),
677 Options::builder().with_default_attribute("kept", "caller"),
678 ] {
679 let options = builder.build()?;
680 for parsed in [
681 parse(input, &options)?,
682 parse_from_reader(input.as_bytes(), &options)?,
683 ] {
684 assert_eq!(parsed.document().attributes.text("kept"), Some("caller"));
685 assert!(parsed.warnings().is_empty(), "{:?}", parsed.warnings());
686 }
687 }
688 Ok(())
689 }
690
691 #[test]
692 fn header_comment_does_not_pull_body_attributes_into_the_header() -> Result<(), Error> {
693 let parsed = parse_file(
694 "fixtures/tests/header_comment_boundaries.adoc",
695 &Options::default(),
696 )?;
697 let doc = parsed.document();
698 assert_eq!(doc.attributes.text("kept"), Some("header"));
699 assert!(!doc.attributes.contains_key("body"));
700 assert!(!doc.attributes.contains_key("source-highlighter"));
701 let event = doc
702 .blocks
703 .iter()
704 .find_map(|block| {
705 if let Block::DocumentAttribute(attribute) = block {
706 Some(attribute)
707 } else {
708 None
709 }
710 })
711 .expect("body assignment");
712 assert_eq!(event.name, "body");
713 let source = include_str!("../fixtures/tests/header_comment_boundaries.adoc");
714 let line = u32::try_from(
715 source
716 .lines()
717 .position(|line| line == ":body: body-value")
718 .expect("body entry")
719 + 1,
720 )
721 .expect("source line fits u32");
722 assert_eq!(event.location.start.line, line);
723 assert_eq!(event.location.end.line, line);
724 assert_eq!(
725 &source[event.location.absolute_start..=event.location.absolute_end],
726 ":body: body-value"
727 );
728 Ok(())
729 }
730
731 #[test]
732 fn header_comment_unterminated_reports_one_warning_without_effects() -> Result<(), Error> {
733 let parsed = parse_file(
734 "fixtures/tests/header_comment_unterminated.adoc",
735 &Options::default(),
736 )?;
737 assert_eq!(parsed.document().attributes.text("kept"), Some("original"));
738 assert_eq!(parsed.warnings().len(), 1, "{:?}", parsed.warnings());
739 let warning = parsed
740 .warnings()
741 .first()
742 .expect("unterminated comment warning");
743 assert!(matches!(
744 &warning.kind,
745 WarningKind::UnterminatedDelimitedBlock { kind, delimiter }
746 if *kind == "comment" && delimiter == "////"
747 ));
748 Ok(())
749 }
750
751 #[test]
752 fn header_comment_empty_blocks_keep_header_and_body_boundaries() -> Result<(), Error> {
753 for input in [
754 "////\n////\n\n= T\n:kept: value\n\nBody.\n",
755 "= T\n////\n////\nCorrect Author\n:kept: value\n\nBody.\n",
756 "= T\n:kept: value\n\n////\n////\n\nBody.\n",
757 ] {
758 let parsed = parse(input, &Options::default())?;
759 assert!(parsed.document().header.is_some(), "{input}");
760 assert_eq!(parsed.document().attributes.text("kept"), Some("value"));
761 assert!(
762 parsed.warnings().is_empty(),
763 "{input}: {:?}",
764 parsed.warnings()
765 );
766 assert!(parsed.document().blocks.iter().any(|block| matches!(
767 block, Block::Paragraph(paragraph) if paragraph.content.iter().any(|inline| matches!(inline,
768 InlineNode::PlainText(text) if text.content == "Body."))
769 )));
770 }
771 Ok(())
772 }
773
774 #[test]
775 fn document_attribute_locations_exclude_newlines_and_keep_unicode() -> Result<(), Error> {
776 for body in [
777 ":value: cafΓ©\n\nBody.",
778 "[.role]\n:value: cafΓ©\nBody.",
779 ":value: cafΓ©",
780 ] {
781 let source = format!("= T\n\n{body}");
782 let parsed = parse(&source, &Options::default())?;
783 let attribute = parsed
784 .document()
785 .blocks
786 .iter()
787 .find_map(|block| {
788 if let Block::DocumentAttribute(attribute) = block {
789 Some(attribute)
790 } else {
791 None
792 }
793 })
794 .expect("body attribute event");
795 let location = &attribute.location;
796 let last = source[location.absolute_end..]
797 .chars()
798 .next()
799 .expect("final declaration character");
800 let end = location.absolute_end + last.len_utf8();
801 assert_eq!(&source[location.absolute_start..end], ":value: cafΓ©");
802 assert_eq!(last, 'Γ©');
803 assert_eq!(location.start.line, location.end.line);
804 }
805 Ok(())
806 }
807
808 #[test]
809 fn caller_hardbreak_attributes_create_line_break_nodes() -> Result<(), Box<dyn StdError>> {
810 for name in ["hardbreaks", "hardbreaks-option"] {
811 let options = Options::builder().with_attribute(name, "false").build()?;
812 let parsed = parse("First line\nSecond line\n", &options)
813 .expect("parse paragraph with caller hard breaks");
814 let paragraph = parsed
815 .document()
816 .blocks
817 .first()
818 .and_then(|block| {
819 if let Block::Paragraph(paragraph) = block {
820 Some(paragraph)
821 } else {
822 None
823 }
824 })
825 .expect("paragraph");
826
827 assert!(matches!(
828 paragraph.content.as_slice(),
829 [
830 InlineNode::PlainText(_),
831 InlineNode::LineBreak(_),
832 InlineNode::PlainText(_)
833 ]
834 ));
835
836 let parsed_inline = parse_inline("First line\nSecond line", &options)
837 .expect("parse inline content with caller hard breaks");
838 assert!(matches!(
839 parsed_inline.inlines(),
840 [
841 InlineNode::PlainText(_),
842 InlineNode::LineBreak(_),
843 InlineNode::PlainText(_)
844 ]
845 ));
846 }
847 Ok(())
848 }
849
850 #[test]
851 fn parse_inline_accepts_pass_macros_without_substitution_names() {
852 let parsed = parse_inline("pass:[raw] pass:[]", &Options::default())
853 .expect("parse pass macros without substitution names");
854 let [
855 InlineNode::Macro(InlineMacro::Pass(with_content)),
856 InlineNode::PlainText(separator),
857 InlineNode::Macro(InlineMacro::Pass(empty)),
858 ] = parsed.inlines()
859 else {
860 panic!("expected two pass macros, got {:?}", parsed.inlines());
861 };
862
863 assert_eq!(with_content.text, Some("raw"));
864 assert_eq!(with_content.substitutions, []);
865 assert_eq!(separator.content, " ");
866 assert_eq!(empty.text, Some(""));
867 assert_eq!(empty.substitutions, []);
868 }
869
870 #[test]
871 fn indent_include_remaps_columns_to_origin() -> Result<(), Box<dyn StdError>> {
872 let opts = Options::builder()
878 .with_safe_mode(SafeMode::Unsafe)
879 .build()?;
880 let result = parse_file("fixtures/preprocessor/include_indent_main.adoc", &opts)
881 .expect("parse indented include");
882 let doc = result.document();
883 let inlines = doc
884 .blocks
885 .iter()
886 .find_map(|b| {
887 if let Block::DelimitedBlock(d) = b
888 && let DelimitedBlockType::DelimitedListing(inlines) = &d.inner
889 {
890 Some(inlines)
891 } else {
892 None
893 }
894 })
895 .expect("listing block from the indented include");
896 let loc = inlines.first().expect("listing content inline").location();
897
898 assert_eq!(loc.start.line, 1, "origin line");
899 assert_eq!(
900 loc.start.column, 1,
901 "origin column (the 6-space indent stripped off)"
902 );
903 assert_eq!(loc.end.line, 1);
904 assert_eq!(
905 loc.end.column, 10,
906 "`TARGETLINE` is 10 columns wide in the origin"
907 );
908 assert_eq!(
909 loc.start
910 .file
911 .as_deref()
912 .and_then(|chain| chain.last())
913 .map(String::as_str),
914 Some("include_indent_target.rb"),
915 );
916 assert!(loc.absolute_start <= loc.absolute_end);
917 Ok(())
918 }
919
920 #[test]
921 fn quote_attribution_and_citetitle_remap_to_included_file() -> Result<(), Box<dyn StdError>> {
922 fn file_name(loc: &Location) -> Option<&str> {
923 loc.start
924 .file
925 .as_deref()
926 .and_then(|chain| chain.last())
927 .map(String::as_str)
928 }
929
930 let opts = Options::builder()
935 .with_safe_mode(SafeMode::Unsafe)
936 .build()?;
937 let result = parse_file("fixtures/preprocessor/include_quote_main.adoc", &opts)
938 .expect("parse included quote block");
939 let doc = result.document();
940 let metadata = doc
941 .blocks
942 .iter()
943 .find_map(|b| {
944 if let Block::DelimitedBlock(d) = b
945 && d.metadata.attribution.is_some()
946 {
947 Some(&d.metadata)
948 } else {
949 None
950 }
951 })
952 .expect("quote block with an attribution");
953
954 let part = Some("include_quote_part.adoc");
955
956 let attribution = metadata
957 .attribution
958 .as_ref()
959 .and_then(|a| a.first())
960 .expect("attribution inline")
961 .location();
962 assert_eq!(attribution.start.line, 3, "attribution origin line");
963 assert_eq!(file_name(attribution), part, "attribution origin file");
964
965 let citetitle = metadata
966 .citetitle
967 .as_ref()
968 .and_then(|c| c.first())
969 .expect("citetitle inline")
970 .location();
971 assert_eq!(citetitle.start.line, 3, "citetitle origin line");
972 assert_eq!(file_name(citetitle), part, "citetitle origin file");
973 assert_eq!(
974 metadata.positional_attributes().collect::<Vec<_>>(),
975 vec![Some("Brian Quote"), Some("The Source Book")]
976 );
977 for (slot, expected_location) in metadata
978 .raw_positional_attributes()
979 .iter()
980 .zip([attribution, citetitle])
981 {
982 assert_eq!(slot.location.as_ref(), Some(expected_location));
983 }
984 Ok(())
985 }
986
987 #[test]
988 fn inline_preprocessor_warning_reports_included_file_line() -> Result<(), Box<dyn StdError>> {
989 let opts = Options::builder()
994 .with_safe_mode(SafeMode::Unsafe)
995 .build()?;
996 let result = parse_file("fixtures/preprocessor/include_counter_main.adoc", &opts)
997 .expect("parse included counter");
998 let warning = result
999 .warnings()
1000 .iter()
1001 .find(|w| w.kind.to_string().contains("Counters"))
1002 .expect("counter warning");
1003 let loc = warning
1004 .source_location()
1005 .expect("counter warning carries a location");
1006 assert_eq!(
1007 loc.file
1008 .as_deref()
1009 .and_then(|p| p.file_name())
1010 .and_then(std::ffi::OsStr::to_str),
1011 Some("include_counter_part.adoc"),
1012 "warning should name the included file",
1013 );
1014 assert_eq!(
1015 loc.location.start.line, 3,
1016 "warning origin line in the included file"
1017 );
1018 Ok(())
1019 }
1020
1021 #[test]
1022 fn toc_entries_and_references_remap_to_included_file() -> Result<(), Box<dyn StdError>> {
1023 fn file_name(loc: &Location) -> Option<&str> {
1028 loc.start
1029 .file
1030 .as_deref()
1031 .and_then(|chain| chain.last())
1032 .map(String::as_str)
1033 }
1034
1035 let opts = Options::builder()
1036 .with_safe_mode(SafeMode::Unsafe)
1037 .build()?;
1038 let result = parse_file("fixtures/preprocessor/include_refs_main.adoc", &opts)
1039 .expect("parse included section");
1040 let doc = result.document();
1041 let part = Some("include_refs_part.adoc");
1042
1043 let entry = doc
1044 .toc_entries
1045 .iter()
1046 .find(|e| e.id == "_included_section")
1047 .expect("toc entry for the included section");
1048 assert_eq!(entry.location.start.line, 1, "toc entry origin line");
1049 assert_eq!(file_name(&entry.location), part, "toc entry origin file");
1050
1051 let reference = doc
1052 .references
1053 .get("_included_section")
1054 .expect("reference for the included section");
1055 assert_eq!(reference.location.start.line, 1, "reference origin line");
1056 assert_eq!(
1057 file_name(&reference.location),
1058 part,
1059 "reference origin file"
1060 );
1061 Ok(())
1062 }
1063
1064 #[test]
1065 fn footnote_location_is_document_absolute_without_include() {
1066 let input = "= Title\n\nPara one.\n\nPara two with a note.footnote:[The note.]\n";
1070 let result = parse(input, &Options::default()).expect("parse footnote doc");
1071 let doc = result.document();
1072 let footnote = doc.footnotes.first().expect("one footnote");
1073 assert_eq!(
1074 footnote.location.start.line, 5,
1075 "footnote's real document line"
1076 );
1077 assert!(
1078 footnote.location.start.file.is_none(),
1079 "primary-input footnote carries no file",
1080 );
1081 }
1082
1083 #[test]
1084 fn footnote_location_remaps_to_included_file() -> Result<(), Box<dyn StdError>> {
1085 let opts = Options::builder()
1088 .with_safe_mode(SafeMode::Unsafe)
1089 .build()?;
1090 let result = parse_file("fixtures/preprocessor/include_footnote_main.adoc", &opts)
1091 .expect("parse included footnote");
1092 let doc = result.document();
1093 let footnote = doc.footnotes.first().expect("one footnote");
1094 assert_eq!(footnote.location.start.line, 3, "footnote origin line");
1095 assert_eq!(
1096 footnote
1097 .location
1098 .start
1099 .file
1100 .as_deref()
1101 .and_then(|chain| chain.last())
1102 .map(String::as_str),
1103 Some("include_footnote_part.adoc"),
1104 );
1105 Ok(())
1106 }
1107
1108 #[test]
1109 fn named_footnote_keeps_defining_occurrence() {
1110 let input =
1114 "= Title\n\nFirst ref.footnote:fn[The definition.]\n\nSecond ref.footnote:fn[]\n";
1115 let result = parse(input, &Options::default()).expect("parse named footnote doc");
1116 let doc = result.document();
1117 assert_eq!(doc.footnotes.len(), 1, "one distinct footnote");
1118 let footnote = doc.footnotes.first().expect("the distinct footnote");
1119 assert_eq!(footnote.location.start.line, 3, "defining occurrence line");
1120 assert!(
1121 !footnote.content.is_empty(),
1122 "keeps the defining occurrence's content, not the empty reference",
1123 );
1124 }
1125
1126 #[test]
1127 fn document_root_follows_per_boundary_file_model() -> Result<(), Box<dyn StdError>> {
1128 let opts = Options::builder()
1134 .with_safe_mode(SafeMode::Unsafe)
1135 .build()?;
1136 let result =
1137 parse_file("fixtures/include_chain/main.adoc", &opts).expect("parse include chain");
1138 let location = &result.document().location;
1139
1140 assert!(
1141 location.start.file.is_none(),
1142 "document start is primary input (no file)",
1143 );
1144 let expected = vec!["outer.adoc".to_string(), "inner.adoc".to_string()];
1145 assert_eq!(
1146 location.end.file.as_deref(),
1147 Some(&expected),
1148 "document end carries the include chain it ends in",
1149 );
1150 Ok(())
1151 }
1152
1153 #[rstest::rstest]
1154 #[case("fixtures/tests/positional_block_attributes.adoc", &[
1155 &[None, Some("svg")][..],
1156 &[None, Some("svg")],
1157 &[None, Some("svg")],
1158 &[None, None],
1159 &[],
1160 &[Some("rust"), Some("linenums"), Some("extra")],
1161 &[Some("*Alice*"), Some("Book, One")],
1162 &[Some("*Alice*"), Some("Book, One")],
1163 &[Some("asciimath"), Some("extra")],
1164 &[Some("new"), Some("svg")],
1165 &[None, Some("pdf")],
1166 &[Some("old"), Some("svg")],
1167 &[None, Some("svg")],
1168 &[Some("*Alice*"), Some("svg")],
1169 ])]
1170 #[case("fixtures/tests/positional_attribute_routing.adoc", &[
1171 &[Some("Alice"), Some("Book")][..],
1172 &[Some("Alice"), Some("Book"), Some("linenums")],
1173 &[Some("Alice"), Some("Book"), Some("extra")],
1174 &[Some("100"), Some("200"), Some("extra")],
1175 &[Some("Alice"), Some("Book"), Some("extra")],
1176 &[Some("100"), Some("200"), Some("extra")],
1177 &[Some("Alice"), Some("Book"), Some("extra")],
1178 &[Some("rust"), Some("Book"), Some("extra")],
1179 ])]
1180 #[case("fixtures/tests/image_dimensions.adoc", &[
1181 &[Some("Alice"), Some("Book")][..],
1182 &[Some("100"), Some("200")],
1183 &[Some("100"), Some("200")],
1184 &[Some("300"), Some("400")],
1185 &[None, Some("400")],
1186 &[Some("300"), Some("400")],
1187 &[],
1188 &[Some("300"), Some("400")],
1189 ])]
1190 #[case("fixtures/tests/video_dimensions.adoc", &[
1191 &[Some("Alice"), Some("Book")][..],
1192 &[Some("100"), Some("200")],
1193 &[Some("100"), Some("200")],
1194 &[Some("300"), Some("400")],
1195 &[None, Some("400")],
1196 &[Some("300"), Some("400")],
1197 &[],
1198 &[Some("300"), Some("400")],
1199 ])]
1200 fn test_retained_positional_attributes(
1201 #[case] path: &str,
1202 #[case] expected: &[&[Option<&str>]],
1203 ) -> Result<(), Box<dyn StdError>> {
1204 let parsed = parse_file(path, &Options::default())?;
1205 let metadata: Vec<_> = parsed
1206 .document()
1207 .blocks
1208 .iter()
1209 .filter_map(Block::metadata)
1210 .collect();
1211 assert_eq!(metadata.len(), expected.len());
1212 for (index, (metadata, positions)) in metadata.iter().zip(expected).enumerate() {
1213 assert_eq!(
1214 metadata.positional_attributes().collect::<Vec<_>>(),
1215 *positions,
1216 "block {index} in {path}",
1217 );
1218 assert_eq!(metadata.positional_attributes().len(), positions.len());
1219 for (slot, expected) in positions.iter().enumerate() {
1220 assert_eq!(metadata.positional_attribute(slot), *expected);
1221 }
1222 assert_eq!(metadata.positional_attribute(positions.len()), None);
1223
1224 let mut working = (*metadata).clone();
1226 working.positional_attributes = metadata.raw_positional_attributes().to_vec();
1227 working.retained_positional_attributes = None;
1228 assert_eq!(*metadata, &working);
1229
1230 let mut retained = working.clone();
1231 for slot in &mut retained.positional_attributes {
1232 slot.location = None;
1233 slot.substitutions = !slot.substitutions;
1234 }
1235 retained.retained_positional_attributes =
1236 Some(std::mem::take(&mut retained.positional_attributes));
1237 assert_eq!(*metadata, &retained);
1238
1239 let mut changed = retained.clone();
1240 if let Some(slot) = changed
1241 .retained_positional_attributes
1242 .as_mut()
1243 .and_then(|slots| slots.first_mut())
1244 {
1245 slot.value = "changed positional value";
1246 assert_ne!(*metadata, &changed, "positional values affect equality");
1247 }
1248 if let Some(slots) = &mut retained.retained_positional_attributes {
1249 slots.push(model::PositionalAttribute::default());
1250 }
1251 assert_ne!(*metadata, &retained, "an extra empty slot affects equality");
1252
1253 let mut edited = (*metadata).clone().with_style(Some("source"));
1254 edited
1255 .attributes
1256 .set("language".into(), AttributeValue::String("python".into()));
1257 assert_eq!(
1258 edited.positional_attributes().collect::<Vec<_>>(),
1259 *positions
1260 );
1261 assert_ne!(*metadata, &edited, "named attributes still affect equality");
1262 }
1263 let empty = BlockMetadata {
1264 retained_positional_attributes: Some(Vec::new()),
1265 ..BlockMetadata::default()
1266 };
1267 assert_eq!(empty, BlockMetadata::default());
1268 assert_eq!(BlockMetadata::new().positional_attributes().len(), 0);
1269 Ok(())
1270 }
1271
1272 #[rstest::rstest]
1273 #[tracing_test::traced_test]
1274 fn test_with_fixtures(
1275 #[files("fixtures/tests/**/*.adoc")]
1276 #[exclude(r"[/\\]includes[/\\]")]
1277 path: PathBuf,
1278 ) -> Result<(), Error> {
1279 let stem = path
1280 .file_stem()
1281 .and_then(|stem| stem.to_str())
1282 .unwrap_or("");
1283
1284 #[cfg(not(feature = "pre-spec-subs"))]
1285 if stem.starts_with("subs_") {
1286 return Ok(());
1287 }
1288
1289 let setext_fixture = stem.contains("with_setext");
1295 #[cfg(not(feature = "setext"))]
1296 if setext_fixture {
1297 return Ok(());
1298 }
1299
1300 let builder = Options::builder().with_safe_mode(SafeMode::Unsafe);
1301 #[cfg(feature = "setext")]
1302 let builder = if setext_fixture {
1303 builder.with_setext()
1304 } else {
1305 builder
1306 };
1307 let options = builder.build()?;
1308
1309 match parse_file(&path, &options) {
1310 Ok(result) => {
1311 let expected = read_file_contents_with_extension(&path, "json")?;
1312 let actual = serde_json::to_string_pretty(result.document())
1313 .expect("could not serialize result");
1314 assert_eq!(expected, actual);
1315 }
1316 Err(e) => {
1317 let file_contents = read_file_contents_with_extension(&path, "error")?;
1318 let expected = file_contents.trim();
1320 assert_eq!(expected, e.to_string());
1321 }
1322 }
1323 Ok(())
1324 }
1325
1326 #[test]
1327 fn quoted_xref_labels_preserve_single_quote_byte_ranges() -> Result<(), Box<dyn StdError>> {
1328 for (source, expected) in [
1330 (r#"xref:target["\"",role=hot]"#, r#"\""#),
1331 (r"xref:target['\'',role=hot]", r"\'"),
1332 ] {
1333 let parsed = parse(source, &Options::default())?;
1334 let Some(Block::Paragraph(paragraph)) = parsed.document().blocks.first() else {
1335 panic!("expected a paragraph");
1336 };
1337 let Some(InlineNode::Macro(InlineMacro::CrossReference(xref))) =
1338 paragraph.content.first()
1339 else {
1340 panic!("expected an xref");
1341 };
1342 let location = xref.text.first().ok_or("expected xref text")?.location();
1343 assert_eq!(
1344 source.get(location.absolute_start..=location.absolute_end),
1345 Some(expected)
1346 );
1347 }
1348 Ok(())
1349 }
1350
1351 #[test]
1352 fn node_locations_are_source_relative_after_dropped_comment() -> Result<(), Box<dyn StdError>> {
1353 use crate::Block;
1354 let input =
1358 "= Doc\n\nfirst para\n// dropped comment\nsecond para\n\n== Section\n\nbody text\n";
1359 let result = parse(input, &Options::builder().build()?).expect("parse");
1360 let section = result
1361 .document()
1362 .blocks
1363 .iter()
1364 .find_map(|b| {
1365 if let Block::Section(s) = b {
1366 Some(s)
1367 } else {
1368 None
1369 }
1370 })
1371 .expect("section present");
1372 assert_eq!(
1373 section.location.start.line, 7,
1374 "section title at source line 7"
1375 );
1376 let body = section
1377 .content
1378 .iter()
1379 .find_map(|b| {
1380 if let Block::Paragraph(p) = b {
1381 Some(p)
1382 } else {
1383 None
1384 }
1385 })
1386 .expect("section body paragraph");
1387 assert_eq!(
1388 body.location.start.line, 9,
1389 "body paragraph at source line 9"
1390 );
1391 Ok(())
1392 }
1393
1394 #[test]
1395 fn node_locations_carry_origin_file_across_include() -> Result<(), Box<dyn StdError>> {
1396 use crate::{Block, SafeMode};
1397 let path = PathBuf::from("fixtures/tests/leveloffset_include.adoc");
1398 let options = Options::builder()
1399 .with_safe_mode(SafeMode::Unsafe)
1400 .build()?;
1401 let result = parse_file(&path, &options).expect("parse");
1402 let section = result
1404 .document()
1405 .blocks
1406 .iter()
1407 .find_map(|b| {
1408 if let Block::Section(s) = b {
1409 Some(s)
1410 } else {
1411 None
1412 }
1413 })
1414 .expect("included section present");
1415 let chain = section.location.start.file.as_deref().map(Vec::as_slice);
1417 assert_eq!(
1418 chain,
1419 Some(["leveloffset_included.adoc".to_string()].as_slice())
1420 );
1421 assert_eq!(
1422 section.location.start.line, 1,
1423 "included section at its own line 1"
1424 );
1425 Ok(())
1426 }
1427
1428 #[test]
1429 fn node_locations_carry_full_include_chain() -> Result<(), Box<dyn StdError>> {
1430 use crate::{Block, SafeMode};
1431 let path = PathBuf::from("fixtures/include_chain/main.adoc");
1435 let options = Options::builder()
1436 .with_safe_mode(SafeMode::Unsafe)
1437 .build()?;
1438 let result = parse_file(&path, &options).expect("parse");
1439
1440 let paragraphs = result
1441 .document()
1442 .blocks
1443 .iter()
1444 .filter_map(|block| {
1445 if let Block::Paragraph(para) = block {
1446 Some(para)
1447 } else {
1448 None
1449 }
1450 })
1451 .collect::<Vec<_>>();
1452 let [main, outer, inner] = paragraphs.as_slice() else {
1453 panic!("expected main, outer, and inner paragraphs");
1454 };
1455 assert!(
1456 matches!(&main.content[..], [InlineNode::PlainText(text)] if text.content == "Main paragraph.")
1457 );
1458 assert!(
1459 matches!(&outer.content[..], [InlineNode::PlainText(text)] if text.content == "Outer paragraph.")
1460 );
1461 assert!(
1462 matches!(&inner.content[..], [InlineNode::PlainText(text)] if text.content == "Inner paragraph.")
1463 );
1464 assert!(main.location.start.file.is_none());
1466 assert_eq!(
1467 outer
1468 .location
1469 .start
1470 .file
1471 .as_ref()
1472 .map(|chain| chain.as_slice()),
1473 Some(["outer.adoc".to_string()].as_slice())
1474 );
1475 assert_eq!(
1476 inner
1477 .location
1478 .start
1479 .file
1480 .as_ref()
1481 .map(|chain| chain.as_slice()),
1482 Some(["outer.adoc".to_string(), "inner.adoc".to_string()].as_slice())
1483 );
1484 Ok(())
1485 }
1486
1487 #[cfg(test)]
1488 mod empty_document_tests {
1489 use crate::{Options, parse};
1490
1491 #[test]
1492 fn test_whitespace_only_documents() {
1493 let test_cases = vec![
1494 "\n", "\n\n", "\t", " \n\t\n ", " ",
1495 "\n\n\t",
1496 ];
1497
1498 for input in test_cases {
1499 let options = Options::default();
1500 let result = parse(input, &options);
1501
1502 match result {
1503 Ok(parsed) => {
1504 let doc = parsed.document();
1505 assert!(
1507 doc.location.absolute_start <= doc.location.absolute_end,
1508 "Failed for input {input:?}: absolute_start {} > absolute_end {}",
1509 doc.location.absolute_start,
1510 doc.location.absolute_end
1511 );
1512
1513 doc.location.validate(input).unwrap_or_else(|e| {
1515 panic!("Location validation failed for {input:?}: {e}")
1516 });
1517 }
1518 Err(e) => {
1519 panic!("Failed to parse {input:?}: {e}");
1520 }
1521 }
1522 }
1523 }
1524
1525 #[test]
1526 fn test_document_with_content_after_whitespace() {
1527 let test_cases = vec!["\n\nHello", "\t\tWorld", " \n = Title"];
1528
1529 for input in test_cases {
1530 let options = Options::default();
1531 let parsed =
1532 parse(input, &options).unwrap_or_else(|_| panic!("Should parse {input:?}"));
1533 let doc = parsed.document();
1534
1535 assert!(
1536 doc.location.absolute_start <= doc.location.absolute_end,
1537 "Failed for input {input:?}: absolute_start {} > absolute_end {}",
1538 doc.location.absolute_start,
1539 doc.location.absolute_end
1540 );
1541
1542 doc.location
1544 .validate(input)
1545 .unwrap_or_else(|e| panic!("Location validation failed for {input:?}: {e}"));
1546 }
1547 }
1548
1549 #[test]
1550 fn test_unicode_characters() {
1551 let test_cases = vec![
1553 "π", "Χ", "Hello δΈη", "\u{200b}", ];
1558
1559 for input in test_cases {
1560 let options = Options::default();
1561 let result = parse(input, &options);
1562
1563 match result {
1564 Ok(parsed) => {
1565 let doc = parsed.document();
1566 assert!(
1568 input.is_char_boundary(doc.location.absolute_start),
1569 "Absolute start {} not on UTF-8 boundary for {input:?}",
1570 doc.location.absolute_start,
1571 );
1572 assert!(
1573 input.is_char_boundary(doc.location.absolute_end),
1574 "Absolute end {} not on UTF-8 boundary for {input:?}",
1575 doc.location.absolute_end,
1576 );
1577
1578 doc.location.validate(input).unwrap_or_else(|e| {
1580 panic!("Location validation failed for {input:?}: {e}");
1581 });
1582 }
1583 Err(e) => {
1584 println!("Failed to parse {input:?}: {e} (this might be expected)");
1587 }
1588 }
1589 }
1590 }
1591 }
1592
1593 mod warning_deduplication_tests {
1600 use crate::{Options, parse};
1601
1602 #[test]
1603 #[tracing_test::traced_test]
1604 fn counter_reference_peg_backtracking_does_not_duplicate() {
1605 let input = "= Title\n\n{counter:hits} then {counter:hits} again";
1609 let options = Options::default();
1610 let result = parse(input, &options).expect("should parse");
1611 let counter_warnings = result
1612 .warnings()
1613 .iter()
1614 .filter(|w| {
1615 w.kind
1616 .to_string()
1617 .contains("not supported and will be removed")
1618 })
1619 .count();
1620 assert_eq!(
1621 counter_warnings,
1622 2,
1623 "expected 2 counter warnings (one per position), got {counter_warnings}: {:?}",
1624 result.warnings(),
1625 );
1626 assert!(
1628 result
1629 .warnings()
1630 .iter()
1631 .all(|w| w.source_location().is_some()),
1632 "counter warnings must carry locations",
1633 );
1634 }
1635
1636 #[test]
1637 #[tracing_test::traced_test]
1638 fn distinct_warnings_all_emitted() {
1639 let input = "= Title\n\n{counter:a} and {counter2:b}";
1641 let options = Options::default();
1642 let _doc = parse(input, &options).expect("should parse");
1643 assert!(logs_contain(
1644 "Counters ({counter:a}) are not supported and will be removed from output"
1645 ));
1646 assert!(logs_contain(
1647 "Counters ({counter2:b}) are not supported and will be removed from output"
1648 ));
1649 }
1650 }
1651
1652 mod parse_result_tests {
1653 use crate::{Options, WarningKind, parse, parse_file};
1654
1655 #[test]
1661 fn missing_include_warning_surfaces_on_parse_result() {
1662 use std::io::Write;
1663 let tmp = std::env::temp_dir().join("acdc_test_missing_include.adoc");
1665 let mut f = std::fs::File::create(&tmp).expect("create tmp");
1666 writeln!(
1667 f,
1668 "= Doc Title\n\ninclude::definitely-missing-{}.adoc[]\n",
1669 std::process::id()
1670 )
1671 .expect("write tmp");
1672 drop(f);
1673
1674 let options = Options::default();
1675 let result = parse_file(&tmp, &options).expect("should parse");
1676 let _ = std::fs::remove_file(&tmp);
1677
1678 let has_missing_include = result
1679 .warnings()
1680 .iter()
1681 .any(|w| w.kind.to_string().starts_with("include file not found:"));
1682 assert!(
1683 has_missing_include,
1684 "expected missing-include warning, got: {:?}",
1685 result.warnings(),
1686 );
1687 }
1688
1689 #[test]
1693 fn section_level_out_of_sequence_surfaces_on_parse_result() {
1694 let input = "= Doc Title\n\n=== Starts at level 2\n\nContent\n";
1695 let options = Options::default();
1696 let result = parse(input, &options).expect("document should parse");
1697
1698 assert_eq!(
1699 result.warnings().len(),
1700 1,
1701 "expected exactly one warning, got: {:?}",
1702 result.warnings(),
1703 );
1704 let warning = result.warnings().first().expect("asserted non-empty");
1705 assert!(
1706 matches!(
1707 &warning.kind,
1708 WarningKind::SectionLevelOutOfSequence { got: 2, .. },
1709 ),
1710 "unexpected warning kind: {:?}",
1711 warning.kind,
1712 );
1713 assert!(
1714 warning.source_location().is_some(),
1715 "warning should carry a source location",
1716 );
1717 }
1718
1719 #[test]
1722 fn valid_document_has_no_warnings() {
1723 let input = "= Doc Title\n\n== First\n\nContent\n";
1724 let options = Options::default();
1725 let result = parse(input, &options).expect("document should parse");
1726 assert!(
1727 result.warnings().is_empty(),
1728 "expected no warnings, got: {:?}",
1729 result.warnings(),
1730 );
1731 }
1732
1733 #[test]
1736 fn subs_attribute_always_surfaces_a_warning() {
1737 let input = "[subs=\"-quotes\"]\nContent\n";
1738 let options = Options::default();
1739 let result = parse(input, &options).expect("document should parse");
1740
1741 let messages: Vec<String> = result
1742 .warnings()
1743 .iter()
1744 .map(|w| w.kind.to_string())
1745 .collect();
1746 assert!(
1747 messages.iter().any(|m| m.contains("subs=")),
1748 "expected a `subs=` warning, got: {messages:?}",
1749 );
1750 #[cfg(feature = "pre-spec-subs")]
1751 assert!(
1752 messages.iter().any(|m| m.contains(
1753 "https://gitlab.eclipse.org/eclipse/asciidoc-lang/asciidoc-lang/-/issues/16"
1754 )),
1755 "expected warning with feature on, got: {messages:?}",
1756 );
1757 #[cfg(not(feature = "pre-spec-subs"))]
1758 assert!(
1759 messages.iter().any(|m| m.contains("not honoured")),
1760 "expected feature-off warning, got: {messages:?}",
1761 );
1762 }
1763 }
1764
1765 mod attribute_resolution_tests {
1766 use crate::{Options, parse};
1767
1768 #[test]
1769 fn test_definition_time_resolution_bar_defined_first() {
1770 let input = r":bar: resolved-bar
1772:foo: {bar}
1773
1774{foo}
1775";
1776 let options = Options::default();
1777 let parsed = parse(input, &options).expect("should parse");
1778 let doc = parsed.document();
1779
1780 assert_eq!(doc.attributes.text("foo"), Some("resolved-bar"));
1782 }
1783
1784 #[test]
1785 fn test_definition_time_resolution_bar_defined_after() {
1786 let input = r":foo: {bar}
1788:bar: resolved-bar
1789
1790{foo}
1791";
1792 let options = Options::default();
1793 let parsed = parse(input, &options).expect("should parse");
1794 let doc = parsed.document();
1795
1796 assert_eq!(doc.attributes.text("foo"), Some("{bar}"));
1798 }
1799
1800 #[test]
1801 fn test_chained_attribute_resolution() {
1802 let input = r":c: final-value
1805:b: {c}
1806:a: {b}
1807
1808{a}
1809";
1810 let options = Options::default();
1811 let parsed = parse(input, &options).expect("should parse");
1812 let doc = parsed.document();
1813
1814 assert_eq!(doc.attributes.text("c"), Some("final-value"));
1816 assert_eq!(doc.attributes.text("b"), Some("final-value"));
1817 assert_eq!(doc.attributes.text("a"), Some("final-value"));
1818 }
1819 }
1820}