Skip to main content

asciidoc_parser/parser/
parser.rs

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