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