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//! # Include loading
53//!
54//! Parsing defaults to [`SafeMode::Secure`]. Include directives become links
55//! without reading their targets. Select a lower mode to enable include reads.
56//! [`Options::include_loader`] defaults to [`IncludeLoader::System`], the
57//! filesystem and optional HTTP(S) loader. Use [`IncludeLoader::custom`] to
58//! supply sources from an editor or another store. In modes below Secure,
59//! [`IncludeLoader::Disabled`] preserves literal directives without reading targets.
60//! These settings apply to string, reader, and file input; a custom provider does
61//! not lower the safe mode or grant URI permission.
62//!
63//! # Local include confinement
64//!
65//! [`SafeMode::Safe`] and [`SafeMode::Server`] use the effective include base as
66//! their local boundary. Set it through [`OptionsBuilder::with_base_dir`]; file
67//! input otherwise uses the entry file's directory, and string or reader input
68//! uses the current directory. Given an entry document at
69//! `/workspace/docs/main.adoc`:
70//!
71//! - `../shared.adoc` becomes `/workspace/docs/shared.adoc` and emits a warning;
72//! - `/tmp/shared.adoc` becomes `/workspace/docs/tmp/shared.adoc` and emits a warning;
73//! - a nested include from `/workspace/docs/chapters/part.adoc` still uses
74//!   `/workspace/docs` as its boundary.
75//!
76//! The check does not resolve symlinks. If `/workspace/docs/linked.adoc` points to
77//! `/private/secret.adoc`, including `linked.adoc` reads `/private/secret.adoc`.
78//! These path transformations match `asciidoctor`; they are not strict symlink
79//! containment.
80//!
81//! # Remote includes
82//!
83//! HTTP(S) includes need a safe mode below [`SafeMode::Secure`] and the caller's
84//! `allow-uri-read` attribute. A document cannot grant itself this permission.
85//! [`IncludeLoader::System`] also needs the `network` feature.
86//! A custom [`IncludeSourceProvider`] can supply permitted URI content through
87//! another transport.
88//!
89//! On bare WebAssembly targets such as `wasm32-unknown-unknown`, `network` does
90//! not provide an HTTP client. If the system loader attempts an HTTP(S) include,
91//! the parser reports a warning and leaves the directive unresolved.
92//! Fetch content in the host before parsing and supply it through a custom provider.
93//!
94//! # Include source size limit
95//!
96//! Each include can select up to 10 MiB of normalized UTF-8 text, including newlines.
97//! The parser checks this limit before and after `indent=`.
98//! Use `lines=`, `tag=`, or `tags=` to select a small part of a larger source.
99//! If the selected text exceeds the limit, parsing fails with [`Error::IncludeSourceTooLarge`].
100//! Excess tag metadata causes [`Error::IncludeSelectionTooComplex`].
101//!
102//! All includes in one parse share a budget, with a default of 64 MiB.
103//! Each include counts its selected UTF-8 text after `indent=`, plus one byte
104//! for a separating newline if the text is not empty. This count precedes
105//! nested processing and removal of comments and inactive conditionals.
106//! Repeated includes count each time.
107//! Nested includes share the budget; their expanded output is not counted again
108//! in the parent. Exceeding the budget causes [`Error::IncludeExpansionTooLarge`]
109//! at the include directive. Set [`OptionsBuilder::with_max_total_include_bytes`]
110//! to change the budget in bytes. Zero permits only empty selections.
111//! Document attributes cannot change it.
112//! Safe-mode permissions and the per-include limit still apply.
113//!
114//! A finite `lines=` selection stops at its highest requested line.
115//! Errors after that line do not affect the result.
116//! The parser removes trailing whitespace from each line independently.
117//! Full includes, open-ended ranges, and tag selections read to the end of the source.
118//!
119//! The built-in HTTP(S) loader has a separate 10 MiB limit for bytes read after decompression.
120//! This includes bytes read ahead of the selection.
121//! When a finite line selection is complete, the loader closes the response without reading the rest.
122//!
123//! These resource limits differ from `asciidoctor`.
124//! They do not limit scan time.
125//! They do not apply to the entry document.
126//!
127//! # Include indentation
128//!
129//! The `indent` attribute on an include accepts `0..=4096`. Larger values return
130//! a located [`Error::IncludeIndentTooLarge`] before the target is read. This fixed
131//! safety limit is an intentional acdc security policy, not an `AsciiDoc` language
132//! requirement. It prevents one directive from requesting an unbounded indentation
133//! prefix and deliberately diverges from `asciidoctor`, which has no documented or
134//! implemented maximum.
135//!
136//! # Table limits
137//!
138//! Tables accept at most 100 logical columns and 1,000 rows, bounding an accepted
139//! table to 100,000 materialized cells. The column bound also applies to `cols`
140//! multipliers, cell duplication counts, and column spans; row spans are bounded by
141//! the row limit. Larger requests return a located [`Error::Parse`].
142//!
143//! These fixed internal safety limits cannot be changed through [`Options`] or
144//! document attributes. They intentionally diverge from `asciidoctor`, which has no
145//! equivalent table dimension cap.
146//!
147
148use 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/// Type-based parser for `AsciiDoc` content.
204///
205/// `Parser` provides a more discoverable, fluent API for parsing `AsciiDoc` documents.
206///
207/// # Examples
208///
209/// Basic usage:
210///
211/// ```
212/// use acdc_parser::Parser;
213///
214/// let content = "= Document Title\n\nParagraph text.";
215/// let doc = Parser::new(content).parse()?;
216/// # Ok::<(), acdc_parser::Error>(())
217/// ```
218///
219/// With options:
220///
221/// ```
222/// use acdc_parser::{Parser, Options, SafeMode};
223///
224/// let content = "= Document Title\n\nParagraph text.";
225/// let options = Options::builder()
226///     .with_safe_mode(SafeMode::Safe)
227///     .with_timings()
228///     .build()?;
229///
230/// let doc = Parser::new(content)
231///     .with_options(options)
232///     .parse()?;
233/// # Ok::<(), acdc_parser::Error>(())
234/// ```
235///
236/// For a file, use [`parse_file`] to retain its path for includes and diagnostics:
237///
238/// ```no_run
239/// use acdc_parser::{Options, parse_file};
240///
241/// let parsed = parse_file("document.adoc", &Options::default())?;
242/// # Ok::<(), acdc_parser::Error>(())
243/// ```
244#[derive(Debug)]
245pub struct Parser<'input> {
246    input: &'input str,
247    options: Options<'input>,
248}
249
250impl<'input> Parser<'input> {
251    /// Create a new parser for the given input string.
252    ///
253    /// The parser will use default options. Use `with_options` to customize.
254    ///
255    /// # Example
256    ///
257    /// ```
258    /// use acdc_parser::Parser;
259    ///
260    /// let parser = Parser::new("= Title\n\nContent");
261    /// let doc = parser.parse()?;
262    /// # Ok::<(), acdc_parser::Error>(())
263    /// ```
264    #[must_use]
265    pub fn new(input: &'input str) -> Self {
266        Self {
267            input,
268            options: Options::default(),
269        }
270    }
271
272    /// Set the options for this parser.
273    ///
274    /// This consumes the parser and returns a new one with the specified options.
275    ///
276    /// # Example
277    ///
278    /// ```
279    /// use acdc_parser::{Parser, Options, SafeMode};
280    ///
281    /// let options = Options::builder()
282    ///     .with_safe_mode(SafeMode::Safe)
283    ///     .build()?;
284    ///
285    /// let parser = Parser::new("= Title")
286    ///     .with_options(options);
287    /// # Ok::<(), acdc_parser::Error>(())
288    /// ```
289    #[must_use]
290    pub fn with_options(mut self, options: Options<'input>) -> Self {
291        self.options = options;
292        self
293    }
294
295    /// Parse the input into a Document.
296    ///
297    /// # Example
298    ///
299    /// ```
300    /// use acdc_parser::Parser;
301    ///
302    /// let doc = Parser::new("= Title\n\nContent").parse()?;
303    /// # Ok::<(), acdc_parser::Error>(())
304    /// ```
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if the input cannot be parsed as valid `AsciiDoc`.
309    pub fn parse(self) -> Result<ParseResult, Error> {
310        parse(self.input, &self.options)
311    }
312
313    /// Parse only inline elements from the input.
314    ///
315    /// This is useful for parsing fragments of `AsciiDoc` that contain only
316    /// inline markup like bold, italic, links, etc.
317    ///
318    /// # Example
319    ///
320    /// ```
321    /// use acdc_parser::Parser;
322    ///
323    /// let inlines = Parser::new("This is *bold* text").parse_inline()?;
324    /// # Ok::<(), acdc_parser::Error>(())
325    /// ```
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the input cannot be parsed.
330    pub fn parse_inline(self) -> Result<ParseInlineResult, Error> {
331        parse_inline(self.input, &self.options)
332    }
333}
334
335/// Parse `AsciiDoc` content from a reader.
336///
337/// This function reads the content from the provided reader and parses it as `AsciiDoc`.
338/// The default [`SafeMode::Secure`] turns include directives into links without
339/// reading their targets. To load includes, select a lower safe mode and enable
340/// [`Options::include_loader`].
341///
342/// # Example
343///
344/// ```
345/// use acdc_parser::{Options, SafeMode, parse_from_reader};
346/// use std::io::Cursor;
347///
348/// let options = Options::builder()
349///     .with_safe_mode(SafeMode::Unsafe)
350///     .build()?;
351/// let reader = Cursor::new("= Example\n\nContent.\n");
352/// let parsed = parse_from_reader(reader, &options)?;
353/// assert!(!parsed.document().blocks.is_empty());
354/// # Ok::<(), acdc_parser::Error>(())
355/// ```
356///
357/// # Errors
358/// Returns an error if the reader fails, input decoding or preprocessing fails,
359/// or the document cannot be parsed. Recoverable problems appear in the result's warnings.
360#[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    // Shared across the preprocessor and the grammar state so both layers'
369    // warnings land in the same `ParseResult::warnings()` slice.
370    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/// Parse `AsciiDoc` content from a string.
394///
395/// The default [`SafeMode::Secure`] turns include directives into links without
396/// reading their targets. Select a lower safe mode to enable include reads.
397/// [`Options::include_loader`] defaults to [`IncludeLoader::System`].
398///
399/// # Example
400///
401/// Enable includes relative to a directory:
402///
403/// ```no_run
404/// use acdc_parser::{IncludeLoader, Options, SafeMode, parse};
405///
406/// let options = Options::builder()
407///     .with_safe_mode(SafeMode::Safe)
408///     .with_include_loader(IncludeLoader::System)
409///     .with_base_dir("/workspace/docs")
410///     .build()?;
411/// let document = parse("include::part.adoc[]", &options)?;
412/// # Ok::<(), acdc_parser::Error>(())
413/// ```
414///
415/// # Example
416///
417/// ```
418/// use acdc_parser::{Options, SafeMode, parse};
419///
420/// let options = Options::builder()
421///     .with_safe_mode(SafeMode::Unsafe)
422///     .build()?;
423/// let content = "= Document Title\n\nThis is a paragraph.\n\n== Section Title\n\nThis is a subsection.";
424/// let document = parse(content, &options)?;
425/// # Ok::<(), acdc_parser::Error>(())
426/// ```
427///
428/// # Errors
429/// Returns an error if preprocessing fails or the document cannot be parsed.
430/// Recoverable problems appear in the result's warnings.
431#[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/// Parse `AsciiDoc` content from a file.
460///
461/// This function reads the content from the provided file and parses it as `AsciiDoc`.
462///
463/// The default [`SafeMode::Secure`] turns include directives into links without
464/// reading their targets. In lower modes, [`Options::include_loader`] controls
465/// include reads. Neither setting prevents reading the entry file.
466///
467/// # Example
468///
469/// ```no_run
470/// use std::path::Path;
471/// use acdc_parser::{Options, SafeMode, parse_file};
472///
473/// let options = Options::builder()
474///     .with_safe_mode(SafeMode::Unsafe)
475///     .build()?;
476/// let file_path = Path::new("document.adoc");
477/// let document = parse_file(file_path, &options)?;
478/// # Ok::<(), acdc_parser::Error>(())
479/// ```
480///
481/// # Errors
482/// Returns an error if the file cannot be read or decoded, preprocessing fails,
483/// or the document cannot be parsed. Recoverable problems appear in the result's warnings.
484#[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
522/// Helper to convert a PEG parse error to our `SourceLocation` type,
523/// resolving the correct file and line for included content.
524fn 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    // Pin the preprocessed source text and a fresh `bumpalo::Bump` arena
560    // together. The grammar borrows `&owner.source` and allocates every owned
561    // string into `&owner.arena` via `ParserState::intern_str`. The returned
562    // `Document<'_>` borrows from both β€” `ParseResult` keeps them alive
563    // for as long as the consumer holds the wrapper. On drop, the arena,
564    // source, and warnings free together in one shot.
565    let owner = parsed::OwnedInput::new(input);
566    let options_owned = options.into_static();
567    // `warnings_handle` is shared with the preprocessor stage (which has
568    // already appended any preprocessor-side warnings) and with
569    // `ParserState` (which will append grammar-side warnings). The state
570    // drops its clone when the builder closure returns, so the outer
571    // handle is normally unique by the time `ParseResult::try_new`
572    // unwraps it.
573    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                // Rewrite every node's location from preprocessed coordinates to the
589                // original source (file + line + byte offset). No-op when the
590                // preprocessor recorded no ranges (no includes/edits).
591                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/// Parse inline `AsciiDoc` content from a string.
612///
613/// The result owns its inline nodes without a block document. Use it for fragments
614/// containing emphasis, links, macros, or other inline content.
615///
616/// # Example
617///
618/// ```
619/// use acdc_parser::{Options, SafeMode, parse_inline};
620///
621/// let options = Options::builder()
622///     .with_safe_mode(SafeMode::Unsafe)
623///     .build()?;
624/// let content = "This is *strong* text with a https://example.com[link].";
625/// let inline_nodes = parse_inline(content, &options)?;
626/// # Ok::<(), acdc_parser::Error>(())
627/// ```
628///
629/// # Errors
630/// This function returns an error if the inline content cannot be parsed.
631#[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                // Nested parsing uses inline scope to retain whitespace. Finalize
662                // diagnostics only once all locations refer to the original input.
663                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        // A `----` listing including a one-line file with `indent=6`. The remap must
954        // report the included token at its ORIGIN columns (1..10) β€” stripping back the
955        // six inserted spaces β€” not the preprocessed columns (7..16). For re-indented
956        // content `absolute_*` stays in preprocessed coordinates (not serialized to the
957        // ASG), so we only assert it stays a valid `start <= end` span.
958        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        // A quote block lives on line 3 of an included file, spliced in at primary
1012        // line 5 (so its preprocessed line is 7). The attribution and citetitle inline
1013        // nodes must remap to the included file at its true line 3 β€” not stay at the
1014        // preprocessed line with `file: None` like the rest of the block.
1015        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        // A `{counter:foo}` (an inline-preprocessor warning) sits on line 3 of an
1071        // included file, spliced in at primary line 5 (preprocessed line 7). The
1072        // warning must name the included file at its true line 3 β€” not `file: None`
1073        // and the post-splice line, matching the error path and the AST nodes.
1074        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        // A section on line 1 of an included file (spliced in at primary line 6) must
1105        // surface in `toc_entries` and `references` at the included file's true line 1
1106        // β€” not the post-splice line with `file: None`. `references` is the LSP
1107        // go-to-definition target.
1108        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        // `Document.footnotes` locations were captured during inline parsing in
1148        // paragraph-local coordinates; they must report the footnote's real document
1149        // line (5 here), not the line a byte-offset-into-the-paragraph would fall on.
1150        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        // A footnote on line 3 of an included file (spliced at primary line 5) must
1167        // report that file at its true line 3.
1168        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        // A named footnote referenced twice shares one `Document.footnotes` entry; the
1192        // defining (first) occurrence's content and location win β€” a later bare
1193        // reference does not overwrite them.
1194        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        // `include_chain`: main.adoc ends with `include::outer.adoc[]`, and outer.adoc
1210        // ends with `include::inner.adoc[]`. The document's last content thus comes from
1211        // inner.adoc, so per the ASG's per-`locationBoundary` `file` model the document's
1212        // END carries the include chain while its START (primary main.adoc) carries none.
1213        // The document root is NOT special-cased to the primary file.
1214        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            // Routing and private source details must not change positional equality.
1306            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        // Fixtures whose name contains `with_setext` exercise Setext (two-line
1371        // underlined) headings, which are opt-in: their expected JSON captures the
1372        // `--enable-setext-compatibility` behaviour. Skip when the feature is off
1373        // (the AST would diverge); enable the option when it is on. The `with_`
1374        // prefix keeps the marker unambiguous (vs a future `without_setext`).
1375        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                // Error fixtures contain expected error message as plain text
1400                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        // Fixture JSON stores line/column locations, not absolute byte ranges.
1410        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        // The adjacent comment on line 4 is dropped by the preprocessor; the section
1436        // and its body must still report their ORIGINAL source lines (7 and 9), not
1437        // the shifted preprocessed lines.
1438        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        // The first section comes from the included file, at its own line 1.
1484        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        // The include chain is the single (as-written) target of the include.
1497        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        // main.adoc includes outer.adoc which includes inner.adoc. Each paragraph's
1513        // `file` is the chain of include targets (as written) reaching it; primary
1514        // content has none.
1515        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        // Primary content has no include chain.
1546        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                /* The original proptest failing case -> */ "\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                        // Validate the invariant using absolute offsets
1587                        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                        // Validate with our helper
1595                        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                // Validate with our helper
1624                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            // Test that UTF-8 safety is maintained
1633            let test_cases = vec![
1634                "πŸ˜€",         // 4-byte emoji
1635                "א",          // 2-byte Hebrew
1636                "Hello δΈ–η•Œ", // Mixed content
1637                "\u{200b}",   // Zero-width space
1638            ];
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                        // All offsets should be on UTF-8 boundaries
1648                        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                        // Validate with our helper
1660                        doc.location.validate(input).unwrap_or_else(|e| {
1661                            panic!("Location validation failed for {input:?}: {e}");
1662                        });
1663                    }
1664                    Err(e) => {
1665                        // Some of these might fail to parse, which is OK for now
1666                        // We're just testing that if they parse, the locations are valid
1667                        println!("Failed to parse {input:?}: {e} (this might be expected)");
1668                    }
1669                }
1670            }
1671        }
1672    }
1673
1674    /// Integration tests for attribute resolution behavior.
1675    ///
1676    /// These tests verify that acdc matches asciidoctor's attribute resolution semantics:
1677    /// - Attributes are resolved at definition time (not reference time)
1678    /// - If {bar} is undefined when :foo: {bar} is parsed, foo stores literal "{bar}"
1679    /// - If {bar} IS defined when :foo: {bar} is parsed, foo stores bar's resolved value
1680    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            // Each distinct counter reference position is its own diagnostic,
1687            // but PEG backtracking at a single position must not fire the same
1688            // warning multiple times. Two positions => two warnings.
1689            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            // Each warning must carry a location.
1708            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            // Different warnings should each appear once.
1721            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        /// Preprocessor warnings (missing include file, bad line number,
1754        /// URL restrictions, if/endif mismatch) must reach
1755        /// `ParseResult::warnings()` alongside grammar warnings. Test with
1756        /// a missing include: `parse_file` against a doc whose include
1757        /// target doesn't exist.
1758        #[test]
1759        fn missing_include_warning_surfaces_on_parse_result() {
1760            use std::io::Write;
1761            // Write a tmp doc that references a non-existent include.
1762            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        /// When the document has a title but the first section jumps past
1791        /// level 1, the parser must surface a typed warning on the
1792        /// returned `ParseResult` β€” not only via tracing.
1793        #[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        /// Valid documents must have an empty warnings slice (the
1821        /// common-case contract: silence means clean).
1822        #[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        /// Explicit `subs` settings warn in both feature modes: experimental
1835        /// behavior when enabled, or ignored settings when disabled.
1836        #[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            // When bar is defined BEFORE foo, {bar} in foo's value should be expanded
1872            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            // foo should have bar's value expanded at definition time
1882            assert_eq!(doc.attributes.text("foo"), Some("resolved-bar"));
1883        }
1884
1885        #[test]
1886        fn test_definition_time_resolution_bar_defined_after() {
1887            // When bar is defined AFTER foo, {bar} should stay literal in foo's value
1888            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            // foo should keep {bar} as literal since bar wasn't defined yet
1898            assert_eq!(doc.attributes.text("foo"), Some("{bar}"));
1899        }
1900
1901        #[test]
1902        fn test_chained_attribute_resolution() {
1903            // When attributes form a chain: a -> b -> c, each should resolve
1904            // based on what's defined at each definition point
1905            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            // c is defined first, so b gets "final-value", then a gets "final-value"
1916            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}