Skip to main content

asciidoc_parser/parser/
parser.rs

1use std::{
2    cell::{Cell, RefCell},
3    collections::{HashMap, HashSet},
4    rc::Rc,
5    sync::{Arc, LazyLock},
6};
7
8use regex::Regex;
9
10use crate::{
11    Document, HasSpan,
12    blocks::{SectionNumber, SectionType},
13    document::{Attribute, Catalog, InterpretedValue, RefType},
14    parser::{
15        AllowableValue, AttributeValue, DatetimeContext, DocinfoFileHandler,
16        HtmlSubstitutionRenderer, ImageFileHandler, IncludeFileHandler, InlineSubstitutionRenderer,
17        ModificationContext, PathResolver, ReferenceTime, ResolvedAttributes, SafeMode, SourceLine,
18        SourceMap, SvgFileHandler,
19        built_in_attrs::{
20            built_in_attr, built_in_default_values, derived_backend_value,
21            is_derived_backend_value, max_attribute_value_size_default, synthesized_attr,
22            user_home_default,
23        },
24        is_datetime_attribute,
25        preprocessor::preprocess,
26        safe_mode::masked_doc_path,
27    },
28    warnings::{Warning, WarningType},
29};
30
31/// The [`Parser`] struct and its related structs allow a caller to configure
32/// how AsciiDoc parsing occurs and then to initiate the parsing process.
33#[derive(Clone, Debug)]
34pub struct Parser {
35    /// Per-parser attribute values: **only** the attributes this parser has
36    /// defined, overridden, or explicitly unset. The large set of built-in
37    /// defaults is *not* copied in here; [`attribute_value`] falls back to the
38    /// shared built-in table (see [`built_in_attrs`]) on a lookup miss, so
39    /// creating or cloning a parser allocates nothing per built-in attribute.
40    ///
41    /// A per-parser entry always shadows the built-in default of the same name,
42    /// including an [`Unset`](InterpretedValue::Unset) tombstone that records a
43    /// built-in having been unset. The map is wrapped in an [`Arc`] so a parser
44    /// clone (e.g. for a nested AsciiDoc table cell) shares it copy-on-write
45    /// and only copies these (few) entries when it next modifies an
46    /// attribute.
47    ///
48    /// [`attribute_value`]: Self::attribute_value
49    /// [`built_in_attrs`]: super::built_in_attrs
50    pub(crate) attribute_values: Arc<HashMap<String, AttributeValue>>,
51
52    /// Default values for attributes if "set." Immutable after construction and
53    /// shared via [`Arc`] (never copied per parser).
54    default_attribute_values: Arc<HashMap<String, String>>,
55
56    /// Specifies how the basic raw text of a simple block will be converted to
57    /// the format which will ultimately be presented in the final output.
58    ///
59    /// Typically this is an [`HtmlSubstitutionRenderer`] but clients may
60    /// provide alternative implementations.
61    pub(crate) renderer: Rc<dyn InlineSubstitutionRenderer>,
62
63    /// Specifies the name of the primary file to be parsed.
64    pub(crate) primary_file_name: Option<String>,
65
66    /// Specifies how to generate clean and secure paths relative to the parsing
67    /// context.
68    pub path_resolver: PathResolver,
69
70    /// Handler for resolving include:: directives.
71    pub(crate) include_file_handler: Option<Rc<dyn IncludeFileHandler>>,
72
73    /// Handler for resolving docinfo files. If absent, no docinfo content is
74    /// resolved.
75    pub(crate) docinfo_file_handler: Option<Rc<dyn DocinfoFileHandler>>,
76
77    /// Handler for reading the contents of an SVG file requested by an inline
78    /// image with the `inline` option. If absent, inline SVG images fall back
79    /// to rendering their alt text.
80    pub(crate) svg_file_handler: Option<Rc<dyn SvgFileHandler>>,
81
82    /// Handler for reading the bytes of an image file that must be embedded as
83    /// a `data:` URI (when the `data-uri` attribute is set below
84    /// [`SafeMode::Secure`]). If absent, such images fall back to an ordinary
85    /// web path.
86    pub(crate) image_file_handler: Option<Rc<dyn ImageFileHandler>>,
87
88    /// Whether referenced images are recorded in the document catalog as they
89    /// are encountered (Asciidoctor's `catalog_assets` API option). When
90    /// `false` (the default), the `image:`/`image::` macros do not populate
91    /// [`Catalog::images`](crate::document::Catalog::images).
92    pub(crate) catalog_assets: bool,
93
94    /// The safe mode under which the document is parsed and rendered. Controls
95    /// security-sensitive rendering behavior (such as whether an interactive
96    /// SVG image is rendered as an `<object>` element). Defaults to
97    /// [`SafeMode::Secure`].
98    pub(crate) safe: SafeMode,
99
100    /// Document catalog for tracking referenceable elements during parsing.
101    /// This is created during parsing and transferred to the Document when
102    /// complete.
103    ///
104    /// Wrapped in a [`RefCell`] so that anchors and references discovered deep
105    /// inside inline substitution (where only a shared `&Parser` is available,
106    /// e.g. within a regex [`Replacer`](regex::Replacer)) can still be
107    /// registered.
108    catalog: RefCell<Catalog>,
109
110    /// Most recently-assigned section number.
111    pub(crate) last_section_number: SectionNumber,
112
113    /// Most recently-assigned appendix section number.
114    pub(crate) last_appendix_section_number: SectionNumber,
115
116    /// Saved copy of sectnumlevels at end of document header.
117    pub(crate) sectnumlevels: usize,
118
119    /// Section type of outermost section. (Used to determine whether to number
120    /// child sections as a normal section or appendix.)
121    pub(crate) topmost_section_type: SectionType,
122
123    /// True while parsing the direct block children of a section that carries
124    /// the `bibliography` style.
125    ///
126    /// A top-level unordered list parsed in this scope implicitly inherits the
127    /// `bibliography` style (matching Asciidoctor), even without its own
128    /// `[bibliography]` attribute. The flag is saved and restored around each
129    /// section body, so a non-bibliography subsection clears it for its own
130    /// children (the style does not propagate into subsections).
131    pub(crate) parsing_bibliography_section_body: bool,
132
133    /// True while the principal text of a bibliography list item is being
134    /// substituted.
135    ///
136    /// Read through a shared `&Parser` by the macros substitution step so it
137    /// recognizes a leading bibliography anchor (`[[[id]]]`). It is wrapped in
138    /// a [`Cell`] because the substitution code paths (e.g. a regex
139    /// [`Replacer`](regex::Replacer)) only hold a shared reference to the
140    /// parser.
141    pub(crate) in_bibliography_list_item: Cell<bool>,
142
143    /// True while a section title is being substituted, so each `footnote:[…]`
144    /// macro's rendered marker is bracketed with
145    /// [`FOOTNOTE_MARKER_START`](crate::content::FOOTNOTE_MARKER_START) /
146    /// [`FOOTNOTE_MARKER_END`](crate::content::FOOTNOTE_MARKER_END) sentinels.
147    /// The footnote is still defined and numbered in document order; the
148    /// sentinels merely let the marker be excised from the section's reference
149    /// text and auto-generated ID without a second substitution pass (see
150    /// `SectionBlock::parse`).
151    ///
152    /// Wrapped in a [`Cell`] for the same reason as
153    /// [`in_bibliography_list_item`](Self::in_bibliography_list_item): the
154    /// substitution code paths hold only a shared reference to the parser.
155    pub(crate) mark_footnote_spans: Cell<bool>,
156
157    /// A block title carried over from a section heading to the first block
158    /// parsed after it.
159    ///
160    /// A block title above a section heading does not title the section; it is
161    /// carried over to the first block inside the section (matching
162    /// Asciidoctor, where the block-attribute hash holding the title is passed
163    /// through to the section body's first block). `SectionBlock::parse`
164    /// stashes the rendered title here and the next block parsed claims it —
165    /// which may be a nested section, re-stashing it for *its* first block, or
166    /// (when the section body is empty) a sibling section reached after the
167    /// stashing section ends. A block with a title of its own wins over the
168    /// carried title, which is then discarded.
169    ///
170    /// The title is carried as an owned snapshot (this struct is lifetime-free
171    /// and cannot hold the `.Title` line's source span), so a block claiming a
172    /// carried title has no `title_source` — the same shape as a title
173    /// supplied via a `title=` attribute. The snapshot keeps any deferred
174    /// cross-references, so an embedded `<<id>>` in a carried title still
175    /// resolves once the catalog is complete.
176    pub(crate) pending_block_title: Option<crate::content::OwnedTitle>,
177
178    /// Live values of [counter] attributes, keyed by counter name (e.g.
179    /// `index`, `example-number`, `table-number`).
180    ///
181    /// A counter is a specialized document attribute: its value is *also* the
182    /// value of the document attribute of the same name. Counters are resolved
183    /// (and advanced) deep inside the attribute-reference substitution step,
184    /// where only a shared `&Parser` is available, so the new value is recorded
185    /// here through a [`RefCell`] and read back as an attribute by
186    /// [`attribute_value()`]. An explicit attribute assignment to a counter's
187    /// name supersedes this overlay (and is what allows `:!name:` to reset a
188    /// counter), so every attribute setter clears the matching entry.
189    ///
190    /// Captioned blocks (example, table, …) are numbered with this same
191    /// mechanism: each context's caption number is the counter named
192    /// `<context>-number`, mirroring Asciidoctor's `Document#counter`.
193    ///
194    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
195    /// [`attribute_value()`]: Self::attribute_value
196    pub(crate) counter_values: RefCell<HashMap<String, String>>,
197
198    /// Running state for inline `{counter:…}` / `{counter2:…}` counters whose
199    /// target attribute is *locked* (API-set or a locked built-in).
200    ///
201    /// Such a counter must keep advancing across repeated references, but it
202    /// must not overwrite the locked attribute's readable value — so its
203    /// sequence is tracked here rather than in the readable
204    /// [`counter_values`](Self::counter_values) overlay. This mirrors
205    /// Asciidoctor's `Document#counter`, which advances `@counters` while
206    /// leaving `@attributes` untouched for a locked attribute (see
207    /// [`counter_impl`](Self::counter_impl)). A captioning counter is exempt:
208    /// its value is committed to the readable overlay even when locked.
209    ///
210    /// Accepted limitation: a locked inline counter tracks its sequence here
211    /// while a captioning counter tracks it in
212    /// [`counter_values`](Self::counter_values), so the two sequences diverge
213    /// when the *same* locked attribute is advanced both ways in one document
214    /// (e.g. an API-locked `example-number` driven by both example blocks and
215    /// inline `{counter:example-number}` references) — Asciidoctor keeps a
216    /// single `@counters` sequence shared across both. This is left unmatched
217    /// deliberately. The scenario — API-locking a `<context>-number` attribute
218    /// *and* mixing caption and inline use — is pathological, and exact parity
219    /// is unreachable regardless: this crate resolves inline counters during
220    /// parsing, whereas Asciidoctor advances captions during parsing but inline
221    /// references during conversion, so the two sequences interleave
222    /// differently no matter how the state is stored. Unifying the maps would
223    /// therefore add read-path complexity (the gate would have to be threaded
224    /// through [`ResolvedAttributes`] too) without actually matching
225    /// Asciidoctor's output here.
226    pub(crate) locked_counter_values: RefCell<HashMap<String, String>>,
227
228    /// Canonical names of attributes that are locked against modification from
229    /// the document body for the current scope.
230    ///
231    /// An AsciiDoc table cell creates a nested document that inherits the
232    /// parent document's attributes. An attribute that is *set* in the
233    /// parent _cannot_ be modified inside the cell (matching Asciidoctor,
234    /// which here diverges from the spec's "set or explicitly unset" wording),
235    /// so while a cell is being parsed every inherited attribute name
236    /// (other than a handful of exceptions) is recorded here and a body
237    /// attribute assignment to such a name is silently ignored. The set is
238    /// saved and restored around each cell, so the lock applies only within
239    /// the cell (and nests correctly).
240    pub(crate) locked_attribute_names: HashSet<String>,
241
242    /// Number of AsciiDoc table cells currently being parsed in the call stack.
243    ///
244    /// An AsciiDoc table cell creates a nested, standalone AsciiDoc document.
245    /// While that document is being parsed this counter is greater than zero,
246    /// which (matching Asciidoctor's `Document#nested?`) changes the default
247    /// cell separator of any table found inside from the vertical bar (`|`) to
248    /// the exclamation mark (`!`), so a nested table needs no explicit
249    /// `separator` attribute. The counter is incremented and decremented around
250    /// each AsciiDoc cell, so it nests correctly.
251    pub(crate) nested_document_depth: usize,
252
253    /// Number of privately-owned sub-sources currently being parsed in the call
254    /// stack.
255    ///
256    /// A Markdown-style blockquote and an AsciiDoc table cell parse their
257    /// blocks from an owned source string whose byte offsets do not map to
258    /// the primary document source. A footnote defined while such a
259    /// sub-source is being substituted therefore cannot record a
260    /// document-relative location for its cross-reference warning;
261    /// `define_footnote` reads this counter to detect that case and leave
262    /// the location unset (see
263    /// [`Footnote::location`](crate::document::Footnote)). It is incremented
264    /// and decremented around each owned sub-source parse, so it nests
265    /// correctly.
266    ///
267    /// Unlike [`nested_document_depth`](Self::nested_document_depth), this also
268    /// counts Markdown-style blockquotes (which are not nested documents), so
269    /// the two cannot be merged.
270    pub(crate) owned_subsource_depth: usize,
271
272    /// Source map of the document currently being parsed, populated by
273    /// [`Document::parse`] for the duration of the parse (and `None` outside
274    /// it).
275    ///
276    /// Block parsing works from the *preprocessed* source, so a span's line
277    /// number is relative to that flattened source rather than to the original
278    /// input file(s). An AsciiDoc table cell whose first line is an `include::`
279    /// directive re-runs the preprocessor over the cell's content: to report an
280    /// unresolved directive against the file and line where it *originally*
281    /// appeared (rather than "(root file)"), the cell must map its position in
282    /// the preprocessed source back through this map. It is only consulted
283    /// while parsing the top-level document (`nested_document_depth == 0`),
284    /// where a cell's span still refers to that source.
285    ///
286    /// [`Document::parse`]: crate::Document
287    pub(crate) source_map: Option<Rc<SourceMap>>,
288
289    /// Stack of source maps for the include-expanded (owned) AsciiDoc table
290    /// cells currently being parsed in the call stack (innermost last).
291    ///
292    /// An AsciiDoc cell whose first line is an `include::` directive is parsed
293    /// from a private, preprocessor-expanded copy of its content rather than
294    /// from the document source. While that owned copy is being parsed a span's
295    /// line number no longer indexes the document
296    /// [`source_map`](Self::source_map); it indexes the owned copy instead.
297    /// Each owned cell's own source map (produced by re-running the
298    /// preprocessor over its content) is pushed here for the duration of
299    /// its parse, so a directive buried inside the owned content can still
300    /// be mapped back to the file and line it *originally* came from —
301    /// needed to name that file in the "Unresolved directive" message and
302    /// to report the warning's true cursor via
303    /// [`Warning::origin`](crate::warnings::Warning::origin).
304    ///
305    /// The stack is empty while parsing the top-level document (and while
306    /// parsing a *borrowed* cell, which keeps document spans). It is non-empty
307    /// exactly when a span's line indexes an owned copy rather than the
308    /// document, which the source-map lookup uses to decide which map to
309    /// consult. It is pushed and popped around each owned-cell parse, so it
310    /// nests correctly.
311    pub(crate) owned_cell_source_maps: Vec<Rc<SourceMap>>,
312
313    /// Warnings raised by an `include::` directive buried inside an owned
314    /// (include-expanded) AsciiDoc table cell, each already resolved to the
315    /// `(file, line)` where the directive originally appeared.
316    ///
317    /// Such a warning is raised deep inside the owned cell's parse, where the
318    /// only spans available borrow the owned copy and cannot escape it, and no
319    /// document span maps to the directive. It is therefore recorded here (in a
320    /// lifetime-free, pre-resolved form) and drained once an enclosing
321    /// document-level cell can anchor it to a real document span while carrying
322    /// its true origin (see
323    /// [`Warning::origin`](crate::warnings::Warning::origin)).
324    ///
325    /// Wrapped in a [`RefCell`] only for symmetry with the other deferred
326    /// warning buffers; it is mutated through `&mut self`-free helpers so the
327    /// owned-cell parse (which holds the parser mutably) can still record into
328    /// it from within a `self_cell` construction closure.
329    owned_cell_warnings: RefCell<Vec<ResolvedWarning>>,
330
331    /// Catalog of callout numbers registered by verbatim blocks, used to
332    /// validate the callout lists that annotate them.
333    ///
334    /// Wrapped in a [`RefCell`] because callouts are registered deep inside the
335    /// callouts substitution step, where only a shared `&Parser` is available.
336    callouts: RefCell<CalloutCatalog>,
337
338    /// Warnings produced while replacing attribute references (e.g. a reference
339    /// to a missing attribute when `attribute-missing` is `warn`).
340    ///
341    /// Wrapped in a [`RefCell`] because attribute references are replaced deep
342    /// inside the attributes substitution step, where only a shared `&Parser`
343    /// is available. Each entry stores the byte offset and length of the source
344    /// span the warning refers to (rather than a borrowed
345    /// [`Span`](crate::Span), which the lifetime-free `Parser` cannot
346    /// hold), so the warnings can be turned into
347    /// spanned [`Warning`]s once the document's owned source is available.
348    substitution_warnings: RefCell<Vec<DeferredWarning>>,
349
350    /// An optional fixed reference time that pins the clock used to compute the
351    /// time-dependent document attributes (`docdate`, `doctime`, `docdatetime`,
352    /// `docyear`, and their `local*` siblings), for reproducible output.
353    ///
354    /// When set, it supersedes both the real wall clock and the
355    /// `SOURCE_DATE_EPOCH` environment variable as the value of "now" (which
356    /// drives the `local*` attributes and, absent an [`input_mtime`], the
357    /// `doc*` attributes too). See [`with_reference_time`] and
358    /// [`resolve_datetime_attribute`].
359    ///
360    /// [`input_mtime`]: Self::input_mtime
361    /// [`with_reference_time`]: Self::with_reference_time
362    /// [`resolve_datetime_attribute`]: Self::resolve_datetime_attribute
363    reference_time: Option<ReferenceTime>,
364
365    /// An optional fixed modification time of the source document, pinning the
366    /// clock that drives the `doc*` attributes (`docdate`, `doctime`,
367    /// `docdatetime`, `docyear`) specifically.
368    ///
369    /// This mirrors Asciidoctor's `input_mtime` option: the `local*` attributes
370    /// continue to track "now", while the `doc*` attributes reflect this source
371    /// modification time. See [`with_input_mtime`] and
372    /// [`resolve_datetime_attribute`].
373    ///
374    /// [`with_input_mtime`]: Self::with_input_mtime
375    /// [`resolve_datetime_attribute`]: Self::resolve_datetime_attribute
376    input_mtime: Option<ReferenceTime>,
377
378    /// The reference instants used to compute the time-dependent document
379    /// attributes, captured lazily the first time one of those attributes is
380    /// read during a parse (and reset at the start of each parse).
381    ///
382    /// The time-dependent attributes are *not* materialized into
383    /// [`attribute_values`](Self::attribute_values); instead they are resolved
384    /// on demand from this context (see
385    /// [`resolve_datetime_attribute`](Self::resolve_datetime_attribute)). A
386    /// parse that never references one therefore does no clock, environment, or
387    /// allocation work for them, and repeated reads within a parse observe a
388    /// single consistent instant.
389    ///
390    /// Wrapped in a [`RefCell`] because the capture happens through the shared
391    /// `&Parser` attribute readers (which the substitution code paths reach
392    /// with only a shared reference).
393    datetime_context: RefCell<Option<DatetimeContext>>,
394}
395
396/// A warning recorded in a form that does not borrow the source so it can live
397/// on the [`Parser`] (or be returned from preprocessing), to be reconstituted
398/// into a spanned [`Warning`] once the document's owned source is available.
399///
400/// This is used both for warnings raised while replacing attribute references
401/// and for warnings raised during preprocessing (e.g. an unresolved include
402/// directive). The `offset`/`len` pair locates the relevant text within the
403/// (preprocessed) document source.
404#[derive(Clone, Debug)]
405pub(crate) struct DeferredWarning {
406    /// Byte offset into the document source of the span this warning refers to.
407    pub(crate) offset: usize,
408
409    /// Byte length of the span this warning refers to.
410    pub(crate) len: usize,
411
412    /// The type of warning, already carrying any owned data it needs (such as
413    /// the missing attribute's name).
414    pub(crate) warning: WarningType,
415
416    /// A pre-resolved originating `(file, line)` for this warning, carried
417    /// through to [`Warning::origin`].
418    ///
419    /// This is `None` for warnings that point at real (emitted) output: their
420    /// [`offset`](Self::offset)/[`len`](Self::len) span resolves the location
421    /// through the document source map. It is `Some` for a preprocessor
422    /// directive that produces no output of its own — a malformed or
423    /// unterminated conditional directive — where there is no output span to
424    /// resolve against, so the directive's own file and line are recorded here
425    /// directly (the `offset`/`len` span is then only a best-effort anchor).
426    ///
427    /// [`Warning::origin`]: crate::warnings::Warning::origin
428    pub(crate) origin: Option<SourceLine>,
429}
430
431/// A warning whose location is already resolved to an originating
432/// `(file, line)`, independent of any source map.
433///
434/// Used for a warning raised inside an owned (include-expanded) AsciiDoc table
435/// cell, whose directive never appears in the document source: it is resolved
436/// against the owning cell's own source map when raised, then carried in this
437/// form until an enclosing document-level cell can surface it (see
438/// [`Parser::owned_cell_warnings`]).
439#[derive(Clone, Debug)]
440pub(crate) struct ResolvedWarning {
441    /// The originating file and line where the directive appeared.
442    pub(crate) origin: SourceLine,
443
444    /// The type of warning, already carrying any owned data it needs (such as
445    /// the missing include target).
446    pub(crate) warning: WarningType,
447}
448
449/// Tracks the callout numbers defined by verbatim blocks so that a callout list
450/// can be validated against the callouts it annotates.
451///
452/// This mirrors the relevant behavior of Asciidoctor's `Callouts` catalog: each
453/// verbatim block registers the callout numbers it defines into the current
454/// list, and each callout list checks its items against that list (warning
455/// about any item with no matching callout) before the list is closed.
456#[derive(Clone, Debug, Default)]
457struct CalloutCatalog {
458    /// Callout numbers registered (in document order) since the last callout
459    /// list was closed.
460    current: Vec<u32>,
461}
462
463impl Default for Parser {
464    fn default() -> Self {
465        Self {
466            // Starts empty: built-in defaults are resolved on the fly via the
467            // shared table (see `attribute_value`), not copied in per parser.
468            attribute_values: Arc::new(HashMap::new()),
469            default_attribute_values: built_in_default_values(),
470            renderer: Rc::new(HtmlSubstitutionRenderer {}),
471            primary_file_name: None,
472            path_resolver: PathResolver::default(),
473            include_file_handler: None,
474            docinfo_file_handler: None,
475            svg_file_handler: None,
476            image_file_handler: None,
477            catalog_assets: false,
478            safe: SafeMode::default(),
479            catalog: RefCell::new(Catalog::new()),
480            last_section_number: SectionNumber::default(),
481            last_appendix_section_number: SectionNumber {
482                section_type: SectionType::Appendix,
483                components: vec![],
484                appendix_letter: None,
485            },
486            sectnumlevels: 3,
487            topmost_section_type: SectionType::Normal,
488            parsing_bibliography_section_body: false,
489            in_bibliography_list_item: Cell::new(false),
490            mark_footnote_spans: Cell::new(false),
491            pending_block_title: None,
492            counter_values: RefCell::new(HashMap::new()),
493            locked_counter_values: RefCell::new(HashMap::new()),
494            locked_attribute_names: HashSet::new(),
495            nested_document_depth: 0,
496            owned_subsource_depth: 0,
497            source_map: None,
498            owned_cell_source_maps: vec![],
499            owned_cell_warnings: RefCell::new(vec![]),
500            callouts: RefCell::new(CalloutCatalog::default()),
501            substitution_warnings: RefCell::new(vec![]),
502            reference_time: None,
503            input_mtime: None,
504            datetime_context: RefCell::new(None),
505        }
506    }
507}
508
509impl Parser {
510    /// Parse a UTF-8 string as an AsciiDoc document.
511    ///
512    /// The [`Document`] data structure returned by this call has a '`static`
513    /// lifetime; this is an implementation detail. It retains a copy of the
514    /// `source` string that was passed in, but it is not tied to the lifetime
515    /// of that string.
516    ///
517    /// Nearly all of the data structures contained within the [`Document`]
518    /// structure are tied to the lifetime of the document and have a `'src`
519    /// lifetime to signal their dependency on the source document.
520    ///
521    /// **IMPORTANT:** The AsciiDoc language documentation states that UTF-16
522    /// encoding is allowed if a byte-order-mark (BOM) is present at the
523    /// start of a file. This format is not directly supported by the
524    /// `asciidoc-parser` crate. Any UTF-16 content must be re-encoded as
525    /// UTF-8 prior to parsing.
526    ///
527    /// The `Parser` struct will be updated with document attribute values
528    /// discovered during parsing. These values may be inspected using
529    /// [`attribute_value()`].
530    ///
531    /// # Warnings, not errors
532    ///
533    /// Any UTF-8 string is a valid AsciiDoc document, so this function does not
534    /// return an [`Option`] or [`Result`] data type. There may be any number of
535    /// character sequences that have ambiguous or potentially unintended
536    /// meanings. For that reason, a caller is advised to review the warnings
537    /// provided via the [`warnings()`] iterator.
538    ///
539    /// [`warnings()`]: Document::warnings
540    /// [`attribute_value()`]: Self::attribute_value
541    pub fn parse(&mut self, source: &str) -> Document<'static> {
542        let mut document = self.parse_deferred(source);
543
544        // Resolve cross-references against this document's own catalog. For
545        // multi-document workflows, use `parse_deferred` and resolve later with
546        // a caller-supplied resolver via `Document::resolve_references`.
547        document.resolve_against_own_catalog(&*self.renderer);
548
549        document
550    }
551
552    /// Parse a UTF-8 string as an AsciiDoc document, leaving cross-references
553    /// unresolved.
554    ///
555    /// This behaves like [`parse()`], except it does not resolve
556    /// cross-references (`<<id>>`, `xref:id[…]`). The returned [`Document`]
557    /// carries its references in a deferred state; resolve them later with
558    /// [`Document::resolve_references`].
559    ///
560    /// This is the entry point for multi-document workflows (e.g. Antora-style
561    /// site generation): parse every document with this method, build a
562    /// combined index from each document's [`catalog()`], then resolve each
563    /// document against that index. This crate does not merge catalogs
564    /// itself.
565    ///
566    /// [`parse()`]: Self::parse
567    /// [`catalog()`]: Document::catalog
568    pub fn parse_deferred(&mut self, source: &str) -> Document<'static> {
569        // The time-dependent document attributes (docdate, doctime, docdatetime,
570        // docyear, and their local* siblings) are resolved lazily from a
571        // reference instant captured the first time one is read (see
572        // `resolve_datetime_attribute`). Reset that capture so each parse sees a
573        // fresh "now"; a parse that never references one does no datetime work.
574        *self.datetime_context.borrow_mut() = None;
575
576        // Drop leading YAML-style front matter (and record it in the
577        // `front-matter` attribute) when `skip-front-matter` is set, before the
578        // source reaches the preprocessor or header parser.
579        let stripped_source = self.skip_front_matter(source);
580        let source = stripped_source.as_deref().unwrap_or(source);
581
582        let (preprocessed_source, source_map, preprocessor_warnings, includes) =
583            preprocess(source, self);
584
585        // NOTE: `Document::parse` will transfer the catalog to itself at the end of the
586        // parsing operation. Start each parse with a fresh catalog.
587        *self.catalog.borrow_mut() = Catalog::new();
588
589        // Seed the fresh catalog with the files the preprocessor just expanded,
590        // so an inter-document cross reference to an included file can resolve
591        // to an internal anchor while the document is parsed. Replaying each
592        // event lets the catalog resolve a file that was included both fully and
593        // partially to a full include.
594        {
595            let mut catalog = self.catalog.borrow_mut();
596            for (key, full) in includes {
597                catalog.register_include(&key, full);
598            }
599        }
600
601        // Start each parse with an empty callout catalog.
602        *self.callouts.borrow_mut() = CalloutCatalog::default();
603
604        // Start each parse with no pending substitution warnings.
605        self.substitution_warnings.borrow_mut().clear();
606
607        // Reset section numbering for each new document.
608        self.last_section_number = SectionNumber::default();
609
610        // Start each parse with no block title carried over from a section
611        // heading.
612        self.pending_block_title = None;
613
614        // Reset counter (and captioned-block) numbering for each new document.
615        self.counter_values.borrow_mut().clear();
616        self.locked_counter_values.borrow_mut().clear();
617
618        Document::parse(
619            &preprocessed_source,
620            source_map,
621            preprocessor_warnings,
622            self,
623        )
624    }
625
626    /// Drops leading YAML-style front matter from `source`, mirroring
627    /// Asciidoctor's `Reader#skip_front_matter!`.
628    ///
629    /// Front matter is a block opened by a line of exactly `---` at the very
630    /// start of the document and closed by a matching `---` line. Only this
631    /// `---` fence (the YAML convention) is recognized – a `+++` TOML fence is
632    /// not – and the captured content is stored verbatim, never parsed. It is
633    /// only removed when the `skip-front-matter` attribute is set
634    /// (typically via the API); otherwise `---` retains its ordinary
635    /// meaning and this is a no-op. When a well-formed block is found, its
636    /// content (the lines between the delimiters, joined by LF and with the
637    /// delimiters excluded) is stored in the `front-matter` document
638    /// attribute, and a rewritten copy of the source is returned in which
639    /// every removed line – both delimiters and the content – is replaced
640    /// by a blank line. Preserving the line *count* keeps every following
641    /// line at its original line number, matching Asciidoctor (whose reader
642    /// advances `lineno` past the skipped block); the leading blank lines
643    /// are ignored by the header parser.
644    ///
645    /// Returns `None` (leaving the source untouched and setting no attribute)
646    /// when `skip-front-matter` is not set, when the first line is not `---`,
647    /// or when no closing `---` delimiter is found.
648    fn skip_front_matter(&mut self, source: &str) -> Option<String> {
649        if !self.is_attribute_set("skip-front-matter") {
650            return None;
651        }
652
653        // Strip a line's trailing end-of-line sequence (LF or CRLF) so the
654        // delimiter comparison and the captured content match Asciidoctor's
655        // chomped lines.
656        fn line_content(line: &str) -> &str {
657            let line = line.strip_suffix('\n').unwrap_or(line);
658            line.strip_suffix('\r').unwrap_or(line)
659        }
660
661        let mut lines = source.split_inclusive('\n');
662
663        // Front matter must open on the very first line, which must be exactly
664        // `---`.
665        let first = lines.next()?;
666        if line_content(first) != "---" {
667            return None;
668        }
669
670        // Byte offset just past the region being consumed, and the number of
671        // physical lines consumed (starting with the opening delimiter).
672        let mut consumed_end = first.len();
673        let mut consumed_lines = 1usize;
674
675        let mut front_matter = String::new();
676        let mut closed = false;
677
678        for line in lines {
679            consumed_end += line.len();
680            consumed_lines += 1;
681
682            if line_content(line) == "---" {
683                closed = true;
684                break;
685            }
686
687            if !front_matter.is_empty() {
688                front_matter.push('\n');
689            }
690
691            front_matter.push_str(line_content(line));
692        }
693
694        // Without a closing delimiter the block is malformed; leave the source
695        // (and attributes) untouched, matching Asciidoctor.
696        if !closed {
697            return None;
698        }
699
700        self.set_attribute_by_value_from_header("front-matter", &front_matter);
701
702        // Replace the consumed region with one blank line per removed physical
703        // line so the remaining content keeps its original line numbers.
704        let mut rewritten = String::with_capacity(source.len());
705        for _ in 0..consumed_lines {
706            rewritten.push('\n');
707        }
708
709        rewritten.push_str(&source[consumed_end..]);
710
711        Some(rewritten)
712    }
713
714    /// Retrieves the current interpreted value of a [document attribute].
715    ///
716    /// Each document holds a set of name-value pairs called document
717    /// attributes. These attributes provide a means of configuring the AsciiDoc
718    /// processor, declaring document metadata, and defining reusable content.
719    /// This page introduces document attributes and answers some questions
720    /// about the terminology used when referring to them.
721    ///
722    /// ## What are document attributes?
723    ///
724    /// Document attributes are effectively document-scoped variables for the
725    /// AsciiDoc language. The AsciiDoc language defines a set of built-in
726    /// attributes, and also allows the author (or extensions) to define
727    /// additional document attributes, which may replace built-in attributes
728    /// when permitted.
729    ///
730    /// Built-in attributes either provide access to read-only information about
731    /// the document and its environment or allow the author to configure
732    /// behavior of the AsciiDoc processor for a whole document or select
733    /// regions. Built-in attributes are effectively unordered. User-defined
734    /// attribute serve as a powerful text replacement tool. User-defined
735    /// attributes are stored in the order in which they are defined.
736    ///
737    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
738    pub fn attribute_value<N: AsRef<str>>(&self, name: N) -> InterpretedValue {
739        let name = name.as_ref();
740
741        // A counter's current value lives in the overlay and supersedes any
742        // earlier value of the attribute of the same name (see
743        // [`counter_values`](Self::counter_values)).
744        if let Some(value) = self.counter_values.borrow().get(name) {
745            return InterpretedValue::Value(value.clone());
746        }
747
748        // An unset `relfilesuffix` reads as the *effective* value of
749        // `outfilesuffix` — routed through this same reader so an
750        // `outfilesuffix` counter overlay is honored too (see
751        // [`tracks_outfilesuffix`](Self::tracks_outfilesuffix)).
752        if self.tracks_outfilesuffix(name) {
753            return self.attribute_value("outfilesuffix");
754        }
755
756        // Under `SafeMode::Server` or greater, `docdir` reads as empty and
757        // `docfile` is relativized (its `docdir` prefix stripped), matching
758        // Ruby Asciidoctor's document-init masking. Resolved here at read time
759        // (like `relfilesuffix` above and `max-attribute-value-size`) so the
760        // API-provided values are never rewritten and builder-call order does
761        // not matter (see [`masked_doc_path`]).
762        //
763        // [`masked_doc_path`]: crate::parser::safe_mode::masked_doc_path
764        if self.safe >= SafeMode::Server
765            && let Some(masked) = masked_doc_path(name, |n| self.raw_set_value(n))
766        {
767            return masked;
768        }
769
770        // `basebackend` / `filetype` are derived on the fly from the current
771        // `backend` (see [`derived_backend_value`]) rather than stored; they are
772        // read-only intrinsics, so no per-parser entry ever shadows this.
773        if let Some(value) = derived_backend_value(name, &self.attribute_values) {
774            return value;
775        }
776
777        match self.effective_attribute(name) {
778            Some(av) => {
779                if let InterpretedValue::Set = av.value
780                    && let Some(default) = self.default_attribute_values.get(name)
781                {
782                    InterpretedValue::Value(default.clone())
783                } else {
784                    av.value.clone()
785                }
786            }
787            // A time-dependent attribute is not materialized in either table; it
788            // is resolved on demand from the captured reference instant.
789            None => self
790                .resolve_datetime_attribute(name)
791                .unwrap_or(InterpretedValue::Unset),
792        }
793    }
794
795    /// Returns the raw stored string value of `name` if it currently resolves
796    /// to a plain [`Value`](InterpretedValue::Value), *before* any
797    /// safe-mode masking is applied. Returns `None` when the attribute is unset
798    /// or resolves to a non-value form.
799    ///
800    /// This feeds [`masked_doc_path`], which must compute the `docfile`
801    /// relativization from the *original* API-provided `docdir` rather than its
802    /// masked (blanked) form.
803    fn raw_set_value(&self, name: &str) -> Option<String> {
804        match self.effective_attribute(name)?.value {
805            InterpretedValue::Value(ref v) => Some(v.clone()),
806            _ => None,
807        }
808    }
809
810    /// Returns the effective attribute definition for `name`: a per-parser
811    /// entry (an override or an explicit [unset] tombstone) shadows the
812    /// shared built-in default, which in turn shadows an on-the-fly
813    /// synthesized attribute (the active `backend-html5-doctype-*` and
814    /// `safe-mode-*` flags). The synthesized attributes are never
815    /// materialized in either table.
816    ///
817    /// This is the *raw* lookup used by the attribute writers to decide whether
818    /// a name is locked against modification. The attribute *readers*
819    /// additionally resolve the read-only default of `relfilesuffix` (see
820    /// [`tracks_outfilesuffix`](Self::tracks_outfilesuffix)).
821    ///
822    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
823    pub(crate) fn effective_attribute(&self, name: &str) -> Option<&AttributeValue> {
824        if let Some(av) = self.attribute_values.get(name) {
825            return Some(av);
826        }
827        // `max-attribute-value-size` carries its `4096` default only under
828        // Secure, so it is resolved as a mode-aware synthesized attribute rather
829        // than a fixed built-in. It is consulted here *after* the per-parser map
830        // so a caller-supplied value (always API-only, hence always in that map)
831        // wins regardless of builder-call order, and a `with_safe_mode` change
832        // never rewrites it.
833        if name == "max-attribute-value-size" {
834            return Some(max_attribute_value_size_default(
835                self.safe == SafeMode::Secure,
836            ));
837        }
838        // `user-home` is the user's home directory below `SafeMode::Server` and
839        // the masking `.` at `Server`/`Secure`, so it too is resolved as a
840        // mode-aware synthesized attribute. Consulted here *after* the
841        // per-parser map so a caller-supplied `user-home` (always API-only,
842        // hence always in that map) wins regardless of builder-call order.
843        if name == "user-home" {
844            return Some(user_home_default(self.safe < SafeMode::Server));
845        }
846        if let Some(av) = built_in_attr(name) {
847            return Some(av);
848        }
849        synthesized_attr(name, &self.attribute_values)
850    }
851
852    /// Reports whether `name` is `relfilesuffix` in its unset state, in which
853    /// case a *read* resolves it to the current value of `outfilesuffix` (the
854    /// two diverge for non-HTML backends, e.g. `.xml` for DocBook — see
855    /// [issue #657](https://github.com/asciidoc-rs/asciidoc-parser/issues/657)).
856    ///
857    /// Returns `false` once `relfilesuffix` is explicitly set or unset (an
858    /// entry — a value or an [unset] tombstone — then lives in the
859    /// per-parser map), and for every other name. The redirect is
860    /// deliberately confined to the value *readers*: the attribute
861    /// *writers* consult [`effective_attribute`](Self::effective_attribute)
862    /// directly, so `relfilesuffix` stays modifiable anywhere rather than
863    /// inheriting the header-only modification context of `outfilesuffix`.
864    /// Callers must apply a like-named counter overlay first, so a
865    /// `{counter:relfilesuffix}` still wins over the tracked default.
866    ///
867    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
868    pub(crate) fn tracks_outfilesuffix(&self, name: &str) -> bool {
869        name == "relfilesuffix" && !self.attribute_values.contains_key(name)
870    }
871
872    /// Returns `true` if the parser has a [document attribute] by this name.
873    ///
874    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
875    pub fn has_attribute<N: AsRef<str>>(&self, name: N) -> bool {
876        let name = name.as_ref();
877        if self.counter_values.borrow().contains_key(name) {
878            return true;
879        }
880        if self.tracks_outfilesuffix(name) {
881            return self.has_attribute("outfilesuffix");
882        }
883        // A derived `basebackend` / `filetype` is present only while `backend`
884        // resolves to a non-empty value (see [`derived_backend_value`]).
885        if derived_backend_value(name, &self.attribute_values).is_some() {
886            return true;
887        }
888        self.effective_attribute(name).is_some() || self.resolve_datetime_attribute(name).is_some()
889    }
890
891    /// Returns `true` if the parser has a [document attribute] by this name
892    /// which has been set (i.e. is present and not [unset]).
893    ///
894    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
895    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
896    pub fn is_attribute_set<N: AsRef<str>>(&self, name: N) -> bool {
897        let name = name.as_ref();
898
899        // A counter always holds a concrete (set) value.
900        if self.counter_values.borrow().contains_key(name) {
901            return true;
902        }
903
904        if self.tracks_outfilesuffix(name) {
905            return self.is_attribute_set("outfilesuffix");
906        }
907
908        // A derived `basebackend` / `filetype` holds a concrete (set) value
909        // whenever it is present, i.e. while `backend` is non-empty (see
910        // [`derived_backend_value`]).
911        if derived_backend_value(name, &self.attribute_values).is_some() {
912            return true;
913        }
914
915        self.effective_attribute(name)
916            .map(|a| a.value != InterpretedValue::Unset)
917            .unwrap_or_else(|| self.resolve_datetime_attribute(name).is_some())
918    }
919
920    /// Returns the current `leveloffset` document attribute as a signed
921    /// integer.
922    ///
923    /// The `leveloffset` attribute shifts the effective level of every section
924    /// heading in scope (see the include directive's `leveloffset` option and
925    /// the `:leveloffset:` attribute entry). Relative assignments (`+N` / `-N`)
926    /// are resolved to an absolute value when the attribute is set (see
927    /// [`resolve_leveloffset_assignment`](Self::resolve_leveloffset_assignment)),
928    /// so the stored value is always a plain integer; a non-integer or unset
929    /// value yields an offset of `0`.
930    pub(crate) fn level_offset(&self) -> i32 {
931        match self.attribute_value("leveloffset") {
932            InterpretedValue::Value(v) => v.trim().parse::<i32>().unwrap_or(0),
933            _ => 0,
934        }
935    }
936
937    /// Returns the effective `max-attribute-value-size`: the byte limit applied
938    /// to a resolved attribute-entry value, or `None` when no limit is in
939    /// force.
940    ///
941    /// The value is coerced as Ruby's `String#to_i` would (matching
942    /// Asciidoctor); a non-positive result — including an explicit unset or `0`
943    /// — disables the limit. The `4096` default only exists under
944    /// `SafeMode::Secure` (see
945    /// [`apply_safe_mode_attributes`](Self::apply_safe_mode_attributes)), so in
946    /// a relaxed safe mode this resolves to `None` unless the caller sets an
947    /// explicit positive value.
948    fn max_attribute_value_size(&self) -> Option<usize> {
949        match self.attribute_value("max-attribute-value-size") {
950            InterpretedValue::Value(value) => {
951                let size = super::preprocessor::ruby_to_i(&value);
952                (size > 0).then(|| usize::try_from(size).unwrap_or(usize::MAX))
953            }
954            _ => None,
955        }
956    }
957
958    /// Applies the [`max-attribute-value-size`](Self::max_attribute_value_size)
959    /// limit to a freshly resolved attribute-entry `value`, truncating it (on a
960    /// character boundary, so a multibyte character is never split) when it
961    /// exceeds the limit. Values that already fit, and non-`Value` variants,
962    /// are returned unchanged.
963    fn limit_attribute_value_size(&self, value: InterpretedValue) -> InterpretedValue {
964        let Some(max) = self.max_attribute_value_size() else {
965            return value;
966        };
967
968        let InterpretedValue::Value(text) = value else {
969            return value;
970        };
971
972        if text.len() <= max {
973            return InterpretedValue::Value(text);
974        }
975
976        // Back up to the nearest character boundary at or below `max` so a
977        // multibyte character straddling the limit is dropped whole rather than
978        // split (which would otherwise yield invalid UTF-8).
979        let mut end = max;
980        while end > 0 && !text.is_char_boundary(end) {
981            end -= 1;
982        }
983
984        let mut text = text;
985        text.truncate(end);
986        InterpretedValue::Value(text)
987    }
988
989    /// Resolves a `leveloffset` assignment value, converting a relative form
990    /// (`+N` / `-N`) into the absolute value it produces given the
991    /// `leveloffset` currently in effect. Absolute values, and values that
992    /// aren't a signed integer, are returned unchanged.
993    ///
994    /// This mirrors Asciidoctor, where a relative `leveloffset` accumulates on
995    /// top of the offset already in effect. That accumulation is what lets the
996    /// offsets of nested includes compose: each `include::[leveloffset=+1]`
997    /// (and its `:leveloffset: +1` wrapper) shifts headings one level further
998    /// down relative to wherever the surrounding content already sits.
999    fn resolve_leveloffset_assignment(&self, value: InterpretedValue) -> InterpretedValue {
1000        let InterpretedValue::Value(ref v) = value else {
1001            return value;
1002        };
1003
1004        // Only a leading `+`/`-` marks a relative assignment; anything else
1005        // (an absolute value, or a non-numeric value) is stored unchanged.
1006        let trimmed = v.trim();
1007        if !trimmed.starts_with(['+', '-']) {
1008            return value;
1009        }
1010
1011        // Parse the whole signed value as `i64` so the extreme relative delta
1012        // `-2147483648` (whose magnitude exceeds `i32::MAX`) is still read as
1013        // itself rather than failing and being stored as an absolute value.
1014        match trimmed.parse::<i64>() {
1015            // The running offset is a valid `i32`, so widening it to `i64`
1016            // makes the accumulation itself infallible; `saturating_add` then
1017            // guards the (already absurd) case of a delta near `i64::MIN/MAX`,
1018            // and the result is clamped back into the `i32` the attribute
1019            // stores. This keeps a pathological offset from overflowing —
1020            // which would panic in debug builds and wrap in release builds —
1021            // rather than imposing a real bound the syntax does not otherwise
1022            // impose.
1023            Ok(delta) => InterpretedValue::Value(
1024                (self.level_offset() as i64)
1025                    .saturating_add(delta)
1026                    .clamp(i32::MIN as i64, i32::MAX as i64)
1027                    .to_string(),
1028            ),
1029            Err(_) => value,
1030        }
1031    }
1032
1033    /// Resolves a `leveloffset` assignment (see
1034    /// [`resolve_leveloffset_assignment`](Self::resolve_leveloffset_assignment))
1035    /// and, if the resulting absolute offset is so large or small that *every*
1036    /// heading would be shifted outside the supported 1..=5 section-level
1037    /// range, records a [`LeveloffsetExcludesAllHeadingLevels`] warning
1038    /// against `span`.
1039    ///
1040    /// [`LeveloffsetExcludesAllHeadingLevels`]:
1041    /// crate::warnings::WarningType::LeveloffsetExcludesAllHeadingLevels
1042    fn resolve_leveloffset_and_warn<'src>(
1043        &self,
1044        value: InterpretedValue,
1045        span: crate::Span<'src>,
1046        warnings: &mut Vec<Warning<'src>>,
1047    ) -> InterpretedValue {
1048        let value = self.resolve_leveloffset_assignment(value);
1049
1050        if let InterpretedValue::Value(ref v) = value
1051            && let Ok(offset) = v.trim().parse::<i32>()
1052            && !leveloffset_admits_any_heading(offset)
1053        {
1054            warnings.push(Warning {
1055                source: span,
1056                warning: WarningType::LeveloffsetExcludesAllHeadingLevels(offset),
1057                origin: None,
1058            });
1059        }
1060
1061        value
1062    }
1063
1064    /// Captures the parser's fully-resolved document-attribute state so it can
1065    /// outlive the parser — for example, retained on a [`Document`] to answer
1066    /// [`attribute_value`]/[`has_attribute`]/[`is_attribute_set`] without a
1067    /// parser in hand (the embed path a renderer uses for `convert_document`).
1068    ///
1069    /// This shares the parser's attribute tables by [`Arc`] rather than copying
1070    /// them, so it is cheap to take on every parse (the large built-in table is
1071    /// never deep-cloned). The time-dependent attributes are not materialized;
1072    /// the snapshot carries the reference-time configuration so it can resolve
1073    /// them on demand exactly as the parser does. See [`ResolvedAttributes`].
1074    ///
1075    /// [`Document`]: crate::Document
1076    /// [`attribute_value`]: Self::attribute_value
1077    /// [`has_attribute`]: Self::has_attribute
1078    /// [`is_attribute_set`]: Self::is_attribute_set
1079    pub(crate) fn snapshot_attributes(&self) -> ResolvedAttributes {
1080        ResolvedAttributes::new(
1081            Arc::clone(&self.attribute_values),
1082            Arc::clone(&self.default_attribute_values),
1083            self.counter_values.borrow().clone(),
1084            self.safe,
1085            self.reference_time.clone(),
1086            self.input_mtime.clone(),
1087        )
1088    }
1089
1090    /// Resolves whether a document title should be displayed, from the
1091    /// `showtitle`/`notitle` attribute pair (which are complements).
1092    ///
1093    /// `showtitle` takes precedence: if present, the title shows precisely when
1094    /// it is set. Otherwise `notitle`, if present, hides the title when set.
1095    /// When neither attribute is present, `default_shown` decides — a
1096    /// standalone document (such as a nested AsciiDoc table cell) shows its
1097    /// title, while an embedded document does not.
1098    pub(crate) fn resolve_show_title(&self, default_shown: bool) -> bool {
1099        if self.has_attribute("showtitle") {
1100            self.is_attribute_set("showtitle")
1101        } else if self.has_attribute("notitle") {
1102            !self.is_attribute_set("notitle")
1103        } else {
1104            default_shown
1105        }
1106    }
1107
1108    /// Applies the `notitle` ⇔ `showtitle` inverse-toggle linkage
1109    /// (Asciidoctor asciidoctor/asciidoctor#3804).
1110    ///
1111    /// `notitle` and `showtitle` are two spellings of a single "show the
1112    /// document title" switch, wired as opposites: assigning either attribute
1113    /// updates the other so the resolved document reflects one consistent
1114    /// toggle. This yields last-assignment-wins semantics for free, since each
1115    /// assignment rewrites the partner left by the previous one.
1116    ///
1117    /// `attr_name` is the attribute just assigned and `value` its stored value;
1118    /// the call is a no-op for any other name. Turning the toggle *off* (an
1119    /// explicit [unset], e.g. `:!notitle:`) turns the partner *on* — it is
1120    /// stored [set] with the same `modification_context` and
1121    /// `silent_when_locked` flag as the triggering assignment. Turning the
1122    /// toggle *on* (an empty `Set` or an explicit value, e.g. `:notitle:`)
1123    /// *removes* the partner entirely.
1124    ///
1125    /// The partner is removed — rather than left as an explicit unset
1126    /// tombstone — to mirror Asciidoctor's attribute-hash semantics, where an
1127    /// "off" attribute is simply absent. That keeps every observer consistent:
1128    /// `has_attribute`, `ifdef`/`ifndef`, and `{partner}` reference
1129    /// substitution all see the same absence Asciidoctor does (so, e.g., a
1130    /// `{showtitle}` reference stays literal after `:notitle:` rather than
1131    /// silently resolving to an empty string).
1132    ///
1133    /// [set]: https://docs.asciidoctor.org/asciidoc/latest/attributes/set-attributes/
1134    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
1135    fn apply_title_visibility_linkage(
1136        &mut self,
1137        attr_name: &str,
1138        value: &InterpretedValue,
1139        modification_context: ModificationContext,
1140        silent_when_locked: bool,
1141    ) {
1142        let partner = match attr_name {
1143            "notitle" => "showtitle",
1144            "showtitle" => "notitle",
1145            _ => return,
1146        };
1147
1148        // Either way the partner supersedes (and resets) any counter of the
1149        // same name, mirroring a direct assignment.
1150        self.counter_values.borrow_mut().remove(partner);
1151
1152        if let InterpretedValue::Unset = value {
1153            // The toggle is off, so the partner turns on.
1154            Arc::make_mut(&mut self.attribute_values).insert(
1155                partner.to_string(),
1156                AttributeValue {
1157                    allowable_value: AllowableValue::Any,
1158                    modification_context,
1159                    silent_when_locked,
1160                    value: InterpretedValue::Set,
1161                },
1162            );
1163        } else if self.attribute_values.contains_key(partner) {
1164            // The toggle is on, so the partner turns off — and, matching
1165            // Asciidoctor, "off" means absent. (Guarded so the common case of
1166            // no prior partner entry does not clone the shared map.)
1167            Arc::make_mut(&mut self.attribute_values).remove(partner);
1168        }
1169    }
1170
1171    /// Forces the `doctype` attribute to `value`.
1172    ///
1173    /// Used when a nested AsciiDoc table cell resets its doctype to the default
1174    /// (a cell does not inherit the parent's doctype). The value stays
1175    /// modifiable from the document body so the cell may still set its own
1176    /// doctype.
1177    ///
1178    /// The derived `backend-html5-doctype-{doctype}` attribute needs no
1179    /// explicit refresh: it is synthesized on the fly for whatever
1180    /// `doctype` currently resolves to (see
1181    /// [`attribute_value`](Self::attribute_value)).
1182    pub(crate) fn force_doctype(&mut self, value: &str) {
1183        Arc::make_mut(&mut self.attribute_values).insert(
1184            "doctype".to_string(),
1185            AttributeValue {
1186                allowable_value: AllowableValue::Any,
1187                modification_context: ModificationContext::ApiOrDocumentBody,
1188                silent_when_locked: false,
1189                value: InterpretedValue::Value(value.to_string()),
1190            },
1191        );
1192    }
1193
1194    /// Sets the value of an [intrinsic attribute].
1195    ///
1196    /// Intrinsic attributes are set automatically by the processor. These
1197    /// attributes provide information about the document being processed (e.g.,
1198    /// `docfile`), the security mode under which the processor is running
1199    /// (e.g., `safe-mode-name`), and information about the user’s environment
1200    /// (e.g., `user-home`).
1201    ///
1202    /// The [`modification_context`](ModificationContext) establishes whether
1203    /// the value can be subsequently modified by the document header and/or in
1204    /// the document body.
1205    ///
1206    /// Subsequent calls to this function or [`with_intrinsic_attribute_bool()`]
1207    /// are always permitted. The last such call for any given attribute name
1208    /// takes precendence.
1209    ///
1210    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1211    ///
1212    /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
1213    pub fn with_intrinsic_attribute<N: AsRef<str>, V: AsRef<str>>(
1214        mut self,
1215        name: N,
1216        value: V,
1217        modification_context: ModificationContext,
1218    ) -> Self {
1219        let name = name.as_ref().to_lowercase();
1220        let value = InterpretedValue::Value(value.as_ref().to_string());
1221        let attribute_value = AttributeValue {
1222            allowable_value: AllowableValue::Any,
1223            modification_context,
1224            silent_when_locked: false,
1225            value: value.clone(),
1226        };
1227
1228        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1229
1230        self.apply_title_visibility_linkage(&name, &value, modification_context, false);
1231
1232        self
1233    }
1234
1235    /// Sets the value of an [intrinsic attribute], rejecting any disallowed
1236    /// subsequent write *silently*.
1237    ///
1238    /// This behaves exactly like [`with_intrinsic_attribute()`] except that a
1239    /// document header or body assignment that the
1240    /// [`modification_context`](ModificationContext) does not permit is dropped
1241    /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
1242    /// The rejected write is otherwise handled identically (the value is left
1243    /// unchanged).
1244    ///
1245    /// This reproduces Asciidoctor's *silent* safe-mode attribute restrictions:
1246    /// under `SERVER`/`SECURE`, a document assignment of a restricted
1247    /// conversion attribute (`backend`, `doctype`, `docinfo`,
1248    /// `source-highlighter`) is simply dropped, with no diagnostic. Seed
1249    /// such an attribute as an [`ApiOnly`](ModificationContext::ApiOnly)
1250    /// silent intrinsic to lock it against document assignment without
1251    /// warning.
1252    ///
1253    /// Subsequent calls to this function or the other
1254    /// `with_intrinsic_attribute` variants are always permitted. The last
1255    /// such call for any given attribute name takes precedence.
1256    ///
1257    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1258    ///
1259    /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
1260    pub fn with_intrinsic_attribute_silent<N: AsRef<str>, V: AsRef<str>>(
1261        mut self,
1262        name: N,
1263        value: V,
1264        modification_context: ModificationContext,
1265    ) -> Self {
1266        let name = name.as_ref().to_lowercase();
1267        let value = InterpretedValue::Value(value.as_ref().to_string());
1268        let attribute_value = AttributeValue {
1269            allowable_value: AllowableValue::Any,
1270            modification_context,
1271            silent_when_locked: true,
1272            value: value.clone(),
1273        };
1274
1275        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1276
1277        self.apply_title_visibility_linkage(&name, &value, modification_context, true);
1278
1279        self
1280    }
1281
1282    /// Register a referenceable element (anchor, section, bibliography entry)
1283    /// in the document catalog.
1284    ///
1285    /// This takes `&self` (rather than `&mut self`) so that it can be called
1286    /// from inline-substitution code paths that only hold a shared reference to
1287    /// the parser, such as a regex [`Replacer`](regex::Replacer).
1288    pub(crate) fn register_ref(
1289        &self,
1290        id: &str,
1291        reftext: Option<&str>,
1292        ref_type: RefType,
1293    ) -> Result<(), crate::document::DuplicateIdError> {
1294        self.catalog
1295            .borrow_mut()
1296            .register_ref(id, reftext, ref_type)
1297    }
1298
1299    /// Attaches an [`XrefSignifier`](crate::parser::XrefSignifier) to an
1300    /// already-registered catalog element, so a cross-reference to it can build
1301    /// `full`/`short` [`xrefstyle`](crate::parser::XrefStyle) text.
1302    ///
1303    /// Takes `&self` for the same reason as
1304    /// [`register_ref`](Self::register_ref).
1305    pub(crate) fn set_ref_signifier(&self, id: &str, signifier: crate::parser::XrefSignifier) {
1306        self.catalog.borrow_mut().set_signifier(id, signifier);
1307    }
1308
1309    /// Records a referenced image in the document catalog when
1310    /// [`catalog_assets`](Self::with_catalog_assets) is enabled. A no-op
1311    /// otherwise.
1312    ///
1313    /// `target` is the (already attribute-substituted) image target as written
1314    /// in the macro; `imagesdir` is the value of the document `imagesdir`
1315    /// attribute at the point of reference, or `None` when it is unset.
1316    ///
1317    /// Takes `&self` so it can be called from the macros substitution step,
1318    /// which only holds a shared reference to the parser.
1319    pub(crate) fn register_image(&self, target: String, imagesdir: Option<String>) {
1320        if self.catalog_assets {
1321            self.catalog.borrow_mut().register_image(target, imagesdir);
1322        }
1323    }
1324
1325    /// Records a referenced link target in the document catalog when
1326    /// [`catalog_assets`](Self::with_catalog_assets) is enabled. A no-op
1327    /// otherwise.
1328    ///
1329    /// `target` is the final link target as it appears in the rendered `href`
1330    /// (e.g. `https://example.org`, `mailto:fred@example.com`).
1331    ///
1332    /// Takes `&self` so it can be called from the macros substitution step,
1333    /// which only holds a shared reference to the parser.
1334    pub(crate) fn register_link(&self, target: String) {
1335        if self.catalog_assets {
1336            self.catalog.borrow_mut().register_link(target);
1337        }
1338    }
1339
1340    /// Registers a callout number defined by a verbatim block.
1341    ///
1342    /// Takes `&self` so it can be called from the callouts substitution step,
1343    /// which only holds a shared reference to the parser.
1344    pub(crate) fn register_callout(&self, number: u32) {
1345        self.callouts.borrow_mut().current.push(number);
1346    }
1347
1348    /// Returns `true` if a callout numbered `number` was registered for the
1349    /// current (not-yet-closed) callout list.
1350    pub(crate) fn callout_defined(&self, number: u32) -> bool {
1351        self.callouts.borrow().current.contains(&number)
1352    }
1353
1354    /// Closes the current callout list, so callouts registered afterward belong
1355    /// to the next list.
1356    pub(crate) fn close_callout_list(&self) {
1357        self.callouts.borrow_mut().current.clear();
1358    }
1359
1360    /// Returns the number of an already-defined footnote with the given ID, if
1361    /// one exists in the current document's footnote registry.
1362    ///
1363    /// Takes `&self` so it can be called from the macros substitution step,
1364    /// which only holds a shared reference to the parser.
1365    pub(crate) fn footnote_index_for_id(&self, id: &str) -> Option<String> {
1366        self.catalog
1367            .borrow()
1368            .footnote_with_id(id)
1369            .map(|f| f.index.clone())
1370    }
1371
1372    /// Defines a new footnote, advancing the `footnote-number` counter and
1373    /// registering the footnote in the current document's registry. Returns the
1374    /// number assigned to the footnote.
1375    ///
1376    /// `source` is the span of the content the defining `footnote:[…]` macro
1377    /// was written in; its offset into the document source is recorded so a
1378    /// cross-reference warning can be anchored at the footnote rather than at
1379    /// the whole document. When the footnote is defined while substituting a
1380    /// privately-owned sub-source (a Markdown-style blockquote or an AsciiDoc
1381    /// table cell — see
1382    /// [`owned_subsource_depth`](Self::owned_subsource_depth)), that offset
1383    /// does not map to the document, so no location is recorded and
1384    /// resolution falls back to the whole-document span.
1385    ///
1386    /// Takes `&self` so it can be called from the macros substitution step.
1387    pub(crate) fn define_footnote(
1388        &self,
1389        id: Option<&str>,
1390        text: String,
1391        xrefs: Vec<crate::content::XrefSegment>,
1392        source: crate::Span<'_>,
1393    ) -> String {
1394        // A footnote's text is extracted out of the block during macro
1395        // substitution, so any cross-reference inside it never reaches the
1396        // document-level resolution pass over block content. Those
1397        // cross-references are captured (as placeholders in `text` plus the
1398        // `xrefs` segments) so they can be resolved alongside the block
1399        // references. The stored `text` is the unresolved fallback rendering
1400        // until then, so it is always clean.
1401        let (text, deferred) = if xrefs.is_empty() {
1402            (text, None)
1403        } else {
1404            let deferred = crate::content::FootnoteDeferred::new(text, xrefs);
1405            let rendered = deferred.render(&*self.renderer);
1406            (rendered, Some(Box::new(deferred)))
1407        };
1408
1409        // Footnotes are numbered consecutively throughout the document via the
1410        // `footnote-number` counter, which is seeded to `0` so the first
1411        // footnote is numbered `1`. The counter is a document-wide attribute, so
1412        // numbering continues across nested documents (AsciiDoc table cells)
1413        // even though the footnote *list* does not. The counter honors any seed
1414        // the document sets, so a non-integer seed yields a non-integer number
1415        // (matching Asciidoctor); the value is therefore kept as a string.
1416        let index = self.counter("footnote-number", None);
1417
1418        // Record the defining occurrence's location only when it is locatable in
1419        // the document source. A footnote defined inside an owned sub-source
1420        // indexes that private source, whose offset would misplace the warning,
1421        // so it is left unrecorded (resolution then falls back to the
1422        // whole-document span).
1423        let location = if self.owned_subsource_depth == 0 {
1424            Some((source.byte_offset(), source.data().len()))
1425        } else {
1426            None
1427        };
1428
1429        self.catalog
1430            .borrow_mut()
1431            .register_footnote(crate::document::Footnote {
1432                index: index.clone(),
1433                id: id.map(|s| s.to_owned()),
1434                text,
1435                deferred,
1436                location,
1437            });
1438
1439        index
1440    }
1441
1442    /// Removes and returns the current document's footnote list, leaving an
1443    /// empty list behind. Used to give a nested document (an AsciiDoc table
1444    /// cell) its own footnote registry; see [`restore_footnotes`].
1445    ///
1446    /// [`restore_footnotes`]: Self::restore_footnotes
1447    pub(crate) fn take_footnotes(&self) -> Vec<crate::document::Footnote> {
1448        self.catalog.borrow_mut().take_footnotes()
1449    }
1450
1451    /// Restores a previously-[taken](Self::take_footnotes) footnote list,
1452    /// discarding any footnotes registered in the meantime (i.e. those defined
1453    /// inside the nested document).
1454    pub(crate) fn restore_footnotes(&self, footnotes: Vec<crate::document::Footnote>) {
1455        self.catalog.borrow_mut().restore_footnotes(footnotes);
1456    }
1457
1458    /// Records a warning produced while replacing attribute references.
1459    ///
1460    /// Takes `&self` so it can be called from the attributes substitution step,
1461    /// which only holds a shared reference to the parser. `source` locates the
1462    /// text the warning refers to; its byte offset and length are stored so a
1463    /// spanned [`Warning`] can be reconstructed later (see
1464    /// [`take_substitution_warnings`](Self::take_substitution_warnings)).
1465    pub(crate) fn record_substitution_warning(
1466        &self,
1467        source: crate::Span<'_>,
1468        warning: WarningType,
1469    ) {
1470        self.substitution_warnings
1471            .borrow_mut()
1472            .push(DeferredWarning {
1473                offset: source.byte_offset(),
1474                len: source.len(),
1475                warning,
1476                origin: None,
1477            });
1478    }
1479
1480    /// Returns the number of substitution warnings recorded so far.
1481    ///
1482    /// Used together with [`truncate_substitution_warnings`] to discard
1483    /// warnings recorded while parsing an owned (e.g. include-expanded) source,
1484    /// whose offsets do not refer to the primary document source.
1485    ///
1486    /// [`truncate_substitution_warnings`]: Self::truncate_substitution_warnings
1487    pub(crate) fn substitution_warnings_len(&self) -> usize {
1488        self.substitution_warnings.borrow().len()
1489    }
1490
1491    /// Discards any substitution warnings recorded since the buffer held `len`
1492    /// entries.
1493    pub(crate) fn truncate_substitution_warnings(&self, len: usize) {
1494        self.substitution_warnings.borrow_mut().truncate(len);
1495    }
1496
1497    /// Removes and returns any substitution warnings recorded since the buffer
1498    /// held `len` entries.
1499    pub(crate) fn drain_substitution_warnings_since(&self, len: usize) -> Vec<DeferredWarning> {
1500        self.substitution_warnings.borrow_mut().split_off(len)
1501    }
1502
1503    /// Takes the substitution warnings recorded during parsing, leaving the
1504    /// buffer empty.
1505    pub(crate) fn take_substitution_warnings(&self) -> Vec<DeferredWarning> {
1506        std::mem::take(&mut *self.substitution_warnings.borrow_mut())
1507    }
1508
1509    /// Returns `true` while the parser is parsing the content of an owned
1510    /// (include-expanded) AsciiDoc table cell, i.e. when a span's line indexes
1511    /// an owned copy rather than the document source.
1512    pub(crate) fn is_in_owned_cell_source(&self) -> bool {
1513        !self.owned_cell_source_maps.is_empty()
1514    }
1515
1516    /// Pushes an owned cell's source map for the duration of its parse. Paired
1517    /// with [`pop_owned_cell_source_map`](Self::pop_owned_cell_source_map).
1518    pub(crate) fn push_owned_cell_source_map(&mut self, source_map: Rc<SourceMap>) {
1519        self.owned_cell_source_maps.push(source_map);
1520    }
1521
1522    /// Pops the source map pushed by the matching
1523    /// [`push_owned_cell_source_map`](Self::push_owned_cell_source_map).
1524    pub(crate) fn pop_owned_cell_source_map(&mut self) {
1525        self.owned_cell_source_maps.pop();
1526    }
1527
1528    /// Resolves a line number in the innermost owned cell's source back to the
1529    /// file and line it originally came from, using that cell's source map.
1530    ///
1531    /// Returns `None` when not inside an owned cell source.
1532    pub(crate) fn owned_cell_original_file_and_line(&self, line: usize) -> Option<SourceLine> {
1533        self.owned_cell_source_maps
1534            .last()
1535            .and_then(|sm| sm.original_file_and_line(line))
1536    }
1537
1538    /// Records a warning raised by a directive at `line` in the innermost owned
1539    /// cell's source, resolving `line` to the file and line it originally came
1540    /// from so the warning can be surfaced later with a real cursor (see
1541    /// [`take_owned_cell_warnings`]).
1542    ///
1543    /// A no-op when not inside an owned cell source (the line does not resolve
1544    /// to an owned origin) — the caller only reaches this from an owned-cell
1545    /// parse, but the guard keeps a stray call from recording an unanchorable
1546    /// warning.
1547    ///
1548    /// Takes `&self`: an owned-cell parse holds the parser mutably behind a
1549    /// `self_cell` construction closure, so recording goes through interior
1550    /// mutability.
1551    ///
1552    /// [`take_owned_cell_warnings`]: Self::take_owned_cell_warnings
1553    pub(crate) fn record_owned_cell_warning(
1554        &self,
1555        line: usize,
1556        warning: WarningType,
1557        origin_override: Option<SourceLine>,
1558    ) {
1559        // A no-output directive that originated in a file the cell *included*
1560        // carries a true `(file, line)` origin already; prefer it. Otherwise
1561        // resolve the cell's own directive line through the enclosing owned
1562        // cell's source map.
1563        let origin = origin_override.or_else(|| self.owned_cell_original_file_and_line(line));
1564        if let Some(origin) = origin {
1565            self.owned_cell_warnings
1566                .borrow_mut()
1567                .push(ResolvedWarning { origin, warning });
1568        }
1569    }
1570
1571    /// Takes the owned-cell warnings recorded during parsing, leaving the
1572    /// buffer empty.
1573    pub(crate) fn take_owned_cell_warnings(&self) -> Vec<ResolvedWarning> {
1574        std::mem::take(&mut *self.owned_cell_warnings.borrow_mut())
1575    }
1576
1577    /// Generate a unique ID derived from `base_id` and register it in the
1578    /// document catalog, returning the ID that was assigned.
1579    pub(crate) fn generate_and_register_unique_id(
1580        &self,
1581        base_id: &str,
1582        reftext: Option<&str>,
1583        ref_type: RefType,
1584    ) -> String {
1585        // A synthetic ID that collides with an existing one is enumerated using
1586        // the `idseparator` (e.g. `_section_one`, `_section_one_2`), matching
1587        // Ruby Asciidoctor — not a hardcoded hyphen. Mirrors the separator
1588        // resolution in `generate_section_id`.
1589        let separator = self
1590            .attribute_value("idseparator")
1591            .as_maybe_str()
1592            .unwrap_or_default()
1593            .chars()
1594            .next()
1595            .map(|c| c.to_string())
1596            .unwrap_or_default();
1597
1598        self.catalog
1599            .borrow_mut()
1600            .generate_and_register_unique_id(base_id, reftext, ref_type, &separator)
1601    }
1602
1603    /// Takes the catalog from the parser, transferring ownership and leaving an
1604    /// empty catalog in its place.
1605    ///
1606    /// This is used by `Document::parse` to transfer the catalog from the
1607    /// parser to the document at the end of parsing.
1608    pub(crate) fn take_catalog(&mut self) -> Catalog {
1609        std::mem::take(&mut *self.catalog.borrow_mut())
1610    }
1611
1612    /* Comment out until we're prepared to use and test this.
1613        /// Sets the default value for an [intrinsic attribute].
1614        ///
1615        /// Default values for attributes are provided automatically by the
1616        /// processor. These values provide a falllback textual value for an
1617        /// attribute when it is merely "set" by the document via API, header, or
1618        /// document body.
1619        ///
1620        /// Calling this does not imply that the value is set automatically by
1621        /// default, nor does it establish any policy for where the value may be
1622        /// modified. For that, please use [`with_intrinsic_attribute`].
1623        ///
1624        /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1625        /// [`with_intrinsic_attribute`]: Self::with_intrinsic_attribute
1626        pub fn with_default_attribute_value<N: AsRef<str>, V: AsRef<str>>(
1627            mut self,
1628            name: N,
1629            value: V,
1630        ) -> Self {
1631            self.default_attribute_values
1632                .insert(name.as_ref().to_string(), value.as_ref().to_string());
1633
1634            self
1635        }
1636    */
1637
1638    /// Sets the value of an [intrinsic attribute] from a boolean flag.
1639    ///
1640    /// A boolean `true` is interpreted as "set." A boolean `false` is
1641    /// interpreted as "unset."
1642    ///
1643    /// Intrinsic attributes are set automatically by the processor. These
1644    /// attributes provide information about the document being processed (e.g.,
1645    /// `docfile`), the security mode under which the processor is running
1646    /// (e.g., `safe-mode-name`), and information about the user’s environment
1647    /// (e.g., `user-home`).
1648    ///
1649    /// The [`modification_context`](ModificationContext) establishes whether
1650    /// the value can be subsequently modified by the document header and/or in
1651    /// the document body.
1652    ///
1653    /// Subsequent calls to this function or [`with_intrinsic_attribute()`] are
1654    /// always permitted. The last such call for any given attribute name takes
1655    /// precendence.
1656    ///
1657    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1658    ///
1659    /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
1660    pub fn with_intrinsic_attribute_bool<N: AsRef<str>>(
1661        mut self,
1662        name: N,
1663        value: bool,
1664        modification_context: ModificationContext,
1665    ) -> Self {
1666        let name = name.as_ref().to_lowercase();
1667        let value = if value {
1668            InterpretedValue::Set
1669        } else {
1670            InterpretedValue::Unset
1671        };
1672        let attribute_value = AttributeValue {
1673            allowable_value: AllowableValue::Any,
1674            modification_context,
1675            silent_when_locked: false,
1676            value: value.clone(),
1677        };
1678
1679        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1680
1681        self.apply_title_visibility_linkage(&name, &value, modification_context, false);
1682
1683        self
1684    }
1685
1686    /// Sets the value of an [intrinsic attribute] from a boolean flag,
1687    /// rejecting any disallowed subsequent write *silently*.
1688    ///
1689    /// This behaves exactly like [`with_intrinsic_attribute_bool()`] except
1690    /// that a document header or body assignment that the
1691    /// [`modification_context`](ModificationContext) does not permit is dropped
1692    /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
1693    /// See [`with_intrinsic_attribute_silent()`] for the motivating use case
1694    /// (Asciidoctor's silent safe-mode attribute restrictions).
1695    ///
1696    /// A boolean `true` is interpreted as "set." A boolean `false` is
1697    /// interpreted as "unset."
1698    ///
1699    /// Subsequent calls to this function or the other
1700    /// `with_intrinsic_attribute` variants are always permitted. The last
1701    /// such call for any given attribute name takes precedence.
1702    ///
1703    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1704    ///
1705    /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
1706    /// [`with_intrinsic_attribute_silent()`]: Self::with_intrinsic_attribute_silent
1707    pub fn with_intrinsic_attribute_bool_silent<N: AsRef<str>>(
1708        mut self,
1709        name: N,
1710        value: bool,
1711        modification_context: ModificationContext,
1712    ) -> Self {
1713        let name = name.as_ref().to_lowercase();
1714        let value = if value {
1715            InterpretedValue::Set
1716        } else {
1717            InterpretedValue::Unset
1718        };
1719        let attribute_value = AttributeValue {
1720            allowable_value: AllowableValue::Any,
1721            modification_context,
1722            silent_when_locked: true,
1723            value: value.clone(),
1724        };
1725
1726        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1727
1728        self.apply_title_visibility_linkage(&name, &value, modification_context, true);
1729
1730        self
1731    }
1732
1733    /// Pins the reference time (the value of "now") used to compute the
1734    /// time-dependent document attributes, for reproducible output.
1735    ///
1736    /// AsciiDoc derives `localdate`, `localtime`, `localdatetime`, and
1737    /// `localyear` from the current wall-clock time, and `docdate`, `doctime`,
1738    /// `docdatetime`, and `docyear` from the source file's modification time
1739    /// (falling back to "now" when no modification time is known). Because
1740    /// those values change from run to run, any output that embeds them is
1741    /// not reproducible. Supplying a [`ReferenceTime`] pins "now" to a
1742    /// fixed instant so the computed attributes are stable.
1743    ///
1744    /// This is the API counterpart of the `SOURCE_DATE_EPOCH` environment
1745    /// variable; a value set here takes precedence over that variable. To pin
1746    /// only the source-modification time that drives the `doc*` attributes
1747    /// (leaving `local*` on the real clock), use [`with_input_mtime`] instead;
1748    /// an [`with_input_mtime`] value takes precedence over this one for the
1749    /// `doc*` attributes.
1750    ///
1751    /// A value set via the document header or body (e.g. an explicit
1752    /// `:docdate:`) still wins over the computed default.
1753    ///
1754    /// [`with_input_mtime`]: Self::with_input_mtime
1755    pub fn with_reference_time(mut self, reference_time: ReferenceTime) -> Self {
1756        self.reference_time = Some(reference_time);
1757        self
1758    }
1759
1760    /// Pins the modification time of the source document, which drives the
1761    /// `docdate`, `doctime`, `docdatetime`, and `docyear` attributes.
1762    ///
1763    /// This mirrors Asciidoctor's `input_mtime` option: the `local*` attributes
1764    /// continue to reflect "now" (the real clock, a [`with_reference_time`]
1765    /// value, or `SOURCE_DATE_EPOCH`), while the `doc*` attributes reflect the
1766    /// supplied source modification time. A value set here takes precedence
1767    /// over a [`with_reference_time`] value for the `doc*` attributes.
1768    ///
1769    /// A value set via the document header or body (e.g. an explicit
1770    /// `:docdate:`) still wins over the computed default.
1771    ///
1772    /// [`with_reference_time`]: Self::with_reference_time
1773    pub fn with_input_mtime(mut self, input_mtime: ReferenceTime) -> Self {
1774        self.input_mtime = Some(input_mtime);
1775        self
1776    }
1777
1778    /// Resolves a time-dependent document attribute (`docdate`, `doctime`,
1779    /// `docdatetime`, `docyear`, or a `local*` sibling) on demand, returning
1780    /// `None` for any other name (or when the attribute resolves to no value,
1781    /// as `docyear` / `localyear` do for an explicit date without a `YYYY-`
1782    /// prefix).
1783    ///
1784    /// The reference instant is captured lazily on the first such read of a
1785    /// parse and cached (see [`datetime_context`](Self::datetime_context)), so
1786    /// a parse that never references a time-dependent attribute does no
1787    /// clock, environment, or allocation work, and repeated reads observe
1788    /// one consistent instant. An explicit value assigned via the API,
1789    /// header, or body always wins; the derived `*year` / `*datetime` are
1790    /// computed from whichever value each sibling resolves to. See
1791    /// [`DatetimeContext`].
1792    ///
1793    /// Takes `&self` so it can be called from the shared-reference attribute
1794    /// readers (which the substitution code paths reach with only a `&Parser`);
1795    /// the lazy capture goes through the [`RefCell`].
1796    fn resolve_datetime_attribute(&self, name: &str) -> Option<InterpretedValue> {
1797        if !is_datetime_attribute(name) {
1798            return None;
1799        }
1800
1801        let context = {
1802            let mut slot = self.datetime_context.borrow_mut();
1803            slot.get_or_insert_with(|| {
1804                DatetimeContext::capture(self.reference_time.as_ref(), self.input_mtime.as_ref())
1805            })
1806            .clone()
1807        };
1808
1809        context
1810            .resolve(name, |sibling| self.stored_datetime_override(sibling))
1811            .map(InterpretedValue::Value)
1812    }
1813
1814    /// Returns the *explicitly-set* value of `name` from the per-parser
1815    /// attribute map, as an owned string (a value-less "set" reads as an empty
1816    /// string), or `None` when it has no such entry.
1817    ///
1818    /// This reads only the stored overrides — never the on-the-fly datetime
1819    /// resolution — so it can supply the explicit sibling values
1820    /// [`resolve_datetime_attribute`](Self::resolve_datetime_attribute) needs
1821    /// without recursing. It mirrors the Ruby truthiness the datetime
1822    /// computation relies on (`attrs['docdate']`), where any present value —
1823    /// including an empty string — counts as explicitly supplied.
1824    fn stored_datetime_override(&self, name: &str) -> Option<String> {
1825        self.attribute_values
1826            .get(name)
1827            .and_then(|av| match &av.value {
1828                InterpretedValue::Value(value) => Some(value.clone()),
1829                InterpretedValue::Set => Some(String::new()),
1830                InterpretedValue::Unset => None,
1831            })
1832    }
1833
1834    /// Replace the default [`InlineSubstitutionRenderer`] for this parser.
1835    ///
1836    /// The default implementation of [`InlineSubstitutionRenderer`] that is
1837    /// provided is suitable for HTML5 rendering. If you are targeting a
1838    /// different back-end rendering, you will need to provide your own
1839    /// implementation and set it using this call before parsing.
1840    pub fn with_inline_substitution_renderer<ISR: InlineSubstitutionRenderer + 'static>(
1841        mut self,
1842        renderer: ISR,
1843    ) -> Self {
1844        self.renderer = Rc::new(renderer);
1845        self
1846    }
1847
1848    /// Sets the name of the primary file to be parsed when [`parse()`] is
1849    /// called.
1850    ///
1851    /// This name will be used for any error messages detected in this file and
1852    /// also will be passed to [`IncludeFileHandler::resolve_target()`] as the
1853    /// `source` argument for any `include::` file resolution requests from this
1854    /// file.
1855    ///
1856    /// [`parse()`]: Self::parse
1857    /// [`IncludeFileHandler::resolve_target()`]: crate::parser::IncludeFileHandler::resolve_target
1858    pub fn with_primary_file_name<S: AsRef<str>>(mut self, name: S) -> Self {
1859        self.primary_file_name = Some(name.as_ref().to_owned());
1860        self
1861    }
1862
1863    /// Sets the [`IncludeFileHandler`] for this parser.
1864    ///
1865    /// The include file handler is responsible for resolving `include::`
1866    /// directives encountered during preprocessing. If no handler is provided,
1867    /// include directives will be ignored.
1868    ///
1869    /// [`IncludeFileHandler`]: crate::parser::IncludeFileHandler
1870    pub fn with_include_file_handler<IFH: IncludeFileHandler + 'static>(
1871        mut self,
1872        handler: IFH,
1873    ) -> Self {
1874        self.include_file_handler = Some(Rc::new(handler));
1875        self
1876    }
1877
1878    /// Sets the [`DocinfoFileHandler`] for this parser.
1879    ///
1880    /// The docinfo file handler is responsible for providing the content of
1881    /// [docinfo files] requested while resolving a document's docinfo (see the
1882    /// `docinfo` attribute). If no handler is provided, no docinfo content is
1883    /// resolved and [`Document::docinfo`] returns an empty string for every
1884    /// location.
1885    ///
1886    /// [`DocinfoFileHandler`]: crate::parser::DocinfoFileHandler
1887    /// [docinfo files]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
1888    /// [`Document::docinfo`]: crate::Document::docinfo
1889    pub fn with_docinfo_file_handler<DFH: DocinfoFileHandler + 'static>(
1890        mut self,
1891        handler: DFH,
1892    ) -> Self {
1893        self.docinfo_file_handler = Some(Rc::new(handler));
1894        self
1895    }
1896
1897    /// Sets the [`SvgFileHandler`] for this parser.
1898    ///
1899    /// The SVG file handler is responsible for providing the raw contents of an
1900    /// SVG file requested by an inline image with the `inline` option (e.g.
1901    /// `image:diagram.svg[opts=inline]`). If no handler is provided, inline SVG
1902    /// images fall back to rendering their alt text.
1903    ///
1904    /// [`SvgFileHandler`]: crate::parser::SvgFileHandler
1905    pub fn with_svg_file_handler<SFH: SvgFileHandler + 'static>(mut self, handler: SFH) -> Self {
1906        self.svg_file_handler = Some(Rc::new(handler));
1907        self
1908    }
1909
1910    /// Sets the [`ImageFileHandler`] for this parser.
1911    ///
1912    /// The image file handler is responsible for providing the raw bytes of an
1913    /// image that must be embedded as a `data:` URI – i.e. when the `data-uri`
1914    /// document attribute is set and the safe mode is below
1915    /// [`SafeMode::Secure`]. If no handler is provided (or it cannot find the
1916    /// file), such images fall back to an ordinary web path, exactly as if
1917    /// `data-uri` were not set.
1918    ///
1919    /// [`ImageFileHandler`]: crate::parser::ImageFileHandler
1920    pub fn with_image_file_handler<IFH: ImageFileHandler + 'static>(
1921        mut self,
1922        handler: IFH,
1923    ) -> Self {
1924        self.image_file_handler = Some(Rc::new(handler));
1925        self
1926    }
1927
1928    /// Enables or disables cataloging of referenced image assets.
1929    ///
1930    /// When enabled (Asciidoctor's `catalog_assets` API option), each image
1931    /// referenced by an `image:`/`image::` macro is recorded in the document
1932    /// catalog and can be retrieved afterward via
1933    /// [`Catalog::images`](crate::document::Catalog::images). The default is
1934    /// disabled, in which case no image references are recorded.
1935    pub fn with_catalog_assets(mut self, catalog_assets: bool) -> Self {
1936        self.catalog_assets = catalog_assets;
1937        self
1938    }
1939
1940    /// Sets the [`SafeMode`] under which the document is parsed and rendered.
1941    ///
1942    /// The default is [`SafeMode::Secure`], the most conservative setting.
1943    /// Relaxing the safe mode enables security-sensitive rendering behavior,
1944    /// such as rendering an interactive SVG image as an `<object>` element.
1945    ///
1946    /// [`SafeMode`]: crate::SafeMode
1947    pub fn with_safe_mode(mut self, safe: SafeMode) -> Self {
1948        self.safe = safe;
1949        self.apply_safe_mode_attributes();
1950        self
1951    }
1952
1953    /// Overrides the `safe-mode-*` family of [intrinsic attributes] from the
1954    /// current safe mode.
1955    ///
1956    /// These attributes let a document (or a downstream converter) inspect the
1957    /// security mode under which it is being processed:
1958    ///
1959    /// * `safe-mode-level` — the numeric level (`0`, `1`, `10`, or `20`).
1960    /// * `safe-mode-name` — the lowercase mode name (`unsafe`, `safe`,
1961    ///   `server`, or `secure`).
1962    /// * `safe-mode-<name>` — a single flag attribute (set to an empty value)
1963    ///   naming the active mode; the flags for the other modes are absent so
1964    ///   that a reference to them resolves literally.
1965    ///
1966    /// Only `safe-mode-level` and `safe-mode-name` are stored here (shadowing
1967    /// their built-in Secure-mode defaults). The active `safe-mode-<name>` flag
1968    /// is synthesized on the fly from `safe-mode-name` (see
1969    /// [`synthesized_attr`]), so exactly one flag is ever defined and the
1970    /// inactive flags stay absent without any per-mode bookkeeping here.
1971    ///
1972    /// All of these are read-only from the document's perspective (they can
1973    /// only be established via the API), matching Ruby Asciidoctor.
1974    ///
1975    /// [intrinsic attributes]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1976    fn apply_safe_mode_attributes(&mut self) {
1977        let intrinsic = |value: InterpretedValue| AttributeValue {
1978            allowable_value: AllowableValue::Any,
1979            modification_context: ModificationContext::ApiOnly,
1980            silent_when_locked: false,
1981            value,
1982        };
1983
1984        let attrs = Arc::make_mut(&mut self.attribute_values);
1985        attrs.insert(
1986            "safe-mode-level".to_string(),
1987            intrinsic(InterpretedValue::Value(self.safe.level().to_string())),
1988        );
1989        attrs.insert(
1990            "safe-mode-name".to_string(),
1991            intrinsic(InterpretedValue::Value(self.safe.name().to_string())),
1992        );
1993
1994        // NOTE: `max-attribute-value-size` is deliberately *not* touched here.
1995        // Its Secure-only default is resolved as a mode-aware synthesized
1996        // attribute in [`effective_attribute`](Self::effective_attribute), so a
1997        // caller's explicit limit (which lives in `attribute_values`) is never
1998        // clobbered by a safe-mode change, whatever the builder-call order.
1999    }
2000
2001    /// Returns the [`SafeMode`] under which this parser operates.
2002    ///
2003    /// [`SafeMode`]: crate::SafeMode
2004    pub fn safe_mode(&self) -> SafeMode {
2005        self.safe
2006    }
2007
2008    /// Returns the document name (`docname`): the base name of the primary
2009    /// file, stripped of its directory and final extension.
2010    ///
2011    /// This is the `<docname>` used to build private docinfo file names (e.g.
2012    /// `mydoc-docinfo.html` for `mydoc.adoc`). Returns `None` when no primary
2013    /// file name has been set, in which case private docinfo files cannot be
2014    /// resolved.
2015    pub(crate) fn docname(&self) -> Option<String> {
2016        let primary = self.primary_file_name.as_deref()?;
2017
2018        // Strip the directory portion (handling both separators, since the
2019        // primary file name may have been supplied on either platform).
2020        let base = primary.rsplit(['/', '\\']).next().unwrap_or(primary);
2021
2022        // Strip a single trailing extension, if present. A leading-dot name
2023        // (e.g. `.adoc`) is treated as having no extension and is kept whole as
2024        // the stem, matching Ruby's `File.basename(".adoc", ".*")`.
2025        let stem = match base.rfind('.') {
2026            Some(0) | None => base,
2027            Some(idx) => &base[..idx],
2028        };
2029
2030        if stem.is_empty() {
2031            None
2032        } else {
2033            Some(stem.to_string())
2034        }
2035    }
2036
2037    /// Returns `true` if the AsciiDoc file named by `key` (an inter-document
2038    /// xref path — relative to this document, AsciiDoc extension removed) was
2039    /// included into this document *in full* by the preprocessor.
2040    ///
2041    /// A cross reference to such a file collapses to a same-document reference,
2042    /// since the file's anchors are now part of this document. Takes `&self` so
2043    /// it can be called from an inline-substitution
2044    /// [`Replacer`](regex::Replacer) that holds only a shared reference to
2045    /// the parser. See
2046    /// [`Catalog::include_is_full`](crate::document::Catalog::include_is_full).
2047    pub(crate) fn catalog_include_is_full(&self, key: &str) -> bool {
2048        self.catalog.borrow().include_is_full(key)
2049    }
2050
2051    /// Records an included AsciiDoc file in the document catalog's include
2052    /// registry, mid-parse.
2053    ///
2054    /// `Parser::parse_deferred` seeds the registry with the outermost
2055    /// document's own includes before parsing begins; this entry point is for
2056    /// an include performed while a nested scope with a shared catalog — an
2057    /// AsciiDoc table cell — is parsed. Takes `&self` for the same reason as
2058    /// [`catalog_include_is_full`](Self::catalog_include_is_full). See
2059    /// [`Catalog::register_include`](crate::document::Catalog::register_include).
2060    pub(crate) fn register_include(&self, key: &str, full: bool) {
2061        self.catalog.borrow_mut().register_include(key, full);
2062    }
2063
2064    /// Called from [`Header::parse()`] to accept or reject an attribute value.
2065    ///
2066    /// [`Header::parse()`]: crate::document::Header::parse
2067    pub(crate) fn set_attribute_from_header<'src>(
2068        &mut self,
2069        attr: &Attribute<'src>,
2070        warnings: &mut Vec<Warning<'src>>,
2071    ) {
2072        let attr_name = remap_attr_name(attr.name().data());
2073
2074        // The derived backend-family namespace is a read-only synthesized
2075        // intrinsic; a document must not write any of it (see
2076        // [`is_reserved_derived_attr`]).
2077        if is_reserved_derived_attr(&attr_name) {
2078            return;
2079        }
2080
2081        // Verify that we have permission to overwrite any existing attribute
2082        // value, considering both a per-parser entry and the shared built-in
2083        // default it would shadow (a built-in such as `sp` is `ApiOnly`).
2084        if let Some(existing_attr) = self.effective_attribute(&attr_name)
2085            && (existing_attr.modification_context == ModificationContext::ApiOnly
2086                || existing_attr.modification_context == ModificationContext::ApiOrDocumentBody)
2087        {
2088            // A silently-locked intrinsic rejects the write without recording a
2089            // warning (see `AttributeValue::silent_when_locked`).
2090            if !existing_attr.silent_when_locked {
2091                warnings.push(Warning {
2092                    source: attr.span(),
2093                    warning: WarningType::AttributeValueIsLocked(attr_name),
2094                    origin: None,
2095                });
2096            }
2097            return;
2098        }
2099
2100        let mut value = attr.value().clone();
2101
2102        if let InterpretedValue::Set = value
2103            && let Some(default_value) = self.default_attribute_values.get(&attr_name)
2104        {
2105            value = InterpretedValue::Value(default_value.clone());
2106        }
2107
2108        // A relative `leveloffset` (`+N` / `-N`) accumulates on top of the
2109        // offset already in effect; resolve it to an absolute value so the
2110        // stored attribute is always a plain integer, and warn if the result is
2111        // so extreme that no heading could ever land in the valid level range.
2112        if attr_name == "leveloffset" {
2113            value = self.resolve_leveloffset_and_warn(value, attr.span(), warnings);
2114        }
2115
2116        // Cap the resolved value at `max-attribute-value-size` bytes (a no-op
2117        // unless that limit is in force — by default, only under Secure).
2118        value = self.limit_attribute_value_size(value);
2119
2120        // `notitle` and `showtitle` are inverse spellings of one title-
2121        // visibility toggle; keep the partner in sync (see
2122        // [`apply_title_visibility_linkage`](Self::apply_title_visibility_linkage)).
2123        self.apply_title_visibility_linkage(
2124            &attr_name,
2125            &value,
2126            ModificationContext::Anywhere,
2127            false,
2128        );
2129
2130        let attribute_value = AttributeValue {
2131            allowable_value: AllowableValue::Any,
2132            modification_context: ModificationContext::Anywhere,
2133            silent_when_locked: false,
2134            value,
2135        };
2136
2137        // An explicit assignment supersedes (and resets) any counter of the same
2138        // name.
2139        self.counter_values.borrow_mut().remove(&attr_name);
2140
2141        // The derived `backend-html5-doctype-*` attribute tracks `doctype`
2142        // automatically (it is synthesized on lookup), so no refresh is needed.
2143        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
2144    }
2145
2146    /// Called from [`Header::parse()`] for a value that is derived from parsing
2147    /// the header (except for attribute lines).
2148    ///
2149    /// [`Header::parse()`]: crate::document::Header::parse
2150    pub(crate) fn set_attribute_by_value_from_header<N: AsRef<str>, V: AsRef<str>>(
2151        &mut self,
2152        name: N,
2153        value: V,
2154    ) {
2155        let attr_name = remap_attr_name(name);
2156
2157        let attribute_value = AttributeValue {
2158            allowable_value: AllowableValue::Any,
2159            modification_context: ModificationContext::Anywhere,
2160            silent_when_locked: false,
2161            value: InterpretedValue::Value(value.as_ref().to_owned()),
2162        };
2163
2164        self.counter_values.borrow_mut().remove(&attr_name);
2165        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
2166    }
2167
2168    /// Applies the `imagesdir`-relative default for the `iconsdir` attribute.
2169    ///
2170    /// The `iconsdir` attribute defaults to `{imagesdir}/icons`; when
2171    /// `imagesdir` is left empty this resolves to the built-in
2172    /// [`DEFAULT_ICONSDIR`] (`./images/icons`). When `imagesdir` is set to a
2173    /// non-empty value and `iconsdir` was left at its built-in default, the
2174    /// icons directory is derived as `{imagesdir}/icons`.
2175    ///
2176    /// The derivation is skipped — so an explicit `iconsdir` wins — when either
2177    /// the attribute was set in the header (`iconsdir_set_in_header`) or its
2178    /// resolved value differs from [`DEFAULT_ICONSDIR`] (which is how an
2179    /// override applied any other way, e.g. via the API, is detected). The one
2180    /// case this cannot detect is a non-header override whose value happens to
2181    /// equal the built-in default (e.g. an API caller setting `iconsdir` to
2182    /// exactly `./images/icons`): it is indistinguishable from the default and
2183    /// so is re-derived. That combination is contradictory in practice (it
2184    /// pins `iconsdir` to the value it would take were `imagesdir` unset) and
2185    /// is not worth a dedicated provenance flag.
2186    ///
2187    /// This is called once, after the document header is parsed, mirroring
2188    /// Asciidoctor's document-initialization timing (a later `imagesdir` change
2189    /// in the document body does not retroactively re-derive `iconsdir`). See
2190    /// icons-image.adoc.
2191    ///
2192    /// [`DEFAULT_ICONSDIR`]: super::built_in_attrs::DEFAULT_ICONSDIR
2193    pub(crate) fn apply_iconsdir_default(&mut self, iconsdir_set_in_header: bool) {
2194        if iconsdir_set_in_header {
2195            return;
2196        }
2197
2198        // Preserve any override whose value differs from the built-in default
2199        // (e.g. one applied via the API); only the built-in default itself is
2200        // eligible for `imagesdir`-relative derivation. See the doc comment for
2201        // the one indistinguishable corner case.
2202        if self.attribute_value("iconsdir").as_maybe_str()
2203            != Some(super::built_in_attrs::DEFAULT_ICONSDIR)
2204        {
2205            return;
2206        }
2207
2208        let imagesdir = self.attribute_value("imagesdir");
2209        let derived = match imagesdir.as_maybe_str().filter(|d| !d.is_empty()) {
2210            Some(dir) => format!("{}/icons", dir.trim_end_matches('/')),
2211            None => return,
2212        };
2213
2214        self.set_attribute_by_value_from_header("iconsdir", derived);
2215    }
2216
2217    /// Called while parsing a block (see [`Block::parse_with_outcome()`]) to
2218    /// accept or reject an attribute value from a document (body) attribute.
2219    ///
2220    /// [`Block::parse_with_outcome()`]: crate::blocks::Block::parse_with_outcome
2221    pub(crate) fn set_attribute_from_body<'src>(
2222        &mut self,
2223        attr: &Attribute<'src>,
2224        warnings: &mut Vec<Warning<'src>>,
2225    ) {
2226        let attr_name = remap_attr_name(attr.name().data());
2227
2228        // The derived backend-family namespace is a read-only synthesized
2229        // intrinsic; a document must not write any of it (see
2230        // [`is_reserved_derived_attr`]).
2231        if is_reserved_derived_attr(&attr_name) {
2232            return;
2233        }
2234
2235        // An attribute inherited from the parent document of an AsciiDoc table
2236        // cell is locked for the duration of that cell: a body assignment to it
2237        // is silently ignored (no warning), matching Asciidoctor.
2238        if self.locked_attribute_names.contains(&attr_name) {
2239            return;
2240        }
2241
2242        // Verify that we have permission to overwrite any existing attribute
2243        // value, considering both a per-parser entry and the shared built-in
2244        // default it would shadow.
2245        if let Some(existing_attr) = self.effective_attribute(&attr_name)
2246            && (existing_attr.modification_context != ModificationContext::Anywhere
2247                && existing_attr.modification_context != ModificationContext::ApiOrDocumentBody)
2248        {
2249            // A silently-locked intrinsic rejects the write without recording a
2250            // warning (see `AttributeValue::silent_when_locked`).
2251            if !existing_attr.silent_when_locked {
2252                warnings.push(Warning {
2253                    source: attr.span(),
2254                    warning: WarningType::AttributeValueIsLocked(attr_name),
2255                    origin: None,
2256                });
2257            }
2258            return;
2259        }
2260
2261        let mut value = attr.value().clone();
2262
2263        // A relative `leveloffset` (`+N` / `-N`) accumulates on top of the
2264        // offset already in effect; resolve it to an absolute value so the
2265        // stored attribute is always a plain integer, and warn if the result is
2266        // so extreme that no heading could ever land in the valid level range.
2267        if attr_name == "leveloffset" {
2268            value = self.resolve_leveloffset_and_warn(value, attr.span(), warnings);
2269        }
2270
2271        // Cap the resolved value at `max-attribute-value-size` bytes (a no-op
2272        // unless that limit is in force — by default, only under Secure).
2273        value = self.limit_attribute_value_size(value);
2274
2275        // `notitle` and `showtitle` are inverse spellings of one title-
2276        // visibility toggle; keep the partner in sync (see
2277        // [`apply_title_visibility_linkage`](Self::apply_title_visibility_linkage)).
2278        self.apply_title_visibility_linkage(
2279            &attr_name,
2280            &value,
2281            ModificationContext::Anywhere,
2282            false,
2283        );
2284
2285        let attribute_value = AttributeValue {
2286            allowable_value: AllowableValue::Any,
2287            modification_context: ModificationContext::Anywhere,
2288            silent_when_locked: false,
2289            value,
2290        };
2291
2292        // An explicit assignment supersedes (and resets) any counter of the same
2293        // name. This is what lets `:!name:` reset a counter.
2294        self.counter_values.borrow_mut().remove(&attr_name);
2295
2296        // The derived `backend-html5-doctype-*` attribute tracks `doctype`
2297        // automatically (it is synthesized on lookup), so no refresh is needed.
2298        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
2299    }
2300
2301    /// Assign the next section number for a given level.
2302    pub(crate) fn assign_section_number(&mut self, level: usize) -> SectionNumber {
2303        match self.topmost_section_type {
2304            SectionType::Appendix => {
2305                self.last_appendix_section_number.assign_next_number(level);
2306                self.last_appendix_section_number.clone()
2307            }
2308
2309            // `topmost_section_type` is only ever `Normal` or `Appendix`: a
2310            // discrete heading never becomes the topmost section type (see
2311            // `SectionBlock::parse`). `Discrete` therefore cannot reach this
2312            // point, so it is folded in with `Normal` rather than carried as a
2313            // separate, untestable arm.
2314            SectionType::Normal | SectionType::Discrete => {
2315                self.last_section_number.assign_next_number(level);
2316                self.last_section_number.clone()
2317            }
2318        }
2319    }
2320
2321    /// Resolves a [counter] of the given `name`, advancing it to the next value
2322    /// in its sequence and returning that value.
2323    ///
2324    /// A counter is a specialized document attribute: its value is stored as
2325    /// (and read back from) the attribute of the same name, so a later
2326    /// `{name}` reference shows the current value and an attribute assignment
2327    /// such as `:!name:` resets it. Each resolution advances the counter:
2328    ///
2329    /// * an integer value is incremented (`1` -> `2`);
2330    /// * any other value is advanced like Ruby's `String#succ` (`a` -> `b`, `z`
2331    ///   -> `aa`, `Az` -> `Ba`), matching Asciidoctor.
2332    ///
2333    /// `seed` (from the `{counter:name:seed}` form) supplies the first value,
2334    /// but only when the counter is currently unset; otherwise it is ignored.
2335    /// With no seed the sequence starts at `1`.
2336    ///
2337    /// This mirrors Asciidoctor's `Document#counter`.
2338    ///
2339    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
2340    pub(crate) fn counter(&self, name: &str, seed: Option<&str>) -> String {
2341        self.counter_impl(name, seed, false)
2342    }
2343
2344    /// Like [`counter`](Self::counter), but the advanced value stays readable
2345    /// as the attribute of the same name even when that attribute is
2346    /// *locked* (API-set or a locked built-in). This is the captioning
2347    /// counter, used for the `<context>-number` of a numbered block.
2348    ///
2349    /// Mirrors Asciidoctor's `increment_and_store_counter`: it too advances a
2350    /// locked counter, and its block attribute entry is replayed onto the
2351    /// document attributes during conversion, so a locked `example-number`
2352    /// reads back as its latest counter value (unlike a plain inline
2353    /// `{counter:…}`, which leaves the locked value in place).
2354    pub(crate) fn counter_for_caption(&self, name: &str, seed: Option<&str>) -> String {
2355        self.counter_impl(name, seed, true)
2356    }
2357
2358    /// Advances the `name` counter and returns its new value. `seed` supplies
2359    /// the starting value when the counter has no current value to advance
2360    /// from.
2361    ///
2362    /// A counter reads the current value to produce (and display) the next one.
2363    /// For an unlocked attribute the advanced value is stored in the readable
2364    /// [`counter_values`](Self::counter_values) overlay, so a later reference
2365    /// reads it. For a *locked* attribute — one set via the API, or a locked
2366    /// built-in such as `max-include-depth` — the write path depends on the
2367    /// caller:
2368    ///
2369    /// * `commit_when_locked` (the captioning counter): the value is stored in
2370    ///   the readable overlay anyway, matching Asciidoctor's
2371    ///   `increment_and_store_counter`, whose block attribute entry is replayed
2372    ///   onto the document attributes during conversion.
2373    /// * otherwise (an inline `{counter:…}` / `{counter2:…}` directive): the
2374    ///   running value is kept in the private
2375    ///   [`locked_counter_values`](Self::locked_counter_values) map instead, so
2376    ///   the sequence still advances across repeated references while a plain
2377    ///   reference to the attribute continues to read the locked value. This
2378    ///   mirrors Asciidoctor's `Document#counter`, which advances `@counters`
2379    ///   but leaves `@attributes` untouched while the attribute is
2380    ///   `attribute_locked?`.
2381    fn counter_impl(&self, name: &str, seed: Option<&str>, commit_when_locked: bool) -> String {
2382        let use_private_state = !commit_when_locked && self.attribute_is_locked(name);
2383
2384        // The value to advance from: the private running state first (only
2385        // populated when it is in use), otherwise the current readable value of
2386        // the attribute (which, for an unlocked counter, already reflects the
2387        // overlay).
2388        let current = if use_private_state {
2389            self.locked_counter_values.borrow().get(name).cloned()
2390        } else {
2391            None
2392        }
2393        .or_else(|| match self.attribute_value(name) {
2394            InterpretedValue::Value(current) if !current.is_empty() => Some(current),
2395            _ => None,
2396        });
2397
2398        let next = match current {
2399            Some(current) => next_counter_value(&current),
2400            None => match seed {
2401                Some(seed) if !seed.is_empty() => seed.to_string(),
2402                _ => "1".to_string(),
2403            },
2404        };
2405
2406        if use_private_state {
2407            self.locked_counter_values
2408                .borrow_mut()
2409                .insert(name.to_string(), next.clone());
2410        } else {
2411            self.counter_values
2412                .borrow_mut()
2413                .insert(name.to_string(), next.clone());
2414        }
2415
2416        next
2417    }
2418
2419    /// Reports whether `name` currently resolves to an attribute that is
2420    /// *locked* against modification by a counter: it has an effective value
2421    /// whose [`ModificationContext`] is
2422    /// [`ApiOnly`](ModificationContext::ApiOnly) — an API-set override or a
2423    /// locked built-in such as `max-include-depth`.
2424    ///
2425    /// This mirrors Asciidoctor's `Document#attribute_locked?`, which is `true`
2426    /// exactly for an attribute supplied through the API (its
2427    /// `@attribute_overrides`). It is deliberately *narrower* than the
2428    /// write-permission check in
2429    /// [`set_attribute_from_body`](Self::set_attribute_from_body): a
2430    /// header-only attribute such as an unset `outfilesuffix`
2431    /// ([`ApiOrHeader`](ModificationContext::ApiOrHeader)) cannot be assigned
2432    /// from the body, yet a counter *may* advance it (matching Asciidoctor,
2433    /// where `{counter:outfilesuffix}` moves it while it is not API-locked).
2434    fn attribute_is_locked(&self, name: &str) -> bool {
2435        self.effective_attribute(name)
2436            .is_some_and(|a| a.modification_context == ModificationContext::ApiOnly)
2437    }
2438}
2439
2440/// Whether a `leveloffset` of `offset` leaves at least one syntactic heading
2441/// level able to land inside the supported section-level range.
2442///
2443/// Syntactic heading levels run 0 (`=`) through 5 (`======`) and valid section
2444/// levels run 1 through 5, so an offset keeps some heading in range only while
2445/// it stays within `1 - 5 ..= 5 - 0`, i.e. `-4..=5`. Outside that window every
2446/// heading is clamped, so the offset can never place a heading at its intended
2447/// level.
2448fn leveloffset_admits_any_heading(offset: i32) -> bool {
2449    (-4..=5).contains(&offset)
2450}
2451
2452/// Advances a counter value to the next value in its sequence, mirroring
2453/// Asciidoctor's `Helpers.nextval`.
2454///
2455/// A canonical integer string (one that round-trips through integer parsing,
2456/// e.g. `7` but not `07` or `+7`) is incremented numerically. Anything else is
2457/// advanced with [`string_succ`].
2458fn next_counter_value(current: &str) -> String {
2459    if let Ok(n) = current.parse::<i64>()
2460        && n.to_string() == current
2461    {
2462        // `saturating_add` keeps a counter that has somehow reached `i64::MAX`
2463        // pinned there rather than panicking (debug) or wrapping (release).
2464        return n.saturating_add(1).to_string();
2465    }
2466
2467    string_succ(current)
2468}
2469
2470/// Returns the successor of a string, mirroring Ruby's `String#succ` for the
2471/// ASCII cases that AsciiDoc counters can produce.
2472///
2473/// The right-most alphanumeric character is incremented within its own class
2474/// (digits, lowercase letters, uppercase letters), carrying leftward on
2475/// wrap-around (`9` -> `0`, `z` -> `a`, `Z` -> `A`) and prepending a fresh
2476/// leading character (`1`, `a`, or `A`) when the carry runs off the front
2477/// (`z` -> `aa`, `Zz` -> `AAa`). A string with no alphanumeric characters has
2478/// the code point of its last character incremented.
2479fn string_succ(current: &str) -> String {
2480    let chars: Vec<char> = current.chars().collect();
2481
2482    // Without an alphanumeric to carry through, Ruby increments the code point
2483    // of the final character.
2484    if !chars.iter().any(char::is_ascii_alphanumeric) {
2485        let mut chars = chars;
2486        if let Some(last) = chars.last_mut() {
2487            *last = char::from_u32(*last as u32 + 1).unwrap_or(*last);
2488        }
2489        return chars.into_iter().collect();
2490    }
2491
2492    // Walk right to left. `carrying` stays true while we are still looking for
2493    // (or carrying through) the alphanumeric run: trailing non-alphanumeric
2494    // characters are passed over unchanged, then the right-most alphanumeric is
2495    // incremented within its class and any wrap-around carries leftward to the
2496    // next alphanumeric. When the carry runs off the front, a fresh leading
2497    // character of the same class is prepended (`z` -> `aa`, `9` -> `10`).
2498    let mut out_rev: Vec<char> = Vec::with_capacity(chars.len() + 1);
2499    let mut carrying = true;
2500    let mut lead = '1';
2501
2502    for &c in chars.iter().rev() {
2503        if carrying && c.is_ascii_alphanumeric() {
2504            // Increment within the character's class, carrying on wrap-around.
2505            // The arms are exhaustive over ASCII alphanumerics, so the catch-all
2506            // can only be `Z` (the one value not matched above).
2507            let (next, carry) = match c {
2508                '0'..='8' | 'a'..='y' | 'A'..='Y' => ((c as u8 + 1) as char, false),
2509                '9' => ('0', true),
2510                'z' => ('a', true),
2511                _ => ('A', true),
2512            };
2513            out_rev.push(next);
2514            carrying = carry;
2515            // On a carry, remember the class of leading character to prepend if
2516            // the carry runs off the front; `next` is `0`, `a`, or `A` here.
2517            lead = match next {
2518                '0' => '1',
2519                'a' => 'a',
2520                _ => 'A',
2521            };
2522        } else {
2523            // Either the carry is spent, or this is a trailing non-alphanumeric
2524            // we pass over while still searching for the run to increment.
2525            out_rev.push(c);
2526        }
2527    }
2528
2529    if carrying {
2530        out_rev.push(lead);
2531    }
2532
2533    out_rev.into_iter().rev().collect()
2534}
2535
2536/// Matches every character that Asciidoctor's `sanitize_attribute_name` strips
2537/// from an attribute name: anything that is not a [word character] (`\w`, i.e.
2538/// `\p{Word}`) or a hyphen. Mirrors Asciidoctor's `InvalidAttributeNameCharsRx`
2539/// (`/[^#{CC_WORD}-]/`).
2540///
2541/// [word character]: crate::internal::is_word_char
2542static INVALID_ATTR_NAME_CHARS: LazyLock<Regex> = LazyLock::new(|| {
2543    #[allow(clippy::unwrap_used)]
2544    Regex::new(r"[^\w-]").unwrap()
2545});
2546
2547fn remap_attr_name<N: AsRef<str>>(raw_attr_name: N) -> String {
2548    // Sanitize the name the way Asciidoctor's `sanitize_attribute_name` does:
2549    // drop every character that is not a word character or a hyphen, then
2550    // lower-case the result. This is what lets an attribute entry written as
2551    // `:Author Initials:` set the `authorinitials` attribute, `:Foo 3^ # -
2552    // Bar[:` set `foo3-bar`, and `:My frog:` set `myfrog`. Unicode word
2553    // characters are preserved, so `:café:` sets `café` and `:سمن:` sets `سمن`.
2554    //
2555    // The full Unicode case fold (Asciidoctor's `downcase`, not merely ASCII)
2556    // is what makes an attribute reference case-insensitive: an entry written
2557    // `:He-Man:` is reachable as `{he-man}` or `{HE-MAN}`. A reference is folded
2558    // through this same `to_lowercase()` before lookup (see `AttributeReplacer`
2559    // in `content::substitution_step`), so definition and reference stay
2560    // symmetric even when a fold expands a character (e.g. `İ` -> `i` + combining
2561    // dot): both sides land on the identical key.
2562    let attr_name: String = INVALID_ATTR_NAME_CHARS
2563        .replace_all(raw_attr_name.as_ref(), "")
2564        .to_lowercase();
2565
2566    // Some attribute names have aliases. Remap to the primary name.
2567    //
2568    // `numbered` is a legacy alias for `sectnums`, so setting `numbered` sets
2569    // `sectnums` (and `numbered!` unsets it). Mirrors Asciidoctor's
2570    // `Parser.store_attribute`, which renames the attribute before storing it.
2571    match attr_name.as_str() {
2572        "hardbreaks" => "hardbreaks-option".to_string(),
2573        "numbered" => "sectnums".to_string(),
2574        _ => attr_name,
2575    }
2576}
2577
2578/// Returns `true` if `name` is a derived backend-family attribute whose
2579/// assignment must be rejected *even while it is inactive*, because the flag it
2580/// would name can become active later in the same parse and the stored override
2581/// would then shadow the read-only intrinsic:
2582///
2583/// * The bare derived values `basebackend` / `filetype` — always resolved on
2584///   the fly from `backend` (see [`derived_backend_value`]), never stored.
2585/// * The doctype-keyed flags `backend-<b>-doctype-<d>` /
2586///   `basebackend-<bb>-doctype-<d>` — the `doctype` component shifts mid-parse
2587///   (e.g. an AsciiDoc table cell that resets, then changes, its doctype), so
2588///   an assignment to an inactive one (`backend-html5-doctype-article` while
2589///   the doctype is `book`) must not be stored where it could shadow the
2590///   intrinsic once the doctype switches.
2591///
2592/// The remaining flag names (`backend-<b>`, `basebackend-<bb>`, `filetype-<f>`,
2593/// and bare `doctype-<d>`) are deliberately **not** reserved: rejecting them
2594/// would swallow author-defined attributes such as `:backend-custom:` or
2595/// `:doctype-draft:` (used as `ifdef` flags), which Asciidoctor keeps. The
2596/// *active* one of these is still write-protected by the normal permission
2597/// check, since [`synthesized_attr`] resolves it to a locked intrinsic.
2598fn is_reserved_derived_attr(name: &str) -> bool {
2599    is_derived_backend_value(name)
2600        || ((name.starts_with("backend-") || name.starts_with("basebackend-"))
2601            && name.contains("-doctype-"))
2602}
2603
2604#[cfg(test)]
2605mod tests {
2606    #![allow(clippy::panic)]
2607    #![allow(clippy::unwrap_used)]
2608
2609    use crate::{
2610        attributes::Attrlist,
2611        blocks::Block,
2612        parser::{
2613            CharacterReplacementType, IconRenderParams, ImageRenderParams,
2614            InlineSubstitutionRenderer, LinkRenderParams, QuoteScope, QuoteType, SpecialCharacter,
2615        },
2616        tests::prelude::*,
2617    };
2618
2619    #[test]
2620    fn default_is_unset() {
2621        let p = Parser::default();
2622        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
2623    }
2624
2625    mod remap_attr_name {
2626        use super::super::remap_attr_name;
2627
2628        #[test]
2629        fn strips_non_word_and_lower_cases_ascii() {
2630            assert_eq!(remap_attr_name("Foo Bar"), "foobar");
2631            assert_eq!(remap_attr_name("Foo 3^ # - Bar["), "foo3-bar");
2632            assert_eq!(remap_attr_name("My frog"), "myfrog");
2633        }
2634
2635        #[test]
2636        fn preserves_unicode_word_characters() {
2637            // Unicode letters and digits are word characters, so they survive
2638            // sanitization; the `{café}` / `{سمن}` references then resolve.
2639            assert_eq!(remap_attr_name("café"), "café");
2640            assert_eq!(remap_attr_name("سمن"), "سمن");
2641        }
2642
2643        #[test]
2644        fn preserves_marks_and_join_controls() {
2645            // `\p{Word}` includes combining marks and join controls, so a
2646            // decomposed name and a name embedding a ZWNJ are not mangled.
2647            let decomposed = "cafe\u{301}";
2648            assert_eq!(remap_attr_name(decomposed), decomposed);
2649
2650            let with_zwnj = "\u{645}\u{200c}\u{646}";
2651            assert_eq!(remap_attr_name(with_zwnj), with_zwnj);
2652        }
2653
2654        #[test]
2655        fn folds_case_with_full_unicode() {
2656            // The name is folded with the full Unicode `to_lowercase()`, so a
2657            // reference lookup is case-insensitive. A fold that expands a
2658            // character (`İ` -> `i` + U+0307 combining dot above) is harmless:
2659            // an attribute reference is folded through the same function, so
2660            // definition and reference still land on the identical key.
2661            assert_eq!(remap_attr_name("He-Man"), "he-man");
2662            assert_eq!(remap_attr_name("İstanbul"), "i\u{307}stanbul");
2663            assert_eq!(remap_attr_name("İstanbul"), "İstanbul".to_lowercase());
2664        }
2665    }
2666
2667    #[test]
2668    fn attribute_reference_resolves_case_insensitively() {
2669        // A reference is folded with the same Unicode `to_lowercase()` used to
2670        // store the name, so any casing of the reference resolves the entry.
2671        let doc = Parser::default().parse(":He-Man: the foe\n\n{he-man} / {HE-MAN} / {He-Man}");
2672        assert_eq!(
2673            rendered_paragraphs(&doc),
2674            vec!["the foe / the foe / the foe"]
2675        );
2676    }
2677
2678    #[test]
2679    fn attribute_reference_case_fold_round_trips_when_it_expands() {
2680        // `İ` folds to `i` + U+0307 under `to_lowercase()`. Because both the
2681        // definition and the reference fold through that same function, the
2682        // entry stays reachable by its own spelling despite the expansion.
2683        let doc = Parser::default().parse(":İ: dotted\n\n{İ}");
2684        assert_eq!(rendered_paragraphs(&doc), vec!["dotted"]);
2685    }
2686
2687    #[test]
2688    fn unicode_attribute_reference_resolves_in_preprocessor() {
2689        // The preprocessor (conditional directives, include targets) resolves
2690        // `{name}` references with the same Unicode word-character class as the
2691        // main substitution pass, so a Unicode-named attribute drives an
2692        // `ifeval` condition. See #726.
2693        let doc = Parser::default()
2694            .parse(":café: yes\n\nifeval::[\"{café}\" == \"yes\"]\nShown.\nendif::[]");
2695        assert_eq!(rendered_paragraphs(&doc), vec!["Shown."]);
2696    }
2697
2698    #[test]
2699    fn case_insensitive_attribute_reference_resolves_in_preprocessor() {
2700        // The preprocessor folds a `{name}` reference the same way the main
2701        // substitution pass does, so a mismatched-case reference still drives an
2702        // `ifeval` condition.
2703        let doc = Parser::default()
2704            .parse(":Answer: yes\n\nifeval::[\"{answer}\" == \"yes\"]\nShown.\nendif::[]");
2705        assert_eq!(rendered_paragraphs(&doc), vec!["Shown."]);
2706    }
2707
2708    #[test]
2709    fn owned_cell_warning_is_recorded_only_inside_an_owned_cell_source() {
2710        use std::rc::Rc;
2711
2712        use crate::{
2713            parser::{SourceLine, SourceMap},
2714            warnings::WarningType,
2715        };
2716
2717        let mut p = Parser::default();
2718
2719        // Outside an owned cell source there is no map to resolve against, so a
2720        // recorded warning has no origin and is dropped rather than queued.
2721        assert!(!p.is_in_owned_cell_source());
2722        p.record_owned_cell_warning(
2723            1,
2724            WarningType::IncludeFileNotFound("x.adoc".to_owned()),
2725            None,
2726        );
2727        assert!(p.take_owned_cell_warnings().is_empty());
2728
2729        // An explicit origin override is queued even without a cell source map.
2730        p.record_owned_cell_warning(
2731            1,
2732            WarningType::UnterminatedConditionalDirective("ifdef::foo[]".to_owned()),
2733            Some(SourceLine(Some("inc.adoc".to_owned()), 3)),
2734        );
2735        let overridden = p.take_owned_cell_warnings();
2736        let [overridden] = overridden.as_slice() else {
2737            panic!("expected exactly one recorded warning, got {overridden:?}");
2738        };
2739        assert_eq!(
2740            overridden.origin,
2741            SourceLine(Some("inc.adoc".to_owned()), 3)
2742        );
2743
2744        // Publish a cell source map (output line 1 came from `cell.adoc` line 2,
2745        // the way the preprocessor would record an include-expanded cell).
2746        let mut sm = SourceMap::default();
2747        sm.append(1, SourceLine(Some("cell.adoc".to_owned()), 2));
2748        p.push_owned_cell_source_map(Rc::new(sm));
2749        assert!(p.is_in_owned_cell_source());
2750
2751        // Now the same call resolves the line to its origin and queues the
2752        // warning with that pre-resolved (file, line).
2753        p.record_owned_cell_warning(
2754            1,
2755            WarningType::IncludeFileNotFound("y.adoc".to_owned()),
2756            None,
2757        );
2758        let recorded = p.take_owned_cell_warnings();
2759        let [recorded] = recorded.as_slice() else {
2760            panic!("expected exactly one recorded warning, got {recorded:?}");
2761        };
2762        assert_eq!(recorded.origin, SourceLine(Some("cell.adoc".to_owned()), 2));
2763        assert_eq!(
2764            recorded.warning,
2765            WarningType::IncludeFileNotFound("y.adoc".to_owned())
2766        );
2767
2768        // Taking drains the buffer, and popping restores the not-in-owned-cell
2769        // state.
2770        assert!(p.take_owned_cell_warnings().is_empty());
2771        p.pop_owned_cell_source_map();
2772        assert!(!p.is_in_owned_cell_source());
2773    }
2774
2775    #[test]
2776    fn creates_catalog_if_needed() {
2777        let mut p = Parser::default();
2778        let doc = p.parse("= Hello, World!\n\n== First Section Title");
2779        let cat = doc.catalog();
2780        assert!(cat.refs.contains_key("_first_section_title"));
2781
2782        let doc = p.parse("= Hello, World!\n\n== Second Section Title");
2783        let cat = doc.catalog();
2784        assert!(!cat.refs.contains_key("_first_section_title"));
2785        assert!(cat.refs.contains_key("_second_section_title"));
2786    }
2787
2788    #[test]
2789    fn with_intrinsic_attribute() {
2790        let p =
2791            Parser::default().with_intrinsic_attribute("foo", "bar", ModificationContext::Anywhere);
2792
2793        assert_eq!(p.attribute_value("foo"), InterpretedValue::Value("bar"));
2794        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
2795
2796        assert!(p.is_attribute_set("foo"));
2797        assert!(!p.is_attribute_set("foo2"));
2798        assert!(!p.is_attribute_set("xyz"));
2799    }
2800
2801    // Under `SafeMode::Server` or greater, `docdir` reads as empty and
2802    // `docfile` is relativized against `docdir`; see #735 and the ported
2803    // upstream tests in `tests/asciidoctor_rb/attributes_test.rs`. These cover
2804    // crate-specific edge cases not exercised by the single upstream test.
2805    #[test]
2806    fn masks_docdir_and_docfile_under_secure_mode() {
2807        // Secure (the default) is stricter than Server, so masking also applies.
2808        let p = Parser::default()
2809            .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2810            .with_intrinsic_attribute(
2811                "docfile",
2812                "/some/dir/sample.adoc",
2813                ModificationContext::ApiOnly,
2814            );
2815        assert_eq!(p.safe_mode(), SafeMode::Secure);
2816        assert_eq!(p.attribute_value("docdir"), InterpretedValue::Value(""));
2817        assert_eq!(
2818            p.attribute_value("docfile"),
2819            InterpretedValue::Value("sample.adoc")
2820        );
2821        // The masked `docdir` is still a *set* (present) attribute.
2822        assert!(p.is_attribute_set("docdir"));
2823        assert!(p.has_attribute("docfile"));
2824    }
2825
2826    #[test]
2827    fn relativizes_docfile_in_a_subdirectory_of_docdir() {
2828        // A `docfile` nested below `docdir` keeps its sub-path relative to
2829        // `docdir` (not merely its base name), matching Asciidoctor's
2830        // `docfile[(docdir.length + 1)..-1]` slice.
2831        let p = Parser::default()
2832            .with_safe_mode(SafeMode::Server)
2833            .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2834            .with_intrinsic_attribute(
2835                "docfile",
2836                "/some/dir/sub/sample.adoc",
2837                ModificationContext::ApiOnly,
2838            );
2839        assert_eq!(
2840            p.attribute_value("docfile"),
2841            InterpretedValue::Value("sub/sample.adoc")
2842        );
2843    }
2844
2845    #[test]
2846    fn relativizes_docfile_not_under_docdir_to_its_basename() {
2847        // An inconsistent `docdir` / `docfile` pairing (docfile not under
2848        // docdir) must not be truncated at an unrelated byte offset; it
2849        // relativizes to the base name instead (see #735 review feedback).
2850        let p = Parser::default()
2851            .with_safe_mode(SafeMode::Server)
2852            .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2853            .with_intrinsic_attribute(
2854                "docfile",
2855                "/some/different/file.adoc",
2856                ModificationContext::ApiOnly,
2857            );
2858        assert_eq!(
2859            p.attribute_value("docfile"),
2860            InterpretedValue::Value("file.adoc")
2861        );
2862    }
2863
2864    #[test]
2865    fn docfile_without_docdir_falls_back_to_basename_under_server_mode() {
2866        let p = Parser::default()
2867            .with_safe_mode(SafeMode::Server)
2868            .with_intrinsic_attribute(
2869                "docfile",
2870                "/some/dir/sample.adoc",
2871                ModificationContext::ApiOnly,
2872            );
2873        assert_eq!(
2874            p.attribute_value("docfile"),
2875            InterpretedValue::Value("sample.adoc")
2876        );
2877    }
2878
2879    #[test]
2880    fn does_not_mask_docdir_and_docfile_below_server_mode() {
2881        // Below Server, the API-provided values pass through verbatim.
2882        let p = Parser::default()
2883            .with_safe_mode(SafeMode::Safe)
2884            .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2885            .with_intrinsic_attribute(
2886                "docfile",
2887                "/some/dir/sample.adoc",
2888                ModificationContext::ApiOnly,
2889            );
2890        assert_eq!(
2891            p.attribute_value("docdir"),
2892            InterpretedValue::Value("/some/dir")
2893        );
2894        assert_eq!(
2895            p.attribute_value("docfile"),
2896            InterpretedValue::Value("/some/dir/sample.adoc")
2897        );
2898    }
2899
2900    #[test]
2901    fn unset_docdir_and_docfile_stay_missing_under_server_mode() {
2902        // Masking never conjures a value for an attribute that was never set, so
2903        // a reference to an unset `docdir` / `docfile` still resolves as missing.
2904        let p = Parser::default().with_safe_mode(SafeMode::Server);
2905        assert_eq!(p.attribute_value("docdir"), InterpretedValue::Unset);
2906        assert_eq!(p.attribute_value("docfile"), InterpretedValue::Unset);
2907        assert!(!p.has_attribute("docdir"));
2908        assert!(!p.has_attribute("docfile"));
2909    }
2910
2911    #[test]
2912    fn leaves_non_string_docdir_and_docfile_untouched_under_server_mode() {
2913        // A `docdir` / `docfile` present as a boolean flag (not a path string)
2914        // carries no host path to leak, so the masking has nothing to blank or
2915        // relativize and leaves the (empty) `Set` value as-is.
2916        let p = Parser::default()
2917            .with_safe_mode(SafeMode::Server)
2918            .with_intrinsic_attribute_bool("docdir", true, ModificationContext::ApiOnly)
2919            .with_intrinsic_attribute_bool("docfile", true, ModificationContext::ApiOnly);
2920        assert_eq!(p.attribute_value("docdir"), InterpretedValue::Set);
2921        assert_eq!(p.attribute_value("docfile"), InterpretedValue::Set);
2922    }
2923
2924    #[test]
2925    fn with_intrinsic_attribute_set() {
2926        let p = Parser::default().with_intrinsic_attribute_bool(
2927            "foo",
2928            true,
2929            ModificationContext::Anywhere,
2930        );
2931
2932        assert_eq!(p.attribute_value("foo"), InterpretedValue::Set);
2933        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
2934
2935        assert!(p.is_attribute_set("foo"));
2936        assert!(!p.is_attribute_set("foo2"));
2937        assert!(!p.is_attribute_set("xyz"));
2938    }
2939
2940    #[test]
2941    fn with_intrinsic_attribute_unset() {
2942        let p = Parser::default().with_intrinsic_attribute_bool(
2943            "foo",
2944            false,
2945            ModificationContext::Anywhere,
2946        );
2947
2948        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
2949        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
2950
2951        assert!(!p.is_attribute_set("foo"));
2952        assert!(!p.is_attribute_set("foo2"));
2953        assert!(!p.is_attribute_set("xyz"));
2954    }
2955
2956    #[test]
2957    fn can_not_override_locked_default_value() {
2958        let mut parser = Parser::default();
2959
2960        let doc = parser.parse(":sp: not a space!");
2961
2962        assert_eq!(
2963            doc.warnings().next().unwrap().warning,
2964            WarningType::AttributeValueIsLocked("sp".to_owned())
2965        );
2966
2967        assert_eq!(parser.attribute_value("sp"), InterpretedValue::Value(" "));
2968    }
2969
2970    #[test]
2971    fn asciidoc_parser_version_is_predefined() {
2972        // The crate predefines `asciidoc-parser-version` with its own version
2973        // (the parser-specific counterpart of Ruby Asciidoctor's
2974        // `asciidoctor-version` intrinsic).
2975        let mut parser = Parser::default();
2976
2977        assert_eq!(
2978            parser.attribute_value("asciidoc-parser-version"),
2979            InterpretedValue::Value(env!("CARGO_PKG_VERSION"))
2980        );
2981
2982        // The value is available to attribute references and `ifeval`
2983        // expressions in document content.
2984        let doc = parser.parse(concat!(
2985            "= Title\n",
2986            "\n",
2987            "ifeval::['{asciidoc-parser-version}' >= '0.1.0']\n",
2988            "v{asciidoc-parser-version}\n",
2989            "endif::[]\n",
2990        ));
2991
2992        assert_eq!(
2993            rendered_paragraphs(&doc),
2994            vec![format!("v{}", env!("CARGO_PKG_VERSION"))]
2995        );
2996    }
2997
2998    #[test]
2999    fn asciidoc_parser_version_is_locked() {
3000        // The parser version describes the processor itself, so a document
3001        // assignment is rejected with a warning and the built-in value stays
3002        // in place.
3003        let mut parser = Parser::default();
3004
3005        let doc = parser.parse(":asciidoc-parser-version: 99.99.99");
3006
3007        assert_eq!(
3008            doc.warnings().next().unwrap().warning,
3009            WarningType::AttributeValueIsLocked("asciidoc-parser-version".to_owned())
3010        );
3011
3012        assert_eq!(
3013            parser.attribute_value("asciidoc-parser-version"),
3014            InterpretedValue::Value(env!("CARGO_PKG_VERSION"))
3015        );
3016    }
3017
3018    #[test]
3019    fn asciidoctor_version_is_predefined() {
3020        // The crate predefines `asciidoctor-version` with the Asciidoctor
3021        // release whose behavior it implements, so documents written against
3022        // Asciidoctor's own intrinsic behave the same here.
3023        let mut parser = Parser::default();
3024
3025        assert_eq!(
3026            parser.attribute_value("asciidoctor-version"),
3027            InterpretedValue::Value(crate::ASCIIDOCTOR_VERSION)
3028        );
3029
3030        // The value is available to `ifdef` gating and to attribute references
3031        // and `ifeval` expressions in document content.
3032        let doc = parser.parse(concat!(
3033            "= Title\n",
3034            "\n",
3035            "ifdef::asciidoctor-version[]\n",
3036            "ifeval::['{asciidoctor-version}' >= '0.1.0']\n",
3037            "v{asciidoctor-version}\n",
3038            "endif::[]\n",
3039            "endif::[]\n",
3040        ));
3041
3042        assert_eq!(
3043            rendered_paragraphs(&doc),
3044            vec![format!("v{}", crate::ASCIIDOCTOR_VERSION)]
3045        );
3046    }
3047
3048    #[test]
3049    fn asciidoctor_version_is_locked() {
3050        // Like its `asciidoc-parser-version` companion, this describes the
3051        // processor itself, so a document assignment is rejected with a warning
3052        // and the built-in value stays in place.
3053        let mut parser = Parser::default();
3054
3055        let doc = parser.parse(":asciidoctor-version: 99.99.99");
3056
3057        assert_eq!(
3058            doc.warnings().next().unwrap().warning,
3059            WarningType::AttributeValueIsLocked("asciidoctor-version".to_owned())
3060        );
3061
3062        assert_eq!(
3063            parser.attribute_value("asciidoctor-version"),
3064            InterpretedValue::Value(crate::ASCIIDOCTOR_VERSION)
3065        );
3066    }
3067
3068    #[test]
3069    fn asciidoctor_flag_is_predefined() {
3070        // The crate predefines the always-set `asciidoctor` boolean flag, so a
3071        // document guarding Asciidoctor-only content with `ifdef::asciidoctor[]`
3072        // behaves the same here. A `////` comment block containing a directive
3073        // that would corrupt it once the flag is defined must stay untouched
3074        // (see issue #810).
3075        let mut parser = Parser::default();
3076
3077        assert_eq!(parser.attribute_value("asciidoctor"), InterpretedValue::Set);
3078
3079        let doc = parser.parse(concat!(
3080            "= Title\n",
3081            "\n",
3082            "ifdef::asciidoctor[]\n",
3083            "shown when asciidoctor is set\n",
3084            "endif::[]\n",
3085            "\n",
3086            "////\n",
3087            "ifdef::asciidoctor[////]\n",
3088            "////\n",
3089            "\n",
3090            "line after comment block\n",
3091        ));
3092
3093        assert_eq!(
3094            rendered_paragraphs(&doc),
3095            vec![
3096                "shown when asciidoctor is set".to_owned(),
3097                "line after comment block".to_owned(),
3098            ]
3099        );
3100    }
3101
3102    #[test]
3103    fn asciidoctor_flag_is_locked() {
3104        // Like the version intrinsics, the flag describes the processor itself,
3105        // so a document assignment is rejected with a warning and the built-in
3106        // value stays in place.
3107        let mut parser = Parser::default();
3108
3109        let doc = parser.parse(":asciidoctor: 99.99.99");
3110
3111        assert_eq!(
3112            doc.warnings().next().unwrap().warning,
3113            WarningType::AttributeValueIsLocked("asciidoctor".to_owned())
3114        );
3115
3116        assert_eq!(parser.attribute_value("asciidoctor"), InterpretedValue::Set);
3117    }
3118
3119    #[test]
3120    fn asciidoc_parser_version_distinguishes_the_two_processors() {
3121        // Both version intrinsics are defined, so a document tells the
3122        // processors apart via `asciidoc-parser-version`, which Ruby
3123        // Asciidoctor does not define.
3124        let mut parser = Parser::default();
3125
3126        let doc = parser.parse(concat!(
3127            "= Title\n",
3128            "\n",
3129            "ifdef::asciidoc-parser-version[]\n",
3130            "This is asciidoc-parser.\n",
3131            "endif::[]\n",
3132        ));
3133
3134        assert_eq!(rendered_paragraphs(&doc), vec!["This is asciidoc-parser."]);
3135    }
3136
3137    #[test]
3138    fn silently_locked_intrinsic_rejects_header_and_body_without_warning() {
3139        // A silently-locked `ApiOnly` intrinsic (as a converter would seed a
3140        // safe-mode-restricted attribute) rejects both a header assignment and a
3141        // body assignment of the same name, leaving the value unchanged and
3142        // recording no warning.
3143        let mut parser = Parser::default().with_intrinsic_attribute_silent(
3144            "backend",
3145            "html5",
3146            ModificationContext::ApiOnly,
3147        );
3148
3149        let doc = parser.parse(concat!(
3150            "= Title\n",
3151            ":backend: docbook5\n",
3152            "\n",
3153            "Body paragraph.\n",
3154            "\n",
3155            ":backend: manpage\n",
3156        ));
3157
3158        assert_eq!(doc.warnings().count(), 0);
3159        assert_eq!(
3160            parser.attribute_value("backend"),
3161            InterpretedValue::Value("html5")
3162        );
3163    }
3164
3165    #[test]
3166    fn silently_locked_bool_intrinsic_rejects_without_warning() {
3167        let mut parser = Parser::default().with_intrinsic_attribute_bool_silent(
3168            "sectids",
3169            true,
3170            ModificationContext::ApiOnly,
3171        );
3172
3173        let doc = parser.parse(concat!("= Title\n", ":!sectids:\n"));
3174
3175        assert_eq!(doc.warnings().count(), 0);
3176        assert_eq!(parser.attribute_value("sectids"), InterpretedValue::Set);
3177    }
3178
3179    #[test]
3180    fn silently_locked_bool_intrinsic_false_is_unset() {
3181        // A `false` flag records an `Unset` tombstone, and a locked (`ApiOnly`)
3182        // attribute rejects a document body reassignment without warning.
3183        let mut parser = Parser::default().with_intrinsic_attribute_bool_silent(
3184            "sectids",
3185            false,
3186            ModificationContext::ApiOnly,
3187        );
3188
3189        let doc = parser.parse(concat!("= Title\n", ":sectids:\n"));
3190
3191        assert_eq!(doc.warnings().count(), 0);
3192        assert_eq!(parser.attribute_value("sectids"), InterpretedValue::Unset);
3193    }
3194
3195    #[test]
3196    fn normally_locked_intrinsic_still_warns() {
3197        // Regression: a non-silent `ApiOnly` intrinsic still records
3198        // `AttributeValueIsLocked` when the document tries to reassign it.
3199        let mut parser = Parser::default().with_intrinsic_attribute(
3200            "backend",
3201            "html5",
3202            ModificationContext::ApiOnly,
3203        );
3204
3205        let doc = parser.parse(concat!("= Title\n", ":backend: docbook5\n"));
3206
3207        assert_eq!(
3208            doc.warnings().next().unwrap().warning,
3209            WarningType::AttributeValueIsLocked("backend".to_owned())
3210        );
3211        assert_eq!(
3212            parser.attribute_value("backend"),
3213            InterpretedValue::Value("html5")
3214        );
3215    }
3216
3217    #[test]
3218    fn catalog_transferred_to_document() {
3219        let mut parser = Parser::default();
3220        let doc = parser.parse("= Test Document\n\nSome content");
3221
3222        let catalog = doc.catalog();
3223        assert!(catalog.is_empty());
3224
3225        // The catalog was transferred to the document, leaving the parser with
3226        // an empty catalog.
3227        assert!(parser.catalog.borrow().is_empty());
3228    }
3229
3230    #[test]
3231    fn block_ids_registered_in_catalog() {
3232        let mut parser = Parser::default();
3233        let doc = parser.parse("= Test Document\n\n[#my-block]\nSome content with an ID");
3234
3235        let catalog = doc.catalog();
3236        assert!(!catalog.is_empty());
3237        assert!(catalog.contains_id("my-block"));
3238
3239        let entry = catalog.get_ref("my-block").unwrap();
3240        assert_eq!(entry.id, "my-block");
3241        assert_eq!(entry.ref_type, crate::document::RefType::Anchor);
3242    }
3243
3244    /// A simple test renderer that modifies special characters differently
3245    /// from the default HTML renderer.
3246    #[derive(Debug)]
3247    struct TestRenderer;
3248
3249    impl InlineSubstitutionRenderer for TestRenderer {
3250        fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
3251            // Custom rendering: wrap special characters in brackets.
3252            match type_ {
3253                SpecialCharacter::Lt => dest.push_str("[LT]"),
3254                SpecialCharacter::Gt => dest.push_str("[GT]"),
3255                SpecialCharacter::Ampersand => dest.push_str("[AMP]"),
3256            }
3257        }
3258
3259        fn render_quoted_substitition(
3260            &self,
3261            _type_: QuoteType,
3262            _scope: QuoteScope,
3263            _attrlist: Option<Attrlist<'_>>,
3264            _id: Option<String>,
3265            body: &str,
3266            dest: &mut String,
3267        ) {
3268            dest.push_str(body);
3269        }
3270
3271        fn render_character_replacement(
3272            &self,
3273            _type_: CharacterReplacementType,
3274            dest: &mut String,
3275        ) {
3276            dest.push_str("[CHAR]");
3277        }
3278
3279        fn render_line_break(&self, dest: &mut String) {
3280            dest.push_str("[BR]");
3281        }
3282
3283        fn render_image(&self, _params: &ImageRenderParams, dest: &mut String) {
3284            dest.push_str("[IMAGE]");
3285        }
3286
3287        fn image_uri(
3288            &self,
3289            target_image_path: &str,
3290            _parser: &Parser,
3291            _asset_dir_key: Option<&str>,
3292        ) -> String {
3293            target_image_path.to_string()
3294        }
3295
3296        fn render_icon(&self, _params: &IconRenderParams, dest: &mut String) {
3297            dest.push_str("[ICON]");
3298        }
3299
3300        fn render_link(&self, _params: &LinkRenderParams, dest: &mut String) {
3301            dest.push_str("[LINK]");
3302        }
3303
3304        fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
3305            dest.push_str(&format!("[ANCHOR:{}]", id));
3306        }
3307
3308        fn render_xref(&self, params: &crate::parser::XrefRenderParams, dest: &mut String) {
3309            dest.push_str(&format!("[XREF:{}]", params.target));
3310        }
3311
3312        fn render_callout(&self, params: &crate::parser::CalloutRenderParams, dest: &mut String) {
3313            dest.push_str(&format!("[CALLOUT:{}]", params.number));
3314        }
3315
3316        fn render_index_term(
3317            &self,
3318            params: &crate::parser::IndexTermRenderParams,
3319            dest: &mut String,
3320        ) {
3321            match params.visible_term {
3322                Some(term) => dest.push_str(&format!("[INDEXTERM:{term}]")),
3323                None => dest.push_str("[INDEXTERM]"),
3324            }
3325        }
3326
3327        fn render_button(&self, text: &str, dest: &mut String) {
3328            dest.push_str(&format!("[BUTTON:{text}]"));
3329        }
3330
3331        fn render_keyboard(&self, keys: &[String], dest: &mut String) {
3332            dest.push_str(&format!("[KBD:{}]", keys.join("+")));
3333        }
3334
3335        fn render_menu(&self, params: &crate::parser::MenuRenderParams, dest: &mut String) {
3336            dest.push_str(&format!("[MENU:{}]", params.menu));
3337        }
3338
3339        fn render_footnote(&self, params: &crate::parser::FootnoteRenderParams, dest: &mut String) {
3340            match params.index {
3341                Some(index) => dest.push_str(&format!("[FOOTNOTE:{index}]")),
3342                None => dest.push_str(&format!("[FOOTNOTE:{}]", params.text)),
3343            }
3344        }
3345    }
3346
3347    #[test]
3348    fn with_inline_substitution_renderer() {
3349        let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
3350
3351        // Parse a simple document with special characters and a footnote.
3352        let doc = parser.parse("Hello & goodbye < world > test footnote:[a note]");
3353
3354        // The document should parse successfully.
3355        assert_eq!(doc.warnings().count(), 0);
3356
3357        // Get the first block from the document.
3358        let block = doc.nested_blocks().next().unwrap();
3359
3360        let Block::Simple(simple_block) = block else {
3361            panic!("Expected simple block, got: {block:?}");
3362        };
3363
3364        // Our custom renderer should show [AMP], [LT], and [GT] instead of HTML
3365        // entities, and a resolved footnote as [FOOTNOTE:<index>].
3366        assert_eq!(
3367            simple_block.content().rendered(),
3368            "Hello [AMP] goodbye [LT] world [GT] test [FOOTNOTE:1]"
3369        );
3370    }
3371
3372    #[test]
3373    fn custom_renderer_renders_unresolved_footnote() {
3374        let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
3375
3376        // An unresolved footnote reference exercises the renderer's `None`
3377        // (no index) branch, which our custom renderer shows as
3378        // [FOOTNOTE:<text>].
3379        let doc = parser.parse("test.footnote:missing[]");
3380
3381        let block = doc.nested_blocks().next().unwrap();
3382        let Block::Simple(simple_block) = block else {
3383            panic!("Expected simple block, got: {block:?}");
3384        };
3385
3386        assert_eq!(simple_block.content().rendered(), "test.[FOOTNOTE:missing]");
3387    }
3388
3389    mod resolve_show_title {
3390        use crate::parser::{ModificationContext, Parser};
3391
3392        fn with(name: &str, set: bool) -> Parser {
3393            Parser::default().with_intrinsic_attribute_bool(
3394                name,
3395                set,
3396                ModificationContext::Anywhere,
3397            )
3398        }
3399
3400        #[test]
3401        fn neither_present_uses_default() {
3402            assert!(Parser::default().resolve_show_title(true));
3403            assert!(!Parser::default().resolve_show_title(false));
3404        }
3405
3406        #[test]
3407        fn showtitle_takes_precedence_and_decides() {
3408            // Present and set -> shown; present and unset -> hidden, regardless
3409            // of the default.
3410            assert!(with("showtitle", true).resolve_show_title(false));
3411            assert!(!with("showtitle", false).resolve_show_title(true));
3412        }
3413
3414        #[test]
3415        fn notitle_is_the_complement_when_showtitle_absent() {
3416            // notitle set -> hidden; notitle unset -> shown.
3417            assert!(!with("notitle", true).resolve_show_title(true));
3418            assert!(with("notitle", false).resolve_show_title(false));
3419        }
3420    }
3421
3422    mod notitle_showtitle_linkage {
3423        use crate::{
3424            blocks::{Block, IsBlock},
3425            document::InterpretedValue,
3426            parser::{ModificationContext, Parser},
3427        };
3428
3429        // Asciidoctor asciidoctor/asciidoctor#3804: `notitle` and `showtitle`
3430        // are two spellings of one title-visibility toggle, wired as inverses.
3431        // Assigning either updates the partner so the resolved document carries
3432        // one consistent signal — following Asciidoctor's hash semantics, where
3433        // turning the toggle *on* sets one spelling and *removes* the other.
3434
3435        fn parse_header(entries: &str) -> Parser {
3436            let mut parser = Parser::default();
3437            parser.parse(&format!("= Title\n{entries}\n\nbody"));
3438            parser
3439        }
3440
3441        #[test]
3442        fn header_showtitle_set_unsets_notitle() {
3443            // `:showtitle:` => notitle removed (absent).
3444            let parser = parse_header(":showtitle:");
3445            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3446            assert!(!parser.has_attribute("notitle"));
3447        }
3448
3449        #[test]
3450        fn header_showtitle_unset_sets_notitle() {
3451            // `:!showtitle:` => notitle set.
3452            let parser = parse_header(":!showtitle:");
3453            assert!(!parser.is_attribute_set("showtitle"));
3454            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3455            assert!(parser.is_attribute_set("notitle"));
3456        }
3457
3458        #[test]
3459        fn header_notitle_set_unsets_showtitle() {
3460            // `:notitle:` => showtitle removed (absent).
3461            let parser = parse_header(":notitle:");
3462            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3463            assert!(!parser.has_attribute("showtitle"));
3464        }
3465
3466        #[test]
3467        fn header_notitle_unset_sets_showtitle() {
3468            // `:!notitle:` => showtitle set. This is the case called out in the
3469            // issue: a consumer keying off `showtitle` now sees a signal.
3470            let parser = parse_header(":!notitle:");
3471            assert!(!parser.is_attribute_set("notitle"));
3472            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3473            assert!(parser.is_attribute_set("showtitle"));
3474        }
3475
3476        #[test]
3477        fn last_assignment_wins() {
3478            // Each assignment rewrites the partner, so whichever is assigned
3479            // last decides the resolved toggle.
3480            let parser = parse_header(":notitle:\n:showtitle:");
3481            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3482            assert!(!parser.has_attribute("notitle"));
3483
3484            let parser = parse_header(":showtitle:\n:notitle:");
3485            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3486            assert!(!parser.has_attribute("showtitle"));
3487        }
3488
3489        #[test]
3490        fn body_assignment_is_linked() {
3491            // A body attribute entry links the partner just as a header entry
3492            // does.
3493            let mut parser = Parser::default();
3494            parser.parse("= Title\n\nintro\n\n:notitle:\n\nmore");
3495            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3496            assert!(!parser.has_attribute("showtitle"));
3497        }
3498
3499        #[test]
3500        fn api_assignment_is_linked() {
3501            // Setting either attribute via the API links the partner, matching
3502            // Asciidoctor's `attributes: { 'notitle!' => '' }` etc.
3503            let parser = Parser::default().with_intrinsic_attribute_bool(
3504                "notitle",
3505                true,
3506                ModificationContext::Anywhere,
3507            );
3508            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3509            assert!(!parser.has_attribute("showtitle"));
3510
3511            let parser = Parser::default().with_intrinsic_attribute_bool(
3512                "notitle",
3513                false,
3514                ModificationContext::Anywhere,
3515            );
3516            assert!(!parser.is_attribute_set("notitle"));
3517            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3518        }
3519
3520        #[test]
3521        fn turning_the_toggle_on_leaves_no_partner_tombstone() {
3522            // `:notitle:` removes `showtitle` outright rather than leaving an
3523            // unset tombstone, so a `{showtitle}` reference stays literal (as it
3524            // would with the attribute absent) instead of resolving to an empty
3525            // string. This guards the interaction flagged in review.
3526            let parser = parse_header(":notitle:");
3527            assert!(!parser.has_attribute("showtitle"));
3528
3529            let mut parser = Parser::default();
3530            let doc = parser.parse("= Title\n:notitle:\n\n{showtitle}");
3531            let block = doc.nested_blocks().next().unwrap();
3532            let Block::Simple(simple_block) = block else {
3533                panic!("expected a simple block");
3534            };
3535            assert_eq!(simple_block.content().rendered(), "{showtitle}");
3536        }
3537
3538        #[test]
3539        fn unrelated_attributes_are_untouched() {
3540            // A document that never assigns either spelling leaves both absent —
3541            // the linkage is a no-op for every other attribute.
3542            let parser = parse_header(":sectnums:");
3543            assert!(!parser.has_attribute("notitle"));
3544            assert!(!parser.has_attribute("showtitle"));
3545        }
3546    }
3547
3548    mod derived_backend_family_attrs {
3549        use crate::{
3550            document::InterpretedValue,
3551            parser::{AllowableValue, AttributeValue, ModificationContext, Parser},
3552        };
3553
3554        #[test]
3555        fn tracks_the_active_doctype() {
3556            let mut parser = Parser::default();
3557
3558            // The default doctype is `article`, so only its derived attribute is
3559            // defined (to an empty value).
3560            assert_eq!(
3561                parser.attribute_value("backend-html5-doctype-article"),
3562                InterpretedValue::Value(String::new())
3563            );
3564            assert_eq!(
3565                parser.attribute_value("backend-html5-doctype-book"),
3566                InterpretedValue::Unset
3567            );
3568
3569            // Forcing a new doctype moves the derived attribute with it.
3570            parser.force_doctype("book");
3571            assert_eq!(
3572                parser.attribute_value("backend-html5-doctype-book"),
3573                InterpretedValue::Value(String::new())
3574            );
3575            assert_eq!(
3576                parser.attribute_value("backend-html5-doctype-article"),
3577                InterpretedValue::Unset
3578            );
3579        }
3580
3581        #[test]
3582        fn defines_no_derived_attr_when_doctype_is_not_a_value() {
3583            let mut parser = Parser::default();
3584
3585            // The default article derived attribute starts out defined.
3586            assert_eq!(
3587                parser.attribute_value("backend-html5-doctype-article"),
3588                InterpretedValue::Value(String::new())
3589            );
3590
3591            // Shadow the built-in `doctype` default with an explicit unset
3592            // tombstone. With `doctype` no longer resolving to a `Value`, no
3593            // derived attribute is synthesized for any doctype.
3594            std::sync::Arc::make_mut(&mut parser.attribute_values).insert(
3595                "doctype".to_string(),
3596                AttributeValue {
3597                    allowable_value: AllowableValue::Any,
3598                    modification_context: ModificationContext::Anywhere,
3599                    silent_when_locked: false,
3600                    value: InterpretedValue::Unset,
3601                },
3602            );
3603
3604            assert_eq!(parser.attribute_value("doctype"), InterpretedValue::Unset);
3605            assert_eq!(
3606                parser.attribute_value("backend-html5-doctype-article"),
3607                InterpretedValue::Unset
3608            );
3609        }
3610
3611        #[test]
3612        fn document_header_cannot_assign_a_derived_doctype_flag() {
3613            // The `backend-html5-doctype-*` namespace is a read-only intrinsic,
3614            // so a document header assignment to it is ignored: the flag for the
3615            // (inactive) `book` doctype stays undefined rather than taking the
3616            // assigned value, so it cannot later shadow the intrinsic.
3617            let mut parser = Parser::default();
3618            let _doc = parser.parse("= Title\n:backend-html5-doctype-book: custom\n\nbody");
3619
3620            assert_eq!(
3621                parser.attribute_value("backend-html5-doctype-book"),
3622                InterpretedValue::Unset
3623            );
3624        }
3625
3626        #[test]
3627        fn default_backend_family_is_materialized() {
3628            let parser = Parser::default();
3629
3630            // The default backend is `html5`; its whole derived family resolves
3631            // to queryable document attributes (empty-valued flags plus the
3632            // `backend` / `basebackend` / `filetype` values).
3633            for (name, value) in [
3634                ("backend", "html5"),
3635                ("backend-html5", ""),
3636                ("basebackend", "html"),
3637                ("basebackend-html", ""),
3638                ("filetype", "html"),
3639                ("filetype-html", ""),
3640                ("doctype-article", ""),
3641                ("backend-html5-doctype-article", ""),
3642                ("basebackend-html-doctype-article", ""),
3643            ] {
3644                assert!(parser.has_attribute(name), "missing {name:?}");
3645                assert!(parser.is_attribute_set(name), "not set: {name:?}");
3646                assert_eq!(
3647                    parser.attribute_value(name),
3648                    InterpretedValue::Value(value.to_string()),
3649                    "unexpected value for {name:?}"
3650                );
3651            }
3652        }
3653
3654        #[test]
3655        fn family_tracks_a_non_html_backend() {
3656            // Setting a different backend re-derives the whole family from it
3657            // (basebackend strips the trailing digits, filetype maps through the
3658            // Asciidoctor extension table), and the inactive `html5` flags fall
3659            // away.
3660            let doc = Parser::default().parse(":backend: docbook5\n\nbody");
3661
3662            assert_eq!(
3663                doc.attribute_value("backend"),
3664                InterpretedValue::Value("docbook5".to_string())
3665            );
3666            assert_eq!(
3667                doc.attribute_value("basebackend"),
3668                InterpretedValue::Value("docbook".to_string())
3669            );
3670            assert_eq!(
3671                doc.attribute_value("filetype"),
3672                InterpretedValue::Value("xml".to_string())
3673            );
3674            assert!(doc.has_attribute("backend-docbook5"));
3675            assert!(doc.has_attribute("basebackend-docbook"));
3676            assert!(doc.has_attribute("backend-docbook5-doctype-article"));
3677
3678            // The derived values report as set through the post-parse
3679            // `Document` (snapshot) reader, not just the live parser.
3680            assert!(doc.is_attribute_set("basebackend"));
3681            assert!(doc.is_attribute_set("filetype"));
3682
3683            // The html5 flags are no longer active.
3684            assert!(!doc.has_attribute("backend-html5"));
3685            assert!(!doc.has_attribute("basebackend-html"));
3686            assert!(!doc.has_attribute("backend-html5-doctype-article"));
3687        }
3688
3689        #[test]
3690        fn derived_value_and_flag_attributes_are_read_only() {
3691            // `basebackend` / `filetype` and the derived flag namespace are
3692            // read-only intrinsics; a document assignment is silently ignored and
3693            // the synthesized value stands.
3694            let doc = Parser::default().parse(
3695                ":basebackend: custom\n:filetype: custom\n:backend-html5: custom\n:doctype-article: custom\n\nbody",
3696            );
3697
3698            assert_eq!(
3699                doc.attribute_value("basebackend"),
3700                InterpretedValue::Value("html".to_string())
3701            );
3702            assert_eq!(
3703                doc.attribute_value("filetype"),
3704                InterpretedValue::Value("html".to_string())
3705            );
3706            assert_eq!(
3707                doc.attribute_value("backend-html5"),
3708                InterpretedValue::Value(String::new())
3709            );
3710            assert_eq!(
3711                doc.attribute_value("doctype-article"),
3712                InterpretedValue::Value(String::new())
3713            );
3714        }
3715
3716        #[test]
3717        fn custom_prefixed_flags_stay_assignable() {
3718            // Author-defined attributes that share a derived-family prefix but
3719            // name no active flag (and are not the doctype-keyed namespace) are
3720            // kept, not swallowed by the read-only reservation, so they stay
3721            // visible to `ifdef` / attribute references — matching Asciidoctor.
3722            let doc = Parser::default().parse(
3723                ":backend-custom: enabled\n:basebackend-custom: on\n:filetype-custom: yes\n:doctype-draft: 1\n\nbody",
3724            );
3725
3726            for (name, value) in [
3727                ("backend-custom", "enabled"),
3728                ("basebackend-custom", "on"),
3729                ("filetype-custom", "yes"),
3730                ("doctype-draft", "1"),
3731            ] {
3732                assert!(doc.has_attribute(name), "missing {name:?}");
3733                assert_eq!(
3734                    doc.attribute_value(name),
3735                    InterpretedValue::Value(value.to_string()),
3736                    "unexpected value for {name:?}"
3737                );
3738            }
3739        }
3740
3741        #[test]
3742        fn unset_backend_makes_the_family_absent() {
3743            // Explicitly unsetting `backend` leaves nothing to derive from, so
3744            // `basebackend` / `filetype` and the backend-keyed flags are absent
3745            // rather than resolving to empty traits or degenerate `backend-` /
3746            // `filetype-` names.
3747            let doc = Parser::default().parse(":backend!:\n\nbody");
3748
3749            assert_eq!(doc.attribute_value("backend"), InterpretedValue::Unset);
3750            for name in ["basebackend", "filetype"] {
3751                assert!(!doc.has_attribute(name), "unexpectedly present: {name:?}");
3752                assert!(!doc.is_attribute_set(name), "unexpectedly set: {name:?}");
3753                assert_eq!(doc.attribute_value(name), InterpretedValue::Unset);
3754            }
3755
3756            // No degenerate empty-suffix flags, and the html5 flags are gone.
3757            for name in [
3758                "backend-",
3759                "basebackend-",
3760                "filetype-",
3761                "backend-html5",
3762                "basebackend-html",
3763            ] {
3764                assert!(!doc.has_attribute(name), "unexpectedly present: {name:?}");
3765            }
3766
3767            // The doctype-only flag does not depend on `backend`, so it remains.
3768            assert!(doc.has_attribute("doctype-article"));
3769        }
3770    }
3771
3772    mod docname {
3773        use crate::Parser;
3774
3775        #[test]
3776        fn none_without_primary_file_name() {
3777            assert_eq!(Parser::default().docname(), None);
3778        }
3779
3780        #[test]
3781        fn strips_directory_and_extension() {
3782            assert_eq!(
3783                Parser::default()
3784                    .with_primary_file_name("mydoc.adoc")
3785                    .docname()
3786                    .as_deref(),
3787                Some("mydoc")
3788            );
3789            assert_eq!(
3790                Parser::default()
3791                    .with_primary_file_name("docs/guide/mydoc.adoc")
3792                    .docname()
3793                    .as_deref(),
3794                Some("mydoc")
3795            );
3796            // A Windows-style separator is handled too, since the primary file
3797            // name may be supplied on either platform.
3798            assert_eq!(
3799                Parser::default()
3800                    .with_primary_file_name(r"docs\guide\mydoc.adoc")
3801                    .docname()
3802                    .as_deref(),
3803                Some("mydoc")
3804            );
3805        }
3806
3807        #[test]
3808        fn keeps_name_with_no_extension() {
3809            assert_eq!(
3810                Parser::default()
3811                    .with_primary_file_name("README")
3812                    .docname()
3813                    .as_deref(),
3814                Some("README")
3815            );
3816        }
3817
3818        #[test]
3819        fn none_when_path_has_no_file_component() {
3820            // A primary file name that ends in a separator has an empty base
3821            // name, which yields no document name.
3822            assert_eq!(
3823                Parser::default()
3824                    .with_primary_file_name("docs/guide/")
3825                    .docname(),
3826                None
3827            );
3828        }
3829
3830        #[test]
3831        fn leading_dot_name_is_kept_whole() {
3832            // A leading-dot name (e.g. `.adoc`) is treated as a dotfile with no
3833            // extension and kept whole, matching Ruby's
3834            // `File.basename(".adoc", ".*")`.
3835            assert_eq!(
3836                Parser::default()
3837                    .with_primary_file_name(".adoc")
3838                    .docname()
3839                    .as_deref(),
3840                Some(".adoc")
3841            );
3842        }
3843    }
3844
3845    mod counter {
3846        use super::super::next_counter_value;
3847        use crate::{document::InterpretedValue, tests::prelude::*};
3848
3849        #[test]
3850        fn next_counter_value_integer() {
3851            assert_eq!(next_counter_value("1"), "2");
3852            assert_eq!(next_counter_value("9"), "10");
3853            assert_eq!(next_counter_value("0"), "1");
3854            assert_eq!(next_counter_value("-1"), "0");
3855        }
3856
3857        #[test]
3858        fn next_counter_value_non_canonical_integer_is_advanced_as_a_string() {
3859            // A leading zero (or sign) does not round-trip through integer
3860            // parsing, so it is advanced like a string instead.
3861            assert_eq!(next_counter_value("07"), "08");
3862            assert_eq!(next_counter_value("+5"), "+6");
3863            // A leading-zero value still carries digit-to-digit like a string.
3864            assert_eq!(next_counter_value("09"), "10");
3865            assert_eq!(next_counter_value("099"), "100");
3866        }
3867
3868        #[test]
3869        fn next_counter_value_saturates_at_i64_max() {
3870            // A counter pinned at `i64::MAX` stays there rather than panicking
3871            // (debug) or wrapping (release).
3872            let max = i64::MAX.to_string();
3873            assert_eq!(next_counter_value(&max), max);
3874        }
3875
3876        #[test]
3877        fn next_counter_value_characters() {
3878            assert_eq!(next_counter_value("a"), "b");
3879            assert_eq!(next_counter_value("A"), "B");
3880            assert_eq!(next_counter_value("z"), "aa");
3881            assert_eq!(next_counter_value("Z"), "AA");
3882            assert_eq!(next_counter_value("az"), "ba");
3883            assert_eq!(next_counter_value("zz"), "aaa");
3884            assert_eq!(next_counter_value("Zz"), "AAa");
3885        }
3886
3887        #[test]
3888        fn next_counter_value_trailing_non_alphanumeric() {
3889            // The right-most alphanumeric is incremented; trailing punctuation is
3890            // left in place.
3891            assert_eq!(next_counter_value("a)"), "b)");
3892        }
3893
3894        #[test]
3895        fn next_counter_value_no_alphanumeric() {
3896            // With nothing alphanumeric to carry, the final code point advances.
3897            assert_eq!(next_counter_value("{"), "|");
3898        }
3899
3900        #[test]
3901        fn counter_defaults_to_one() {
3902            let p = Parser::default();
3903            assert_eq!(p.counter("x", None), "1");
3904            assert_eq!(p.counter("x", None), "2");
3905            assert_eq!(
3906                p.attribute_value("x"),
3907                InterpretedValue::Value("2".to_string())
3908            );
3909            assert!(p.has_attribute("x"));
3910            assert!(p.is_attribute_set("x"));
3911        }
3912
3913        #[test]
3914        fn counter_seed_used_only_while_unset() {
3915            let p = Parser::default();
3916            assert_eq!(p.counter("c", Some("A")), "A");
3917            // Once set, a later seed is ignored.
3918            assert_eq!(p.counter("c", Some("Q")), "B");
3919        }
3920
3921        #[test]
3922        fn counter_empty_seed_falls_back_to_one() {
3923            let p = Parser::default();
3924            assert_eq!(p.counter("c", Some("")), "1");
3925        }
3926    }
3927
3928    /// Coverage for the time-dependent document attributes (`docdate`,
3929    /// `doctime`, `docdatetime`, `docyear`, and their `local*` siblings) that
3930    /// is *not* a direct port of Asciidoctor's Ruby tests: the injectable
3931    /// clock ([`Parser::with_reference_time`] /
3932    /// [`Parser::with_input_mtime`]), and resolution *during* a parse (a
3933    /// `{docdate}` reference or an `ifdef::docdate[]` directive) rather
3934    /// than off the finished document.
3935    ///
3936    /// The direct Ruby ports live alongside the vendored suite in
3937    /// `tests/asciidoctor_rb/document_test.rs`.
3938    mod datetime_attributes {
3939        use crate::{parser::ReferenceTime, tests::prelude::*};
3940
3941        #[test]
3942        fn pins_local_attributes_with_reference_time() {
3943            // The injectable clock (this crate's stable-output mechanism) pins
3944            // the `local*` attributes, which Asciidoctor derives from
3945            // `::Time.now`.
3946            let doc = Parser::default()
3947                .with_reference_time(ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 6 * 3600))
3948                .parse("");
3949
3950            assert_eq!(
3951                doc.attribute_value("localdate"),
3952                InterpretedValue::Value("2019-01-02")
3953            );
3954            assert_eq!(
3955                doc.attribute_value("localyear"),
3956                InterpretedValue::Value("2019")
3957            );
3958            assert_eq!(
3959                doc.attribute_value("localtime"),
3960                InterpretedValue::Value("03:04:05 +0600")
3961            );
3962            assert_eq!(
3963                doc.attribute_value("localdatetime"),
3964                InterpretedValue::Value("2019-01-02 03:04:05 +0600")
3965            );
3966        }
3967
3968        #[test]
3969        fn resolves_date_attributes_referenced_in_the_document_body() {
3970            // A `{docdate}` reference resolves the attribute on demand through
3971            // the parser (during substitution), not off the finished document
3972            // snapshot.
3973            let doc = Parser::default()
3974                .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
3975                .parse("docdate={docdate} docyear={docyear} docdatetime={docdatetime}");
3976
3977            assert_eq!(
3978                rendered_paragraphs(&doc),
3979                vec![
3980                    "docdate=2015-01-01 docyear=2015 docdatetime=2015-01-01 10:00:00 UTC"
3981                        .to_string()
3982                ]
3983            );
3984        }
3985
3986        #[test]
3987        fn conditional_directive_sees_a_computed_date_attribute() {
3988            // `ifdef` queries `is_attribute_set`, which must report the computed
3989            // `docdate` as set.
3990            let doc = Parser::default()
3991                .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
3992                .parse("ifdef::docdate[present]");
3993
3994            assert_eq!(rendered_paragraphs(&doc), vec!["present".to_string()]);
3995        }
3996
3997        #[test]
3998        fn an_explicit_doctime_feeds_the_computed_docdatetime() {
3999            // An explicit `doctime` (a stored value) supplies the time portion
4000            // of the computed `docdatetime`, both when referenced in the body
4001            // and when read off the document.
4002            let mut parser = Parser::default()
4003                .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
4004                .with_intrinsic_attribute(
4005                    "doctime",
4006                    "09:09:09-0500",
4007                    ModificationContext::ApiOrHeader,
4008                );
4009            let doc = parser.parse("at {docdatetime}");
4010
4011            assert_eq!(
4012                rendered_paragraphs(&doc),
4013                vec!["at 2015-01-01 09:09:09-0500".to_string()]
4014            );
4015            assert_eq!(
4016                doc.attribute_value("docdatetime"),
4017                InterpretedValue::Value("2015-01-01 09:09:09-0500")
4018            );
4019        }
4020
4021        #[test]
4022        fn an_unset_doctime_falls_back_to_the_reference_time() {
4023            // An explicitly unset `doctime` is treated as absent, so
4024            // `docdatetime` falls back to the reference instant's time.
4025            let mut parser = Parser::default()
4026                .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
4027                .with_intrinsic_attribute_bool("doctime", false, ModificationContext::ApiOrHeader);
4028            let doc = parser.parse("at {docdatetime}");
4029
4030            assert_eq!(
4031                rendered_paragraphs(&doc),
4032                vec!["at 2015-01-01 10:00:00 UTC".to_string()]
4033            );
4034            assert_eq!(
4035                doc.attribute_value("docdatetime"),
4036                InterpretedValue::Value("2015-01-01 10:00:00 UTC")
4037            );
4038        }
4039
4040        #[test]
4041        fn a_value_less_doctime_reads_as_an_empty_time() {
4042            // A value-less `doctime` (set, but with no value) contributes an
4043            // empty time, leaving a trailing space in the computed
4044            // `docdatetime`.
4045            let mut parser = Parser::default()
4046                .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
4047                .with_intrinsic_attribute_bool("doctime", true, ModificationContext::ApiOrHeader);
4048            let doc = parser.parse("x{docdatetime}x");
4049
4050            assert_eq!(rendered_paragraphs(&doc), vec!["x2015-01-01 x".to_string()]);
4051            assert_eq!(
4052                doc.attribute_value("docdatetime"),
4053                InterpretedValue::Value("2015-01-01 ")
4054            );
4055        }
4056    }
4057
4058    // Crate-native `skip-front-matter` cases (no Asciidoctor analog) covering
4059    // edge conditions beyond the reader-suite ports in
4060    // `tests::asciidoctor_rb::reader_test`. See [`Parser::skip_front_matter`].
4061    mod skip_front_matter {
4062        use crate::tests::prelude::*;
4063
4064        #[test]
4065        fn crlf_line_endings() {
4066            // The front-matter delimiters are matched after a CRLF line ending
4067            // is stripped, and the captured `front-matter` value is likewise
4068            // chomped, so a document with `\r\n` line endings is handled the
4069            // same as one with bare `\n`.
4070            let doc = Parser::default()
4071                .with_intrinsic_attribute_bool(
4072                    "skip-front-matter",
4073                    true,
4074                    ModificationContext::ApiOnly,
4075                )
4076                .parse("---\r\nlayout: post\r\ntitle: Document Title\r\n---\r\n= Document Title\r\nAuthor Name\r\n\r\npreamble\r\n");
4077
4078            assert_eq!(
4079                doc.attribute_value("front-matter"),
4080                InterpretedValue::Value("layout: post\ntitle: Document Title")
4081            );
4082            assert_eq!(doc.header().title(), Some("Document Title"));
4083            assert_eq!(doc.header().title_source().unwrap().line(), 5);
4084        }
4085
4086        #[test]
4087        fn first_line_is_not_a_delimiter() {
4088            // With `skip-front-matter` set but no opening `---` on the first
4089            // line, there is nothing to skip: the document parses normally and
4090            // no `front-matter` attribute is recorded.
4091            let doc = Parser::default()
4092                .with_intrinsic_attribute_bool(
4093                    "skip-front-matter",
4094                    true,
4095                    ModificationContext::ApiOnly,
4096                )
4097                .parse("= Document Title\nAuthor Name\n\npreamble\n");
4098
4099            assert_eq!(doc.attribute_value("front-matter"), InterpretedValue::Unset);
4100            assert_eq!(doc.header().title(), Some("Document Title"));
4101            assert_eq!(doc.header().title_source().unwrap().line(), 1);
4102        }
4103    }
4104}