Skip to main content

acdc_parser/
lib.rs

1//! `AsciiDoc` parser.
2//!
3//! Parse strings, files, readers, or inline fragments into an abstract syntax tree
4//! with source locations and recoverable warnings.
5//!
6//! # Quick start
7//!
8//! Use [`parse`], [`parse_file`], [`parse_from_reader`], or [`parse_inline`].
9//! [`Parser`] provides a builder-style alternative. [`ParseResult`] owns the AST
10//! and its backing text; keep the result alive while borrowing [`ParseResult::document`].
11//!
12//! ```rust
13//! use acdc_parser::{Options, parse};
14//!
15//! let content = r#"= Document Title
16//!
17//! This is a paragraph.
18//!
19//! == Section Title
20//!
21//! This is a subsection."#;
22//!
23//! let parsed = parse(content, &Options::default())?;
24//! let document = parsed.document();
25//! assert!(!document.blocks.is_empty());
26//!
27//! for warning in parsed.warnings() {
28//!     eprintln!("{warning}");
29//! }
30//! # Ok::<(), acdc_parser::Error>(())
31//! ```
32//!
33//! # Cargo features
34//!
35//! - `pre-spec-subs` (default): experimental block `subs=` settings and their AST types.
36//!   Without it, explicit settings report source recovery and use block defaults.
37//! - `setext` (off by default): legacy underlined headings, enabled through
38//!   `OptionsBuilder::with_setext()` when the feature is compiled.
39//! - `network` (off by default): HTTP(S) includes, subject to safe mode and caller permission.
40//!
41//! The parser uses the draft `AsciiDoc` specification and Asciidoctor as references;
42//! it does not claim complete conformance. A successful parse may contain recovered
43//! content. Check [`ParseResult::source_recovery`] when an application requires
44//! complete input, and [`ParseResult::warnings`] for all collected diagnostics.
45//!
46//! # Checklist markers
47//!
48//! acdc treats `[X]` as a checked list marker, alongside `[x]` and `[*]`.
49//! Asciidoctor leaves `[X]` in the item text. This is an intentional acdc
50//! extension.
51//!
52//! # Local include confinement
53//!
54//! [`SafeMode::Safe`] and [`SafeMode::Server`] use the effective include base as
55//! their local boundary. Set it through [`OptionsBuilder::with_base_dir`]; file
56//! input otherwise uses the entry file's directory, and string or reader input
57//! uses the current directory. Given an entry document at
58//! `/workspace/docs/main.adoc`:
59//!
60//! - `../shared.adoc` becomes `/workspace/docs/shared.adoc` and emits a warning;
61//! - `/tmp/shared.adoc` becomes `/workspace/docs/tmp/shared.adoc` and emits a warning;
62//! - a nested include from `/workspace/docs/chapters/part.adoc` still uses
63//!   `/workspace/docs` as its boundary.
64//!
65//! The check does not resolve symlinks. If `/workspace/docs/linked.adoc` points to
66//! `/private/secret.adoc`, including `linked.adoc` reads `/private/secret.adoc`.
67//! These path transformations match `asciidoctor`; they are not strict symlink
68//! containment.
69//!
70//! # Remote includes
71//!
72//! HTTP(S) includes require the optional `network` feature, a safe mode below
73//! [`SafeMode::Secure`], and caller-supplied `allow-uri-read` authority. A document
74//! cannot grant itself this authority. Each response is limited to 10 MiB after
75//! transport decoding; larger responses return an HTTP request error. This fixed,
76//! per-response limit is not controlled by a document attribute and intentionally
77//! diverges from `asciidoctor`.
78//!
79//! # Include indentation
80//!
81//! The `indent` attribute on an include accepts `0..=4096`. Larger values return
82//! a located [`Error::IncludeIndentTooLarge`] before the target is read. This fixed
83//! safety limit is an intentional acdc security policy, not an `AsciiDoc` language
84//! requirement. It prevents one directive from requesting an unbounded indentation
85//! prefix and deliberately diverges from `asciidoctor`, which has no documented or
86//! implemented maximum.
87//!
88//! # Table limits
89//!
90//! Tables accept at most 100 logical columns and 1,000 rows, bounding an accepted
91//! table to 100,000 materialized cells. The column bound also applies to `cols`
92//! multipliers, cell duplication counts, and column spans; row spans are bounded by
93//! the row limit. Larger requests return a located [`Error::Parse`].
94//!
95//! These fixed internal safety limits cannot be changed through [`Options`] or
96//! document attributes. They intentionally diverge from `asciidoctor`, which has no
97//! equivalent table dimension cap.
98//!
99
100use 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/// Type-based parser for `AsciiDoc` content.
151///
152/// `Parser` provides a more discoverable, fluent API for parsing `AsciiDoc` documents.
153///
154/// # Examples
155///
156/// Basic usage:
157///
158/// ```
159/// use acdc_parser::Parser;
160///
161/// let content = "= Document Title\n\nParagraph text.";
162/// let doc = Parser::new(content).parse()?;
163/// # Ok::<(), acdc_parser::Error>(())
164/// ```
165///
166/// With options:
167///
168/// ```
169/// use acdc_parser::{Parser, Options, SafeMode};
170///
171/// let content = "= Document Title\n\nParagraph text.";
172/// let options = Options::builder()
173///     .with_safe_mode(SafeMode::Safe)
174///     .with_timings()
175///     .build()?;
176///
177/// let doc = Parser::new(content)
178///     .with_options(options)
179///     .parse()?;
180/// # Ok::<(), acdc_parser::Error>(())
181/// ```
182///
183/// For a file, use [`parse_file`] to retain its path for includes and diagnostics:
184///
185/// ```no_run
186/// use acdc_parser::{Options, parse_file};
187///
188/// let parsed = parse_file("document.adoc", &Options::default())?;
189/// # Ok::<(), acdc_parser::Error>(())
190/// ```
191#[derive(Debug)]
192pub struct Parser<'input> {
193    input: &'input str,
194    options: Options<'input>,
195}
196
197impl<'input> Parser<'input> {
198    /// Create a new parser for the given input string.
199    ///
200    /// The parser will use default options. Use `with_options` to customize.
201    ///
202    /// # Example
203    ///
204    /// ```
205    /// use acdc_parser::Parser;
206    ///
207    /// let parser = Parser::new("= Title\n\nContent");
208    /// let doc = parser.parse()?;
209    /// # Ok::<(), acdc_parser::Error>(())
210    /// ```
211    #[must_use]
212    pub fn new(input: &'input str) -> Self {
213        Self {
214            input,
215            options: Options::default(),
216        }
217    }
218
219    /// Set the options for this parser.
220    ///
221    /// This consumes the parser and returns a new one with the specified options.
222    ///
223    /// # Example
224    ///
225    /// ```
226    /// use acdc_parser::{Parser, Options, SafeMode};
227    ///
228    /// let options = Options::builder()
229    ///     .with_safe_mode(SafeMode::Safe)
230    ///     .build()?;
231    ///
232    /// let parser = Parser::new("= Title")
233    ///     .with_options(options);
234    /// # Ok::<(), acdc_parser::Error>(())
235    /// ```
236    #[must_use]
237    pub fn with_options(mut self, options: Options<'input>) -> Self {
238        self.options = options;
239        self
240    }
241
242    /// Parse the input into a Document.
243    ///
244    /// # Example
245    ///
246    /// ```
247    /// use acdc_parser::Parser;
248    ///
249    /// let doc = Parser::new("= Title\n\nContent").parse()?;
250    /// # Ok::<(), acdc_parser::Error>(())
251    /// ```
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the input cannot be parsed as valid `AsciiDoc`.
256    pub fn parse(self) -> Result<ParseResult, Error> {
257        parse(self.input, &self.options)
258    }
259
260    /// Parse only inline elements from the input.
261    ///
262    /// This is useful for parsing fragments of `AsciiDoc` that contain only
263    /// inline markup like bold, italic, links, etc.
264    ///
265    /// # Example
266    ///
267    /// ```
268    /// use acdc_parser::Parser;
269    ///
270    /// let inlines = Parser::new("This is *bold* text").parse_inline()?;
271    /// # Ok::<(), acdc_parser::Error>(())
272    /// ```
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if the input cannot be parsed.
277    pub fn parse_inline(self) -> Result<ParseInlineResult, Error> {
278        parse_inline(self.input, &self.options)
279    }
280}
281
282/// Parse `AsciiDoc` content from a reader.
283///
284/// This function reads the content from the provided reader and parses it as `AsciiDoc`.
285///
286/// # Example
287///
288/// ```
289/// use acdc_parser::{Options, SafeMode, parse_from_reader};
290/// use std::io::Cursor;
291///
292/// let options = Options::builder()
293///     .with_safe_mode(SafeMode::Unsafe)
294///     .build()?;
295/// let reader = Cursor::new("= Example\n\nContent.\n");
296/// let parsed = parse_from_reader(reader, &options)?;
297/// assert!(!parsed.document().blocks.is_empty());
298/// # Ok::<(), acdc_parser::Error>(())
299/// ```
300///
301/// # Errors
302/// Returns an error if the reader fails, input decoding or preprocessing fails,
303/// or the document cannot be parsed. Recoverable problems appear in the result's warnings.
304#[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    // Shared across the preprocessor and the grammar state so both layers'
313    // warnings land in the same `ParseResult::warnings()` slice.
314    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/// Parse `AsciiDoc` content from a string.
338///
339/// This function parses the provided string as `AsciiDoc`.
340///
341/// # Example
342///
343/// ```
344/// use acdc_parser::{Options, SafeMode, parse};
345///
346/// let options = Options::builder()
347///     .with_safe_mode(SafeMode::Unsafe)
348///     .build()?;
349/// let content = "= Document Title\n\nThis is a paragraph.\n\n== Section Title\n\nThis is a subsection.";
350/// let document = parse(content, &options)?;
351/// # Ok::<(), acdc_parser::Error>(())
352/// ```
353///
354/// # Errors
355/// Returns an error if preprocessing fails or the document cannot be parsed.
356/// Recoverable problems appear in the result's warnings.
357#[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/// Parse `AsciiDoc` content from a file.
386///
387/// This function reads the content from the provided file and parses it as `AsciiDoc`.
388///
389/// # Example
390///
391/// ```no_run
392/// use std::path::Path;
393/// use acdc_parser::{Options, SafeMode, parse_file};
394///
395/// let options = Options::builder()
396///     .with_safe_mode(SafeMode::Unsafe)
397///     .build()?;
398/// let file_path = Path::new("document.adoc");
399/// let document = parse_file(file_path, &options)?;
400/// # Ok::<(), acdc_parser::Error>(())
401/// ```
402///
403/// # Errors
404/// Returns an error if the file cannot be read or decoded, preprocessing fails,
405/// or the document cannot be parsed. Recoverable problems appear in the result's warnings.
406#[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
444/// Helper to convert a PEG parse error to our `SourceLocation` type,
445/// resolving the correct file and line for included content.
446fn 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    // Pin the preprocessed source text and a fresh `bumpalo::Bump` arena
482    // together. The grammar borrows `&owner.source` and allocates every owned
483    // string into `&owner.arena` via `ParserState::intern_str`. The returned
484    // `Document<'_>` borrows from both β€” `ParseResult` keeps them alive
485    // for as long as the consumer holds the wrapper. On drop, the arena,
486    // source, and warnings free together in one shot.
487    let owner = parsed::OwnedInput::new(input);
488    let options_owned = options.into_static();
489    // `warnings_handle` is shared with the preprocessor stage (which has
490    // already appended any preprocessor-side warnings) and with
491    // `ParserState` (which will append grammar-side warnings). The state
492    // drops its clone when the builder closure returns, so the outer
493    // handle is normally unique by the time `ParseResult::try_new`
494    // unwraps it.
495    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                // Rewrite every node's location from preprocessed coordinates to the
511                // original source (file + line + byte offset). No-op when the
512                // preprocessor recorded no ranges (no includes/edits).
513                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/// Parse inline `AsciiDoc` content from a string.
534///
535/// The result owns its inline nodes without a block document. Use it for fragments
536/// containing emphasis, links, macros, or other inline content.
537///
538/// # Example
539///
540/// ```
541/// use acdc_parser::{Options, SafeMode, parse_inline};
542///
543/// let options = Options::builder()
544///     .with_safe_mode(SafeMode::Unsafe)
545///     .build()?;
546/// let content = "This is *strong* text with a https://example.com[link].";
547/// let inline_nodes = parse_inline(content, &options)?;
548/// # Ok::<(), acdc_parser::Error>(())
549/// ```
550///
551/// # Errors
552/// This function returns an error if the inline content cannot be parsed.
553#[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                // Nested parsing uses inline scope to retain whitespace. Finalize
584                // diagnostics only once all locations refer to the original input.
585                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        // A `----` listing including a one-line file with `indent=6`. The remap must
873        // report the included token at its ORIGIN columns (1..10) β€” stripping back the
874        // six inserted spaces β€” not the preprocessed columns (7..16). For re-indented
875        // content `absolute_*` stays in preprocessed coordinates (not serialized to the
876        // ASG), so we only assert it stays a valid `start <= end` span.
877        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        // A quote block lives on line 3 of an included file, spliced in at primary
931        // line 5 (so its preprocessed line is 7). The attribution and citetitle inline
932        // nodes must remap to the included file at its true line 3 β€” not stay at the
933        // preprocessed line with `file: None` like the rest of the block.
934        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        // A `{counter:foo}` (an inline-preprocessor warning) sits on line 3 of an
990        // included file, spliced in at primary line 5 (preprocessed line 7). The
991        // warning must name the included file at its true line 3 β€” not `file: None`
992        // and the post-splice line, matching the error path and the AST nodes.
993        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        // A section on line 1 of an included file (spliced in at primary line 6) must
1024        // surface in `toc_entries` and `references` at the included file's true line 1
1025        // β€” not the post-splice line with `file: None`. `references` is the LSP
1026        // go-to-definition target.
1027        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        // `Document.footnotes` locations were captured during inline parsing in
1067        // paragraph-local coordinates; they must report the footnote's real document
1068        // line (5 here), not the line a byte-offset-into-the-paragraph would fall on.
1069        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        // A footnote on line 3 of an included file (spliced at primary line 5) must
1086        // report that file at its true line 3.
1087        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        // A named footnote referenced twice shares one `Document.footnotes` entry; the
1111        // defining (first) occurrence's content and location win β€” a later bare
1112        // reference does not overwrite them.
1113        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        // `include_chain`: main.adoc ends with `include::outer.adoc[]`, and outer.adoc
1129        // ends with `include::inner.adoc[]`. The document's last content thus comes from
1130        // inner.adoc, so per the ASG's per-`locationBoundary` `file` model the document's
1131        // END carries the include chain while its START (primary main.adoc) carries none.
1132        // The document root is NOT special-cased to the primary file.
1133        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            // Routing and private source details must not change positional equality.
1225            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        // Fixtures whose name contains `with_setext` exercise Setext (two-line
1290        // underlined) headings, which are opt-in: their expected JSON captures the
1291        // `--enable-setext-compatibility` behaviour. Skip when the feature is off
1292        // (the AST would diverge); enable the option when it is on. The `with_`
1293        // prefix keeps the marker unambiguous (vs a future `without_setext`).
1294        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                // Error fixtures contain expected error message as plain text
1319                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        // Fixture JSON stores line/column locations, not absolute byte ranges.
1329        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        // The adjacent comment on line 4 is dropped by the preprocessor; the section
1355        // and its body must still report their ORIGINAL source lines (7 and 9), not
1356        // the shifted preprocessed lines.
1357        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        // The first section comes from the included file, at its own line 1.
1403        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        // The include chain is the single (as-written) target of the include.
1416        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        // main.adoc includes outer.adoc which includes inner.adoc. Each paragraph's
1432        // `file` is the chain of include targets (as written) reaching it; primary
1433        // content has none.
1434        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        // Primary content has no include chain.
1465        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                /* The original proptest failing case -> */ "\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                        // Validate the invariant using absolute offsets
1506                        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                        // Validate with our helper
1514                        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                // Validate with our helper
1543                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            // Test that UTF-8 safety is maintained
1552            let test_cases = vec![
1553                "πŸ˜€",         // 4-byte emoji
1554                "א",          // 2-byte Hebrew
1555                "Hello δΈ–η•Œ", // Mixed content
1556                "\u{200b}",   // Zero-width space
1557            ];
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                        // All offsets should be on UTF-8 boundaries
1567                        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                        // Validate with our helper
1579                        doc.location.validate(input).unwrap_or_else(|e| {
1580                            panic!("Location validation failed for {input:?}: {e}");
1581                        });
1582                    }
1583                    Err(e) => {
1584                        // Some of these might fail to parse, which is OK for now
1585                        // We're just testing that if they parse, the locations are valid
1586                        println!("Failed to parse {input:?}: {e} (this might be expected)");
1587                    }
1588                }
1589            }
1590        }
1591    }
1592
1593    /// Integration tests for attribute resolution behavior.
1594    ///
1595    /// These tests verify that acdc matches asciidoctor's attribute resolution semantics:
1596    /// - Attributes are resolved at definition time (not reference time)
1597    /// - If {bar} is undefined when :foo: {bar} is parsed, foo stores literal "{bar}"
1598    /// - If {bar} IS defined when :foo: {bar} is parsed, foo stores bar's resolved value
1599    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            // Each distinct counter reference position is its own diagnostic,
1606            // but PEG backtracking at a single position must not fire the same
1607            // warning multiple times. Two positions => two warnings.
1608            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            // Each warning must carry a location.
1627            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            // Different warnings should each appear once.
1640            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        /// Preprocessor warnings (missing include file, bad line number,
1656        /// URL restrictions, if/endif mismatch) must reach
1657        /// `ParseResult::warnings()` alongside grammar warnings. Test with
1658        /// a missing include: `parse_file` against a doc whose include
1659        /// target doesn't exist.
1660        #[test]
1661        fn missing_include_warning_surfaces_on_parse_result() {
1662            use std::io::Write;
1663            // Write a tmp doc that references a non-existent include.
1664            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        /// When the document has a title but the first section jumps past
1690        /// level 1, the parser must surface a typed warning on the
1691        /// returned `ParseResult` β€” not only via tracing.
1692        #[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        /// Valid documents must have an empty warnings slice (the
1720        /// common-case contract: silence means clean).
1721        #[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        /// Explicit `subs` settings warn in both feature modes: experimental
1734        /// behavior when enabled, or ignored settings when disabled.
1735        #[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            // When bar is defined BEFORE foo, {bar} in foo's value should be expanded
1771            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            // foo should have bar's value expanded at definition time
1781            assert_eq!(doc.attributes.text("foo"), Some("resolved-bar"));
1782        }
1783
1784        #[test]
1785        fn test_definition_time_resolution_bar_defined_after() {
1786            // When bar is defined AFTER foo, {bar} should stay literal in foo's value
1787            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            // foo should keep {bar} as literal since bar wasn't defined yet
1797            assert_eq!(doc.attributes.text("foo"), Some("{bar}"));
1798        }
1799
1800        #[test]
1801        fn test_chained_attribute_resolution() {
1802            // When attributes form a chain: a -> b -> c, each should resolve
1803            // based on what's defined at each definition point
1804            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            // c is defined first, so b gets "final-value", then a gets "final-value"
1815            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}