1use std::{
149 cell::RefCell,
150 path::{Path, PathBuf},
151 rc::Rc,
152 string::ToString,
153};
154
155use tracing::instrument;
156
157mod blocks;
158mod constants;
159mod document_attribute;
160mod error;
161pub(crate) mod grammar;
162mod model;
163mod options;
164mod parsed;
165mod preprocessor;
166mod safe_mode;
167mod source_provider;
168mod warning;
169
170pub(crate) use grammar::{InlinePreprocessorParserState, ProcessedContent, inline_preprocessing};
171use preprocessor::Preprocessor;
172
173pub use error::{Error, SourceLocation};
174pub use grammar::parse_text_for_quotes;
175pub use model::{
176 Admonition, AdmonitionVariant, Anchor, AttributeName, AttributeValue, Attribution, Audio,
177 Author, Autolink, Block, BlockMetadata, Bold, Button, CalloutList, CalloutListItem, CalloutRef,
178 CalloutRefKind, Caption, CaptionKind, CiteTitle, ColumnFormat, ColumnStyle, ColumnWidth,
179 Comment, CommentKind, CrossReference, CurvedApostrophe, CurvedQuotation, DelimitedBlock,
180 DelimitedBlockType, DescriptionList, DescriptionListItem, DiscreteHeader, Document,
181 DocumentAttribute, DocumentAttributeAssignment, DocumentAttributeValue, DocumentAttributes,
182 ElementAttributes, Footnote, Form, HEADER, Header, Highlight, HorizontalAlignment, ICON_SIZES,
183 Icon, Image, IndexTerm, IndexTermKind, IndexTermRelationship, InlineMacro, InlineNode, Italic,
184 Keyboard, LineBreak, Link, ListItem, ListItemCheckedStatus, Location, MAX_SECTION_LEVELS,
185 MAX_TOC_LEVELS, Mailto, Menu, Monospace, NORMAL, OrderedList, PageBreak, Paragraph, Pass,
186 PassthroughKind, Plain, Position, Raw, Reference, Role, Section, SectionKind, Source,
187 SourceUrl, StandaloneCurvedApostrophe, Stem, StemContent, StemNotation, Subscript,
188 Substitution, Subtitle, Superscript, Table, TableColumn, TableFrame, TableGrid,
189 TableOfContents, TablePresentation, TableRow, TableStripes, ThematicBreak, Title, TocEntry,
190 UNNUMBERED_SECTION_STYLES, UnorderedList, Url, VERBATIM, Verbatim, VerticalAlignment, Video,
191 XrefCaptionLabel, XrefSignifier, XrefStyle, strip_quotes, substitute, substitute_attributes,
192};
193#[cfg(feature = "pre-spec-subs")]
194pub use model::{SubstitutionOp, SubstitutionSpec};
195pub use options::{Options, OptionsBuilder, SafeMode};
196pub use parsed::{OwnedSource, ParseInlineResult, ParseResult};
197pub use source_provider::{
198 IncludeLoader, IncludeSource, IncludeSourceError, IncludeSourceErrorKind,
199 IncludeSourceProvider, IncludeSourceTarget,
200};
201pub use warning::{Warning, WarningKind};
202
203#[derive(Debug)]
245pub struct Parser<'input> {
246 input: &'input str,
247 options: Options<'input>,
248}
249
250impl<'input> Parser<'input> {
251 #[must_use]
265 pub fn new(input: &'input str) -> Self {
266 Self {
267 input,
268 options: Options::default(),
269 }
270 }
271
272 #[must_use]
290 pub fn with_options(mut self, options: Options<'input>) -> Self {
291 self.options = options;
292 self
293 }
294
295 pub fn parse(self) -> Result<ParseResult, Error> {
310 parse(self.input, &self.options)
311 }
312
313 pub fn parse_inline(self) -> Result<ParseInlineResult, Error> {
331 parse_inline(self.input, &self.options)
332 }
333}
334
335#[instrument(skip_all, fields(safe_mode = ?options.safe_mode))]
361pub fn parse_from_reader<R: std::io::Read>(
362 reader: R,
363 options: &Options<'_>,
364) -> Result<ParseResult, Error> {
365 let mut options = options
366 .clone()
367 .prepare_for_parse(document_attribute::InputKind::Reader);
368 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
371 let result = {
372 let _span = tracing::info_span!("preprocess").entered();
373 Preprocessor::process_reader(reader, &options, Rc::clone(&warnings_handle))?
374 };
375 if let Some(front_matter) = result.front_matter {
376 options
377 .document_attributes
378 .set_text("front-matter".into(), front_matter.into());
379 }
380 let text: Box<str> = result.text.into_owned().into_boxed_str();
381 let _span = tracing::info_span!("grammar_parse", input_len = text.len()).entered();
382 parse_input(
383 text,
384 options,
385 None,
386 result.leveloffset_ranges,
387 result.source_ranges,
388 result.included_files,
389 warnings_handle,
390 )
391}
392
393#[instrument(skip_all, fields(input_len = input.len(), safe_mode = ?options.safe_mode))]
432pub fn parse(input: &str, options: &Options<'_>) -> Result<ParseResult, Error> {
433 let mut options = options
434 .clone()
435 .prepare_for_parse(document_attribute::InputKind::String);
436 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
437 let result = {
438 let _span = tracing::info_span!("preprocess").entered();
439 Preprocessor::process(input, &options, Rc::clone(&warnings_handle))?
440 };
441 if let Some(front_matter) = result.front_matter {
442 options
443 .document_attributes
444 .set_text("front-matter".into(), front_matter.into());
445 }
446 let text: Box<str> = result.text.into_owned().into_boxed_str();
447 let _span = tracing::info_span!("grammar_parse", input_len = text.len()).entered();
448 parse_input(
449 text,
450 options,
451 None,
452 result.leveloffset_ranges,
453 result.source_ranges,
454 result.included_files,
455 warnings_handle,
456 )
457}
458
459#[instrument(skip_all, fields(safe_mode = ?options.safe_mode))]
485pub fn parse_file<P: AsRef<Path>>(
486 file_path: P,
487 options: &Options<'_>,
488) -> Result<ParseResult, Error> {
489 let mut options = options
490 .clone()
491 .prepare_for_parse(document_attribute::InputKind::File(file_path.as_ref()));
492 let path = file_path.as_ref().to_path_buf();
493 let raw = preprocessor::read_and_decode_file(file_path.as_ref(), None)?;
494 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
495 let result = {
496 let _span = tracing::info_span!("preprocess").entered();
497 Preprocessor::process_with_file(
498 &raw,
499 file_path.as_ref(),
500 &options,
501 Rc::clone(&warnings_handle),
502 )?
503 };
504 if let Some(front_matter) = result.front_matter {
505 options
506 .document_attributes
507 .set_text("front-matter".into(), front_matter.into());
508 }
509 let text: Box<str> = result.text.into_owned().into_boxed_str();
510 let _span = tracing::info_span!("grammar_parse", input_len = text.len()).entered();
511 parse_input(
512 text,
513 options,
514 Some(path),
515 result.leveloffset_ranges,
516 result.source_ranges,
517 result.included_files,
518 warnings_handle,
519 )
520}
521
522fn peg_error_to_source_location(
525 error: &peg::error::ParseError<peg::str::LineCol>,
526 state: &grammar::ParserState,
527) -> SourceLocation {
528 let offset = error.location.offset;
529 if let Some(range) = model::SourceRange::find_containing(&state.source_ranges, offset) {
530 SourceLocation {
531 file: range.file.clone(),
532 location: crate::Location::point(Position::new(
533 state.line_map.source_line(range, state.input, offset),
534 u32::try_from(error.location.column).unwrap_or(u32::MAX),
535 )),
536 }
537 } else {
538 SourceLocation {
539 file: state.current_file.as_deref().cloned(),
540 location: crate::Location::point(Position::from_line_col(
541 error.location.line,
542 error.location.column,
543 )),
544 }
545 }
546}
547
548#[instrument(skip_all)]
549fn parse_input(
550 input: Box<str>,
551 options: Options<'_>,
552 file_path: Option<PathBuf>,
553 leveloffset_ranges: Vec<model::LeveloffsetRange>,
554 source_ranges: Vec<model::SourceRange>,
555 included_files: std::collections::HashSet<String>,
556 warnings_handle: Rc<RefCell<Vec<Warning>>>,
557) -> Result<ParseResult, Error> {
558 tracing::trace!(input_len = input.len(), "post preprocessor");
559 let owner = parsed::OwnedInput::new(input);
566 let options_owned = options.into_static();
567 let warnings_for_state = Rc::clone(&warnings_handle);
574
575 let source_files = parsed::SourceFiles::new(file_path.clone(), &source_ranges);
576 ParseResult::try_new(owner, warnings_handle, source_files, move |owner| {
577 let mut state = grammar::ParserState::new(&owner.source, &owner.arena);
578 state.document_attributes = Rc::new(options_owned.document_attributes.clone());
579 state.options = Rc::new(options_owned);
580 state.initialize_hardbreaks();
581 state.current_file = file_path.map(std::sync::Arc::new);
582 state.leveloffset_ranges = leveloffset_ranges;
583 state.source_ranges = source_ranges;
584 state.included_files = included_files;
585 state.warnings = warnings_for_state;
586 let result = match grammar::document_parser::document(&owner.source, &mut state) {
587 Ok(Ok(mut doc)) => {
588 grammar::remap_document_to_source(
592 &mut doc,
593 &state.source_ranges,
594 &owner.source,
595 &state.line_map,
596 );
597 Ok(doc)
598 }
599 Ok(Err(e)) => Err(e),
600 Err(error) => {
601 tracing::error!("error parsing document content");
602 let source_location = peg_error_to_source_location(&error, &state);
603 Err(Error::Parse(Box::new(source_location), error.to_string()))
604 }
605 };
606 state.emit_warnings();
607 result
608 })
609}
610
611#[instrument(skip_all, fields(input_len = input.len(), safe_mode = ?options.safe_mode))]
632pub fn parse_inline(input: &str, options: &Options<'_>) -> Result<ParseInlineResult, Error> {
633 tracing::trace!(input_len = input.len(), "post preprocessor");
634 let owner = parsed::OwnedInput::new(input.into());
635 let options_owned = options
636 .clone()
637 .prepare_for_parse(document_attribute::InputKind::String)
638 .into_static();
639 let warnings_handle: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));
640 let warnings_for_state = Rc::clone(&warnings_handle);
641
642 ParseInlineResult::try_new(owner, warnings_handle, move |owner| {
643 let mut state = grammar::ParserState::new(&owner.source, &owner.arena);
644 state.scope = grammar::ParserScope::Inline;
645 state.document_attributes = Rc::new(options_owned.document_attributes.clone());
646 state.options = Rc::new(options_owned);
647 state.initialize_hardbreaks();
648 state
649 .inline_ctx
650 .rules
651 .set(grammar::InlineRules::HARD_BREAKS, state.hardbreaks);
652 state.warnings = warnings_for_state;
653 let result = match grammar::inline_parser::inlines(&owner.source, &mut state) {
654 Ok(mut inlines) => {
655 grammar::remap_inlines_to_source(
656 &mut inlines,
657 &state.source_ranges,
658 &owner.source,
659 &state.line_map,
660 );
661 state.scope = grammar::ParserScope::Document;
664 for inline in &mut inlines {
665 grammar::walk_inline_nodes_mut(inline, &mut |node| {
666 if let InlineNode::Macro(InlineMacro::Footnote(note)) = node {
667 state.finalize_footnote(note);
668 }
669 });
670 }
671 Ok(inlines)
672 }
673 Err(error) => {
674 tracing::error!("error parsing inline content");
675 Err(Error::Parse(
676 Box::new(peg_error_to_source_location(&error, &state)),
677 error.to_string(),
678 ))
679 }
680 };
681 state.emit_warnings();
682 result
683 })
684}
685
686#[cfg(test)]
687mod proptests;
688
689#[cfg(test)]
690#[allow(clippy::unwrap_used)]
691#[allow(clippy::panic)]
692#[allow(clippy::expect_used)]
693mod tests {
694 use std::{error::Error as StdError, fs, path::PathBuf};
695
696 use pretty_assertions::assert_eq;
697
698 use super::*;
699
700 fn read_file_contents_with_extension(path: &PathBuf, ext: &str) -> Result<String, Error> {
701 let test_file_path = path.with_extension(ext);
702 let file_contents = fs::read_to_string(&test_file_path).inspect_err(
703 |e| tracing::warn!(?path, ?test_file_path, error = %e, "test file not found"),
704 )?;
705 Ok(file_contents)
706 }
707
708 #[test]
709 fn table_comments_leave_hidden_footnotes_and_targets_unregistered() -> Result<(), Error> {
710 let parsed = parse_file(
711 "fixtures/tests/table_comment_directives.adoc",
712 &Options {
713 safe_mode: crate::SafeMode::Unsafe,
714 ..Options::default()
715 },
716 )?;
717 assert_eq!(parsed.document().footnotes, []);
718 assert!(!parsed.document().references.contains_key("hidden-table"));
719 assert!(parsed.warnings().is_empty(), "{:?}", parsed.warnings());
720 Ok(())
721 }
722
723 #[test]
724 fn header_comments_do_not_assign_attributes_or_register_macros() -> Result<(), Error> {
725 let parsed = parse_file(
726 "fixtures/tests/header_comment_attributes.adoc",
727 &Options::default(),
728 )?;
729 let doc = parsed.document();
730 assert_eq!(doc.attributes.text("kept"), Some("original"));
731 assert_eq!(doc.attributes.text("author"), Some("Correct Author"));
732 assert_eq!(doc.attributes.text("revnumber"), Some("1.0"));
733 assert_eq!(doc.attributes.text("active-header"), Some("active"));
734 for name in [
735 "source-highlighter",
736 "hidden-before",
737 "hidden-author",
738 "hidden-revision",
739 "hidden-header",
740 "hidden-body",
741 ] {
742 assert!(
743 !doc.attributes.contains_key(name),
744 "comment assigned {name}"
745 );
746 }
747 assert_eq!(doc.footnotes, []);
748 assert!(!doc.references.contains_key("hidden"));
749 assert!(parsed.warnings().is_empty(), "{:?}", parsed.warnings());
750 Ok(())
751 }
752
753 #[test]
754 fn header_comment_unsets_preserve_caller_values() -> Result<(), Error> {
755 let input = "= T\n////\n:kept!:\n:tabsize: invalid\n////\n\n{kept}\n";
756 for builder in [
757 Options::builder().with_attribute("kept", "caller"),
758 Options::builder().with_default_attribute("kept", "caller"),
759 ] {
760 let options = builder.build()?;
761 for parsed in [
762 parse(input, &options)?,
763 parse_from_reader(input.as_bytes(), &options)?,
764 ] {
765 assert_eq!(parsed.document().attributes.text("kept"), Some("caller"));
766 assert!(parsed.warnings().is_empty(), "{:?}", parsed.warnings());
767 }
768 }
769 Ok(())
770 }
771
772 #[test]
773 fn header_comment_does_not_pull_body_attributes_into_the_header() -> Result<(), Error> {
774 let parsed = parse_file(
775 "fixtures/tests/header_comment_boundaries.adoc",
776 &Options::default(),
777 )?;
778 let doc = parsed.document();
779 assert_eq!(doc.attributes.text("kept"), Some("header"));
780 assert!(!doc.attributes.contains_key("body"));
781 assert!(!doc.attributes.contains_key("source-highlighter"));
782 let event = doc
783 .blocks
784 .iter()
785 .find_map(|block| {
786 if let Block::DocumentAttribute(attribute) = block {
787 Some(attribute)
788 } else {
789 None
790 }
791 })
792 .expect("body assignment");
793 assert_eq!(event.name, "body");
794 let source = include_str!("../fixtures/tests/header_comment_boundaries.adoc");
795 let line = u32::try_from(
796 source
797 .lines()
798 .position(|line| line == ":body: body-value")
799 .expect("body entry")
800 + 1,
801 )
802 .expect("source line fits u32");
803 assert_eq!(event.location.start.line, line);
804 assert_eq!(event.location.end.line, line);
805 assert_eq!(
806 &source[event.location.absolute_start..=event.location.absolute_end],
807 ":body: body-value"
808 );
809 Ok(())
810 }
811
812 #[test]
813 fn header_comment_unterminated_reports_one_warning_without_effects() -> Result<(), Error> {
814 let parsed = parse_file(
815 "fixtures/tests/header_comment_unterminated.adoc",
816 &Options::default(),
817 )?;
818 assert_eq!(parsed.document().attributes.text("kept"), Some("original"));
819 assert_eq!(parsed.warnings().len(), 1, "{:?}", parsed.warnings());
820 let warning = parsed
821 .warnings()
822 .first()
823 .expect("unterminated comment warning");
824 assert!(matches!(
825 &warning.kind,
826 WarningKind::UnterminatedDelimitedBlock { kind, delimiter }
827 if *kind == "comment" && delimiter == "////"
828 ));
829 Ok(())
830 }
831
832 #[test]
833 fn header_comment_empty_blocks_keep_header_and_body_boundaries() -> Result<(), Error> {
834 for input in [
835 "////\n////\n\n= T\n:kept: value\n\nBody.\n",
836 "= T\n////\n////\nCorrect Author\n:kept: value\n\nBody.\n",
837 "= T\n:kept: value\n\n////\n////\n\nBody.\n",
838 ] {
839 let parsed = parse(input, &Options::default())?;
840 assert!(parsed.document().header.is_some(), "{input}");
841 assert_eq!(parsed.document().attributes.text("kept"), Some("value"));
842 assert!(
843 parsed.warnings().is_empty(),
844 "{input}: {:?}",
845 parsed.warnings()
846 );
847 assert!(parsed.document().blocks.iter().any(|block| matches!(
848 block, Block::Paragraph(paragraph) if paragraph.content.iter().any(|inline| matches!(inline,
849 InlineNode::PlainText(text) if text.content == "Body."))
850 )));
851 }
852 Ok(())
853 }
854
855 #[test]
856 fn document_attribute_locations_exclude_newlines_and_keep_unicode() -> Result<(), Error> {
857 for body in [
858 ":value: cafΓ©\n\nBody.",
859 "[.role]\n:value: cafΓ©\nBody.",
860 ":value: cafΓ©",
861 ] {
862 let source = format!("= T\n\n{body}");
863 let parsed = parse(&source, &Options::default())?;
864 let attribute = parsed
865 .document()
866 .blocks
867 .iter()
868 .find_map(|block| {
869 if let Block::DocumentAttribute(attribute) = block {
870 Some(attribute)
871 } else {
872 None
873 }
874 })
875 .expect("body attribute event");
876 let location = &attribute.location;
877 let last = source[location.absolute_end..]
878 .chars()
879 .next()
880 .expect("final declaration character");
881 let end = location.absolute_end + last.len_utf8();
882 assert_eq!(&source[location.absolute_start..end], ":value: cafΓ©");
883 assert_eq!(last, 'Γ©');
884 assert_eq!(location.start.line, location.end.line);
885 }
886 Ok(())
887 }
888
889 #[test]
890 fn caller_hardbreak_attributes_create_line_break_nodes() -> Result<(), Box<dyn StdError>> {
891 for name in ["hardbreaks", "hardbreaks-option"] {
892 let options = Options::builder().with_attribute(name, "false").build()?;
893 let parsed = parse("First line\nSecond line\n", &options)
894 .expect("parse paragraph with caller hard breaks");
895 let paragraph = parsed
896 .document()
897 .blocks
898 .first()
899 .and_then(|block| {
900 if let Block::Paragraph(paragraph) = block {
901 Some(paragraph)
902 } else {
903 None
904 }
905 })
906 .expect("paragraph");
907
908 assert!(matches!(
909 paragraph.content.as_slice(),
910 [
911 InlineNode::PlainText(_),
912 InlineNode::LineBreak(_),
913 InlineNode::PlainText(_)
914 ]
915 ));
916
917 let parsed_inline = parse_inline("First line\nSecond line", &options)
918 .expect("parse inline content with caller hard breaks");
919 assert!(matches!(
920 parsed_inline.inlines(),
921 [
922 InlineNode::PlainText(_),
923 InlineNode::LineBreak(_),
924 InlineNode::PlainText(_)
925 ]
926 ));
927 }
928 Ok(())
929 }
930
931 #[test]
932 fn parse_inline_accepts_pass_macros_without_substitution_names() {
933 let parsed = parse_inline("pass:[raw] pass:[]", &Options::default())
934 .expect("parse pass macros without substitution names");
935 let [
936 InlineNode::Macro(InlineMacro::Pass(with_content)),
937 InlineNode::PlainText(separator),
938 InlineNode::Macro(InlineMacro::Pass(empty)),
939 ] = parsed.inlines()
940 else {
941 panic!("expected two pass macros, got {:?}", parsed.inlines());
942 };
943
944 assert_eq!(with_content.text, Some("raw"));
945 assert_eq!(with_content.substitutions, []);
946 assert_eq!(separator.content, " ");
947 assert_eq!(empty.text, Some(""));
948 assert_eq!(empty.substitutions, []);
949 }
950
951 #[test]
952 fn indent_include_remaps_columns_to_origin() -> Result<(), Box<dyn StdError>> {
953 let opts = Options::builder()
959 .with_safe_mode(SafeMode::Unsafe)
960 .build()?;
961 let result = parse_file("fixtures/preprocessor/include_indent_main.adoc", &opts)
962 .expect("parse indented include");
963 let doc = result.document();
964 let inlines = doc
965 .blocks
966 .iter()
967 .find_map(|b| {
968 if let Block::DelimitedBlock(d) = b
969 && let DelimitedBlockType::DelimitedListing(inlines) = &d.inner
970 {
971 Some(inlines)
972 } else {
973 None
974 }
975 })
976 .expect("listing block from the indented include");
977 let loc = inlines.first().expect("listing content inline").location();
978
979 assert_eq!(loc.start.line, 1, "origin line");
980 assert_eq!(
981 loc.start.column, 1,
982 "origin column (the 6-space indent stripped off)"
983 );
984 assert_eq!(loc.end.line, 1);
985 assert_eq!(
986 loc.end.column, 10,
987 "`TARGETLINE` is 10 columns wide in the origin"
988 );
989 assert_eq!(
990 loc.start
991 .file
992 .as_deref()
993 .and_then(|chain| chain.last())
994 .map(String::as_str),
995 Some("include_indent_target.rb"),
996 );
997 assert!(loc.absolute_start <= loc.absolute_end);
998 Ok(())
999 }
1000
1001 #[test]
1002 fn quote_attribution_and_citetitle_remap_to_included_file() -> Result<(), Box<dyn StdError>> {
1003 fn file_name(loc: &Location) -> Option<&str> {
1004 loc.start
1005 .file
1006 .as_deref()
1007 .and_then(|chain| chain.last())
1008 .map(String::as_str)
1009 }
1010
1011 let opts = Options::builder()
1016 .with_safe_mode(SafeMode::Unsafe)
1017 .build()?;
1018 let result = parse_file("fixtures/preprocessor/include_quote_main.adoc", &opts)
1019 .expect("parse included quote block");
1020 let doc = result.document();
1021 let metadata = doc
1022 .blocks
1023 .iter()
1024 .find_map(|b| {
1025 if let Block::DelimitedBlock(d) = b
1026 && d.metadata.attribution.is_some()
1027 {
1028 Some(&d.metadata)
1029 } else {
1030 None
1031 }
1032 })
1033 .expect("quote block with an attribution");
1034
1035 let part = Some("include_quote_part.adoc");
1036
1037 let attribution = metadata
1038 .attribution
1039 .as_ref()
1040 .and_then(|a| a.first())
1041 .expect("attribution inline")
1042 .location();
1043 assert_eq!(attribution.start.line, 3, "attribution origin line");
1044 assert_eq!(file_name(attribution), part, "attribution origin file");
1045
1046 let citetitle = metadata
1047 .citetitle
1048 .as_ref()
1049 .and_then(|c| c.first())
1050 .expect("citetitle inline")
1051 .location();
1052 assert_eq!(citetitle.start.line, 3, "citetitle origin line");
1053 assert_eq!(file_name(citetitle), part, "citetitle origin file");
1054 assert_eq!(
1055 metadata.positional_attributes().collect::<Vec<_>>(),
1056 vec![Some("Brian Quote"), Some("The Source Book")]
1057 );
1058 for (slot, expected_location) in metadata
1059 .raw_positional_attributes()
1060 .iter()
1061 .zip([attribution, citetitle])
1062 {
1063 assert_eq!(slot.location.as_ref(), Some(expected_location));
1064 }
1065 Ok(())
1066 }
1067
1068 #[test]
1069 fn inline_preprocessor_warning_reports_included_file_line() -> Result<(), Box<dyn StdError>> {
1070 let opts = Options::builder()
1075 .with_safe_mode(SafeMode::Unsafe)
1076 .build()?;
1077 let result = parse_file("fixtures/preprocessor/include_counter_main.adoc", &opts)
1078 .expect("parse included counter");
1079 let warning = result
1080 .warnings()
1081 .iter()
1082 .find(|w| w.kind.to_string().contains("Counters"))
1083 .expect("counter warning");
1084 let loc = warning
1085 .source_location()
1086 .expect("counter warning carries a location");
1087 assert_eq!(
1088 loc.file
1089 .as_deref()
1090 .and_then(|p| p.file_name())
1091 .and_then(std::ffi::OsStr::to_str),
1092 Some("include_counter_part.adoc"),
1093 "warning should name the included file",
1094 );
1095 assert_eq!(
1096 loc.location.start.line, 3,
1097 "warning origin line in the included file"
1098 );
1099 Ok(())
1100 }
1101
1102 #[test]
1103 fn toc_entries_and_references_remap_to_included_file() -> Result<(), Box<dyn StdError>> {
1104 fn file_name(loc: &Location) -> Option<&str> {
1109 loc.start
1110 .file
1111 .as_deref()
1112 .and_then(|chain| chain.last())
1113 .map(String::as_str)
1114 }
1115
1116 let opts = Options::builder()
1117 .with_safe_mode(SafeMode::Unsafe)
1118 .build()?;
1119 let result = parse_file("fixtures/preprocessor/include_refs_main.adoc", &opts)
1120 .expect("parse included section");
1121 let doc = result.document();
1122 let part = Some("include_refs_part.adoc");
1123
1124 let entry = doc
1125 .toc_entries
1126 .iter()
1127 .find(|e| e.id == "_included_section")
1128 .expect("toc entry for the included section");
1129 assert_eq!(entry.location.start.line, 1, "toc entry origin line");
1130 assert_eq!(file_name(&entry.location), part, "toc entry origin file");
1131
1132 let reference = doc
1133 .references
1134 .get("_included_section")
1135 .expect("reference for the included section");
1136 assert_eq!(reference.location.start.line, 1, "reference origin line");
1137 assert_eq!(
1138 file_name(&reference.location),
1139 part,
1140 "reference origin file"
1141 );
1142 Ok(())
1143 }
1144
1145 #[test]
1146 fn footnote_location_is_document_absolute_without_include() {
1147 let input = "= Title\n\nPara one.\n\nPara two with a note.footnote:[The note.]\n";
1151 let result = parse(input, &Options::default()).expect("parse footnote doc");
1152 let doc = result.document();
1153 let footnote = doc.footnotes.first().expect("one footnote");
1154 assert_eq!(
1155 footnote.location.start.line, 5,
1156 "footnote's real document line"
1157 );
1158 assert!(
1159 footnote.location.start.file.is_none(),
1160 "primary-input footnote carries no file",
1161 );
1162 }
1163
1164 #[test]
1165 fn footnote_location_remaps_to_included_file() -> Result<(), Box<dyn StdError>> {
1166 let opts = Options::builder()
1169 .with_safe_mode(SafeMode::Unsafe)
1170 .build()?;
1171 let result = parse_file("fixtures/preprocessor/include_footnote_main.adoc", &opts)
1172 .expect("parse included footnote");
1173 let doc = result.document();
1174 let footnote = doc.footnotes.first().expect("one footnote");
1175 assert_eq!(footnote.location.start.line, 3, "footnote origin line");
1176 assert_eq!(
1177 footnote
1178 .location
1179 .start
1180 .file
1181 .as_deref()
1182 .and_then(|chain| chain.last())
1183 .map(String::as_str),
1184 Some("include_footnote_part.adoc"),
1185 );
1186 Ok(())
1187 }
1188
1189 #[test]
1190 fn named_footnote_keeps_defining_occurrence() {
1191 let input =
1195 "= Title\n\nFirst ref.footnote:fn[The definition.]\n\nSecond ref.footnote:fn[]\n";
1196 let result = parse(input, &Options::default()).expect("parse named footnote doc");
1197 let doc = result.document();
1198 assert_eq!(doc.footnotes.len(), 1, "one distinct footnote");
1199 let footnote = doc.footnotes.first().expect("the distinct footnote");
1200 assert_eq!(footnote.location.start.line, 3, "defining occurrence line");
1201 assert!(
1202 !footnote.content.is_empty(),
1203 "keeps the defining occurrence's content, not the empty reference",
1204 );
1205 }
1206
1207 #[test]
1208 fn document_root_follows_per_boundary_file_model() -> Result<(), Box<dyn StdError>> {
1209 let opts = Options::builder()
1215 .with_safe_mode(SafeMode::Unsafe)
1216 .build()?;
1217 let result =
1218 parse_file("fixtures/include_chain/main.adoc", &opts).expect("parse include chain");
1219 let location = &result.document().location;
1220
1221 assert!(
1222 location.start.file.is_none(),
1223 "document start is primary input (no file)",
1224 );
1225 let expected = vec!["outer.adoc".to_string(), "inner.adoc".to_string()];
1226 assert_eq!(
1227 location.end.file.as_deref(),
1228 Some(&expected),
1229 "document end carries the include chain it ends in",
1230 );
1231 Ok(())
1232 }
1233
1234 #[rstest::rstest]
1235 #[case("fixtures/tests/positional_block_attributes.adoc", &[
1236 &[None, Some("svg")][..],
1237 &[None, Some("svg")],
1238 &[None, Some("svg")],
1239 &[None, None],
1240 &[],
1241 &[Some("rust"), Some("linenums"), Some("extra")],
1242 &[Some("*Alice*"), Some("Book, One")],
1243 &[Some("*Alice*"), Some("Book, One")],
1244 &[Some("asciimath"), Some("extra")],
1245 &[Some("new"), Some("svg")],
1246 &[None, Some("pdf")],
1247 &[Some("old"), Some("svg")],
1248 &[None, Some("svg")],
1249 &[Some("*Alice*"), Some("svg")],
1250 ])]
1251 #[case("fixtures/tests/positional_attribute_routing.adoc", &[
1252 &[Some("Alice"), Some("Book")][..],
1253 &[Some("Alice"), Some("Book"), Some("linenums")],
1254 &[Some("Alice"), Some("Book"), Some("extra")],
1255 &[Some("100"), Some("200"), Some("extra")],
1256 &[Some("Alice"), Some("Book"), Some("extra")],
1257 &[Some("100"), Some("200"), Some("extra")],
1258 &[Some("Alice"), Some("Book"), Some("extra")],
1259 &[Some("rust"), Some("Book"), Some("extra")],
1260 ])]
1261 #[case("fixtures/tests/image_dimensions.adoc", &[
1262 &[Some("Alice"), Some("Book")][..],
1263 &[Some("100"), Some("200")],
1264 &[Some("100"), Some("200")],
1265 &[Some("300"), Some("400")],
1266 &[None, Some("400")],
1267 &[Some("300"), Some("400")],
1268 &[],
1269 &[Some("300"), Some("400")],
1270 ])]
1271 #[case("fixtures/tests/video_dimensions.adoc", &[
1272 &[Some("Alice"), Some("Book")][..],
1273 &[Some("100"), Some("200")],
1274 &[Some("100"), Some("200")],
1275 &[Some("300"), Some("400")],
1276 &[None, Some("400")],
1277 &[Some("300"), Some("400")],
1278 &[],
1279 &[Some("300"), Some("400")],
1280 ])]
1281 fn test_retained_positional_attributes(
1282 #[case] path: &str,
1283 #[case] expected: &[&[Option<&str>]],
1284 ) -> Result<(), Box<dyn StdError>> {
1285 let parsed = parse_file(path, &Options::default())?;
1286 let metadata: Vec<_> = parsed
1287 .document()
1288 .blocks
1289 .iter()
1290 .filter_map(Block::metadata)
1291 .collect();
1292 assert_eq!(metadata.len(), expected.len());
1293 for (index, (metadata, positions)) in metadata.iter().zip(expected).enumerate() {
1294 assert_eq!(
1295 metadata.positional_attributes().collect::<Vec<_>>(),
1296 *positions,
1297 "block {index} in {path}",
1298 );
1299 assert_eq!(metadata.positional_attributes().len(), positions.len());
1300 for (slot, expected) in positions.iter().enumerate() {
1301 assert_eq!(metadata.positional_attribute(slot), *expected);
1302 }
1303 assert_eq!(metadata.positional_attribute(positions.len()), None);
1304
1305 let mut working = (*metadata).clone();
1307 working.positional_attributes = metadata.raw_positional_attributes().to_vec();
1308 working.retained_positional_attributes = None;
1309 assert_eq!(*metadata, &working);
1310
1311 let mut retained = working.clone();
1312 for slot in &mut retained.positional_attributes {
1313 slot.location = None;
1314 slot.substitutions = !slot.substitutions;
1315 }
1316 retained.retained_positional_attributes =
1317 Some(std::mem::take(&mut retained.positional_attributes));
1318 assert_eq!(*metadata, &retained);
1319
1320 let mut changed = retained.clone();
1321 if let Some(slot) = changed
1322 .retained_positional_attributes
1323 .as_mut()
1324 .and_then(|slots| slots.first_mut())
1325 {
1326 slot.value = "changed positional value";
1327 assert_ne!(*metadata, &changed, "positional values affect equality");
1328 }
1329 if let Some(slots) = &mut retained.retained_positional_attributes {
1330 slots.push(model::PositionalAttribute::default());
1331 }
1332 assert_ne!(*metadata, &retained, "an extra empty slot affects equality");
1333
1334 let mut edited = (*metadata).clone().with_style(Some("source"));
1335 edited
1336 .attributes
1337 .set("language".into(), AttributeValue::String("python".into()));
1338 assert_eq!(
1339 edited.positional_attributes().collect::<Vec<_>>(),
1340 *positions
1341 );
1342 assert_ne!(*metadata, &edited, "named attributes still affect equality");
1343 }
1344 let empty = BlockMetadata {
1345 retained_positional_attributes: Some(Vec::new()),
1346 ..BlockMetadata::default()
1347 };
1348 assert_eq!(empty, BlockMetadata::default());
1349 assert_eq!(BlockMetadata::new().positional_attributes().len(), 0);
1350 Ok(())
1351 }
1352
1353 #[rstest::rstest]
1354 #[tracing_test::traced_test]
1355 fn test_with_fixtures(
1356 #[files("fixtures/tests/**/*.adoc")]
1357 #[exclude(r"[/\\]includes[/\\]")]
1358 path: PathBuf,
1359 ) -> Result<(), Error> {
1360 let stem = path
1361 .file_stem()
1362 .and_then(|stem| stem.to_str())
1363 .unwrap_or("");
1364
1365 #[cfg(not(feature = "pre-spec-subs"))]
1366 if stem.starts_with("subs_") {
1367 return Ok(());
1368 }
1369
1370 let setext_fixture = stem.contains("with_setext");
1376 #[cfg(not(feature = "setext"))]
1377 if setext_fixture {
1378 return Ok(());
1379 }
1380
1381 let builder = Options::builder().with_safe_mode(SafeMode::Unsafe);
1382 #[cfg(feature = "setext")]
1383 let builder = if setext_fixture {
1384 builder.with_setext()
1385 } else {
1386 builder
1387 };
1388 let options = builder.build()?;
1389
1390 match parse_file(&path, &options) {
1391 Ok(result) => {
1392 let expected = read_file_contents_with_extension(&path, "json")?;
1393 let actual = serde_json::to_string_pretty(result.document())
1394 .expect("could not serialize result");
1395 assert_eq!(expected, actual);
1396 }
1397 Err(e) => {
1398 let file_contents = read_file_contents_with_extension(&path, "error")?;
1399 let expected = file_contents.trim();
1401 assert_eq!(expected, e.to_string());
1402 }
1403 }
1404 Ok(())
1405 }
1406
1407 #[test]
1408 fn quoted_xref_labels_preserve_single_quote_byte_ranges() -> Result<(), Box<dyn StdError>> {
1409 for (source, expected) in [
1411 (r#"xref:target["\"",role=hot]"#, r#"\""#),
1412 (r"xref:target['\'',role=hot]", r"\'"),
1413 ] {
1414 let parsed = parse(source, &Options::default())?;
1415 let Some(Block::Paragraph(paragraph)) = parsed.document().blocks.first() else {
1416 panic!("expected a paragraph");
1417 };
1418 let Some(InlineNode::Macro(InlineMacro::CrossReference(xref))) =
1419 paragraph.content.first()
1420 else {
1421 panic!("expected an xref");
1422 };
1423 let location = xref.text.first().ok_or("expected xref text")?.location();
1424 assert_eq!(
1425 source.get(location.absolute_start..=location.absolute_end),
1426 Some(expected)
1427 );
1428 }
1429 Ok(())
1430 }
1431
1432 #[test]
1433 fn node_locations_are_source_relative_after_dropped_comment() -> Result<(), Box<dyn StdError>> {
1434 use crate::Block;
1435 let input =
1439 "= Doc\n\nfirst para\n// dropped comment\nsecond para\n\n== Section\n\nbody text\n";
1440 let result = parse(input, &Options::builder().build()?).expect("parse");
1441 let section = result
1442 .document()
1443 .blocks
1444 .iter()
1445 .find_map(|b| {
1446 if let Block::Section(s) = b {
1447 Some(s)
1448 } else {
1449 None
1450 }
1451 })
1452 .expect("section present");
1453 assert_eq!(
1454 section.location.start.line, 7,
1455 "section title at source line 7"
1456 );
1457 let body = section
1458 .content
1459 .iter()
1460 .find_map(|b| {
1461 if let Block::Paragraph(p) = b {
1462 Some(p)
1463 } else {
1464 None
1465 }
1466 })
1467 .expect("section body paragraph");
1468 assert_eq!(
1469 body.location.start.line, 9,
1470 "body paragraph at source line 9"
1471 );
1472 Ok(())
1473 }
1474
1475 #[test]
1476 fn node_locations_carry_origin_file_across_include() -> Result<(), Box<dyn StdError>> {
1477 use crate::{Block, SafeMode};
1478 let path = PathBuf::from("fixtures/tests/leveloffset_include.adoc");
1479 let options = Options::builder()
1480 .with_safe_mode(SafeMode::Unsafe)
1481 .build()?;
1482 let result = parse_file(&path, &options).expect("parse");
1483 let section = result
1485 .document()
1486 .blocks
1487 .iter()
1488 .find_map(|b| {
1489 if let Block::Section(s) = b {
1490 Some(s)
1491 } else {
1492 None
1493 }
1494 })
1495 .expect("included section present");
1496 let chain = section.location.start.file.as_deref().map(Vec::as_slice);
1498 assert_eq!(
1499 chain,
1500 Some(["leveloffset_included.adoc".to_string()].as_slice())
1501 );
1502 assert_eq!(
1503 section.location.start.line, 1,
1504 "included section at its own line 1"
1505 );
1506 Ok(())
1507 }
1508
1509 #[test]
1510 fn node_locations_carry_full_include_chain() -> Result<(), Box<dyn StdError>> {
1511 use crate::{Block, SafeMode};
1512 let path = PathBuf::from("fixtures/include_chain/main.adoc");
1516 let options = Options::builder()
1517 .with_safe_mode(SafeMode::Unsafe)
1518 .build()?;
1519 let result = parse_file(&path, &options).expect("parse");
1520
1521 let paragraphs = result
1522 .document()
1523 .blocks
1524 .iter()
1525 .filter_map(|block| {
1526 if let Block::Paragraph(para) = block {
1527 Some(para)
1528 } else {
1529 None
1530 }
1531 })
1532 .collect::<Vec<_>>();
1533 let [main, outer, inner] = paragraphs.as_slice() else {
1534 panic!("expected main, outer, and inner paragraphs");
1535 };
1536 assert!(
1537 matches!(&main.content[..], [InlineNode::PlainText(text)] if text.content == "Main paragraph.")
1538 );
1539 assert!(
1540 matches!(&outer.content[..], [InlineNode::PlainText(text)] if text.content == "Outer paragraph.")
1541 );
1542 assert!(
1543 matches!(&inner.content[..], [InlineNode::PlainText(text)] if text.content == "Inner paragraph.")
1544 );
1545 assert!(main.location.start.file.is_none());
1547 assert_eq!(
1548 outer
1549 .location
1550 .start
1551 .file
1552 .as_ref()
1553 .map(|chain| chain.as_slice()),
1554 Some(["outer.adoc".to_string()].as_slice())
1555 );
1556 assert_eq!(
1557 inner
1558 .location
1559 .start
1560 .file
1561 .as_ref()
1562 .map(|chain| chain.as_slice()),
1563 Some(["outer.adoc".to_string(), "inner.adoc".to_string()].as_slice())
1564 );
1565 Ok(())
1566 }
1567
1568 #[cfg(test)]
1569 mod empty_document_tests {
1570 use crate::{Options, parse};
1571
1572 #[test]
1573 fn test_whitespace_only_documents() {
1574 let test_cases = vec![
1575 "\n", "\n\n", "\t", " \n\t\n ", " ",
1576 "\n\n\t",
1577 ];
1578
1579 for input in test_cases {
1580 let options = Options::default();
1581 let result = parse(input, &options);
1582
1583 match result {
1584 Ok(parsed) => {
1585 let doc = parsed.document();
1586 assert!(
1588 doc.location.absolute_start <= doc.location.absolute_end,
1589 "Failed for input {input:?}: absolute_start {} > absolute_end {}",
1590 doc.location.absolute_start,
1591 doc.location.absolute_end
1592 );
1593
1594 doc.location.validate(input).unwrap_or_else(|e| {
1596 panic!("Location validation failed for {input:?}: {e}")
1597 });
1598 }
1599 Err(e) => {
1600 panic!("Failed to parse {input:?}: {e}");
1601 }
1602 }
1603 }
1604 }
1605
1606 #[test]
1607 fn test_document_with_content_after_whitespace() {
1608 let test_cases = vec!["\n\nHello", "\t\tWorld", " \n = Title"];
1609
1610 for input in test_cases {
1611 let options = Options::default();
1612 let parsed =
1613 parse(input, &options).unwrap_or_else(|_| panic!("Should parse {input:?}"));
1614 let doc = parsed.document();
1615
1616 assert!(
1617 doc.location.absolute_start <= doc.location.absolute_end,
1618 "Failed for input {input:?}: absolute_start {} > absolute_end {}",
1619 doc.location.absolute_start,
1620 doc.location.absolute_end
1621 );
1622
1623 doc.location
1625 .validate(input)
1626 .unwrap_or_else(|e| panic!("Location validation failed for {input:?}: {e}"));
1627 }
1628 }
1629
1630 #[test]
1631 fn test_unicode_characters() {
1632 let test_cases = vec![
1634 "π", "Χ", "Hello δΈη", "\u{200b}", ];
1639
1640 for input in test_cases {
1641 let options = Options::default();
1642 let result = parse(input, &options);
1643
1644 match result {
1645 Ok(parsed) => {
1646 let doc = parsed.document();
1647 assert!(
1649 input.is_char_boundary(doc.location.absolute_start),
1650 "Absolute start {} not on UTF-8 boundary for {input:?}",
1651 doc.location.absolute_start,
1652 );
1653 assert!(
1654 input.is_char_boundary(doc.location.absolute_end),
1655 "Absolute end {} not on UTF-8 boundary for {input:?}",
1656 doc.location.absolute_end,
1657 );
1658
1659 doc.location.validate(input).unwrap_or_else(|e| {
1661 panic!("Location validation failed for {input:?}: {e}");
1662 });
1663 }
1664 Err(e) => {
1665 println!("Failed to parse {input:?}: {e} (this might be expected)");
1668 }
1669 }
1670 }
1671 }
1672 }
1673
1674 mod warning_deduplication_tests {
1681 use crate::{Options, parse};
1682
1683 #[test]
1684 #[tracing_test::traced_test]
1685 fn counter_reference_peg_backtracking_does_not_duplicate() {
1686 let input = "= Title\n\n{counter:hits} then {counter:hits} again";
1690 let options = Options::default();
1691 let result = parse(input, &options).expect("should parse");
1692 let counter_warnings = result
1693 .warnings()
1694 .iter()
1695 .filter(|w| {
1696 w.kind
1697 .to_string()
1698 .contains("not supported and will be removed")
1699 })
1700 .count();
1701 assert_eq!(
1702 counter_warnings,
1703 2,
1704 "expected 2 counter warnings (one per position), got {counter_warnings}: {:?}",
1705 result.warnings(),
1706 );
1707 assert!(
1709 result
1710 .warnings()
1711 .iter()
1712 .all(|w| w.source_location().is_some()),
1713 "counter warnings must carry locations",
1714 );
1715 }
1716
1717 #[test]
1718 #[tracing_test::traced_test]
1719 fn distinct_warnings_all_emitted() {
1720 let input = "= Title\n\n{counter:a} and {counter2:b}";
1722 let options = Options::default();
1723 let parsed = parse(input, &options).expect("should parse");
1724 for name in ["counter:a", "counter2:b"] {
1725 assert_eq!(
1726 parsed
1727 .warnings()
1728 .iter()
1729 .filter(|warning| warning.to_string().contains(name))
1730 .count(),
1731 1
1732 );
1733 assert!(!logs_contain(name));
1734 }
1735 assert!(logs_contain("parser warning; see returned diagnostics"));
1736 logs_assert(|lines| {
1737 let count = lines
1738 .iter()
1739 .filter(|line| line.contains("parser warning; see returned diagnostics"))
1740 .count();
1741 if count == 2 {
1742 Ok(())
1743 } else {
1744 Err(format!("expected two warning events, got {count}"))
1745 }
1746 });
1747 }
1748 }
1749
1750 mod parse_result_tests {
1751 use crate::{Options, WarningKind, parse, parse_file};
1752
1753 #[test]
1759 fn missing_include_warning_surfaces_on_parse_result() {
1760 use std::io::Write;
1761 let tmp = std::env::temp_dir().join("acdc_test_missing_include.adoc");
1763 let mut f = std::fs::File::create(&tmp).expect("create tmp");
1764 writeln!(
1765 f,
1766 "= Doc Title\n\ninclude::definitely-missing-{}.adoc[]\n",
1767 std::process::id()
1768 )
1769 .expect("write tmp");
1770 drop(f);
1771
1772 let options = Options {
1773 safe_mode: crate::SafeMode::Unsafe,
1774 ..Options::default()
1775 };
1776 let result = parse_file(&tmp, &options).expect("should parse");
1777 let _ = std::fs::remove_file(&tmp);
1778
1779 let has_missing_include = result
1780 .warnings()
1781 .iter()
1782 .any(|w| w.kind.to_string().starts_with("include file not found:"));
1783 assert!(
1784 has_missing_include,
1785 "expected missing-include warning, got: {:?}",
1786 result.warnings(),
1787 );
1788 }
1789
1790 #[test]
1794 fn section_level_out_of_sequence_surfaces_on_parse_result() {
1795 let input = "= Doc Title\n\n=== Starts at level 2\n\nContent\n";
1796 let options = Options::default();
1797 let result = parse(input, &options).expect("document should parse");
1798
1799 assert_eq!(
1800 result.warnings().len(),
1801 1,
1802 "expected exactly one warning, got: {:?}",
1803 result.warnings(),
1804 );
1805 let warning = result.warnings().first().expect("asserted non-empty");
1806 assert!(
1807 matches!(
1808 &warning.kind,
1809 WarningKind::SectionLevelOutOfSequence { got: 2, .. },
1810 ),
1811 "unexpected warning kind: {:?}",
1812 warning.kind,
1813 );
1814 assert!(
1815 warning.source_location().is_some(),
1816 "warning should carry a source location",
1817 );
1818 }
1819
1820 #[test]
1823 fn valid_document_has_no_warnings() {
1824 let input = "= Doc Title\n\n== First\n\nContent\n";
1825 let options = Options::default();
1826 let result = parse(input, &options).expect("document should parse");
1827 assert!(
1828 result.warnings().is_empty(),
1829 "expected no warnings, got: {:?}",
1830 result.warnings(),
1831 );
1832 }
1833
1834 #[test]
1837 fn subs_attribute_always_surfaces_a_warning() {
1838 let input = "[subs=\"-quotes\"]\nContent\n";
1839 let options = Options::default();
1840 let result = parse(input, &options).expect("document should parse");
1841
1842 let messages: Vec<String> = result
1843 .warnings()
1844 .iter()
1845 .map(|w| w.kind.to_string())
1846 .collect();
1847 assert!(
1848 messages.iter().any(|m| m.contains("subs=")),
1849 "expected a `subs=` warning, got: {messages:?}",
1850 );
1851 #[cfg(feature = "pre-spec-subs")]
1852 assert!(
1853 messages.iter().any(|m| m.contains(
1854 "https://gitlab.eclipse.org/eclipse/asciidoc-lang/asciidoc-lang/-/issues/16"
1855 )),
1856 "expected warning with feature on, got: {messages:?}",
1857 );
1858 #[cfg(not(feature = "pre-spec-subs"))]
1859 assert!(
1860 messages.iter().any(|m| m.contains("not honoured")),
1861 "expected feature-off warning, got: {messages:?}",
1862 );
1863 }
1864 }
1865
1866 mod attribute_resolution_tests {
1867 use crate::{Options, parse};
1868
1869 #[test]
1870 fn test_definition_time_resolution_bar_defined_first() {
1871 let input = r":bar: resolved-bar
1873:foo: {bar}
1874
1875{foo}
1876";
1877 let options = Options::default();
1878 let parsed = parse(input, &options).expect("should parse");
1879 let doc = parsed.document();
1880
1881 assert_eq!(doc.attributes.text("foo"), Some("resolved-bar"));
1883 }
1884
1885 #[test]
1886 fn test_definition_time_resolution_bar_defined_after() {
1887 let input = r":foo: {bar}
1889:bar: resolved-bar
1890
1891{foo}
1892";
1893 let options = Options::default();
1894 let parsed = parse(input, &options).expect("should parse");
1895 let doc = parsed.document();
1896
1897 assert_eq!(doc.attributes.text("foo"), Some("{bar}"));
1899 }
1900
1901 #[test]
1902 fn test_chained_attribute_resolution() {
1903 let input = r":c: final-value
1906:b: {c}
1907:a: {b}
1908
1909{a}
1910";
1911 let options = Options::default();
1912 let parsed = parse(input, &options).expect("should parse");
1913 let doc = parsed.document();
1914
1915 assert_eq!(doc.attributes.text("c"), Some("final-value"));
1917 assert_eq!(doc.attributes.text("b"), Some("final-value"));
1918 assert_eq!(doc.attributes.text("a"), Some("final-value"));
1919 }
1920 }
1921}