Skip to main content

asciidoc_parser/parser/
parser.rs

1use std::{
2    cell::{Cell, RefCell},
3    collections::{HashMap, HashSet},
4    rc::Rc,
5    sync::Arc,
6};
7
8use crate::{
9    Document, HasSpan,
10    blocks::{SectionNumber, SectionType},
11    document::{Attribute, Catalog, InterpretedValue, RefType},
12    parser::{
13        AllowableValue, AttributeValue, DocinfoFileHandler, HtmlSubstitutionRenderer,
14        IncludeFileHandler, InlineSubstitutionRenderer, ModificationContext, PathResolver,
15        ResolvedAttributes, SafeMode, SourceLine, SourceMap, SvgFileHandler,
16        built_in_attrs::{built_in_attr, built_in_default_values, synthesized_attr},
17        preprocessor::preprocess,
18    },
19    warnings::{Warning, WarningType},
20};
21
22/// The [`Parser`] struct and its related structs allow a caller to configure
23/// how AsciiDoc parsing occurs and then to initiate the parsing process.
24#[derive(Clone, Debug)]
25pub struct Parser {
26    /// Per-parser attribute values: **only** the attributes this parser has
27    /// defined, overridden, or explicitly unset. The large set of built-in
28    /// defaults is *not* copied in here; [`attribute_value`] falls back to the
29    /// shared built-in table (see [`built_in_attrs`]) on a lookup miss, so
30    /// creating or cloning a parser allocates nothing per built-in attribute.
31    ///
32    /// A per-parser entry always shadows the built-in default of the same name,
33    /// including an [`Unset`](InterpretedValue::Unset) tombstone that records a
34    /// built-in having been unset. The map is wrapped in an [`Arc`] so a parser
35    /// clone (e.g. for a nested AsciiDoc table cell) shares it copy-on-write
36    /// and only copies these (few) entries when it next modifies an
37    /// attribute.
38    ///
39    /// [`attribute_value`]: Self::attribute_value
40    /// [`built_in_attrs`]: super::built_in_attrs
41    pub(crate) attribute_values: Arc<HashMap<String, AttributeValue>>,
42
43    /// Default values for attributes if "set." Immutable after construction and
44    /// shared via [`Arc`] (never copied per parser).
45    default_attribute_values: Arc<HashMap<String, String>>,
46
47    /// Specifies how the basic raw text of a simple block will be converted to
48    /// the format which will ultimately be presented in the final output.
49    ///
50    /// Typically this is an [`HtmlSubstitutionRenderer`] but clients may
51    /// provide alternative implementations.
52    pub(crate) renderer: Rc<dyn InlineSubstitutionRenderer>,
53
54    /// Specifies the name of the primary file to be parsed.
55    pub(crate) primary_file_name: Option<String>,
56
57    /// Specifies how to generate clean and secure paths relative to the parsing
58    /// context.
59    pub path_resolver: PathResolver,
60
61    /// Handler for resolving include:: directives.
62    pub(crate) include_file_handler: Option<Rc<dyn IncludeFileHandler>>,
63
64    /// Handler for resolving docinfo files. If absent, no docinfo content is
65    /// resolved.
66    pub(crate) docinfo_file_handler: Option<Rc<dyn DocinfoFileHandler>>,
67
68    /// Handler for reading the contents of an SVG file requested by an inline
69    /// image with the `inline` option. If absent, inline SVG images fall back
70    /// to rendering their alt text.
71    pub(crate) svg_file_handler: Option<Rc<dyn SvgFileHandler>>,
72
73    /// The safe mode under which the document is parsed and rendered. Controls
74    /// security-sensitive rendering behavior (such as whether an interactive
75    /// SVG image is rendered as an `<object>` element). Defaults to
76    /// [`SafeMode::Secure`].
77    pub(crate) safe: SafeMode,
78
79    /// Document catalog for tracking referenceable elements during parsing.
80    /// This is created during parsing and transferred to the Document when
81    /// complete.
82    ///
83    /// Wrapped in a [`RefCell`] so that anchors and references discovered deep
84    /// inside inline substitution (where only a shared `&Parser` is available,
85    /// e.g. within a regex [`Replacer`](regex::Replacer)) can still be
86    /// registered.
87    catalog: RefCell<Catalog>,
88
89    /// Most recently-assigned section number.
90    pub(crate) last_section_number: SectionNumber,
91
92    /// Most recently-assigned appendix section number.
93    pub(crate) last_appendix_section_number: SectionNumber,
94
95    /// Saved copy of sectnumlevels at end of document header.
96    pub(crate) sectnumlevels: usize,
97
98    /// Section type of outermost section. (Used to determine whether to number
99    /// child sections as a normal section or appendix.)
100    pub(crate) topmost_section_type: SectionType,
101
102    /// True while parsing the direct block children of a section that carries
103    /// the `bibliography` style.
104    ///
105    /// A top-level unordered list parsed in this scope implicitly inherits the
106    /// `bibliography` style (matching Asciidoctor), even without its own
107    /// `[bibliography]` attribute. The flag is saved and restored around each
108    /// section body, so a non-bibliography subsection clears it for its own
109    /// children (the style does not propagate into subsections).
110    pub(crate) parsing_bibliography_section_body: bool,
111
112    /// True while the principal text of a bibliography list item is being
113    /// substituted.
114    ///
115    /// Read through a shared `&Parser` by the macros substitution step so it
116    /// recognizes a leading bibliography anchor (`[[[id]]]`). It is wrapped in
117    /// a [`Cell`] because the substitution code paths (e.g. a regex
118    /// [`Replacer`](regex::Replacer)) only hold a shared reference to the
119    /// parser.
120    pub(crate) in_bibliography_list_item: Cell<bool>,
121
122    /// True while a section title is being substituted, so each `footnote:[…]`
123    /// macro's rendered marker is bracketed with
124    /// [`FOOTNOTE_MARKER_START`](crate::content::FOOTNOTE_MARKER_START) /
125    /// [`FOOTNOTE_MARKER_END`](crate::content::FOOTNOTE_MARKER_END) sentinels.
126    /// The footnote is still defined and numbered in document order; the
127    /// sentinels merely let the marker be excised from the section's reference
128    /// text and auto-generated ID without a second substitution pass (see
129    /// `SectionBlock::parse`).
130    ///
131    /// Wrapped in a [`Cell`] for the same reason as
132    /// [`in_bibliography_list_item`](Self::in_bibliography_list_item): the
133    /// substitution code paths hold only a shared reference to the parser.
134    pub(crate) mark_footnote_spans: Cell<bool>,
135
136    /// Live values of [counter] attributes, keyed by counter name (e.g.
137    /// `index`, `example-number`, `table-number`).
138    ///
139    /// A counter is a specialized document attribute: its value is *also* the
140    /// value of the document attribute of the same name. Counters are resolved
141    /// (and advanced) deep inside the attribute-reference substitution step,
142    /// where only a shared `&Parser` is available, so the new value is recorded
143    /// here through a [`RefCell`] and read back as an attribute by
144    /// [`attribute_value()`]. An explicit attribute assignment to a counter's
145    /// name supersedes this overlay (and is what allows `:!name:` to reset a
146    /// counter), so every attribute setter clears the matching entry.
147    ///
148    /// Captioned blocks (example, table, …) are numbered with this same
149    /// mechanism: each context's caption number is the counter named
150    /// `<context>-number`, mirroring Asciidoctor's `Document#counter`.
151    ///
152    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
153    /// [`attribute_value()`]: Self::attribute_value
154    pub(crate) counter_values: RefCell<HashMap<String, String>>,
155
156    /// Canonical names of attributes that are locked against modification from
157    /// the document body for the current scope.
158    ///
159    /// An AsciiDoc table cell creates a nested document that inherits the
160    /// parent document's attributes. An attribute that is *set* in the
161    /// parent _cannot_ be modified inside the cell (matching Asciidoctor,
162    /// which here diverges from the spec's "set or explicitly unset" wording),
163    /// so while a cell is being parsed every inherited attribute name
164    /// (other than a handful of exceptions) is recorded here and a body
165    /// attribute assignment to such a name is silently ignored. The set is
166    /// saved and restored around each cell, so the lock applies only within
167    /// the cell (and nests correctly).
168    pub(crate) locked_attribute_names: HashSet<String>,
169
170    /// Number of AsciiDoc table cells currently being parsed in the call stack.
171    ///
172    /// An AsciiDoc table cell creates a nested, standalone AsciiDoc document.
173    /// While that document is being parsed this counter is greater than zero,
174    /// which (matching Asciidoctor's `Document#nested?`) changes the default
175    /// cell separator of any table found inside from the vertical bar (`|`) to
176    /// the exclamation mark (`!`), so a nested table needs no explicit
177    /// `separator` attribute. The counter is incremented and decremented around
178    /// each AsciiDoc cell, so it nests correctly.
179    pub(crate) nested_document_depth: usize,
180
181    /// Source map of the document currently being parsed, populated by
182    /// [`Document::parse`] for the duration of the parse (and `None` outside
183    /// it).
184    ///
185    /// Block parsing works from the *preprocessed* source, so a span's line
186    /// number is relative to that flattened source rather than to the original
187    /// input file(s). An AsciiDoc table cell whose first line is an `include::`
188    /// directive re-runs the preprocessor over the cell's content: to report an
189    /// unresolved directive against the file and line where it *originally*
190    /// appeared (rather than "(root file)"), the cell must map its position in
191    /// the preprocessed source back through this map. It is only consulted
192    /// while parsing the top-level document (`nested_document_depth == 0`),
193    /// where a cell's span still refers to that source.
194    ///
195    /// [`Document::parse`]: crate::Document
196    pub(crate) source_map: Option<Rc<SourceMap>>,
197
198    /// Stack of source maps for the include-expanded (owned) AsciiDoc table
199    /// cells currently being parsed in the call stack (innermost last).
200    ///
201    /// An AsciiDoc cell whose first line is an `include::` directive is parsed
202    /// from a private, preprocessor-expanded copy of its content rather than
203    /// from the document source. While that owned copy is being parsed a span's
204    /// line number no longer indexes the document
205    /// [`source_map`](Self::source_map); it indexes the owned copy instead.
206    /// Each owned cell's own source map (produced by re-running the
207    /// preprocessor over its content) is pushed here for the duration of
208    /// its parse, so a directive buried inside the owned content can still
209    /// be mapped back to the file and line it *originally* came from —
210    /// needed to name that file in the "Unresolved directive" message and
211    /// to report the warning's true cursor via
212    /// [`Warning::origin`](crate::warnings::Warning::origin).
213    ///
214    /// The stack is empty while parsing the top-level document (and while
215    /// parsing a *borrowed* cell, which keeps document spans). It is non-empty
216    /// exactly when a span's line indexes an owned copy rather than the
217    /// document, which the source-map lookup uses to decide which map to
218    /// consult. It is pushed and popped around each owned-cell parse, so it
219    /// nests correctly.
220    pub(crate) owned_cell_source_maps: Vec<Rc<SourceMap>>,
221
222    /// Warnings raised by an `include::` directive buried inside an owned
223    /// (include-expanded) AsciiDoc table cell, each already resolved to the
224    /// `(file, line)` where the directive originally appeared.
225    ///
226    /// Such a warning is raised deep inside the owned cell's parse, where the
227    /// only spans available borrow the owned copy and cannot escape it, and no
228    /// document span maps to the directive. It is therefore recorded here (in a
229    /// lifetime-free, pre-resolved form) and drained once an enclosing
230    /// document-level cell can anchor it to a real document span while carrying
231    /// its true origin (see
232    /// [`Warning::origin`](crate::warnings::Warning::origin)).
233    ///
234    /// Wrapped in a [`RefCell`] only for symmetry with the other deferred
235    /// warning buffers; it is mutated through `&mut self`-free helpers so the
236    /// owned-cell parse (which holds the parser mutably) can still record into
237    /// it from within a `self_cell` construction closure.
238    owned_cell_warnings: RefCell<Vec<ResolvedWarning>>,
239
240    /// Catalog of callout numbers registered by verbatim blocks, used to
241    /// validate the callout lists that annotate them.
242    ///
243    /// Wrapped in a [`RefCell`] because callouts are registered deep inside the
244    /// callouts substitution step, where only a shared `&Parser` is available.
245    callouts: RefCell<CalloutCatalog>,
246
247    /// Warnings produced while replacing attribute references (e.g. a reference
248    /// to a missing attribute when `attribute-missing` is `warn`).
249    ///
250    /// Wrapped in a [`RefCell`] because attribute references are replaced deep
251    /// inside the attributes substitution step, where only a shared `&Parser`
252    /// is available. Each entry stores the byte offset and length of the source
253    /// span the warning refers to (rather than a borrowed
254    /// [`Span`](crate::Span), which the lifetime-free `Parser` cannot
255    /// hold), so the warnings can be turned into
256    /// spanned [`Warning`]s once the document's owned source is available.
257    substitution_warnings: RefCell<Vec<DeferredWarning>>,
258}
259
260/// A warning recorded in a form that does not borrow the source so it can live
261/// on the [`Parser`] (or be returned from preprocessing), to be reconstituted
262/// into a spanned [`Warning`] once the document's owned source is available.
263///
264/// This is used both for warnings raised while replacing attribute references
265/// and for warnings raised during preprocessing (e.g. an unresolved include
266/// directive). The `offset`/`len` pair locates the relevant text within the
267/// (preprocessed) document source.
268#[derive(Clone, Debug)]
269pub(crate) struct DeferredWarning {
270    /// Byte offset into the document source of the span this warning refers to.
271    pub(crate) offset: usize,
272
273    /// Byte length of the span this warning refers to.
274    pub(crate) len: usize,
275
276    /// The type of warning, already carrying any owned data it needs (such as
277    /// the missing attribute's name).
278    pub(crate) warning: WarningType,
279}
280
281/// A warning whose location is already resolved to an originating
282/// `(file, line)`, independent of any source map.
283///
284/// Used for a warning raised inside an owned (include-expanded) AsciiDoc table
285/// cell, whose directive never appears in the document source: it is resolved
286/// against the owning cell's own source map when raised, then carried in this
287/// form until an enclosing document-level cell can surface it (see
288/// [`Parser::owned_cell_warnings`]).
289#[derive(Clone, Debug)]
290pub(crate) struct ResolvedWarning {
291    /// The originating file and line where the directive appeared.
292    pub(crate) origin: SourceLine,
293
294    /// The type of warning, already carrying any owned data it needs (such as
295    /// the missing include target).
296    pub(crate) warning: WarningType,
297}
298
299/// Tracks the callout numbers defined by verbatim blocks so that a callout list
300/// can be validated against the callouts it annotates.
301///
302/// This mirrors the relevant behavior of Asciidoctor's `Callouts` catalog: each
303/// verbatim block registers the callout numbers it defines into the current
304/// list, and each callout list checks its items against that list (warning
305/// about any item with no matching callout) before the list is closed.
306#[derive(Clone, Debug, Default)]
307struct CalloutCatalog {
308    /// Callout numbers registered (in document order) since the last callout
309    /// list was closed.
310    current: Vec<u32>,
311}
312
313impl Default for Parser {
314    fn default() -> Self {
315        Self {
316            // Starts empty: built-in defaults are resolved on the fly via the
317            // shared table (see `attribute_value`), not copied in per parser.
318            attribute_values: Arc::new(HashMap::new()),
319            default_attribute_values: built_in_default_values(),
320            renderer: Rc::new(HtmlSubstitutionRenderer {}),
321            primary_file_name: None,
322            path_resolver: PathResolver::default(),
323            include_file_handler: None,
324            docinfo_file_handler: None,
325            svg_file_handler: None,
326            safe: SafeMode::default(),
327            catalog: RefCell::new(Catalog::new()),
328            last_section_number: SectionNumber::default(),
329            last_appendix_section_number: SectionNumber {
330                section_type: SectionType::Appendix,
331                components: vec![],
332            },
333            sectnumlevels: 3,
334            topmost_section_type: SectionType::Normal,
335            parsing_bibliography_section_body: false,
336            in_bibliography_list_item: Cell::new(false),
337            mark_footnote_spans: Cell::new(false),
338            counter_values: RefCell::new(HashMap::new()),
339            locked_attribute_names: HashSet::new(),
340            nested_document_depth: 0,
341            source_map: None,
342            owned_cell_source_maps: vec![],
343            owned_cell_warnings: RefCell::new(vec![]),
344            callouts: RefCell::new(CalloutCatalog::default()),
345            substitution_warnings: RefCell::new(vec![]),
346        }
347    }
348}
349
350impl Parser {
351    /// Parse a UTF-8 string as an AsciiDoc document.
352    ///
353    /// The [`Document`] data structure returned by this call has a '`static`
354    /// lifetime; this is an implementation detail. It retains a copy of the
355    /// `source` string that was passed in, but it is not tied to the lifetime
356    /// of that string.
357    ///
358    /// Nearly all of the data structures contained within the [`Document`]
359    /// structure are tied to the lifetime of the document and have a `'src`
360    /// lifetime to signal their dependency on the source document.
361    ///
362    /// **IMPORTANT:** The AsciiDoc language documentation states that UTF-16
363    /// encoding is allowed if a byte-order-mark (BOM) is present at the
364    /// start of a file. This format is not directly supported by the
365    /// `asciidoc-parser` crate. Any UTF-16 content must be re-encoded as
366    /// UTF-8 prior to parsing.
367    ///
368    /// The `Parser` struct will be updated with document attribute values
369    /// discovered during parsing. These values may be inspected using
370    /// [`attribute_value()`].
371    ///
372    /// # Warnings, not errors
373    ///
374    /// Any UTF-8 string is a valid AsciiDoc document, so this function does not
375    /// return an [`Option`] or [`Result`] data type. There may be any number of
376    /// character sequences that have ambiguous or potentially unintended
377    /// meanings. For that reason, a caller is advised to review the warnings
378    /// provided via the [`warnings()`] iterator.
379    ///
380    /// [`warnings()`]: Document::warnings
381    /// [`attribute_value()`]: Self::attribute_value
382    pub fn parse(&mut self, source: &str) -> Document<'static> {
383        let mut document = self.parse_deferred(source);
384
385        // Resolve cross-references against this document's own catalog. For
386        // multi-document workflows, use `parse_deferred` and resolve later with
387        // a caller-supplied resolver via `Document::resolve_references`.
388        document.resolve_against_own_catalog(&*self.renderer);
389
390        document
391    }
392
393    /// Parse a UTF-8 string as an AsciiDoc document, leaving cross-references
394    /// unresolved.
395    ///
396    /// This behaves like [`parse()`], except it does not resolve
397    /// cross-references (`<<id>>`, `xref:id[…]`). The returned [`Document`]
398    /// carries its references in a deferred state; resolve them later with
399    /// [`Document::resolve_references`].
400    ///
401    /// This is the entry point for multi-document workflows (e.g. Antora-style
402    /// site generation): parse every document with this method, build a
403    /// combined index from each document's [`catalog()`], then resolve each
404    /// document against that index. This crate does not merge catalogs
405    /// itself.
406    ///
407    /// [`parse()`]: Self::parse
408    /// [`catalog()`]: Document::catalog
409    pub fn parse_deferred(&mut self, source: &str) -> Document<'static> {
410        let (preprocessed_source, source_map, preprocessor_warnings) = preprocess(source, self);
411
412        // NOTE: `Document::parse` will transfer the catalog to itself at the end of the
413        // parsing operation. Start each parse with a fresh catalog.
414        *self.catalog.borrow_mut() = Catalog::new();
415
416        // Start each parse with an empty callout catalog.
417        *self.callouts.borrow_mut() = CalloutCatalog::default();
418
419        // Start each parse with no pending substitution warnings.
420        self.substitution_warnings.borrow_mut().clear();
421
422        // Reset section numbering for each new document.
423        self.last_section_number = SectionNumber::default();
424
425        // Reset counter (and captioned-block) numbering for each new document.
426        self.counter_values.borrow_mut().clear();
427
428        Document::parse(
429            &preprocessed_source,
430            source_map,
431            preprocessor_warnings,
432            self,
433        )
434    }
435
436    /// Retrieves the current interpreted value of a [document attribute].
437    ///
438    /// Each document holds a set of name-value pairs called document
439    /// attributes. These attributes provide a means of configuring the AsciiDoc
440    /// processor, declaring document metadata, and defining reusable content.
441    /// This page introduces document attributes and answers some questions
442    /// about the terminology used when referring to them.
443    ///
444    /// ## What are document attributes?
445    ///
446    /// Document attributes are effectively document-scoped variables for the
447    /// AsciiDoc language. The AsciiDoc language defines a set of built-in
448    /// attributes, and also allows the author (or extensions) to define
449    /// additional document attributes, which may replace built-in attributes
450    /// when permitted.
451    ///
452    /// Built-in attributes either provide access to read-only information about
453    /// the document and its environment or allow the author to configure
454    /// behavior of the AsciiDoc processor for a whole document or select
455    /// regions. Built-in attributes are effectively unordered. User-defined
456    /// attribute serve as a powerful text replacement tool. User-defined
457    /// attributes are stored in the order in which they are defined.
458    ///
459    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
460    pub fn attribute_value<N: AsRef<str>>(&self, name: N) -> InterpretedValue {
461        let name = name.as_ref();
462
463        // A counter's current value lives in the overlay and supersedes any
464        // earlier value of the attribute of the same name (see
465        // [`counter_values`](Self::counter_values)).
466        if let Some(value) = self.counter_values.borrow().get(name) {
467            return InterpretedValue::Value(value.clone());
468        }
469
470        // An unset `relfilesuffix` reads as the *effective* value of
471        // `outfilesuffix` — routed through this same reader so an
472        // `outfilesuffix` counter overlay is honored too (see
473        // [`tracks_outfilesuffix`](Self::tracks_outfilesuffix)).
474        if self.tracks_outfilesuffix(name) {
475            return self.attribute_value("outfilesuffix");
476        }
477
478        match self.effective_attribute(name) {
479            Some(av) => {
480                if let InterpretedValue::Set = av.value
481                    && let Some(default) = self.default_attribute_values.get(name)
482                {
483                    InterpretedValue::Value(default.clone())
484                } else {
485                    av.value.clone()
486                }
487            }
488            None => InterpretedValue::Unset,
489        }
490    }
491
492    /// Returns the effective attribute definition for `name`: a per-parser
493    /// entry (an override or an explicit [unset] tombstone) shadows the
494    /// shared built-in default, which in turn shadows an on-the-fly
495    /// synthesized attribute (the active `backend-html5-doctype-*` and
496    /// `safe-mode-*` flags). The synthesized attributes are never
497    /// materialized in either table.
498    ///
499    /// This is the *raw* lookup used by the attribute writers to decide whether
500    /// a name is locked against modification. The attribute *readers*
501    /// additionally resolve the read-only default of `relfilesuffix` (see
502    /// [`tracks_outfilesuffix`](Self::tracks_outfilesuffix)).
503    ///
504    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
505    pub(crate) fn effective_attribute(&self, name: &str) -> Option<&AttributeValue> {
506        if let Some(av) = self.attribute_values.get(name) {
507            return Some(av);
508        }
509        if let Some(av) = built_in_attr(name) {
510            return Some(av);
511        }
512        synthesized_attr(name, &self.attribute_values)
513    }
514
515    /// Reports whether `name` is `relfilesuffix` in its unset state, in which
516    /// case a *read* resolves it to the current value of `outfilesuffix` (the
517    /// two diverge for non-HTML backends, e.g. `.xml` for DocBook — see
518    /// [issue #657](https://github.com/asciidoc-rs/asciidoc-parser/issues/657)).
519    ///
520    /// Returns `false` once `relfilesuffix` is explicitly set or unset (an
521    /// entry — a value or an [unset] tombstone — then lives in the
522    /// per-parser map), and for every other name. The redirect is
523    /// deliberately confined to the value *readers*: the attribute
524    /// *writers* consult [`effective_attribute`](Self::effective_attribute)
525    /// directly, so `relfilesuffix` stays modifiable anywhere rather than
526    /// inheriting the header-only modification context of `outfilesuffix`.
527    /// Callers must apply a like-named counter overlay first, so a
528    /// `{counter:relfilesuffix}` still wins over the tracked default.
529    ///
530    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
531    pub(crate) fn tracks_outfilesuffix(&self, name: &str) -> bool {
532        name == "relfilesuffix" && !self.attribute_values.contains_key(name)
533    }
534
535    /// Returns `true` if the parser has a [document attribute] by this name.
536    ///
537    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
538    pub fn has_attribute<N: AsRef<str>>(&self, name: N) -> bool {
539        let name = name.as_ref();
540        if self.counter_values.borrow().contains_key(name) {
541            return true;
542        }
543        if self.tracks_outfilesuffix(name) {
544            return self.has_attribute("outfilesuffix");
545        }
546        self.effective_attribute(name).is_some()
547    }
548
549    /// Returns `true` if the parser has a [document attribute] by this name
550    /// which has been set (i.e. is present and not [unset]).
551    ///
552    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
553    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
554    pub fn is_attribute_set<N: AsRef<str>>(&self, name: N) -> bool {
555        let name = name.as_ref();
556
557        // A counter always holds a concrete (set) value.
558        if self.counter_values.borrow().contains_key(name) {
559            return true;
560        }
561
562        if self.tracks_outfilesuffix(name) {
563            return self.is_attribute_set("outfilesuffix");
564        }
565
566        self.effective_attribute(name)
567            .map(|a| a.value != InterpretedValue::Unset)
568            .unwrap_or(false)
569    }
570
571    /// Returns the current `leveloffset` document attribute as a signed
572    /// integer.
573    ///
574    /// The `leveloffset` attribute shifts the effective level of every section
575    /// heading in scope (see the include directive's `leveloffset` option and
576    /// the `:leveloffset:` attribute entry). Relative assignments (`+N` / `-N`)
577    /// are resolved to an absolute value when the attribute is set (see
578    /// [`resolve_leveloffset_assignment`](Self::resolve_leveloffset_assignment)),
579    /// so the stored value is always a plain integer; a non-integer or unset
580    /// value yields an offset of `0`.
581    pub(crate) fn level_offset(&self) -> i32 {
582        match self.attribute_value("leveloffset") {
583            InterpretedValue::Value(v) => v.trim().parse::<i32>().unwrap_or(0),
584            _ => 0,
585        }
586    }
587
588    /// Resolves a `leveloffset` assignment value, converting a relative form
589    /// (`+N` / `-N`) into the absolute value it produces given the
590    /// `leveloffset` currently in effect. Absolute values, and values that
591    /// aren't a signed integer, are returned unchanged.
592    ///
593    /// This mirrors Asciidoctor, where a relative `leveloffset` accumulates on
594    /// top of the offset already in effect. That accumulation is what lets the
595    /// offsets of nested includes compose: each `include::[leveloffset=+1]`
596    /// (and its `:leveloffset: +1` wrapper) shifts headings one level further
597    /// down relative to wherever the surrounding content already sits.
598    fn resolve_leveloffset_assignment(&self, value: InterpretedValue) -> InterpretedValue {
599        let InterpretedValue::Value(ref v) = value else {
600            return value;
601        };
602
603        // Only a leading `+`/`-` marks a relative assignment; anything else
604        // (an absolute value, or a non-numeric value) is stored unchanged.
605        let trimmed = v.trim();
606        if !trimmed.starts_with(['+', '-']) {
607            return value;
608        }
609
610        // Parse the whole signed value as `i64` so the extreme relative delta
611        // `-2147483648` (whose magnitude exceeds `i32::MAX`) is still read as
612        // itself rather than failing and being stored as an absolute value.
613        match trimmed.parse::<i64>() {
614            // The running offset is a valid `i32`, so widening it to `i64`
615            // makes the accumulation itself infallible; `saturating_add` then
616            // guards the (already absurd) case of a delta near `i64::MIN/MAX`,
617            // and the result is clamped back into the `i32` the attribute
618            // stores. This keeps a pathological offset from overflowing —
619            // which would panic in debug builds and wrap in release builds —
620            // rather than imposing a real bound the syntax does not otherwise
621            // impose.
622            Ok(delta) => InterpretedValue::Value(
623                (self.level_offset() as i64)
624                    .saturating_add(delta)
625                    .clamp(i32::MIN as i64, i32::MAX as i64)
626                    .to_string(),
627            ),
628            Err(_) => value,
629        }
630    }
631
632    /// Resolves a `leveloffset` assignment (see
633    /// [`resolve_leveloffset_assignment`](Self::resolve_leveloffset_assignment))
634    /// and, if the resulting absolute offset is so large or small that *every*
635    /// heading would be shifted outside the supported 1..=5 section-level
636    /// range, records a [`LeveloffsetExcludesAllHeadingLevels`] warning
637    /// against `span`.
638    ///
639    /// [`LeveloffsetExcludesAllHeadingLevels`]:
640    /// crate::warnings::WarningType::LeveloffsetExcludesAllHeadingLevels
641    fn resolve_leveloffset_and_warn<'src>(
642        &self,
643        value: InterpretedValue,
644        span: crate::Span<'src>,
645        warnings: &mut Vec<Warning<'src>>,
646    ) -> InterpretedValue {
647        let value = self.resolve_leveloffset_assignment(value);
648
649        if let InterpretedValue::Value(ref v) = value
650            && let Ok(offset) = v.trim().parse::<i32>()
651            && !leveloffset_admits_any_heading(offset)
652        {
653            warnings.push(Warning {
654                source: span,
655                warning: WarningType::LeveloffsetExcludesAllHeadingLevels(offset),
656                origin: None,
657            });
658        }
659
660        value
661    }
662
663    /// Captures the parser's fully-resolved document-attribute state so it can
664    /// outlive the parser — for example, retained on a [`Document`] to answer
665    /// [`attribute_value`]/[`has_attribute`]/[`is_attribute_set`] without a
666    /// parser in hand (the embed path a renderer uses for `convert_document`).
667    ///
668    /// This shares the parser's attribute tables by [`Arc`] rather than copying
669    /// them, so it is cheap to take on every parse (the large built-in table is
670    /// never deep-cloned). See [`ResolvedAttributes`].
671    ///
672    /// [`Document`]: crate::Document
673    /// [`attribute_value`]: Self::attribute_value
674    /// [`has_attribute`]: Self::has_attribute
675    /// [`is_attribute_set`]: Self::is_attribute_set
676    pub(crate) fn snapshot_attributes(&self) -> ResolvedAttributes {
677        ResolvedAttributes::new(
678            Arc::clone(&self.attribute_values),
679            Arc::clone(&self.default_attribute_values),
680            self.counter_values.borrow().clone(),
681        )
682    }
683
684    /// Resolves whether a document title should be displayed, from the
685    /// `showtitle`/`notitle` attribute pair (which are complements).
686    ///
687    /// `showtitle` takes precedence: if present, the title shows precisely when
688    /// it is set. Otherwise `notitle`, if present, hides the title when set.
689    /// When neither attribute is present, `default_shown` decides — a
690    /// standalone document (such as a nested AsciiDoc table cell) shows its
691    /// title, while an embedded document does not.
692    pub(crate) fn resolve_show_title(&self, default_shown: bool) -> bool {
693        if self.has_attribute("showtitle") {
694            self.is_attribute_set("showtitle")
695        } else if self.has_attribute("notitle") {
696            !self.is_attribute_set("notitle")
697        } else {
698            default_shown
699        }
700    }
701
702    /// Applies the `notitle` ⇔ `showtitle` inverse-toggle linkage
703    /// (Asciidoctor asciidoctor/asciidoctor#3804).
704    ///
705    /// `notitle` and `showtitle` are two spellings of a single "show the
706    /// document title" switch, wired as opposites: assigning either attribute
707    /// updates the other so the resolved document reflects one consistent
708    /// toggle. This yields last-assignment-wins semantics for free, since each
709    /// assignment rewrites the partner left by the previous one.
710    ///
711    /// `attr_name` is the attribute just assigned and `value` its stored value;
712    /// the call is a no-op for any other name. Turning the toggle *off* (an
713    /// explicit [unset], e.g. `:!notitle:`) turns the partner *on* — it is
714    /// stored [set] with the same `modification_context` and
715    /// `silent_when_locked` flag as the triggering assignment. Turning the
716    /// toggle *on* (an empty `Set` or an explicit value, e.g. `:notitle:`)
717    /// *removes* the partner entirely.
718    ///
719    /// The partner is removed — rather than left as an explicit unset
720    /// tombstone — to mirror Asciidoctor's attribute-hash semantics, where an
721    /// "off" attribute is simply absent. That keeps every observer consistent:
722    /// `has_attribute`, `ifdef`/`ifndef`, and `{partner}` reference
723    /// substitution all see the same absence Asciidoctor does (so, e.g., a
724    /// `{showtitle}` reference stays literal after `:notitle:` rather than
725    /// silently resolving to an empty string).
726    ///
727    /// [set]: https://docs.asciidoctor.org/asciidoc/latest/attributes/set-attributes/
728    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
729    fn apply_title_visibility_linkage(
730        &mut self,
731        attr_name: &str,
732        value: &InterpretedValue,
733        modification_context: ModificationContext,
734        silent_when_locked: bool,
735    ) {
736        let partner = match attr_name {
737            "notitle" => "showtitle",
738            "showtitle" => "notitle",
739            _ => return,
740        };
741
742        // Either way the partner supersedes (and resets) any counter of the
743        // same name, mirroring a direct assignment.
744        self.counter_values.borrow_mut().remove(partner);
745
746        if let InterpretedValue::Unset = value {
747            // The toggle is off, so the partner turns on.
748            Arc::make_mut(&mut self.attribute_values).insert(
749                partner.to_string(),
750                AttributeValue {
751                    allowable_value: AllowableValue::Any,
752                    modification_context,
753                    silent_when_locked,
754                    value: InterpretedValue::Set,
755                },
756            );
757        } else if self.attribute_values.contains_key(partner) {
758            // The toggle is on, so the partner turns off — and, matching
759            // Asciidoctor, "off" means absent. (Guarded so the common case of
760            // no prior partner entry does not clone the shared map.)
761            Arc::make_mut(&mut self.attribute_values).remove(partner);
762        }
763    }
764
765    /// Forces the `doctype` attribute to `value`.
766    ///
767    /// Used when a nested AsciiDoc table cell resets its doctype to the default
768    /// (a cell does not inherit the parent's doctype). The value stays
769    /// modifiable from the document body so the cell may still set its own
770    /// doctype.
771    ///
772    /// The derived `backend-html5-doctype-{doctype}` attribute needs no
773    /// explicit refresh: it is synthesized on the fly for whatever
774    /// `doctype` currently resolves to (see
775    /// [`attribute_value`](Self::attribute_value)).
776    pub(crate) fn force_doctype(&mut self, value: &str) {
777        Arc::make_mut(&mut self.attribute_values).insert(
778            "doctype".to_string(),
779            AttributeValue {
780                allowable_value: AllowableValue::Any,
781                modification_context: ModificationContext::ApiOrDocumentBody,
782                silent_when_locked: false,
783                value: InterpretedValue::Value(value.to_string()),
784            },
785        );
786    }
787
788    /// Sets the value of an [intrinsic attribute].
789    ///
790    /// Intrinsic attributes are set automatically by the processor. These
791    /// attributes provide information about the document being processed (e.g.,
792    /// `docfile`), the security mode under which the processor is running
793    /// (e.g., `safe-mode-name`), and information about the user’s environment
794    /// (e.g., `user-home`).
795    ///
796    /// The [`modification_context`](ModificationContext) establishes whether
797    /// the value can be subsequently modified by the document header and/or in
798    /// the document body.
799    ///
800    /// Subsequent calls to this function or [`with_intrinsic_attribute_bool()`]
801    /// are always permitted. The last such call for any given attribute name
802    /// takes precendence.
803    ///
804    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
805    ///
806    /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
807    pub fn with_intrinsic_attribute<N: AsRef<str>, V: AsRef<str>>(
808        mut self,
809        name: N,
810        value: V,
811        modification_context: ModificationContext,
812    ) -> Self {
813        let name = name.as_ref().to_lowercase();
814        let value = InterpretedValue::Value(value.as_ref().to_string());
815        let attribute_value = AttributeValue {
816            allowable_value: AllowableValue::Any,
817            modification_context,
818            silent_when_locked: false,
819            value: value.clone(),
820        };
821
822        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
823
824        self.apply_title_visibility_linkage(&name, &value, modification_context, false);
825
826        self
827    }
828
829    /// Sets the value of an [intrinsic attribute], rejecting any disallowed
830    /// subsequent write *silently*.
831    ///
832    /// This behaves exactly like [`with_intrinsic_attribute()`] except that a
833    /// document header or body assignment that the
834    /// [`modification_context`](ModificationContext) does not permit is dropped
835    /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
836    /// The rejected write is otherwise handled identically (the value is left
837    /// unchanged).
838    ///
839    /// This reproduces Asciidoctor's *silent* safe-mode attribute restrictions:
840    /// under `SERVER`/`SECURE`, a document assignment of a restricted
841    /// conversion attribute (`backend`, `doctype`, `docinfo`,
842    /// `source-highlighter`) is simply dropped, with no diagnostic. Seed
843    /// such an attribute as an [`ApiOnly`](ModificationContext::ApiOnly)
844    /// silent intrinsic to lock it against document assignment without
845    /// warning.
846    ///
847    /// Subsequent calls to this function or the other
848    /// `with_intrinsic_attribute` variants are always permitted. The last
849    /// such call for any given attribute name takes precedence.
850    ///
851    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
852    ///
853    /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
854    pub fn with_intrinsic_attribute_silent<N: AsRef<str>, V: AsRef<str>>(
855        mut self,
856        name: N,
857        value: V,
858        modification_context: ModificationContext,
859    ) -> Self {
860        let name = name.as_ref().to_lowercase();
861        let value = InterpretedValue::Value(value.as_ref().to_string());
862        let attribute_value = AttributeValue {
863            allowable_value: AllowableValue::Any,
864            modification_context,
865            silent_when_locked: true,
866            value: value.clone(),
867        };
868
869        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
870
871        self.apply_title_visibility_linkage(&name, &value, modification_context, true);
872
873        self
874    }
875
876    /// Register a referenceable element (anchor, section, bibliography entry)
877    /// in the document catalog.
878    ///
879    /// This takes `&self` (rather than `&mut self`) so that it can be called
880    /// from inline-substitution code paths that only hold a shared reference to
881    /// the parser, such as a regex [`Replacer`](regex::Replacer).
882    pub(crate) fn register_ref(
883        &self,
884        id: &str,
885        reftext: Option<&str>,
886        ref_type: RefType,
887    ) -> Result<(), crate::document::DuplicateIdError> {
888        self.catalog
889            .borrow_mut()
890            .register_ref(id, reftext, ref_type)
891    }
892
893    /// Attaches an [`XrefSignifier`](crate::parser::XrefSignifier) to an
894    /// already-registered catalog element, so a cross-reference to it can build
895    /// `full`/`short` [`xrefstyle`](crate::parser::XrefStyle) text.
896    ///
897    /// Takes `&self` for the same reason as
898    /// [`register_ref`](Self::register_ref).
899    pub(crate) fn set_ref_signifier(&self, id: &str, signifier: crate::parser::XrefSignifier) {
900        self.catalog.borrow_mut().set_signifier(id, signifier);
901    }
902
903    /// Registers a callout number defined by a verbatim block.
904    ///
905    /// Takes `&self` so it can be called from the callouts substitution step,
906    /// which only holds a shared reference to the parser.
907    pub(crate) fn register_callout(&self, number: u32) {
908        self.callouts.borrow_mut().current.push(number);
909    }
910
911    /// Returns `true` if a callout numbered `number` was registered for the
912    /// current (not-yet-closed) callout list.
913    pub(crate) fn callout_defined(&self, number: u32) -> bool {
914        self.callouts.borrow().current.contains(&number)
915    }
916
917    /// Closes the current callout list, so callouts registered afterward belong
918    /// to the next list.
919    pub(crate) fn close_callout_list(&self) {
920        self.callouts.borrow_mut().current.clear();
921    }
922
923    /// Returns the number of an already-defined footnote with the given ID, if
924    /// one exists in the current document's footnote registry.
925    ///
926    /// Takes `&self` so it can be called from the macros substitution step,
927    /// which only holds a shared reference to the parser.
928    pub(crate) fn footnote_index_for_id(&self, id: &str) -> Option<String> {
929        self.catalog
930            .borrow()
931            .footnote_with_id(id)
932            .map(|f| f.index.clone())
933    }
934
935    /// Defines a new footnote, advancing the `footnote-number` counter and
936    /// registering the footnote in the current document's registry. Returns the
937    /// number assigned to the footnote.
938    ///
939    /// Takes `&self` so it can be called from the macros substitution step.
940    pub(crate) fn define_footnote(
941        &self,
942        id: Option<&str>,
943        text: String,
944        xrefs: Vec<crate::content::XrefSegment>,
945    ) -> String {
946        // A footnote's text is extracted out of the block during macro
947        // substitution, so any cross-reference inside it never reaches the
948        // document-level resolution pass over block content. Those
949        // cross-references are captured (as placeholders in `text` plus the
950        // `xrefs` segments) so they can be resolved alongside the block
951        // references. The stored `text` is the unresolved fallback rendering
952        // until then, so it is always clean.
953        let (text, deferred) = if xrefs.is_empty() {
954            (text, None)
955        } else {
956            let deferred = crate::content::FootnoteDeferred::new(text, xrefs);
957            let rendered = deferred.render(&*self.renderer);
958            (rendered, Some(Box::new(deferred)))
959        };
960
961        // Footnotes are numbered consecutively throughout the document via the
962        // `footnote-number` counter, which is seeded to `0` so the first
963        // footnote is numbered `1`. The counter is a document-wide attribute, so
964        // numbering continues across nested documents (AsciiDoc table cells)
965        // even though the footnote *list* does not. The counter honors any seed
966        // the document sets, so a non-integer seed yields a non-integer number
967        // (matching Asciidoctor); the value is therefore kept as a string.
968        let index = self.counter("footnote-number", None);
969
970        self.catalog
971            .borrow_mut()
972            .register_footnote(crate::document::Footnote {
973                index: index.clone(),
974                id: id.map(|s| s.to_owned()),
975                text,
976                deferred,
977            });
978
979        index
980    }
981
982    /// Removes and returns the current document's footnote list, leaving an
983    /// empty list behind. Used to give a nested document (an AsciiDoc table
984    /// cell) its own footnote registry; see [`restore_footnotes`].
985    ///
986    /// [`restore_footnotes`]: Self::restore_footnotes
987    pub(crate) fn take_footnotes(&self) -> Vec<crate::document::Footnote> {
988        self.catalog.borrow_mut().take_footnotes()
989    }
990
991    /// Restores a previously-[taken](Self::take_footnotes) footnote list,
992    /// discarding any footnotes registered in the meantime (i.e. those defined
993    /// inside the nested document).
994    pub(crate) fn restore_footnotes(&self, footnotes: Vec<crate::document::Footnote>) {
995        self.catalog.borrow_mut().restore_footnotes(footnotes);
996    }
997
998    /// Records a warning produced while replacing attribute references.
999    ///
1000    /// Takes `&self` so it can be called from the attributes substitution step,
1001    /// which only holds a shared reference to the parser. `source` locates the
1002    /// text the warning refers to; its byte offset and length are stored so a
1003    /// spanned [`Warning`] can be reconstructed later (see
1004    /// [`take_substitution_warnings`](Self::take_substitution_warnings)).
1005    pub(crate) fn record_substitution_warning(
1006        &self,
1007        source: crate::Span<'_>,
1008        warning: WarningType,
1009    ) {
1010        self.substitution_warnings
1011            .borrow_mut()
1012            .push(DeferredWarning {
1013                offset: source.byte_offset(),
1014                len: source.len(),
1015                warning,
1016            });
1017    }
1018
1019    /// Returns the number of substitution warnings recorded so far.
1020    ///
1021    /// Used together with [`truncate_substitution_warnings`] to discard
1022    /// warnings recorded while parsing an owned (e.g. include-expanded) source,
1023    /// whose offsets do not refer to the primary document source.
1024    ///
1025    /// [`truncate_substitution_warnings`]: Self::truncate_substitution_warnings
1026    pub(crate) fn substitution_warnings_len(&self) -> usize {
1027        self.substitution_warnings.borrow().len()
1028    }
1029
1030    /// Discards any substitution warnings recorded since the buffer held `len`
1031    /// entries.
1032    pub(crate) fn truncate_substitution_warnings(&self, len: usize) {
1033        self.substitution_warnings.borrow_mut().truncate(len);
1034    }
1035
1036    /// Removes and returns any substitution warnings recorded since the buffer
1037    /// held `len` entries.
1038    pub(crate) fn drain_substitution_warnings_since(&self, len: usize) -> Vec<DeferredWarning> {
1039        self.substitution_warnings.borrow_mut().split_off(len)
1040    }
1041
1042    /// Takes the substitution warnings recorded during parsing, leaving the
1043    /// buffer empty.
1044    pub(crate) fn take_substitution_warnings(&self) -> Vec<DeferredWarning> {
1045        std::mem::take(&mut *self.substitution_warnings.borrow_mut())
1046    }
1047
1048    /// Returns `true` while the parser is parsing the content of an owned
1049    /// (include-expanded) AsciiDoc table cell, i.e. when a span's line indexes
1050    /// an owned copy rather than the document source.
1051    pub(crate) fn is_in_owned_cell_source(&self) -> bool {
1052        !self.owned_cell_source_maps.is_empty()
1053    }
1054
1055    /// Pushes an owned cell's source map for the duration of its parse. Paired
1056    /// with [`pop_owned_cell_source_map`](Self::pop_owned_cell_source_map).
1057    pub(crate) fn push_owned_cell_source_map(&mut self, source_map: Rc<SourceMap>) {
1058        self.owned_cell_source_maps.push(source_map);
1059    }
1060
1061    /// Pops the source map pushed by the matching
1062    /// [`push_owned_cell_source_map`](Self::push_owned_cell_source_map).
1063    pub(crate) fn pop_owned_cell_source_map(&mut self) {
1064        self.owned_cell_source_maps.pop();
1065    }
1066
1067    /// Resolves a line number in the innermost owned cell's source back to the
1068    /// file and line it originally came from, using that cell's source map.
1069    ///
1070    /// Returns `None` when not inside an owned cell source.
1071    pub(crate) fn owned_cell_original_file_and_line(&self, line: usize) -> Option<SourceLine> {
1072        self.owned_cell_source_maps
1073            .last()
1074            .and_then(|sm| sm.original_file_and_line(line))
1075    }
1076
1077    /// Records a warning raised by a directive at `line` in the innermost owned
1078    /// cell's source, resolving `line` to the file and line it originally came
1079    /// from so the warning can be surfaced later with a real cursor (see
1080    /// [`take_owned_cell_warnings`]).
1081    ///
1082    /// A no-op when not inside an owned cell source (the line does not resolve
1083    /// to an owned origin) — the caller only reaches this from an owned-cell
1084    /// parse, but the guard keeps a stray call from recording an unanchorable
1085    /// warning.
1086    ///
1087    /// Takes `&self`: an owned-cell parse holds the parser mutably behind a
1088    /// `self_cell` construction closure, so recording goes through interior
1089    /// mutability.
1090    ///
1091    /// [`take_owned_cell_warnings`]: Self::take_owned_cell_warnings
1092    pub(crate) fn record_owned_cell_warning(&self, line: usize, warning: WarningType) {
1093        if let Some(origin) = self.owned_cell_original_file_and_line(line) {
1094            self.owned_cell_warnings
1095                .borrow_mut()
1096                .push(ResolvedWarning { origin, warning });
1097        }
1098    }
1099
1100    /// Takes the owned-cell warnings recorded during parsing, leaving the
1101    /// buffer empty.
1102    pub(crate) fn take_owned_cell_warnings(&self) -> Vec<ResolvedWarning> {
1103        std::mem::take(&mut *self.owned_cell_warnings.borrow_mut())
1104    }
1105
1106    /// Generate a unique ID derived from `base_id` and register it in the
1107    /// document catalog, returning the ID that was assigned.
1108    pub(crate) fn generate_and_register_unique_id(
1109        &self,
1110        base_id: &str,
1111        reftext: Option<&str>,
1112        ref_type: RefType,
1113    ) -> String {
1114        self.catalog
1115            .borrow_mut()
1116            .generate_and_register_unique_id(base_id, reftext, ref_type)
1117    }
1118
1119    /// Takes the catalog from the parser, transferring ownership and leaving an
1120    /// empty catalog in its place.
1121    ///
1122    /// This is used by `Document::parse` to transfer the catalog from the
1123    /// parser to the document at the end of parsing.
1124    pub(crate) fn take_catalog(&mut self) -> Catalog {
1125        std::mem::take(&mut *self.catalog.borrow_mut())
1126    }
1127
1128    /* Comment out until we're prepared to use and test this.
1129        /// Sets the default value for an [intrinsic attribute].
1130        ///
1131        /// Default values for attributes are provided automatically by the
1132        /// processor. These values provide a falllback textual value for an
1133        /// attribute when it is merely "set" by the document via API, header, or
1134        /// document body.
1135        ///
1136        /// Calling this does not imply that the value is set automatically by
1137        /// default, nor does it establish any policy for where the value may be
1138        /// modified. For that, please use [`with_intrinsic_attribute`].
1139        ///
1140        /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1141        /// [`with_intrinsic_attribute`]: Self::with_intrinsic_attribute
1142        pub fn with_default_attribute_value<N: AsRef<str>, V: AsRef<str>>(
1143            mut self,
1144            name: N,
1145            value: V,
1146        ) -> Self {
1147            self.default_attribute_values
1148                .insert(name.as_ref().to_string(), value.as_ref().to_string());
1149
1150            self
1151        }
1152    */
1153
1154    /// Sets the value of an [intrinsic attribute] from a boolean flag.
1155    ///
1156    /// A boolean `true` is interpreted as "set." A boolean `false` is
1157    /// interpreted as "unset."
1158    ///
1159    /// Intrinsic attributes are set automatically by the processor. These
1160    /// attributes provide information about the document being processed (e.g.,
1161    /// `docfile`), the security mode under which the processor is running
1162    /// (e.g., `safe-mode-name`), and information about the user’s environment
1163    /// (e.g., `user-home`).
1164    ///
1165    /// The [`modification_context`](ModificationContext) establishes whether
1166    /// the value can be subsequently modified by the document header and/or in
1167    /// the document body.
1168    ///
1169    /// Subsequent calls to this function or [`with_intrinsic_attribute()`] are
1170    /// always permitted. The last such call for any given attribute name takes
1171    /// precendence.
1172    ///
1173    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1174    ///
1175    /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
1176    pub fn with_intrinsic_attribute_bool<N: AsRef<str>>(
1177        mut self,
1178        name: N,
1179        value: bool,
1180        modification_context: ModificationContext,
1181    ) -> Self {
1182        let name = name.as_ref().to_lowercase();
1183        let value = if value {
1184            InterpretedValue::Set
1185        } else {
1186            InterpretedValue::Unset
1187        };
1188        let attribute_value = AttributeValue {
1189            allowable_value: AllowableValue::Any,
1190            modification_context,
1191            silent_when_locked: false,
1192            value: value.clone(),
1193        };
1194
1195        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1196
1197        self.apply_title_visibility_linkage(&name, &value, modification_context, false);
1198
1199        self
1200    }
1201
1202    /// Sets the value of an [intrinsic attribute] from a boolean flag,
1203    /// rejecting any disallowed subsequent write *silently*.
1204    ///
1205    /// This behaves exactly like [`with_intrinsic_attribute_bool()`] except
1206    /// that a document header or body assignment that the
1207    /// [`modification_context`](ModificationContext) does not permit is dropped
1208    /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
1209    /// See [`with_intrinsic_attribute_silent()`] for the motivating use case
1210    /// (Asciidoctor's silent safe-mode attribute restrictions).
1211    ///
1212    /// A boolean `true` is interpreted as "set." A boolean `false` is
1213    /// interpreted as "unset."
1214    ///
1215    /// Subsequent calls to this function or the other
1216    /// `with_intrinsic_attribute` variants are always permitted. The last
1217    /// such call for any given attribute name takes precedence.
1218    ///
1219    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1220    ///
1221    /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
1222    /// [`with_intrinsic_attribute_silent()`]: Self::with_intrinsic_attribute_silent
1223    pub fn with_intrinsic_attribute_bool_silent<N: AsRef<str>>(
1224        mut self,
1225        name: N,
1226        value: bool,
1227        modification_context: ModificationContext,
1228    ) -> Self {
1229        let name = name.as_ref().to_lowercase();
1230        let value = if value {
1231            InterpretedValue::Set
1232        } else {
1233            InterpretedValue::Unset
1234        };
1235        let attribute_value = AttributeValue {
1236            allowable_value: AllowableValue::Any,
1237            modification_context,
1238            silent_when_locked: true,
1239            value: value.clone(),
1240        };
1241
1242        Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1243
1244        self.apply_title_visibility_linkage(&name, &value, modification_context, true);
1245
1246        self
1247    }
1248
1249    /// Replace the default [`InlineSubstitutionRenderer`] for this parser.
1250    ///
1251    /// The default implementation of [`InlineSubstitutionRenderer`] that is
1252    /// provided is suitable for HTML5 rendering. If you are targeting a
1253    /// different back-end rendering, you will need to provide your own
1254    /// implementation and set it using this call before parsing.
1255    pub fn with_inline_substitution_renderer<ISR: InlineSubstitutionRenderer + 'static>(
1256        mut self,
1257        renderer: ISR,
1258    ) -> Self {
1259        self.renderer = Rc::new(renderer);
1260        self
1261    }
1262
1263    /// Sets the name of the primary file to be parsed when [`parse()`] is
1264    /// called.
1265    ///
1266    /// This name will be used for any error messages detected in this file and
1267    /// also will be passed to [`IncludeFileHandler::resolve_target()`] as the
1268    /// `source` argument for any `include::` file resolution requests from this
1269    /// file.
1270    ///
1271    /// [`parse()`]: Self::parse
1272    /// [`IncludeFileHandler::resolve_target()`]: crate::parser::IncludeFileHandler::resolve_target
1273    pub fn with_primary_file_name<S: AsRef<str>>(mut self, name: S) -> Self {
1274        self.primary_file_name = Some(name.as_ref().to_owned());
1275        self
1276    }
1277
1278    /// Sets the [`IncludeFileHandler`] for this parser.
1279    ///
1280    /// The include file handler is responsible for resolving `include::`
1281    /// directives encountered during preprocessing. If no handler is provided,
1282    /// include directives will be ignored.
1283    ///
1284    /// [`IncludeFileHandler`]: crate::parser::IncludeFileHandler
1285    pub fn with_include_file_handler<IFH: IncludeFileHandler + 'static>(
1286        mut self,
1287        handler: IFH,
1288    ) -> Self {
1289        self.include_file_handler = Some(Rc::new(handler));
1290        self
1291    }
1292
1293    /// Sets the [`DocinfoFileHandler`] for this parser.
1294    ///
1295    /// The docinfo file handler is responsible for providing the content of
1296    /// [docinfo files] requested while resolving a document's docinfo (see the
1297    /// `docinfo` attribute). If no handler is provided, no docinfo content is
1298    /// resolved and [`Document::docinfo`] returns an empty string for every
1299    /// location.
1300    ///
1301    /// [`DocinfoFileHandler`]: crate::parser::DocinfoFileHandler
1302    /// [docinfo files]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
1303    /// [`Document::docinfo`]: crate::Document::docinfo
1304    pub fn with_docinfo_file_handler<DFH: DocinfoFileHandler + 'static>(
1305        mut self,
1306        handler: DFH,
1307    ) -> Self {
1308        self.docinfo_file_handler = Some(Rc::new(handler));
1309        self
1310    }
1311
1312    /// Sets the [`SvgFileHandler`] for this parser.
1313    ///
1314    /// The SVG file handler is responsible for providing the raw contents of an
1315    /// SVG file requested by an inline image with the `inline` option (e.g.
1316    /// `image:diagram.svg[opts=inline]`). If no handler is provided, inline SVG
1317    /// images fall back to rendering their alt text.
1318    ///
1319    /// [`SvgFileHandler`]: crate::parser::SvgFileHandler
1320    pub fn with_svg_file_handler<SFH: SvgFileHandler + 'static>(mut self, handler: SFH) -> Self {
1321        self.svg_file_handler = Some(Rc::new(handler));
1322        self
1323    }
1324
1325    /// Sets the [`SafeMode`] under which the document is parsed and rendered.
1326    ///
1327    /// The default is [`SafeMode::Secure`], the most conservative setting.
1328    /// Relaxing the safe mode enables security-sensitive rendering behavior,
1329    /// such as rendering an interactive SVG image as an `<object>` element.
1330    ///
1331    /// [`SafeMode`]: crate::SafeMode
1332    pub fn with_safe_mode(mut self, safe: SafeMode) -> Self {
1333        self.safe = safe;
1334        self.apply_safe_mode_attributes();
1335        self
1336    }
1337
1338    /// Overrides the `safe-mode-*` family of [intrinsic attributes] from the
1339    /// current safe mode.
1340    ///
1341    /// These attributes let a document (or a downstream converter) inspect the
1342    /// security mode under which it is being processed:
1343    ///
1344    /// * `safe-mode-level` — the numeric level (`0`, `1`, `10`, or `20`).
1345    /// * `safe-mode-name` — the lowercase mode name (`unsafe`, `safe`,
1346    ///   `server`, or `secure`).
1347    /// * `safe-mode-<name>` — a single flag attribute (set to an empty value)
1348    ///   naming the active mode; the flags for the other modes are absent so
1349    ///   that a reference to them resolves literally.
1350    ///
1351    /// Only `safe-mode-level` and `safe-mode-name` are stored here (shadowing
1352    /// their built-in Secure-mode defaults). The active `safe-mode-<name>` flag
1353    /// is synthesized on the fly from `safe-mode-name` (see
1354    /// [`synthesized_attr`]), so exactly one flag is ever defined and the
1355    /// inactive flags stay absent without any per-mode bookkeeping here.
1356    ///
1357    /// All of these are read-only from the document's perspective (they can
1358    /// only be established via the API), matching Ruby Asciidoctor.
1359    ///
1360    /// [intrinsic attributes]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1361    fn apply_safe_mode_attributes(&mut self) {
1362        let intrinsic = |value: InterpretedValue| AttributeValue {
1363            allowable_value: AllowableValue::Any,
1364            modification_context: ModificationContext::ApiOnly,
1365            silent_when_locked: false,
1366            value,
1367        };
1368
1369        let attrs = Arc::make_mut(&mut self.attribute_values);
1370        attrs.insert(
1371            "safe-mode-level".to_string(),
1372            intrinsic(InterpretedValue::Value(self.safe.level().to_string())),
1373        );
1374        attrs.insert(
1375            "safe-mode-name".to_string(),
1376            intrinsic(InterpretedValue::Value(self.safe.name().to_string())),
1377        );
1378    }
1379
1380    /// Returns the [`SafeMode`] under which this parser operates.
1381    ///
1382    /// [`SafeMode`]: crate::SafeMode
1383    pub fn safe_mode(&self) -> SafeMode {
1384        self.safe
1385    }
1386
1387    /// Returns the document name (`docname`): the base name of the primary
1388    /// file, stripped of its directory and final extension.
1389    ///
1390    /// This is the `<docname>` used to build private docinfo file names (e.g.
1391    /// `mydoc-docinfo.html` for `mydoc.adoc`). Returns `None` when no primary
1392    /// file name has been set, in which case private docinfo files cannot be
1393    /// resolved.
1394    pub(crate) fn docname(&self) -> Option<String> {
1395        let primary = self.primary_file_name.as_deref()?;
1396
1397        // Strip the directory portion (handling both separators, since the
1398        // primary file name may have been supplied on either platform).
1399        let base = primary.rsplit(['/', '\\']).next().unwrap_or(primary);
1400
1401        // Strip a single trailing extension, if present. A leading-dot name
1402        // (e.g. `.adoc`) is treated as having no extension and is kept whole as
1403        // the stem, matching Ruby's `File.basename(".adoc", ".*")`.
1404        let stem = match base.rfind('.') {
1405            Some(0) | None => base,
1406            Some(idx) => &base[..idx],
1407        };
1408
1409        if stem.is_empty() {
1410            None
1411        } else {
1412            Some(stem.to_string())
1413        }
1414    }
1415
1416    /// Called from [`Header::parse()`] to accept or reject an attribute value.
1417    ///
1418    /// [`Header::parse()`]: crate::document::Header::parse
1419    pub(crate) fn set_attribute_from_header<'src>(
1420        &mut self,
1421        attr: &Attribute<'src>,
1422        warnings: &mut Vec<Warning<'src>>,
1423    ) {
1424        let attr_name = remap_attr_name(attr.name().data());
1425
1426        // The `backend-html5-doctype-*` namespace is a read-only synthesized
1427        // intrinsic; a document must not write any of it (see
1428        // [`is_reserved_doctype_derived_attr`]).
1429        if is_reserved_doctype_derived_attr(&attr_name) {
1430            return;
1431        }
1432
1433        // Verify that we have permission to overwrite any existing attribute
1434        // value, considering both a per-parser entry and the shared built-in
1435        // default it would shadow (a built-in such as `sp` is `ApiOnly`).
1436        if let Some(existing_attr) = self.effective_attribute(&attr_name)
1437            && (existing_attr.modification_context == ModificationContext::ApiOnly
1438                || existing_attr.modification_context == ModificationContext::ApiOrDocumentBody)
1439        {
1440            // A silently-locked intrinsic rejects the write without recording a
1441            // warning (see `AttributeValue::silent_when_locked`).
1442            if !existing_attr.silent_when_locked {
1443                warnings.push(Warning {
1444                    source: attr.span(),
1445                    warning: WarningType::AttributeValueIsLocked(attr_name),
1446                    origin: None,
1447                });
1448            }
1449            return;
1450        }
1451
1452        let mut value = attr.value().clone();
1453
1454        if let InterpretedValue::Set = value
1455            && let Some(default_value) = self.default_attribute_values.get(&attr_name)
1456        {
1457            value = InterpretedValue::Value(default_value.clone());
1458        }
1459
1460        // A relative `leveloffset` (`+N` / `-N`) accumulates on top of the
1461        // offset already in effect; resolve it to an absolute value so the
1462        // stored attribute is always a plain integer, and warn if the result is
1463        // so extreme that no heading could ever land in the valid level range.
1464        if attr_name == "leveloffset" {
1465            value = self.resolve_leveloffset_and_warn(value, attr.span(), warnings);
1466        }
1467
1468        // `notitle` and `showtitle` are inverse spellings of one title-
1469        // visibility toggle; keep the partner in sync (see
1470        // [`apply_title_visibility_linkage`](Self::apply_title_visibility_linkage)).
1471        self.apply_title_visibility_linkage(
1472            &attr_name,
1473            &value,
1474            ModificationContext::Anywhere,
1475            false,
1476        );
1477
1478        let attribute_value = AttributeValue {
1479            allowable_value: AllowableValue::Any,
1480            modification_context: ModificationContext::Anywhere,
1481            silent_when_locked: false,
1482            value,
1483        };
1484
1485        // An explicit assignment supersedes (and resets) any counter of the same
1486        // name.
1487        self.counter_values.borrow_mut().remove(&attr_name);
1488
1489        // The derived `backend-html5-doctype-*` attribute tracks `doctype`
1490        // automatically (it is synthesized on lookup), so no refresh is needed.
1491        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
1492    }
1493
1494    /// Called from [`Header::parse()`] for a value that is derived from parsing
1495    /// the header (except for attribute lines).
1496    ///
1497    /// [`Header::parse()`]: crate::document::Header::parse
1498    pub(crate) fn set_attribute_by_value_from_header<N: AsRef<str>, V: AsRef<str>>(
1499        &mut self,
1500        name: N,
1501        value: V,
1502    ) {
1503        let attr_name = remap_attr_name(name);
1504
1505        let attribute_value = AttributeValue {
1506            allowable_value: AllowableValue::Any,
1507            modification_context: ModificationContext::Anywhere,
1508            silent_when_locked: false,
1509            value: InterpretedValue::Value(value.as_ref().to_owned()),
1510        };
1511
1512        self.counter_values.borrow_mut().remove(&attr_name);
1513        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
1514    }
1515
1516    /// Applies the `imagesdir`-relative default for the `iconsdir` attribute.
1517    ///
1518    /// The `iconsdir` attribute defaults to `{imagesdir}/icons`; when
1519    /// `imagesdir` is left empty this resolves to the built-in
1520    /// [`DEFAULT_ICONSDIR`] (`./images/icons`). When `imagesdir` is set to a
1521    /// non-empty value and `iconsdir` was left at its built-in default, the
1522    /// icons directory is derived as `{imagesdir}/icons`.
1523    ///
1524    /// The derivation is skipped — so an explicit `iconsdir` wins — when either
1525    /// the attribute was set in the header (`iconsdir_set_in_header`) or its
1526    /// resolved value differs from [`DEFAULT_ICONSDIR`] (which is how an
1527    /// override applied any other way, e.g. via the API, is detected). The one
1528    /// case this cannot detect is a non-header override whose value happens to
1529    /// equal the built-in default (e.g. an API caller setting `iconsdir` to
1530    /// exactly `./images/icons`): it is indistinguishable from the default and
1531    /// so is re-derived. That combination is contradictory in practice (it
1532    /// pins `iconsdir` to the value it would take were `imagesdir` unset) and
1533    /// is not worth a dedicated provenance flag.
1534    ///
1535    /// This is called once, after the document header is parsed, mirroring
1536    /// Asciidoctor's document-initialization timing (a later `imagesdir` change
1537    /// in the document body does not retroactively re-derive `iconsdir`). See
1538    /// icons-image.adoc.
1539    ///
1540    /// [`DEFAULT_ICONSDIR`]: super::built_in_attrs::DEFAULT_ICONSDIR
1541    pub(crate) fn apply_iconsdir_default(&mut self, iconsdir_set_in_header: bool) {
1542        if iconsdir_set_in_header {
1543            return;
1544        }
1545
1546        // Preserve any override whose value differs from the built-in default
1547        // (e.g. one applied via the API); only the built-in default itself is
1548        // eligible for `imagesdir`-relative derivation. See the doc comment for
1549        // the one indistinguishable corner case.
1550        if self.attribute_value("iconsdir").as_maybe_str()
1551            != Some(super::built_in_attrs::DEFAULT_ICONSDIR)
1552        {
1553            return;
1554        }
1555
1556        let imagesdir = self.attribute_value("imagesdir");
1557        let derived = match imagesdir.as_maybe_str().filter(|d| !d.is_empty()) {
1558            Some(dir) => format!("{}/icons", dir.trim_end_matches('/')),
1559            None => return,
1560        };
1561
1562        self.set_attribute_by_value_from_header("iconsdir", derived);
1563    }
1564
1565    /// Called while parsing a block (see [`Block::parse_with_outcome()`]) to
1566    /// accept or reject an attribute value from a document (body) attribute.
1567    ///
1568    /// [`Block::parse_with_outcome()`]: crate::blocks::Block::parse_with_outcome
1569    pub(crate) fn set_attribute_from_body<'src>(
1570        &mut self,
1571        attr: &Attribute<'src>,
1572        warnings: &mut Vec<Warning<'src>>,
1573    ) {
1574        let attr_name = remap_attr_name(attr.name().data());
1575
1576        // The `backend-html5-doctype-*` namespace is a read-only synthesized
1577        // intrinsic; a document must not write any of it (see
1578        // [`is_reserved_doctype_derived_attr`]).
1579        if is_reserved_doctype_derived_attr(&attr_name) {
1580            return;
1581        }
1582
1583        // An attribute inherited from the parent document of an AsciiDoc table
1584        // cell is locked for the duration of that cell: a body assignment to it
1585        // is silently ignored (no warning), matching Asciidoctor.
1586        if self.locked_attribute_names.contains(&attr_name) {
1587            return;
1588        }
1589
1590        // Verify that we have permission to overwrite any existing attribute
1591        // value, considering both a per-parser entry and the shared built-in
1592        // default it would shadow.
1593        if let Some(existing_attr) = self.effective_attribute(&attr_name)
1594            && (existing_attr.modification_context != ModificationContext::Anywhere
1595                && existing_attr.modification_context != ModificationContext::ApiOrDocumentBody)
1596        {
1597            // A silently-locked intrinsic rejects the write without recording a
1598            // warning (see `AttributeValue::silent_when_locked`).
1599            if !existing_attr.silent_when_locked {
1600                warnings.push(Warning {
1601                    source: attr.span(),
1602                    warning: WarningType::AttributeValueIsLocked(attr_name),
1603                    origin: None,
1604                });
1605            }
1606            return;
1607        }
1608
1609        let mut value = attr.value().clone();
1610
1611        // A relative `leveloffset` (`+N` / `-N`) accumulates on top of the
1612        // offset already in effect; resolve it to an absolute value so the
1613        // stored attribute is always a plain integer, and warn if the result is
1614        // so extreme that no heading could ever land in the valid level range.
1615        if attr_name == "leveloffset" {
1616            value = self.resolve_leveloffset_and_warn(value, attr.span(), warnings);
1617        }
1618
1619        // `notitle` and `showtitle` are inverse spellings of one title-
1620        // visibility toggle; keep the partner in sync (see
1621        // [`apply_title_visibility_linkage`](Self::apply_title_visibility_linkage)).
1622        self.apply_title_visibility_linkage(
1623            &attr_name,
1624            &value,
1625            ModificationContext::Anywhere,
1626            false,
1627        );
1628
1629        let attribute_value = AttributeValue {
1630            allowable_value: AllowableValue::Any,
1631            modification_context: ModificationContext::Anywhere,
1632            silent_when_locked: false,
1633            value,
1634        };
1635
1636        // An explicit assignment supersedes (and resets) any counter of the same
1637        // name. This is what lets `:!name:` reset a counter.
1638        self.counter_values.borrow_mut().remove(&attr_name);
1639
1640        // The derived `backend-html5-doctype-*` attribute tracks `doctype`
1641        // automatically (it is synthesized on lookup), so no refresh is needed.
1642        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
1643    }
1644
1645    /// Assign the next section number for a given level.
1646    pub(crate) fn assign_section_number(&mut self, level: usize) -> SectionNumber {
1647        match self.topmost_section_type {
1648            SectionType::Appendix => {
1649                self.last_appendix_section_number.assign_next_number(level);
1650                self.last_appendix_section_number.clone()
1651            }
1652
1653            // `topmost_section_type` is only ever `Normal` or `Appendix`: a
1654            // discrete heading never becomes the topmost section type (see
1655            // `SectionBlock::parse`). `Discrete` therefore cannot reach this
1656            // point, so it is folded in with `Normal` rather than carried as a
1657            // separate, untestable arm.
1658            SectionType::Normal | SectionType::Discrete => {
1659                self.last_section_number.assign_next_number(level);
1660                self.last_section_number.clone()
1661            }
1662        }
1663    }
1664
1665    /// Resolves a [counter] of the given `name`, advancing it to the next value
1666    /// in its sequence and returning that value.
1667    ///
1668    /// A counter is a specialized document attribute: its value is stored as
1669    /// (and read back from) the attribute of the same name, so a later
1670    /// `{name}` reference shows the current value and an attribute assignment
1671    /// such as `:!name:` resets it. Each resolution advances the counter:
1672    ///
1673    /// * an integer value is incremented (`1` -> `2`);
1674    /// * any other value is advanced like Ruby's `String#succ` (`a` -> `b`, `z`
1675    ///   -> `aa`, `Az` -> `Ba`), matching Asciidoctor.
1676    ///
1677    /// `seed` (from the `{counter:name:seed}` form) supplies the first value,
1678    /// but only when the counter is currently unset; otherwise it is ignored.
1679    /// With no seed the sequence starts at `1`.
1680    ///
1681    /// This mirrors Asciidoctor's `Document#counter`.
1682    ///
1683    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
1684    pub(crate) fn counter(&self, name: &str, seed: Option<&str>) -> String {
1685        let next = match self.attribute_value(name) {
1686            InterpretedValue::Value(current) if !current.is_empty() => next_counter_value(&current),
1687            _ => match seed {
1688                Some(seed) if !seed.is_empty() => seed.to_string(),
1689                _ => "1".to_string(),
1690            },
1691        };
1692
1693        self.counter_values
1694            .borrow_mut()
1695            .insert(name.to_string(), next.clone());
1696
1697        next
1698    }
1699}
1700
1701/// Whether a `leveloffset` of `offset` leaves at least one syntactic heading
1702/// level able to land inside the supported section-level range.
1703///
1704/// Syntactic heading levels run 0 (`=`) through 5 (`======`) and valid section
1705/// levels run 1 through 5, so an offset keeps some heading in range only while
1706/// it stays within `1 - 5 ..= 5 - 0`, i.e. `-4..=5`. Outside that window every
1707/// heading is clamped, so the offset can never place a heading at its intended
1708/// level.
1709fn leveloffset_admits_any_heading(offset: i32) -> bool {
1710    (-4..=5).contains(&offset)
1711}
1712
1713/// Advances a counter value to the next value in its sequence, mirroring
1714/// Asciidoctor's `Helpers.nextval`.
1715///
1716/// A canonical integer string (one that round-trips through integer parsing,
1717/// e.g. `7` but not `07` or `+7`) is incremented numerically. Anything else is
1718/// advanced with [`string_succ`].
1719fn next_counter_value(current: &str) -> String {
1720    if let Ok(n) = current.parse::<i64>()
1721        && n.to_string() == current
1722    {
1723        // `saturating_add` keeps a counter that has somehow reached `i64::MAX`
1724        // pinned there rather than panicking (debug) or wrapping (release).
1725        return n.saturating_add(1).to_string();
1726    }
1727
1728    string_succ(current)
1729}
1730
1731/// Returns the successor of a string, mirroring Ruby's `String#succ` for the
1732/// ASCII cases that AsciiDoc counters can produce.
1733///
1734/// The right-most alphanumeric character is incremented within its own class
1735/// (digits, lowercase letters, uppercase letters), carrying leftward on
1736/// wrap-around (`9` -> `0`, `z` -> `a`, `Z` -> `A`) and prepending a fresh
1737/// leading character (`1`, `a`, or `A`) when the carry runs off the front
1738/// (`z` -> `aa`, `Zz` -> `AAa`). A string with no alphanumeric characters has
1739/// the code point of its last character incremented.
1740fn string_succ(current: &str) -> String {
1741    let chars: Vec<char> = current.chars().collect();
1742
1743    // Without an alphanumeric to carry through, Ruby increments the code point
1744    // of the final character.
1745    if !chars.iter().any(char::is_ascii_alphanumeric) {
1746        let mut chars = chars;
1747        if let Some(last) = chars.last_mut() {
1748            *last = char::from_u32(*last as u32 + 1).unwrap_or(*last);
1749        }
1750        return chars.into_iter().collect();
1751    }
1752
1753    // Walk right to left. `carrying` stays true while we are still looking for
1754    // (or carrying through) the alphanumeric run: trailing non-alphanumeric
1755    // characters are passed over unchanged, then the right-most alphanumeric is
1756    // incremented within its class and any wrap-around carries leftward to the
1757    // next alphanumeric. When the carry runs off the front, a fresh leading
1758    // character of the same class is prepended (`z` -> `aa`, `9` -> `10`).
1759    let mut out_rev: Vec<char> = Vec::with_capacity(chars.len() + 1);
1760    let mut carrying = true;
1761    let mut lead = '1';
1762
1763    for &c in chars.iter().rev() {
1764        if carrying && c.is_ascii_alphanumeric() {
1765            // Increment within the character's class, carrying on wrap-around.
1766            // The arms are exhaustive over ASCII alphanumerics, so the catch-all
1767            // can only be `Z` (the one value not matched above).
1768            let (next, carry) = match c {
1769                '0'..='8' | 'a'..='y' | 'A'..='Y' => ((c as u8 + 1) as char, false),
1770                '9' => ('0', true),
1771                'z' => ('a', true),
1772                _ => ('A', true),
1773            };
1774            out_rev.push(next);
1775            carrying = carry;
1776            // On a carry, remember the class of leading character to prepend if
1777            // the carry runs off the front; `next` is `0`, `a`, or `A` here.
1778            lead = match next {
1779                '0' => '1',
1780                'a' => 'a',
1781                _ => 'A',
1782            };
1783        } else {
1784            // Either the carry is spent, or this is a trailing non-alphanumeric
1785            // we pass over while still searching for the run to increment.
1786            out_rev.push(c);
1787        }
1788    }
1789
1790    if carrying {
1791        out_rev.push(lead);
1792    }
1793
1794    out_rev.into_iter().rev().collect()
1795}
1796
1797fn remap_attr_name<N: AsRef<str>>(raw_attr_name: N) -> String {
1798    let attr_name = raw_attr_name.as_ref().to_lowercase();
1799
1800    // Some attribute names have aliases. Remap to the primary name.
1801    match attr_name.as_str() {
1802        "hardbreaks" => "hardbreaks-option".to_string(),
1803        _ => attr_name,
1804    }
1805}
1806
1807/// Returns `true` if `name` belongs to the reserved `backend-html5-doctype-*`
1808/// namespace, which is a read-only synthesized intrinsic keyed on the active
1809/// `doctype` (see [`synthesized_attr`]).
1810///
1811/// A document header or body assignment to any such name is rejected — not only
1812/// the flag that is active when the assignment is parsed. Otherwise a name that
1813/// is inactive at assignment time (e.g. `backend-html5-doctype-article` while
1814/// the doctype is `book`) would resolve to no synthesized attribute, pass the
1815/// permission check, and be stored as a per-parser override that then shadows
1816/// the intrinsic once the doctype switches to that value (e.g. in an AsciiDoc
1817/// table cell that resets, then changes, its doctype).
1818fn is_reserved_doctype_derived_attr(name: &str) -> bool {
1819    name.starts_with("backend-html5-doctype-")
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824    #![allow(clippy::panic)]
1825    #![allow(clippy::unwrap_used)]
1826
1827    use crate::{
1828        attributes::Attrlist,
1829        blocks::Block,
1830        parser::{
1831            CharacterReplacementType, IconRenderParams, ImageRenderParams,
1832            InlineSubstitutionRenderer, LinkRenderParams, QuoteScope, QuoteType, SpecialCharacter,
1833        },
1834        tests::prelude::*,
1835    };
1836
1837    #[test]
1838    fn default_is_unset() {
1839        let p = Parser::default();
1840        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
1841    }
1842
1843    #[test]
1844    fn owned_cell_warning_is_recorded_only_inside_an_owned_cell_source() {
1845        use std::rc::Rc;
1846
1847        use crate::{
1848            parser::{SourceLine, SourceMap},
1849            warnings::WarningType,
1850        };
1851
1852        let mut p = Parser::default();
1853
1854        // Outside an owned cell source there is no map to resolve against, so a
1855        // recorded warning has no origin and is dropped rather than queued.
1856        assert!(!p.is_in_owned_cell_source());
1857        p.record_owned_cell_warning(1, WarningType::IncludeFileNotFound("x.adoc".to_owned()));
1858        assert!(p.take_owned_cell_warnings().is_empty());
1859
1860        // Publish a cell source map (output line 1 came from `cell.adoc` line 2,
1861        // the way the preprocessor would record an include-expanded cell).
1862        let mut sm = SourceMap::default();
1863        sm.append(1, SourceLine(Some("cell.adoc".to_owned()), 2));
1864        p.push_owned_cell_source_map(Rc::new(sm));
1865        assert!(p.is_in_owned_cell_source());
1866
1867        // Now the same call resolves the line to its origin and queues the
1868        // warning with that pre-resolved (file, line).
1869        p.record_owned_cell_warning(1, WarningType::IncludeFileNotFound("y.adoc".to_owned()));
1870        let recorded = p.take_owned_cell_warnings();
1871        let [recorded] = recorded.as_slice() else {
1872            panic!("expected exactly one recorded warning, got {recorded:?}");
1873        };
1874        assert_eq!(recorded.origin, SourceLine(Some("cell.adoc".to_owned()), 2));
1875        assert_eq!(
1876            recorded.warning,
1877            WarningType::IncludeFileNotFound("y.adoc".to_owned())
1878        );
1879
1880        // Taking drains the buffer, and popping restores the not-in-owned-cell
1881        // state.
1882        assert!(p.take_owned_cell_warnings().is_empty());
1883        p.pop_owned_cell_source_map();
1884        assert!(!p.is_in_owned_cell_source());
1885    }
1886
1887    #[test]
1888    fn creates_catalog_if_needed() {
1889        let mut p = Parser::default();
1890        let doc = p.parse("= Hello, World!\n\n== First Section Title");
1891        let cat = doc.catalog();
1892        assert!(cat.refs.contains_key("_first_section_title"));
1893
1894        let doc = p.parse("= Hello, World!\n\n== Second Section Title");
1895        let cat = doc.catalog();
1896        assert!(!cat.refs.contains_key("_first_section_title"));
1897        assert!(cat.refs.contains_key("_second_section_title"));
1898    }
1899
1900    #[test]
1901    fn with_intrinsic_attribute() {
1902        let p =
1903            Parser::default().with_intrinsic_attribute("foo", "bar", ModificationContext::Anywhere);
1904
1905        assert_eq!(p.attribute_value("foo"), InterpretedValue::Value("bar"));
1906        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1907
1908        assert!(p.is_attribute_set("foo"));
1909        assert!(!p.is_attribute_set("foo2"));
1910        assert!(!p.is_attribute_set("xyz"));
1911    }
1912
1913    #[test]
1914    fn with_intrinsic_attribute_set() {
1915        let p = Parser::default().with_intrinsic_attribute_bool(
1916            "foo",
1917            true,
1918            ModificationContext::Anywhere,
1919        );
1920
1921        assert_eq!(p.attribute_value("foo"), InterpretedValue::Set);
1922        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1923
1924        assert!(p.is_attribute_set("foo"));
1925        assert!(!p.is_attribute_set("foo2"));
1926        assert!(!p.is_attribute_set("xyz"));
1927    }
1928
1929    #[test]
1930    fn with_intrinsic_attribute_unset() {
1931        let p = Parser::default().with_intrinsic_attribute_bool(
1932            "foo",
1933            false,
1934            ModificationContext::Anywhere,
1935        );
1936
1937        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
1938        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1939
1940        assert!(!p.is_attribute_set("foo"));
1941        assert!(!p.is_attribute_set("foo2"));
1942        assert!(!p.is_attribute_set("xyz"));
1943    }
1944
1945    #[test]
1946    fn can_not_override_locked_default_value() {
1947        let mut parser = Parser::default();
1948
1949        let doc = parser.parse(":sp: not a space!");
1950
1951        assert_eq!(
1952            doc.warnings().next().unwrap().warning,
1953            WarningType::AttributeValueIsLocked("sp".to_owned())
1954        );
1955
1956        assert_eq!(parser.attribute_value("sp"), InterpretedValue::Value(" "));
1957    }
1958
1959    #[test]
1960    fn silently_locked_intrinsic_rejects_header_and_body_without_warning() {
1961        // A silently-locked `ApiOnly` intrinsic (as a converter would seed a
1962        // safe-mode-restricted attribute) rejects both a header assignment and a
1963        // body assignment of the same name, leaving the value unchanged and
1964        // recording no warning.
1965        let mut parser = Parser::default().with_intrinsic_attribute_silent(
1966            "backend",
1967            "html5",
1968            ModificationContext::ApiOnly,
1969        );
1970
1971        let doc = parser.parse(concat!(
1972            "= Title\n",
1973            ":backend: docbook5\n",
1974            "\n",
1975            "Body paragraph.\n",
1976            "\n",
1977            ":backend: manpage\n",
1978        ));
1979
1980        assert_eq!(doc.warnings().count(), 0);
1981        assert_eq!(
1982            parser.attribute_value("backend"),
1983            InterpretedValue::Value("html5")
1984        );
1985    }
1986
1987    #[test]
1988    fn silently_locked_bool_intrinsic_rejects_without_warning() {
1989        let mut parser = Parser::default().with_intrinsic_attribute_bool_silent(
1990            "sectids",
1991            true,
1992            ModificationContext::ApiOnly,
1993        );
1994
1995        let doc = parser.parse(concat!("= Title\n", ":!sectids:\n"));
1996
1997        assert_eq!(doc.warnings().count(), 0);
1998        assert_eq!(parser.attribute_value("sectids"), InterpretedValue::Set);
1999    }
2000
2001    #[test]
2002    fn silently_locked_bool_intrinsic_false_is_unset() {
2003        // A `false` flag records an `Unset` tombstone, and a locked (`ApiOnly`)
2004        // attribute rejects a document body reassignment without warning.
2005        let mut parser = Parser::default().with_intrinsic_attribute_bool_silent(
2006            "sectids",
2007            false,
2008            ModificationContext::ApiOnly,
2009        );
2010
2011        let doc = parser.parse(concat!("= Title\n", ":sectids:\n"));
2012
2013        assert_eq!(doc.warnings().count(), 0);
2014        assert_eq!(parser.attribute_value("sectids"), InterpretedValue::Unset);
2015    }
2016
2017    #[test]
2018    fn normally_locked_intrinsic_still_warns() {
2019        // Regression: a non-silent `ApiOnly` intrinsic still records
2020        // `AttributeValueIsLocked` when the document tries to reassign it.
2021        let mut parser = Parser::default().with_intrinsic_attribute(
2022            "backend",
2023            "html5",
2024            ModificationContext::ApiOnly,
2025        );
2026
2027        let doc = parser.parse(concat!("= Title\n", ":backend: docbook5\n"));
2028
2029        assert_eq!(
2030            doc.warnings().next().unwrap().warning,
2031            WarningType::AttributeValueIsLocked("backend".to_owned())
2032        );
2033        assert_eq!(
2034            parser.attribute_value("backend"),
2035            InterpretedValue::Value("html5")
2036        );
2037    }
2038
2039    #[test]
2040    fn catalog_transferred_to_document() {
2041        let mut parser = Parser::default();
2042        let doc = parser.parse("= Test Document\n\nSome content");
2043
2044        let catalog = doc.catalog();
2045        assert!(catalog.is_empty());
2046
2047        // The catalog was transferred to the document, leaving the parser with
2048        // an empty catalog.
2049        assert!(parser.catalog.borrow().is_empty());
2050    }
2051
2052    #[test]
2053    fn block_ids_registered_in_catalog() {
2054        let mut parser = Parser::default();
2055        let doc = parser.parse("= Test Document\n\n[#my-block]\nSome content with an ID");
2056
2057        let catalog = doc.catalog();
2058        assert!(!catalog.is_empty());
2059        assert!(catalog.contains_id("my-block"));
2060
2061        let entry = catalog.get_ref("my-block").unwrap();
2062        assert_eq!(entry.id, "my-block");
2063        assert_eq!(entry.ref_type, crate::document::RefType::Anchor);
2064    }
2065
2066    /// A simple test renderer that modifies special characters differently
2067    /// from the default HTML renderer.
2068    #[derive(Debug)]
2069    struct TestRenderer;
2070
2071    impl InlineSubstitutionRenderer for TestRenderer {
2072        fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
2073            // Custom rendering: wrap special characters in brackets.
2074            match type_ {
2075                SpecialCharacter::Lt => dest.push_str("[LT]"),
2076                SpecialCharacter::Gt => dest.push_str("[GT]"),
2077                SpecialCharacter::Ampersand => dest.push_str("[AMP]"),
2078            }
2079        }
2080
2081        fn render_quoted_substitition(
2082            &self,
2083            _type_: QuoteType,
2084            _scope: QuoteScope,
2085            _attrlist: Option<Attrlist<'_>>,
2086            _id: Option<String>,
2087            body: &str,
2088            dest: &mut String,
2089        ) {
2090            dest.push_str(body);
2091        }
2092
2093        fn render_character_replacement(
2094            &self,
2095            _type_: CharacterReplacementType,
2096            dest: &mut String,
2097        ) {
2098            dest.push_str("[CHAR]");
2099        }
2100
2101        fn render_line_break(&self, dest: &mut String) {
2102            dest.push_str("[BR]");
2103        }
2104
2105        fn render_image(&self, _params: &ImageRenderParams, dest: &mut String) {
2106            dest.push_str("[IMAGE]");
2107        }
2108
2109        fn image_uri(
2110            &self,
2111            target_image_path: &str,
2112            _parser: &Parser,
2113            _asset_dir_key: Option<&str>,
2114        ) -> String {
2115            target_image_path.to_string()
2116        }
2117
2118        fn render_icon(&self, _params: &IconRenderParams, dest: &mut String) {
2119            dest.push_str("[ICON]");
2120        }
2121
2122        fn render_link(&self, _params: &LinkRenderParams, dest: &mut String) {
2123            dest.push_str("[LINK]");
2124        }
2125
2126        fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
2127            dest.push_str(&format!("[ANCHOR:{}]", id));
2128        }
2129
2130        fn render_xref(&self, params: &crate::parser::XrefRenderParams, dest: &mut String) {
2131            dest.push_str(&format!("[XREF:{}]", params.target));
2132        }
2133
2134        fn render_callout(&self, params: &crate::parser::CalloutRenderParams, dest: &mut String) {
2135            dest.push_str(&format!("[CALLOUT:{}]", params.number));
2136        }
2137
2138        fn render_index_term(
2139            &self,
2140            params: &crate::parser::IndexTermRenderParams,
2141            dest: &mut String,
2142        ) {
2143            match params.visible_term {
2144                Some(term) => dest.push_str(&format!("[INDEXTERM:{term}]")),
2145                None => dest.push_str("[INDEXTERM]"),
2146            }
2147        }
2148
2149        fn render_button(&self, text: &str, dest: &mut String) {
2150            dest.push_str(&format!("[BUTTON:{text}]"));
2151        }
2152
2153        fn render_keyboard(&self, keys: &[String], dest: &mut String) {
2154            dest.push_str(&format!("[KBD:{}]", keys.join("+")));
2155        }
2156
2157        fn render_menu(&self, params: &crate::parser::MenuRenderParams, dest: &mut String) {
2158            dest.push_str(&format!("[MENU:{}]", params.menu));
2159        }
2160
2161        fn render_footnote(&self, params: &crate::parser::FootnoteRenderParams, dest: &mut String) {
2162            match params.index {
2163                Some(index) => dest.push_str(&format!("[FOOTNOTE:{index}]")),
2164                None => dest.push_str(&format!("[FOOTNOTE:{}]", params.text)),
2165            }
2166        }
2167    }
2168
2169    #[test]
2170    fn with_inline_substitution_renderer() {
2171        let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
2172
2173        // Parse a simple document with special characters and a footnote.
2174        let doc = parser.parse("Hello & goodbye < world > test footnote:[a note]");
2175
2176        // The document should parse successfully.
2177        assert_eq!(doc.warnings().count(), 0);
2178
2179        // Get the first block from the document.
2180        let block = doc.nested_blocks().next().unwrap();
2181
2182        let Block::Simple(simple_block) = block else {
2183            panic!("Expected simple block, got: {block:?}");
2184        };
2185
2186        // Our custom renderer should show [AMP], [LT], and [GT] instead of HTML
2187        // entities, and a resolved footnote as [FOOTNOTE:<index>].
2188        assert_eq!(
2189            simple_block.content().rendered(),
2190            "Hello [AMP] goodbye [LT] world [GT] test [FOOTNOTE:1]"
2191        );
2192    }
2193
2194    #[test]
2195    fn custom_renderer_renders_unresolved_footnote() {
2196        let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
2197
2198        // An unresolved footnote reference exercises the renderer's `None`
2199        // (no index) branch, which our custom renderer shows as
2200        // [FOOTNOTE:<text>].
2201        let doc = parser.parse("test.footnote:missing[]");
2202
2203        let block = doc.nested_blocks().next().unwrap();
2204        let Block::Simple(simple_block) = block else {
2205            panic!("Expected simple block, got: {block:?}");
2206        };
2207
2208        assert_eq!(simple_block.content().rendered(), "test.[FOOTNOTE:missing]");
2209    }
2210
2211    mod resolve_show_title {
2212        use crate::parser::{ModificationContext, Parser};
2213
2214        fn with(name: &str, set: bool) -> Parser {
2215            Parser::default().with_intrinsic_attribute_bool(
2216                name,
2217                set,
2218                ModificationContext::Anywhere,
2219            )
2220        }
2221
2222        #[test]
2223        fn neither_present_uses_default() {
2224            assert!(Parser::default().resolve_show_title(true));
2225            assert!(!Parser::default().resolve_show_title(false));
2226        }
2227
2228        #[test]
2229        fn showtitle_takes_precedence_and_decides() {
2230            // Present and set -> shown; present and unset -> hidden, regardless
2231            // of the default.
2232            assert!(with("showtitle", true).resolve_show_title(false));
2233            assert!(!with("showtitle", false).resolve_show_title(true));
2234        }
2235
2236        #[test]
2237        fn notitle_is_the_complement_when_showtitle_absent() {
2238            // notitle set -> hidden; notitle unset -> shown.
2239            assert!(!with("notitle", true).resolve_show_title(true));
2240            assert!(with("notitle", false).resolve_show_title(false));
2241        }
2242    }
2243
2244    mod notitle_showtitle_linkage {
2245        use crate::{
2246            blocks::{Block, IsBlock},
2247            document::InterpretedValue,
2248            parser::{ModificationContext, Parser},
2249        };
2250
2251        // Asciidoctor asciidoctor/asciidoctor#3804: `notitle` and `showtitle`
2252        // are two spellings of one title-visibility toggle, wired as inverses.
2253        // Assigning either updates the partner so the resolved document carries
2254        // one consistent signal — following Asciidoctor's hash semantics, where
2255        // turning the toggle *on* sets one spelling and *removes* the other.
2256
2257        fn parse_header(entries: &str) -> Parser {
2258            let mut parser = Parser::default();
2259            parser.parse(&format!("= Title\n{entries}\n\nbody"));
2260            parser
2261        }
2262
2263        #[test]
2264        fn header_showtitle_set_unsets_notitle() {
2265            // `:showtitle:` => notitle removed (absent).
2266            let parser = parse_header(":showtitle:");
2267            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
2268            assert!(!parser.has_attribute("notitle"));
2269        }
2270
2271        #[test]
2272        fn header_showtitle_unset_sets_notitle() {
2273            // `:!showtitle:` => notitle set.
2274            let parser = parse_header(":!showtitle:");
2275            assert!(!parser.is_attribute_set("showtitle"));
2276            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
2277            assert!(parser.is_attribute_set("notitle"));
2278        }
2279
2280        #[test]
2281        fn header_notitle_set_unsets_showtitle() {
2282            // `:notitle:` => showtitle removed (absent).
2283            let parser = parse_header(":notitle:");
2284            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
2285            assert!(!parser.has_attribute("showtitle"));
2286        }
2287
2288        #[test]
2289        fn header_notitle_unset_sets_showtitle() {
2290            // `:!notitle:` => showtitle set. This is the case called out in the
2291            // issue: a consumer keying off `showtitle` now sees a signal.
2292            let parser = parse_header(":!notitle:");
2293            assert!(!parser.is_attribute_set("notitle"));
2294            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
2295            assert!(parser.is_attribute_set("showtitle"));
2296        }
2297
2298        #[test]
2299        fn last_assignment_wins() {
2300            // Each assignment rewrites the partner, so whichever is assigned
2301            // last decides the resolved toggle.
2302            let parser = parse_header(":notitle:\n:showtitle:");
2303            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
2304            assert!(!parser.has_attribute("notitle"));
2305
2306            let parser = parse_header(":showtitle:\n:notitle:");
2307            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
2308            assert!(!parser.has_attribute("showtitle"));
2309        }
2310
2311        #[test]
2312        fn body_assignment_is_linked() {
2313            // A body attribute entry links the partner just as a header entry
2314            // does.
2315            let mut parser = Parser::default();
2316            parser.parse("= Title\n\nintro\n\n:notitle:\n\nmore");
2317            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
2318            assert!(!parser.has_attribute("showtitle"));
2319        }
2320
2321        #[test]
2322        fn api_assignment_is_linked() {
2323            // Setting either attribute via the API links the partner, matching
2324            // Asciidoctor's `attributes: { 'notitle!' => '' }` etc.
2325            let parser = Parser::default().with_intrinsic_attribute_bool(
2326                "notitle",
2327                true,
2328                ModificationContext::Anywhere,
2329            );
2330            assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
2331            assert!(!parser.has_attribute("showtitle"));
2332
2333            let parser = Parser::default().with_intrinsic_attribute_bool(
2334                "notitle",
2335                false,
2336                ModificationContext::Anywhere,
2337            );
2338            assert!(!parser.is_attribute_set("notitle"));
2339            assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
2340        }
2341
2342        #[test]
2343        fn turning_the_toggle_on_leaves_no_partner_tombstone() {
2344            // `:notitle:` removes `showtitle` outright rather than leaving an
2345            // unset tombstone, so a `{showtitle}` reference stays literal (as it
2346            // would with the attribute absent) instead of resolving to an empty
2347            // string. This guards the interaction flagged in review.
2348            let parser = parse_header(":notitle:");
2349            assert!(!parser.has_attribute("showtitle"));
2350
2351            let mut parser = Parser::default();
2352            let doc = parser.parse("= Title\n:notitle:\n\n{showtitle}");
2353            let block = doc.nested_blocks().next().unwrap();
2354            let Block::Simple(simple_block) = block else {
2355                panic!("expected a simple block");
2356            };
2357            assert_eq!(simple_block.content().rendered(), "{showtitle}");
2358        }
2359
2360        #[test]
2361        fn unrelated_attributes_are_untouched() {
2362            // A document that never assigns either spelling leaves both absent —
2363            // the linkage is a no-op for every other attribute.
2364            let parser = parse_header(":sectnums:");
2365            assert!(!parser.has_attribute("notitle"));
2366            assert!(!parser.has_attribute("showtitle"));
2367        }
2368    }
2369
2370    mod derived_doctype_attr {
2371        use crate::{
2372            document::InterpretedValue,
2373            parser::{AllowableValue, AttributeValue, ModificationContext, Parser},
2374        };
2375
2376        #[test]
2377        fn tracks_the_active_doctype() {
2378            let mut parser = Parser::default();
2379
2380            // The default doctype is `article`, so only its derived attribute is
2381            // defined (to an empty value).
2382            assert_eq!(
2383                parser.attribute_value("backend-html5-doctype-article"),
2384                InterpretedValue::Value(String::new())
2385            );
2386            assert_eq!(
2387                parser.attribute_value("backend-html5-doctype-book"),
2388                InterpretedValue::Unset
2389            );
2390
2391            // Forcing a new doctype moves the derived attribute with it.
2392            parser.force_doctype("book");
2393            assert_eq!(
2394                parser.attribute_value("backend-html5-doctype-book"),
2395                InterpretedValue::Value(String::new())
2396            );
2397            assert_eq!(
2398                parser.attribute_value("backend-html5-doctype-article"),
2399                InterpretedValue::Unset
2400            );
2401        }
2402
2403        #[test]
2404        fn defines_no_derived_attr_when_doctype_is_not_a_value() {
2405            let mut parser = Parser::default();
2406
2407            // The default article derived attribute starts out defined.
2408            assert_eq!(
2409                parser.attribute_value("backend-html5-doctype-article"),
2410                InterpretedValue::Value(String::new())
2411            );
2412
2413            // Shadow the built-in `doctype` default with an explicit unset
2414            // tombstone. With `doctype` no longer resolving to a `Value`, no
2415            // derived attribute is synthesized for any doctype.
2416            std::sync::Arc::make_mut(&mut parser.attribute_values).insert(
2417                "doctype".to_string(),
2418                AttributeValue {
2419                    allowable_value: AllowableValue::Any,
2420                    modification_context: ModificationContext::Anywhere,
2421                    silent_when_locked: false,
2422                    value: InterpretedValue::Unset,
2423                },
2424            );
2425
2426            assert_eq!(parser.attribute_value("doctype"), InterpretedValue::Unset);
2427            assert_eq!(
2428                parser.attribute_value("backend-html5-doctype-article"),
2429                InterpretedValue::Unset
2430            );
2431        }
2432
2433        #[test]
2434        fn document_header_cannot_assign_a_derived_doctype_flag() {
2435            // The `backend-html5-doctype-*` namespace is a read-only intrinsic,
2436            // so a document header assignment to it is ignored: the flag for the
2437            // (inactive) `book` doctype stays undefined rather than taking the
2438            // assigned value, so it cannot later shadow the intrinsic.
2439            let mut parser = Parser::default();
2440            let _doc = parser.parse("= Title\n:backend-html5-doctype-book: custom\n\nbody");
2441
2442            assert_eq!(
2443                parser.attribute_value("backend-html5-doctype-book"),
2444                InterpretedValue::Unset
2445            );
2446        }
2447    }
2448
2449    mod docname {
2450        use crate::Parser;
2451
2452        #[test]
2453        fn none_without_primary_file_name() {
2454            assert_eq!(Parser::default().docname(), None);
2455        }
2456
2457        #[test]
2458        fn strips_directory_and_extension() {
2459            assert_eq!(
2460                Parser::default()
2461                    .with_primary_file_name("mydoc.adoc")
2462                    .docname()
2463                    .as_deref(),
2464                Some("mydoc")
2465            );
2466            assert_eq!(
2467                Parser::default()
2468                    .with_primary_file_name("docs/guide/mydoc.adoc")
2469                    .docname()
2470                    .as_deref(),
2471                Some("mydoc")
2472            );
2473            // A Windows-style separator is handled too, since the primary file
2474            // name may be supplied on either platform.
2475            assert_eq!(
2476                Parser::default()
2477                    .with_primary_file_name(r"docs\guide\mydoc.adoc")
2478                    .docname()
2479                    .as_deref(),
2480                Some("mydoc")
2481            );
2482        }
2483
2484        #[test]
2485        fn keeps_name_with_no_extension() {
2486            assert_eq!(
2487                Parser::default()
2488                    .with_primary_file_name("README")
2489                    .docname()
2490                    .as_deref(),
2491                Some("README")
2492            );
2493        }
2494
2495        #[test]
2496        fn none_when_path_has_no_file_component() {
2497            // A primary file name that ends in a separator has an empty base
2498            // name, which yields no document name.
2499            assert_eq!(
2500                Parser::default()
2501                    .with_primary_file_name("docs/guide/")
2502                    .docname(),
2503                None
2504            );
2505        }
2506
2507        #[test]
2508        fn leading_dot_name_is_kept_whole() {
2509            // A leading-dot name (e.g. `.adoc`) is treated as a dotfile with no
2510            // extension and kept whole, matching Ruby's
2511            // `File.basename(".adoc", ".*")`.
2512            assert_eq!(
2513                Parser::default()
2514                    .with_primary_file_name(".adoc")
2515                    .docname()
2516                    .as_deref(),
2517                Some(".adoc")
2518            );
2519        }
2520    }
2521
2522    mod counter {
2523        use super::super::next_counter_value;
2524        use crate::{document::InterpretedValue, tests::prelude::*};
2525
2526        #[test]
2527        fn next_counter_value_integer() {
2528            assert_eq!(next_counter_value("1"), "2");
2529            assert_eq!(next_counter_value("9"), "10");
2530            assert_eq!(next_counter_value("0"), "1");
2531            assert_eq!(next_counter_value("-1"), "0");
2532        }
2533
2534        #[test]
2535        fn next_counter_value_non_canonical_integer_is_advanced_as_a_string() {
2536            // A leading zero (or sign) does not round-trip through integer
2537            // parsing, so it is advanced like a string instead.
2538            assert_eq!(next_counter_value("07"), "08");
2539            assert_eq!(next_counter_value("+5"), "+6");
2540            // A leading-zero value still carries digit-to-digit like a string.
2541            assert_eq!(next_counter_value("09"), "10");
2542            assert_eq!(next_counter_value("099"), "100");
2543        }
2544
2545        #[test]
2546        fn next_counter_value_saturates_at_i64_max() {
2547            // A counter pinned at `i64::MAX` stays there rather than panicking
2548            // (debug) or wrapping (release).
2549            let max = i64::MAX.to_string();
2550            assert_eq!(next_counter_value(&max), max);
2551        }
2552
2553        #[test]
2554        fn next_counter_value_characters() {
2555            assert_eq!(next_counter_value("a"), "b");
2556            assert_eq!(next_counter_value("A"), "B");
2557            assert_eq!(next_counter_value("z"), "aa");
2558            assert_eq!(next_counter_value("Z"), "AA");
2559            assert_eq!(next_counter_value("az"), "ba");
2560            assert_eq!(next_counter_value("zz"), "aaa");
2561            assert_eq!(next_counter_value("Zz"), "AAa");
2562        }
2563
2564        #[test]
2565        fn next_counter_value_trailing_non_alphanumeric() {
2566            // The right-most alphanumeric is incremented; trailing punctuation is
2567            // left in place.
2568            assert_eq!(next_counter_value("a)"), "b)");
2569        }
2570
2571        #[test]
2572        fn next_counter_value_no_alphanumeric() {
2573            // With nothing alphanumeric to carry, the final code point advances.
2574            assert_eq!(next_counter_value("{"), "|");
2575        }
2576
2577        #[test]
2578        fn counter_defaults_to_one() {
2579            let p = Parser::default();
2580            assert_eq!(p.counter("x", None), "1");
2581            assert_eq!(p.counter("x", None), "2");
2582            assert_eq!(
2583                p.attribute_value("x"),
2584                InterpretedValue::Value("2".to_string())
2585            );
2586            assert!(p.has_attribute("x"));
2587            assert!(p.is_attribute_set("x"));
2588        }
2589
2590        #[test]
2591        fn counter_seed_used_only_while_unset() {
2592            let p = Parser::default();
2593            assert_eq!(p.counter("c", Some("A")), "A");
2594            // Once set, a later seed is ignored.
2595            assert_eq!(p.counter("c", Some("Q")), "B");
2596        }
2597
2598        #[test]
2599        fn counter_empty_seed_falls_back_to_one() {
2600            let p = Parser::default();
2601            assert_eq!(p.counter("c", Some("")), "1");
2602        }
2603    }
2604}