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, SvgFileHandler,
16        built_in_attrs::{built_in_attrs, built_in_default_values},
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    /// Attribute values at current state of parsing.
27    ///
28    /// Shared (copy-on-write via [`Arc`]) with the immutable built-in attribute
29    /// table, so creating or cloning a parser does not deep-copy the table; the
30    /// map is only copied the first time this parser modifies an attribute.
31    pub(crate) attribute_values: Arc<HashMap<String, AttributeValue>>,
32
33    /// Default values for attributes if "set." Immutable after construction and
34    /// shared via [`Arc`] (never copied per parser).
35    default_attribute_values: Arc<HashMap<String, String>>,
36
37    /// Specifies how the basic raw text of a simple block will be converted to
38    /// the format which will ultimately be presented in the final output.
39    ///
40    /// Typically this is an [`HtmlSubstitutionRenderer`] but clients may
41    /// provide alternative implementations.
42    pub(crate) renderer: Rc<dyn InlineSubstitutionRenderer>,
43
44    /// Specifies the name of the primary file to be parsed.
45    pub(crate) primary_file_name: Option<String>,
46
47    /// Specifies how to generate clean and secure paths relative to the parsing
48    /// context.
49    pub path_resolver: PathResolver,
50
51    /// Handler for resolving include:: directives.
52    pub(crate) include_file_handler: Option<Rc<dyn IncludeFileHandler>>,
53
54    /// Handler for resolving docinfo files. If absent, no docinfo content is
55    /// resolved.
56    pub(crate) docinfo_file_handler: Option<Rc<dyn DocinfoFileHandler>>,
57
58    /// Handler for reading the contents of an SVG file requested by an inline
59    /// image with the `inline` option. If absent, inline SVG images fall back
60    /// to rendering their alt text.
61    pub(crate) svg_file_handler: Option<Rc<dyn SvgFileHandler>>,
62
63    /// The safe mode under which the document is parsed and rendered. Controls
64    /// security-sensitive rendering behavior (such as whether an interactive
65    /// SVG image is rendered as an `<object>` element). Defaults to
66    /// [`SafeMode::Secure`].
67    pub(crate) safe: SafeMode,
68
69    /// Document catalog for tracking referenceable elements during parsing.
70    /// This is created during parsing and transferred to the Document when
71    /// complete.
72    ///
73    /// Wrapped in a [`RefCell`] so that anchors and references discovered deep
74    /// inside inline substitution (where only a shared `&Parser` is available,
75    /// e.g. within a regex [`Replacer`](regex::Replacer)) can still be
76    /// registered.
77    catalog: RefCell<Catalog>,
78
79    /// Most recently-assigned section number.
80    pub(crate) last_section_number: SectionNumber,
81
82    /// Most recently-assigned appendix section number.
83    pub(crate) last_appendix_section_number: SectionNumber,
84
85    /// Saved copy of sectnumlevels at end of document header.
86    pub(crate) sectnumlevels: usize,
87
88    /// Section type of outermost section. (Used to determine whether to number
89    /// child sections as a normal section or appendix.)
90    pub(crate) topmost_section_type: SectionType,
91
92    /// True while parsing the direct block children of a section that carries
93    /// the `bibliography` style.
94    ///
95    /// A top-level unordered list parsed in this scope implicitly inherits the
96    /// `bibliography` style (matching Asciidoctor), even without its own
97    /// `[bibliography]` attribute. The flag is saved and restored around each
98    /// section body, so a non-bibliography subsection clears it for its own
99    /// children (the style does not propagate into subsections).
100    pub(crate) parsing_bibliography_section_body: bool,
101
102    /// True while the principal text of a bibliography list item is being
103    /// substituted.
104    ///
105    /// Read through a shared `&Parser` by the macros substitution step so it
106    /// recognizes a leading bibliography anchor (`[[[id]]]`). It is wrapped in
107    /// a [`Cell`] because the substitution code paths (e.g. a regex
108    /// [`Replacer`](regex::Replacer)) only hold a shared reference to the
109    /// parser.
110    pub(crate) in_bibliography_list_item: Cell<bool>,
111
112    /// Live values of [counter] attributes, keyed by counter name (e.g.
113    /// `index`, `example-number`, `table-number`).
114    ///
115    /// A counter is a specialized document attribute: its value is *also* the
116    /// value of the document attribute of the same name. Counters are resolved
117    /// (and advanced) deep inside the attribute-reference substitution step,
118    /// where only a shared `&Parser` is available, so the new value is recorded
119    /// here through a [`RefCell`] and read back as an attribute by
120    /// [`attribute_value()`]. An explicit attribute assignment to a counter's
121    /// name supersedes this overlay (and is what allows `:!name:` to reset a
122    /// counter), so every attribute setter clears the matching entry.
123    ///
124    /// Captioned blocks (example, table, …) are numbered with this same
125    /// mechanism: each context's caption number is the counter named
126    /// `<context>-number`, mirroring Asciidoctor's `Document#counter`.
127    ///
128    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
129    /// [`attribute_value()`]: Self::attribute_value
130    pub(crate) counter_values: RefCell<HashMap<String, String>>,
131
132    /// Canonical names of attributes that are locked against modification from
133    /// the document body for the current scope.
134    ///
135    /// An AsciiDoc table cell creates a nested document that inherits the
136    /// parent document's attributes. An attribute that is *set* in the
137    /// parent _cannot_ be modified inside the cell (matching Asciidoctor,
138    /// which here diverges from the spec's "set or explicitly unset" wording),
139    /// so while a cell is being parsed every inherited attribute name
140    /// (other than a handful of exceptions) is recorded here and a body
141    /// attribute assignment to such a name is silently ignored. The set is
142    /// saved and restored around each cell, so the lock applies only within
143    /// the cell (and nests correctly).
144    pub(crate) locked_attribute_names: HashSet<String>,
145
146    /// Number of AsciiDoc table cells currently being parsed in the call stack.
147    ///
148    /// An AsciiDoc table cell creates a nested, standalone AsciiDoc document.
149    /// While that document is being parsed this counter is greater than zero,
150    /// which (matching Asciidoctor's `Document#nested?`) changes the default
151    /// cell separator of any table found inside from the vertical bar (`|`) to
152    /// the exclamation mark (`!`), so a nested table needs no explicit
153    /// `separator` attribute. The counter is incremented and decremented around
154    /// each AsciiDoc cell, so it nests correctly.
155    pub(crate) nested_document_depth: usize,
156
157    /// Catalog of callout numbers registered by verbatim blocks, used to
158    /// validate the callout lists that annotate them.
159    ///
160    /// Wrapped in a [`RefCell`] because callouts are registered deep inside the
161    /// callouts substitution step, where only a shared `&Parser` is available.
162    callouts: RefCell<CalloutCatalog>,
163
164    /// Warnings produced while replacing attribute references (e.g. a reference
165    /// to a missing attribute when `attribute-missing` is `warn`).
166    ///
167    /// Wrapped in a [`RefCell`] because attribute references are replaced deep
168    /// inside the attributes substitution step, where only a shared `&Parser`
169    /// is available. Each entry stores the byte offset and length of the source
170    /// span the warning refers to (rather than a borrowed
171    /// [`Span`](crate::Span), which the lifetime-free `Parser` cannot
172    /// hold), so the warnings can be turned into
173    /// spanned [`Warning`]s once the document's owned source is available.
174    substitution_warnings: RefCell<Vec<DeferredWarning>>,
175}
176
177/// A warning recorded in a form that does not borrow the source so it can live
178/// on the [`Parser`] (or be returned from preprocessing), to be reconstituted
179/// into a spanned [`Warning`] once the document's owned source is available.
180///
181/// This is used both for warnings raised while replacing attribute references
182/// and for warnings raised during preprocessing (e.g. an unresolved include
183/// directive). The `offset`/`len` pair locates the relevant text within the
184/// (preprocessed) document source.
185#[derive(Clone, Debug)]
186pub(crate) struct DeferredWarning {
187    /// Byte offset into the document source of the span this warning refers to.
188    pub(crate) offset: usize,
189
190    /// Byte length of the span this warning refers to.
191    pub(crate) len: usize,
192
193    /// The type of warning, already carrying any owned data it needs (such as
194    /// the missing attribute's name).
195    pub(crate) warning: WarningType,
196}
197
198/// Tracks the callout numbers defined by verbatim blocks so that a callout list
199/// can be validated against the callouts it annotates.
200///
201/// This mirrors the relevant behavior of Asciidoctor's `Callouts` catalog: each
202/// verbatim block registers the callout numbers it defines into the current
203/// list, and each callout list checks its items against that list (warning
204/// about any item with no matching callout) before the list is closed.
205#[derive(Clone, Debug, Default)]
206struct CalloutCatalog {
207    /// Callout numbers registered (in document order) since the last callout
208    /// list was closed.
209    current: Vec<u32>,
210}
211
212impl Default for Parser {
213    fn default() -> Self {
214        Self {
215            attribute_values: built_in_attrs(),
216            default_attribute_values: built_in_default_values(),
217            renderer: Rc::new(HtmlSubstitutionRenderer {}),
218            primary_file_name: None,
219            path_resolver: PathResolver::default(),
220            include_file_handler: None,
221            docinfo_file_handler: None,
222            svg_file_handler: None,
223            safe: SafeMode::default(),
224            catalog: RefCell::new(Catalog::new()),
225            last_section_number: SectionNumber::default(),
226            last_appendix_section_number: SectionNumber {
227                section_type: SectionType::Appendix,
228                components: vec![],
229            },
230            sectnumlevels: 3,
231            topmost_section_type: SectionType::Normal,
232            parsing_bibliography_section_body: false,
233            in_bibliography_list_item: Cell::new(false),
234            counter_values: RefCell::new(HashMap::new()),
235            locked_attribute_names: HashSet::new(),
236            nested_document_depth: 0,
237            callouts: RefCell::new(CalloutCatalog::default()),
238            substitution_warnings: RefCell::new(vec![]),
239        }
240    }
241}
242
243impl Parser {
244    /// Parse a UTF-8 string as an AsciiDoc document.
245    ///
246    /// The [`Document`] data structure returned by this call has a '`static`
247    /// lifetime; this is an implementation detail. It retains a copy of the
248    /// `source` string that was passed in, but it is not tied to the lifetime
249    /// of that string.
250    ///
251    /// Nearly all of the data structures contained within the [`Document`]
252    /// structure are tied to the lifetime of the document and have a `'src`
253    /// lifetime to signal their dependency on the source document.
254    ///
255    /// **IMPORTANT:** The AsciiDoc language documentation states that UTF-16
256    /// encoding is allowed if a byte-order-mark (BOM) is present at the
257    /// start of a file. This format is not directly supported by the
258    /// `asciidoc-parser` crate. Any UTF-16 content must be re-encoded as
259    /// UTF-8 prior to parsing.
260    ///
261    /// The `Parser` struct will be updated with document attribute values
262    /// discovered during parsing. These values may be inspected using
263    /// [`attribute_value()`].
264    ///
265    /// # Warnings, not errors
266    ///
267    /// Any UTF-8 string is a valid AsciiDoc document, so this function does not
268    /// return an [`Option`] or [`Result`] data type. There may be any number of
269    /// character sequences that have ambiguous or potentially unintended
270    /// meanings. For that reason, a caller is advised to review the warnings
271    /// provided via the [`warnings()`] iterator.
272    ///
273    /// [`warnings()`]: Document::warnings
274    /// [`attribute_value()`]: Self::attribute_value
275    pub fn parse(&mut self, source: &str) -> Document<'static> {
276        let mut document = self.parse_deferred(source);
277
278        // Resolve cross-references against this document's own catalog. For
279        // multi-document workflows, use `parse_deferred` and resolve later with
280        // a caller-supplied resolver via `Document::resolve_references`.
281        document.resolve_against_own_catalog(&*self.renderer);
282
283        document
284    }
285
286    /// Parse a UTF-8 string as an AsciiDoc document, leaving cross-references
287    /// unresolved.
288    ///
289    /// This behaves like [`parse()`], except it does not resolve
290    /// cross-references (`<<id>>`, `xref:id[…]`). The returned [`Document`]
291    /// carries its references in a deferred state; resolve them later with
292    /// [`Document::resolve_references`].
293    ///
294    /// This is the entry point for multi-document workflows (e.g. Antora-style
295    /// site generation): parse every document with this method, build a
296    /// combined index from each document's [`catalog()`], then resolve each
297    /// document against that index. This crate does not merge catalogs
298    /// itself.
299    ///
300    /// [`parse()`]: Self::parse
301    /// [`catalog()`]: Document::catalog
302    pub fn parse_deferred(&mut self, source: &str) -> Document<'static> {
303        let (preprocessed_source, source_map, preprocessor_warnings) = preprocess(source, self);
304
305        // NOTE: `Document::parse` will transfer the catalog to itself at the end of the
306        // parsing operation. Start each parse with a fresh catalog.
307        *self.catalog.borrow_mut() = Catalog::new();
308
309        // Start each parse with an empty callout catalog.
310        *self.callouts.borrow_mut() = CalloutCatalog::default();
311
312        // Start each parse with no pending substitution warnings.
313        self.substitution_warnings.borrow_mut().clear();
314
315        // Reset section numbering for each new document.
316        self.last_section_number = SectionNumber::default();
317
318        // Reset counter (and captioned-block) numbering for each new document.
319        self.counter_values.borrow_mut().clear();
320
321        Document::parse(
322            &preprocessed_source,
323            source_map,
324            preprocessor_warnings,
325            self,
326        )
327    }
328
329    /// Retrieves the current interpreted value of a [document attribute].
330    ///
331    /// Each document holds a set of name-value pairs called document
332    /// attributes. These attributes provide a means of configuring the AsciiDoc
333    /// processor, declaring document metadata, and defining reusable content.
334    /// This page introduces document attributes and answers some questions
335    /// about the terminology used when referring to them.
336    ///
337    /// ## What are document attributes?
338    ///
339    /// Document attributes are effectively document-scoped variables for the
340    /// AsciiDoc language. The AsciiDoc language defines a set of built-in
341    /// attributes, and also allows the author (or extensions) to define
342    /// additional document attributes, which may replace built-in attributes
343    /// when permitted.
344    ///
345    /// Built-in attributes either provide access to read-only information about
346    /// the document and its environment or allow the author to configure
347    /// behavior of the AsciiDoc processor for a whole document or select
348    /// regions. Built-in attributes are effectively unordered. User-defined
349    /// attribute serve as a powerful text replacement tool. User-defined
350    /// attributes are stored in the order in which they are defined.
351    ///
352    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
353    pub fn attribute_value<N: AsRef<str>>(&self, name: N) -> InterpretedValue {
354        // A counter's current value lives in the overlay and supersedes any
355        // earlier value of the attribute of the same name (see
356        // [`counter_values`](Self::counter_values)).
357        if let Some(value) = self.counter_values.borrow().get(name.as_ref()) {
358            return InterpretedValue::Value(value.clone());
359        }
360
361        self.attribute_values
362            .get(name.as_ref())
363            .map(|av| av.value.clone())
364            .map(|av| {
365                if let InterpretedValue::Set = av
366                    && let Some(default) = self.default_attribute_values.get(name.as_ref())
367                {
368                    InterpretedValue::Value(default.clone())
369                } else {
370                    av
371                }
372            })
373            .unwrap_or(InterpretedValue::Unset)
374    }
375
376    /// Returns `true` if the parser has a [document attribute] by this name.
377    ///
378    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
379    pub fn has_attribute<N: AsRef<str>>(&self, name: N) -> bool {
380        self.counter_values.borrow().contains_key(name.as_ref())
381            || self.attribute_values.contains_key(name.as_ref())
382    }
383
384    /// Returns `true` if the parser has a [document attribute] by this name
385    /// which has been set (i.e. is present and not [unset]).
386    ///
387    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
388    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
389    pub fn is_attribute_set<N: AsRef<str>>(&self, name: N) -> bool {
390        // A counter always holds a concrete (set) value.
391        if self.counter_values.borrow().contains_key(name.as_ref()) {
392            return true;
393        }
394
395        self.attribute_values
396            .get(name.as_ref())
397            .map(|a| a.value != InterpretedValue::Unset)
398            .unwrap_or(false)
399    }
400
401    /// Captures the parser's fully-resolved document-attribute state so it can
402    /// outlive the parser — for example, retained on a [`Document`] to answer
403    /// [`attribute_value`]/[`has_attribute`]/[`is_attribute_set`] without a
404    /// parser in hand (the embed path a renderer uses for `convert_document`).
405    ///
406    /// This shares the parser's attribute tables by [`Arc`] rather than copying
407    /// them, so it is cheap to take on every parse (the large built-in table is
408    /// never deep-cloned). See [`ResolvedAttributes`].
409    ///
410    /// [`Document`]: crate::Document
411    /// [`attribute_value`]: Self::attribute_value
412    /// [`has_attribute`]: Self::has_attribute
413    /// [`is_attribute_set`]: Self::is_attribute_set
414    pub(crate) fn snapshot_attributes(&self) -> ResolvedAttributes {
415        ResolvedAttributes::new(
416            Arc::clone(&self.attribute_values),
417            Arc::clone(&self.default_attribute_values),
418            self.counter_values.borrow().clone(),
419        )
420    }
421
422    /// Resolves whether a document title should be displayed, from the
423    /// `showtitle`/`notitle` attribute pair (which are complements).
424    ///
425    /// `showtitle` takes precedence: if present, the title shows precisely when
426    /// it is set. Otherwise `notitle`, if present, hides the title when set.
427    /// When neither attribute is present, `default_shown` decides — a
428    /// standalone document (such as a nested AsciiDoc table cell) shows its
429    /// title, while an embedded document does not.
430    pub(crate) fn resolve_show_title(&self, default_shown: bool) -> bool {
431        if self.has_attribute("showtitle") {
432            self.is_attribute_set("showtitle")
433        } else if self.has_attribute("notitle") {
434            !self.is_attribute_set("notitle")
435        } else {
436            default_shown
437        }
438    }
439
440    /// Forces the `doctype` attribute to `value`, refreshing the derived
441    /// `backend-html5-doctype-*` attribute.
442    ///
443    /// Used when a nested AsciiDoc table cell resets its doctype to the default
444    /// (a cell does not inherit the parent's doctype). The value stays
445    /// modifiable from the document body so the cell may still set its own
446    /// doctype.
447    pub(crate) fn force_doctype(&mut self, value: &str) {
448        Arc::make_mut(&mut self.attribute_values).insert(
449            "doctype".to_string(),
450            AttributeValue {
451                allowable_value: AllowableValue::Any,
452                modification_context: ModificationContext::ApiOrDocumentBody,
453                silent_when_locked: false,
454                value: InterpretedValue::Value(value.to_string()),
455            },
456        );
457        self.refresh_doctype_derived_attr();
458    }
459
460    /// Recomputes the `backend-html5-doctype-{doctype}` intrinsic attribute so
461    /// exactly one exists — for the active doctype — resolving to an empty
462    /// (defined) value. References to any other doctype stay undefined and so
463    /// render literally.
464    pub(crate) fn refresh_doctype_derived_attr(&mut self) {
465        Arc::make_mut(&mut self.attribute_values)
466            .retain(|name, _| !name.starts_with("backend-html5-doctype-"));
467
468        if let InterpretedValue::Value(doctype) = self.attribute_value("doctype") {
469            Arc::make_mut(&mut self.attribute_values).insert(
470                format!("backend-html5-doctype-{doctype}"),
471                AttributeValue {
472                    allowable_value: AllowableValue::Any,
473                    modification_context: ModificationContext::Anywhere,
474                    silent_when_locked: false,
475                    value: InterpretedValue::Value(String::new()),
476                },
477            );
478        }
479    }
480
481    /// Sets the value of an [intrinsic attribute].
482    ///
483    /// Intrinsic attributes are set automatically by the processor. These
484    /// attributes provide information about the document being processed (e.g.,
485    /// `docfile`), the security mode under which the processor is running
486    /// (e.g., `safe-mode-name`), and information about the user’s environment
487    /// (e.g., `user-home`).
488    ///
489    /// The [`modification_context`](ModificationContext) establishes whether
490    /// the value can be subsequently modified by the document header and/or in
491    /// the document body.
492    ///
493    /// Subsequent calls to this function or [`with_intrinsic_attribute_bool()`]
494    /// are always permitted. The last such call for any given attribute name
495    /// takes precendence.
496    ///
497    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
498    ///
499    /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
500    pub fn with_intrinsic_attribute<N: AsRef<str>, V: AsRef<str>>(
501        mut self,
502        name: N,
503        value: V,
504        modification_context: ModificationContext,
505    ) -> Self {
506        let attribute_value = AttributeValue {
507            allowable_value: AllowableValue::Any,
508            modification_context,
509            silent_when_locked: false,
510            value: InterpretedValue::Value(value.as_ref().to_string()),
511        };
512
513        Arc::make_mut(&mut self.attribute_values)
514            .insert(name.as_ref().to_lowercase(), attribute_value);
515
516        self
517    }
518
519    /// Sets the value of an [intrinsic attribute], rejecting any disallowed
520    /// subsequent write *silently*.
521    ///
522    /// This behaves exactly like [`with_intrinsic_attribute()`] except that a
523    /// document header or body assignment that the
524    /// [`modification_context`](ModificationContext) does not permit is dropped
525    /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
526    /// The rejected write is otherwise handled identically (the value is left
527    /// unchanged).
528    ///
529    /// This reproduces Asciidoctor's *silent* safe-mode attribute restrictions:
530    /// under `SERVER`/`SECURE`, a document assignment of a restricted
531    /// conversion attribute (`backend`, `doctype`, `docinfo`,
532    /// `source-highlighter`) is simply dropped, with no diagnostic. Seed
533    /// such an attribute as an [`ApiOnly`](ModificationContext::ApiOnly)
534    /// silent intrinsic to lock it against document assignment without
535    /// warning.
536    ///
537    /// Subsequent calls to this function or the other
538    /// `with_intrinsic_attribute` variants are always permitted. The last
539    /// such call for any given attribute name takes precedence.
540    ///
541    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
542    ///
543    /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
544    pub fn with_intrinsic_attribute_silent<N: AsRef<str>, V: AsRef<str>>(
545        mut self,
546        name: N,
547        value: V,
548        modification_context: ModificationContext,
549    ) -> Self {
550        let attribute_value = AttributeValue {
551            allowable_value: AllowableValue::Any,
552            modification_context,
553            silent_when_locked: true,
554            value: InterpretedValue::Value(value.as_ref().to_string()),
555        };
556
557        Arc::make_mut(&mut self.attribute_values)
558            .insert(name.as_ref().to_lowercase(), attribute_value);
559
560        self
561    }
562
563    /// Register a referenceable element (anchor, section, bibliography entry)
564    /// in the document catalog.
565    ///
566    /// This takes `&self` (rather than `&mut self`) so that it can be called
567    /// from inline-substitution code paths that only hold a shared reference to
568    /// the parser, such as a regex [`Replacer`](regex::Replacer).
569    pub(crate) fn register_ref(
570        &self,
571        id: &str,
572        reftext: Option<&str>,
573        ref_type: RefType,
574    ) -> Result<(), crate::document::DuplicateIdError> {
575        self.catalog
576            .borrow_mut()
577            .register_ref(id, reftext, ref_type)
578    }
579
580    /// Registers a callout number defined by a verbatim block.
581    ///
582    /// Takes `&self` so it can be called from the callouts substitution step,
583    /// which only holds a shared reference to the parser.
584    pub(crate) fn register_callout(&self, number: u32) {
585        self.callouts.borrow_mut().current.push(number);
586    }
587
588    /// Returns `true` if a callout numbered `number` was registered for the
589    /// current (not-yet-closed) callout list.
590    pub(crate) fn callout_defined(&self, number: u32) -> bool {
591        self.callouts.borrow().current.contains(&number)
592    }
593
594    /// Closes the current callout list, so callouts registered afterward belong
595    /// to the next list.
596    pub(crate) fn close_callout_list(&self) {
597        self.callouts.borrow_mut().current.clear();
598    }
599
600    /// Returns the number of an already-defined footnote with the given ID, if
601    /// one exists in the current document's footnote registry.
602    ///
603    /// Takes `&self` so it can be called from the macros substitution step,
604    /// which only holds a shared reference to the parser.
605    pub(crate) fn footnote_index_for_id(&self, id: &str) -> Option<String> {
606        self.catalog
607            .borrow()
608            .footnote_with_id(id)
609            .map(|f| f.index.clone())
610    }
611
612    /// Defines a new footnote, advancing the `footnote-number` counter and
613    /// registering the footnote in the current document's registry. Returns the
614    /// number assigned to the footnote.
615    ///
616    /// Takes `&self` so it can be called from the macros substitution step.
617    pub(crate) fn define_footnote(
618        &self,
619        id: Option<&str>,
620        text: String,
621        xrefs: Vec<crate::content::XrefSegment>,
622    ) -> String {
623        // A footnote's text is extracted out of the block during macro
624        // substitution, so any cross-reference inside it never reaches the
625        // document-level resolution pass over block content. Those
626        // cross-references are captured (as placeholders in `text` plus the
627        // `xrefs` segments) so they can be resolved alongside the block
628        // references. The stored `text` is the unresolved fallback rendering
629        // until then, so it is always clean.
630        let (text, deferred) = if xrefs.is_empty() {
631            (text, None)
632        } else {
633            let deferred = crate::content::FootnoteDeferred::new(text, xrefs);
634            let rendered = deferred.render(&*self.renderer);
635            (rendered, Some(Box::new(deferred)))
636        };
637
638        // Footnotes are numbered consecutively throughout the document via the
639        // `footnote-number` counter, which is seeded to `0` so the first
640        // footnote is numbered `1`. The counter is a document-wide attribute, so
641        // numbering continues across nested documents (AsciiDoc table cells)
642        // even though the footnote *list* does not. The counter honors any seed
643        // the document sets, so a non-integer seed yields a non-integer number
644        // (matching Asciidoctor); the value is therefore kept as a string.
645        let index = self.counter("footnote-number", None);
646
647        self.catalog
648            .borrow_mut()
649            .register_footnote(crate::document::Footnote {
650                index: index.clone(),
651                id: id.map(|s| s.to_owned()),
652                text,
653                deferred,
654            });
655
656        index
657    }
658
659    /// Removes and returns the current document's footnote list, leaving an
660    /// empty list behind. Used to give a nested document (an AsciiDoc table
661    /// cell) its own footnote registry; see [`restore_footnotes`].
662    ///
663    /// [`restore_footnotes`]: Self::restore_footnotes
664    pub(crate) fn take_footnotes(&self) -> Vec<crate::document::Footnote> {
665        self.catalog.borrow_mut().take_footnotes()
666    }
667
668    /// Restores a previously-[taken](Self::take_footnotes) footnote list,
669    /// discarding any footnotes registered in the meantime (i.e. those defined
670    /// inside the nested document).
671    pub(crate) fn restore_footnotes(&self, footnotes: Vec<crate::document::Footnote>) {
672        self.catalog.borrow_mut().restore_footnotes(footnotes);
673    }
674
675    /// Records a warning produced while replacing attribute references.
676    ///
677    /// Takes `&self` so it can be called from the attributes substitution step,
678    /// which only holds a shared reference to the parser. `source` locates the
679    /// text the warning refers to; its byte offset and length are stored so a
680    /// spanned [`Warning`] can be reconstructed later (see
681    /// [`take_substitution_warnings`](Self::take_substitution_warnings)).
682    pub(crate) fn record_substitution_warning(
683        &self,
684        source: crate::Span<'_>,
685        warning: WarningType,
686    ) {
687        self.substitution_warnings
688            .borrow_mut()
689            .push(DeferredWarning {
690                offset: source.byte_offset(),
691                len: source.len(),
692                warning,
693            });
694    }
695
696    /// Returns the number of substitution warnings recorded so far.
697    ///
698    /// Used together with [`truncate_substitution_warnings`] to discard
699    /// warnings recorded while parsing an owned (e.g. include-expanded) source,
700    /// whose offsets do not refer to the primary document source.
701    ///
702    /// [`truncate_substitution_warnings`]: Self::truncate_substitution_warnings
703    pub(crate) fn substitution_warnings_len(&self) -> usize {
704        self.substitution_warnings.borrow().len()
705    }
706
707    /// Discards any substitution warnings recorded since the buffer held `len`
708    /// entries.
709    pub(crate) fn truncate_substitution_warnings(&self, len: usize) {
710        self.substitution_warnings.borrow_mut().truncate(len);
711    }
712
713    /// Takes the substitution warnings recorded during parsing, leaving the
714    /// buffer empty.
715    pub(crate) fn take_substitution_warnings(&self) -> Vec<DeferredWarning> {
716        std::mem::take(&mut *self.substitution_warnings.borrow_mut())
717    }
718
719    /// Generate a unique ID derived from `base_id` and register it in the
720    /// document catalog, returning the ID that was assigned.
721    pub(crate) fn generate_and_register_unique_id(
722        &self,
723        base_id: &str,
724        reftext: Option<&str>,
725        ref_type: RefType,
726    ) -> String {
727        self.catalog
728            .borrow_mut()
729            .generate_and_register_unique_id(base_id, reftext, ref_type)
730    }
731
732    /// Takes the catalog from the parser, transferring ownership and leaving an
733    /// empty catalog in its place.
734    ///
735    /// This is used by `Document::parse` to transfer the catalog from the
736    /// parser to the document at the end of parsing.
737    pub(crate) fn take_catalog(&mut self) -> Catalog {
738        std::mem::take(&mut *self.catalog.borrow_mut())
739    }
740
741    /* Comment out until we're prepared to use and test this.
742        /// Sets the default value for an [intrinsic attribute].
743        ///
744        /// Default values for attributes are provided automatically by the
745        /// processor. These values provide a falllback textual value for an
746        /// attribute when it is merely "set" by the document via API, header, or
747        /// document body.
748        ///
749        /// Calling this does not imply that the value is set automatically by
750        /// default, nor does it establish any policy for where the value may be
751        /// modified. For that, please use [`with_intrinsic_attribute`].
752        ///
753        /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
754        /// [`with_intrinsic_attribute`]: Self::with_intrinsic_attribute
755        pub fn with_default_attribute_value<N: AsRef<str>, V: AsRef<str>>(
756            mut self,
757            name: N,
758            value: V,
759        ) -> Self {
760            self.default_attribute_values
761                .insert(name.as_ref().to_string(), value.as_ref().to_string());
762
763            self
764        }
765    */
766
767    /// Sets the value of an [intrinsic attribute] from a boolean flag.
768    ///
769    /// A boolean `true` is interpreted as "set." A boolean `false` is
770    /// interpreted as "unset."
771    ///
772    /// Intrinsic attributes are set automatically by the processor. These
773    /// attributes provide information about the document being processed (e.g.,
774    /// `docfile`), the security mode under which the processor is running
775    /// (e.g., `safe-mode-name`), and information about the user’s environment
776    /// (e.g., `user-home`).
777    ///
778    /// The [`modification_context`](ModificationContext) establishes whether
779    /// the value can be subsequently modified by the document header and/or in
780    /// the document body.
781    ///
782    /// Subsequent calls to this function or [`with_intrinsic_attribute()`] are
783    /// always permitted. The last such call for any given attribute name takes
784    /// precendence.
785    ///
786    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
787    ///
788    /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
789    pub fn with_intrinsic_attribute_bool<N: AsRef<str>>(
790        mut self,
791        name: N,
792        value: bool,
793        modification_context: ModificationContext,
794    ) -> Self {
795        let attribute_value = AttributeValue {
796            allowable_value: AllowableValue::Any,
797            modification_context,
798            silent_when_locked: false,
799            value: if value {
800                InterpretedValue::Set
801            } else {
802                InterpretedValue::Unset
803            },
804        };
805
806        Arc::make_mut(&mut self.attribute_values)
807            .insert(name.as_ref().to_lowercase(), attribute_value);
808
809        self
810    }
811
812    /// Sets the value of an [intrinsic attribute] from a boolean flag,
813    /// rejecting any disallowed subsequent write *silently*.
814    ///
815    /// This behaves exactly like [`with_intrinsic_attribute_bool()`] except
816    /// that a document header or body assignment that the
817    /// [`modification_context`](ModificationContext) does not permit is dropped
818    /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
819    /// See [`with_intrinsic_attribute_silent()`] for the motivating use case
820    /// (Asciidoctor's silent safe-mode attribute restrictions).
821    ///
822    /// A boolean `true` is interpreted as "set." A boolean `false` is
823    /// interpreted as "unset."
824    ///
825    /// Subsequent calls to this function or the other
826    /// `with_intrinsic_attribute` variants are always permitted. The last
827    /// such call for any given attribute name takes precedence.
828    ///
829    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
830    ///
831    /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
832    /// [`with_intrinsic_attribute_silent()`]: Self::with_intrinsic_attribute_silent
833    pub fn with_intrinsic_attribute_bool_silent<N: AsRef<str>>(
834        mut self,
835        name: N,
836        value: bool,
837        modification_context: ModificationContext,
838    ) -> Self {
839        let attribute_value = AttributeValue {
840            allowable_value: AllowableValue::Any,
841            modification_context,
842            silent_when_locked: true,
843            value: if value {
844                InterpretedValue::Set
845            } else {
846                InterpretedValue::Unset
847            },
848        };
849
850        Arc::make_mut(&mut self.attribute_values)
851            .insert(name.as_ref().to_lowercase(), attribute_value);
852
853        self
854    }
855
856    /// Replace the default [`InlineSubstitutionRenderer`] for this parser.
857    ///
858    /// The default implementation of [`InlineSubstitutionRenderer`] that is
859    /// provided is suitable for HTML5 rendering. If you are targeting a
860    /// different back-end rendering, you will need to provide your own
861    /// implementation and set it using this call before parsing.
862    pub fn with_inline_substitution_renderer<ISR: InlineSubstitutionRenderer + 'static>(
863        mut self,
864        renderer: ISR,
865    ) -> Self {
866        self.renderer = Rc::new(renderer);
867        self
868    }
869
870    /// Sets the name of the primary file to be parsed when [`parse()`] is
871    /// called.
872    ///
873    /// This name will be used for any error messages detected in this file and
874    /// also will be passed to [`IncludeFileHandler::resolve_target()`] as the
875    /// `source` argument for any `include::` file resolution requests from this
876    /// file.
877    ///
878    /// [`parse()`]: Self::parse
879    /// [`IncludeFileHandler::resolve_target()`]: crate::parser::IncludeFileHandler::resolve_target
880    pub fn with_primary_file_name<S: AsRef<str>>(mut self, name: S) -> Self {
881        self.primary_file_name = Some(name.as_ref().to_owned());
882        self
883    }
884
885    /// Sets the [`IncludeFileHandler`] for this parser.
886    ///
887    /// The include file handler is responsible for resolving `include::`
888    /// directives encountered during preprocessing. If no handler is provided,
889    /// include directives will be ignored.
890    ///
891    /// [`IncludeFileHandler`]: crate::parser::IncludeFileHandler
892    pub fn with_include_file_handler<IFH: IncludeFileHandler + 'static>(
893        mut self,
894        handler: IFH,
895    ) -> Self {
896        self.include_file_handler = Some(Rc::new(handler));
897        self
898    }
899
900    /// Sets the [`DocinfoFileHandler`] for this parser.
901    ///
902    /// The docinfo file handler is responsible for providing the content of
903    /// [docinfo files] requested while resolving a document's docinfo (see the
904    /// `docinfo` attribute). If no handler is provided, no docinfo content is
905    /// resolved and [`Document::docinfo`] returns an empty string for every
906    /// location.
907    ///
908    /// [`DocinfoFileHandler`]: crate::parser::DocinfoFileHandler
909    /// [docinfo files]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
910    /// [`Document::docinfo`]: crate::Document::docinfo
911    pub fn with_docinfo_file_handler<DFH: DocinfoFileHandler + 'static>(
912        mut self,
913        handler: DFH,
914    ) -> Self {
915        self.docinfo_file_handler = Some(Rc::new(handler));
916        self
917    }
918
919    /// Sets the [`SvgFileHandler`] for this parser.
920    ///
921    /// The SVG file handler is responsible for providing the raw contents of an
922    /// SVG file requested by an inline image with the `inline` option (e.g.
923    /// `image:diagram.svg[opts=inline]`). If no handler is provided, inline SVG
924    /// images fall back to rendering their alt text.
925    ///
926    /// [`SvgFileHandler`]: crate::parser::SvgFileHandler
927    pub fn with_svg_file_handler<SFH: SvgFileHandler + 'static>(mut self, handler: SFH) -> Self {
928        self.svg_file_handler = Some(Rc::new(handler));
929        self
930    }
931
932    /// Sets the [`SafeMode`] under which the document is parsed and rendered.
933    ///
934    /// The default is [`SafeMode::Secure`], the most conservative setting.
935    /// Relaxing the safe mode enables security-sensitive rendering behavior,
936    /// such as rendering an interactive SVG image as an `<object>` element.
937    ///
938    /// [`SafeMode`]: crate::SafeMode
939    pub fn with_safe_mode(mut self, safe: SafeMode) -> Self {
940        self.safe = safe;
941        self.apply_safe_mode_attributes();
942        self
943    }
944
945    /// Refreshes the `safe-mode-*` family of [intrinsic attributes] from the
946    /// current safe mode.
947    ///
948    /// These attributes let a document (or a downstream converter) inspect the
949    /// security mode under which it is being processed:
950    ///
951    /// * `safe-mode-level` — the numeric level (`0`, `1`, `10`, or `20`).
952    /// * `safe-mode-name` — the lowercase mode name (`unsafe`, `safe`,
953    ///   `server`, or `secure`).
954    /// * `safe-mode-<name>` — a single flag attribute (set to an empty value)
955    ///   naming the active mode; the flags for the other modes are left unset
956    ///   so that a reference to them resolves literally.
957    ///
958    /// All of these are read-only from the document's perspective (they can
959    /// only be established via the API), matching Ruby Asciidoctor.
960    ///
961    /// [intrinsic attributes]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
962    fn apply_safe_mode_attributes(&mut self) {
963        let attrs = Arc::make_mut(&mut self.attribute_values);
964
965        let intrinsic = |value: InterpretedValue| AttributeValue {
966            allowable_value: AllowableValue::Any,
967            modification_context: ModificationContext::ApiOnly,
968            silent_when_locked: false,
969            value,
970        };
971
972        attrs.insert(
973            "safe-mode-level".to_string(),
974            intrinsic(InterpretedValue::Value(self.safe.level().to_string())),
975        );
976        attrs.insert(
977            "safe-mode-name".to_string(),
978            intrinsic(InterpretedValue::Value(self.safe.name().to_string())),
979        );
980
981        // Exactly one `safe-mode-<name>` flag is set (to an empty value); the
982        // rest are removed so that referencing them resolves literally.
983        for mode in [
984            SafeMode::Unsafe,
985            SafeMode::Safe,
986            SafeMode::Server,
987            SafeMode::Secure,
988        ] {
989            let name = format!("safe-mode-{}", mode.name());
990            if mode == self.safe {
991                attrs.insert(name, intrinsic(InterpretedValue::Set));
992            } else {
993                attrs.remove(&name);
994            }
995        }
996    }
997
998    /// Returns the [`SafeMode`] under which this parser operates.
999    ///
1000    /// [`SafeMode`]: crate::SafeMode
1001    pub fn safe_mode(&self) -> SafeMode {
1002        self.safe
1003    }
1004
1005    /// Returns the document name (`docname`): the base name of the primary
1006    /// file, stripped of its directory and final extension.
1007    ///
1008    /// This is the `<docname>` used to build private docinfo file names (e.g.
1009    /// `mydoc-docinfo.html` for `mydoc.adoc`). Returns `None` when no primary
1010    /// file name has been set, in which case private docinfo files cannot be
1011    /// resolved.
1012    pub(crate) fn docname(&self) -> Option<String> {
1013        let primary = self.primary_file_name.as_deref()?;
1014
1015        // Strip the directory portion (handling both separators, since the
1016        // primary file name may have been supplied on either platform).
1017        let base = primary.rsplit(['/', '\\']).next().unwrap_or(primary);
1018
1019        // Strip a single trailing extension, if present. A leading-dot name
1020        // (e.g. `.adoc`) is treated as having no extension and is kept whole as
1021        // the stem, matching Ruby's `File.basename(".adoc", ".*")`.
1022        let stem = match base.rfind('.') {
1023            Some(0) | None => base,
1024            Some(idx) => &base[..idx],
1025        };
1026
1027        if stem.is_empty() {
1028            None
1029        } else {
1030            Some(stem.to_string())
1031        }
1032    }
1033
1034    /// Called from [`Header::parse()`] to accept or reject an attribute value.
1035    ///
1036    /// [`Header::parse()`]: crate::document::Header::parse
1037    pub(crate) fn set_attribute_from_header<'src>(
1038        &mut self,
1039        attr: &Attribute<'src>,
1040        warnings: &mut Vec<Warning<'src>>,
1041    ) {
1042        let attr_name = remap_attr_name(attr.name().data());
1043
1044        let existing_attr = self.attribute_values.get(&attr_name);
1045
1046        // Verify that we have permission to overwrite any existing attribute value.
1047        if let Some(existing_attr) = existing_attr
1048            && (existing_attr.modification_context == ModificationContext::ApiOnly
1049                || existing_attr.modification_context == ModificationContext::ApiOrDocumentBody)
1050        {
1051            // A silently-locked intrinsic rejects the write without recording a
1052            // warning (see `AttributeValue::silent_when_locked`).
1053            if !existing_attr.silent_when_locked {
1054                warnings.push(Warning {
1055                    source: attr.span(),
1056                    warning: WarningType::AttributeValueIsLocked(attr_name),
1057                });
1058            }
1059            return;
1060        }
1061
1062        let mut value = attr.value().clone();
1063
1064        if let InterpretedValue::Set = value
1065            && let Some(default_value) = self.default_attribute_values.get(&attr_name)
1066        {
1067            value = InterpretedValue::Value(default_value.clone());
1068        }
1069
1070        let attribute_value = AttributeValue {
1071            allowable_value: AllowableValue::Any,
1072            modification_context: ModificationContext::Anywhere,
1073            silent_when_locked: false,
1074            value,
1075        };
1076
1077        // An explicit assignment supersedes (and resets) any counter of the same
1078        // name.
1079        self.counter_values.borrow_mut().remove(&attr_name);
1080
1081        let is_doctype = attr_name == "doctype";
1082        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
1083        if is_doctype {
1084            self.refresh_doctype_derived_attr();
1085        }
1086    }
1087
1088    /// Called from [`Header::parse()`] for a value that is derived from parsing
1089    /// the header (except for attribute lines).
1090    ///
1091    /// [`Header::parse()`]: crate::document::Header::parse
1092    pub(crate) fn set_attribute_by_value_from_header<N: AsRef<str>, V: AsRef<str>>(
1093        &mut self,
1094        name: N,
1095        value: V,
1096    ) {
1097        let attr_name = remap_attr_name(name);
1098
1099        let attribute_value = AttributeValue {
1100            allowable_value: AllowableValue::Any,
1101            modification_context: ModificationContext::Anywhere,
1102            silent_when_locked: false,
1103            value: InterpretedValue::Value(value.as_ref().to_owned()),
1104        };
1105
1106        self.counter_values.borrow_mut().remove(&attr_name);
1107        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
1108    }
1109
1110    /// Applies the `imagesdir`-relative default for the `iconsdir` attribute.
1111    ///
1112    /// The `iconsdir` attribute defaults to `{imagesdir}/icons`; when
1113    /// `imagesdir` is left empty this resolves to the built-in
1114    /// [`DEFAULT_ICONSDIR`] (`./images/icons`). When `imagesdir` is set to a
1115    /// non-empty value and `iconsdir` was left at its built-in default, the
1116    /// icons directory is derived as `{imagesdir}/icons`.
1117    ///
1118    /// The derivation is skipped — so an explicit `iconsdir` wins — when either
1119    /// the attribute was set in the header (`iconsdir_set_in_header`) or its
1120    /// resolved value differs from [`DEFAULT_ICONSDIR`] (which is how an
1121    /// override applied any other way, e.g. via the API, is detected). The one
1122    /// case this cannot detect is a non-header override whose value happens to
1123    /// equal the built-in default (e.g. an API caller setting `iconsdir` to
1124    /// exactly `./images/icons`): it is indistinguishable from the default and
1125    /// so is re-derived. That combination is contradictory in practice (it
1126    /// pins `iconsdir` to the value it would take were `imagesdir` unset) and
1127    /// is not worth a dedicated provenance flag.
1128    ///
1129    /// This is called once, after the document header is parsed, mirroring
1130    /// Asciidoctor's document-initialization timing (a later `imagesdir` change
1131    /// in the document body does not retroactively re-derive `iconsdir`). See
1132    /// icons-image.adoc.
1133    ///
1134    /// [`DEFAULT_ICONSDIR`]: super::built_in_attrs::DEFAULT_ICONSDIR
1135    pub(crate) fn apply_iconsdir_default(&mut self, iconsdir_set_in_header: bool) {
1136        if iconsdir_set_in_header {
1137            return;
1138        }
1139
1140        // Preserve any override whose value differs from the built-in default
1141        // (e.g. one applied via the API); only the built-in default itself is
1142        // eligible for `imagesdir`-relative derivation. See the doc comment for
1143        // the one indistinguishable corner case.
1144        if self.attribute_value("iconsdir").as_maybe_str()
1145            != Some(super::built_in_attrs::DEFAULT_ICONSDIR)
1146        {
1147            return;
1148        }
1149
1150        let imagesdir = self.attribute_value("imagesdir");
1151        let derived = match imagesdir.as_maybe_str().filter(|d| !d.is_empty()) {
1152            Some(dir) => format!("{}/icons", dir.trim_end_matches('/')),
1153            None => return,
1154        };
1155
1156        self.set_attribute_by_value_from_header("iconsdir", derived);
1157    }
1158
1159    /// Called while parsing a block (see [`Block::parse_with_outcome()`]) to
1160    /// accept or reject an attribute value from a document (body) attribute.
1161    ///
1162    /// [`Block::parse_with_outcome()`]: crate::blocks::Block::parse_with_outcome
1163    pub(crate) fn set_attribute_from_body<'src>(
1164        &mut self,
1165        attr: &Attribute<'src>,
1166        warnings: &mut Vec<Warning<'src>>,
1167    ) {
1168        let attr_name = remap_attr_name(attr.name().data());
1169
1170        // An attribute inherited from the parent document of an AsciiDoc table
1171        // cell is locked for the duration of that cell: a body assignment to it
1172        // is silently ignored (no warning), matching Asciidoctor.
1173        if self.locked_attribute_names.contains(&attr_name) {
1174            return;
1175        }
1176
1177        // Verify that we have permission to overwrite any existing attribute value.
1178        if let Some(existing_attr) = self.attribute_values.get(&attr_name)
1179            && (existing_attr.modification_context != ModificationContext::Anywhere
1180                && existing_attr.modification_context != ModificationContext::ApiOrDocumentBody)
1181        {
1182            // A silently-locked intrinsic rejects the write without recording a
1183            // warning (see `AttributeValue::silent_when_locked`).
1184            if !existing_attr.silent_when_locked {
1185                warnings.push(Warning {
1186                    source: attr.span(),
1187                    warning: WarningType::AttributeValueIsLocked(attr_name),
1188                });
1189            }
1190            return;
1191        }
1192
1193        let attribute_value = AttributeValue {
1194            allowable_value: AllowableValue::Any,
1195            modification_context: ModificationContext::Anywhere,
1196            silent_when_locked: false,
1197            value: attr.value().clone(),
1198        };
1199
1200        // An explicit assignment supersedes (and resets) any counter of the same
1201        // name. This is what lets `:!name:` reset a counter.
1202        self.counter_values.borrow_mut().remove(&attr_name);
1203
1204        let is_doctype = attr_name == "doctype";
1205        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
1206        if is_doctype {
1207            self.refresh_doctype_derived_attr();
1208        }
1209    }
1210
1211    /// Assign the next section number for a given level.
1212    pub(crate) fn assign_section_number(&mut self, level: usize) -> SectionNumber {
1213        match self.topmost_section_type {
1214            SectionType::Normal => {
1215                self.last_section_number.assign_next_number(level);
1216                self.last_section_number.clone()
1217            }
1218            SectionType::Appendix => {
1219                self.last_appendix_section_number.assign_next_number(level);
1220                self.last_appendix_section_number.clone()
1221            }
1222            SectionType::Discrete => {
1223                // Shouldn't happen, but ignore if it does.
1224                self.last_section_number.clone()
1225            }
1226        }
1227    }
1228
1229    /// Resolves a [counter] of the given `name`, advancing it to the next value
1230    /// in its sequence and returning that value.
1231    ///
1232    /// A counter is a specialized document attribute: its value is stored as
1233    /// (and read back from) the attribute of the same name, so a later
1234    /// `{name}` reference shows the current value and an attribute assignment
1235    /// such as `:!name:` resets it. Each resolution advances the counter:
1236    ///
1237    /// * an integer value is incremented (`1` -> `2`);
1238    /// * any other value is advanced like Ruby's `String#succ` (`a` -> `b`, `z`
1239    ///   -> `aa`, `Az` -> `Ba`), matching Asciidoctor.
1240    ///
1241    /// `seed` (from the `{counter:name:seed}` form) supplies the first value,
1242    /// but only when the counter is currently unset; otherwise it is ignored.
1243    /// With no seed the sequence starts at `1`.
1244    ///
1245    /// This mirrors Asciidoctor's `Document#counter`.
1246    ///
1247    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
1248    pub(crate) fn counter(&self, name: &str, seed: Option<&str>) -> String {
1249        let next = match self.attribute_value(name) {
1250            InterpretedValue::Value(current) if !current.is_empty() => next_counter_value(&current),
1251            _ => match seed {
1252                Some(seed) if !seed.is_empty() => seed.to_string(),
1253                _ => "1".to_string(),
1254            },
1255        };
1256
1257        self.counter_values
1258            .borrow_mut()
1259            .insert(name.to_string(), next.clone());
1260
1261        next
1262    }
1263}
1264
1265/// Advances a counter value to the next value in its sequence, mirroring
1266/// Asciidoctor's `Helpers.nextval`.
1267///
1268/// A canonical integer string (one that round-trips through integer parsing,
1269/// e.g. `7` but not `07` or `+7`) is incremented numerically. Anything else is
1270/// advanced with [`string_succ`].
1271fn next_counter_value(current: &str) -> String {
1272    if let Ok(n) = current.parse::<i64>()
1273        && n.to_string() == current
1274    {
1275        // `saturating_add` keeps a counter that has somehow reached `i64::MAX`
1276        // pinned there rather than panicking (debug) or wrapping (release).
1277        return n.saturating_add(1).to_string();
1278    }
1279
1280    string_succ(current)
1281}
1282
1283/// Returns the successor of a string, mirroring Ruby's `String#succ` for the
1284/// ASCII cases that AsciiDoc counters can produce.
1285///
1286/// The right-most alphanumeric character is incremented within its own class
1287/// (digits, lowercase letters, uppercase letters), carrying leftward on
1288/// wrap-around (`9` -> `0`, `z` -> `a`, `Z` -> `A`) and prepending a fresh
1289/// leading character (`1`, `a`, or `A`) when the carry runs off the front
1290/// (`z` -> `aa`, `Zz` -> `AAa`). A string with no alphanumeric characters has
1291/// the code point of its last character incremented.
1292fn string_succ(current: &str) -> String {
1293    let chars: Vec<char> = current.chars().collect();
1294
1295    // Without an alphanumeric to carry through, Ruby increments the code point
1296    // of the final character.
1297    if !chars.iter().any(char::is_ascii_alphanumeric) {
1298        let mut chars = chars;
1299        if let Some(last) = chars.last_mut() {
1300            *last = char::from_u32(*last as u32 + 1).unwrap_or(*last);
1301        }
1302        return chars.into_iter().collect();
1303    }
1304
1305    // Walk right to left. `carrying` stays true while we are still looking for
1306    // (or carrying through) the alphanumeric run: trailing non-alphanumeric
1307    // characters are passed over unchanged, then the right-most alphanumeric is
1308    // incremented within its class and any wrap-around carries leftward to the
1309    // next alphanumeric. When the carry runs off the front, a fresh leading
1310    // character of the same class is prepended (`z` -> `aa`, `9` -> `10`).
1311    let mut out_rev: Vec<char> = Vec::with_capacity(chars.len() + 1);
1312    let mut carrying = true;
1313    let mut lead = '1';
1314
1315    for &c in chars.iter().rev() {
1316        if carrying && c.is_ascii_alphanumeric() {
1317            // Increment within the character's class, carrying on wrap-around.
1318            // The arms are exhaustive over ASCII alphanumerics, so the catch-all
1319            // can only be `Z` (the one value not matched above).
1320            let (next, carry) = match c {
1321                '0'..='8' | 'a'..='y' | 'A'..='Y' => ((c as u8 + 1) as char, false),
1322                '9' => ('0', true),
1323                'z' => ('a', true),
1324                _ => ('A', true),
1325            };
1326            out_rev.push(next);
1327            carrying = carry;
1328            // On a carry, remember the class of leading character to prepend if
1329            // the carry runs off the front; `next` is `0`, `a`, or `A` here.
1330            lead = match next {
1331                '0' => '1',
1332                'a' => 'a',
1333                _ => 'A',
1334            };
1335        } else {
1336            // Either the carry is spent, or this is a trailing non-alphanumeric
1337            // we pass over while still searching for the run to increment.
1338            out_rev.push(c);
1339        }
1340    }
1341
1342    if carrying {
1343        out_rev.push(lead);
1344    }
1345
1346    out_rev.into_iter().rev().collect()
1347}
1348
1349fn remap_attr_name<N: AsRef<str>>(raw_attr_name: N) -> String {
1350    let attr_name = raw_attr_name.as_ref().to_lowercase();
1351
1352    // Some attribute names have aliases. Remap to the primary name.
1353    match attr_name.as_str() {
1354        "hardbreaks" => "hardbreaks-option".to_string(),
1355        _ => attr_name,
1356    }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361    #![allow(clippy::panic)]
1362    #![allow(clippy::unwrap_used)]
1363
1364    use crate::{
1365        attributes::Attrlist,
1366        blocks::Block,
1367        parser::{
1368            CharacterReplacementType, IconRenderParams, ImageRenderParams,
1369            InlineSubstitutionRenderer, LinkRenderParams, QuoteScope, QuoteType, SpecialCharacter,
1370        },
1371        tests::prelude::*,
1372    };
1373
1374    #[test]
1375    fn default_is_unset() {
1376        let p = Parser::default();
1377        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
1378    }
1379
1380    #[test]
1381    fn creates_catalog_if_needed() {
1382        let mut p = Parser::default();
1383        let doc = p.parse("= Hello, World!\n\n== First Section Title");
1384        let cat = doc.catalog();
1385        assert!(cat.refs.contains_key("_first_section_title"));
1386
1387        let doc = p.parse("= Hello, World!\n\n== Second Section Title");
1388        let cat = doc.catalog();
1389        assert!(!cat.refs.contains_key("_first_section_title"));
1390        assert!(cat.refs.contains_key("_second_section_title"));
1391    }
1392
1393    #[test]
1394    fn with_intrinsic_attribute() {
1395        let p =
1396            Parser::default().with_intrinsic_attribute("foo", "bar", ModificationContext::Anywhere);
1397
1398        assert_eq!(p.attribute_value("foo"), InterpretedValue::Value("bar"));
1399        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1400
1401        assert!(p.is_attribute_set("foo"));
1402        assert!(!p.is_attribute_set("foo2"));
1403        assert!(!p.is_attribute_set("xyz"));
1404    }
1405
1406    #[test]
1407    fn with_intrinsic_attribute_set() {
1408        let p = Parser::default().with_intrinsic_attribute_bool(
1409            "foo",
1410            true,
1411            ModificationContext::Anywhere,
1412        );
1413
1414        assert_eq!(p.attribute_value("foo"), InterpretedValue::Set);
1415        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1416
1417        assert!(p.is_attribute_set("foo"));
1418        assert!(!p.is_attribute_set("foo2"));
1419        assert!(!p.is_attribute_set("xyz"));
1420    }
1421
1422    #[test]
1423    fn with_intrinsic_attribute_unset() {
1424        let p = Parser::default().with_intrinsic_attribute_bool(
1425            "foo",
1426            false,
1427            ModificationContext::Anywhere,
1428        );
1429
1430        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
1431        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1432
1433        assert!(!p.is_attribute_set("foo"));
1434        assert!(!p.is_attribute_set("foo2"));
1435        assert!(!p.is_attribute_set("xyz"));
1436    }
1437
1438    #[test]
1439    fn can_not_override_locked_default_value() {
1440        let mut parser = Parser::default();
1441
1442        let doc = parser.parse(":sp: not a space!");
1443
1444        assert_eq!(
1445            doc.warnings().next().unwrap().warning,
1446            WarningType::AttributeValueIsLocked("sp".to_owned())
1447        );
1448
1449        assert_eq!(parser.attribute_value("sp"), InterpretedValue::Value(" "));
1450    }
1451
1452    #[test]
1453    fn silently_locked_intrinsic_rejects_header_and_body_without_warning() {
1454        // A silently-locked `ApiOnly` intrinsic (as a converter would seed a
1455        // safe-mode-restricted attribute) rejects both a header assignment and a
1456        // body assignment of the same name, leaving the value unchanged and
1457        // recording no warning.
1458        let mut parser = Parser::default().with_intrinsic_attribute_silent(
1459            "backend",
1460            "html5",
1461            ModificationContext::ApiOnly,
1462        );
1463
1464        let doc = parser.parse(concat!(
1465            "= Title\n",
1466            ":backend: docbook5\n",
1467            "\n",
1468            "Body paragraph.\n",
1469            "\n",
1470            ":backend: manpage\n",
1471        ));
1472
1473        assert_eq!(doc.warnings().count(), 0);
1474        assert_eq!(
1475            parser.attribute_value("backend"),
1476            InterpretedValue::Value("html5")
1477        );
1478    }
1479
1480    #[test]
1481    fn silently_locked_bool_intrinsic_rejects_without_warning() {
1482        let mut parser = Parser::default().with_intrinsic_attribute_bool_silent(
1483            "sectids",
1484            true,
1485            ModificationContext::ApiOnly,
1486        );
1487
1488        let doc = parser.parse(concat!("= Title\n", ":!sectids:\n"));
1489
1490        assert_eq!(doc.warnings().count(), 0);
1491        assert_eq!(parser.attribute_value("sectids"), InterpretedValue::Set);
1492    }
1493
1494    #[test]
1495    fn normally_locked_intrinsic_still_warns() {
1496        // Regression: a non-silent `ApiOnly` intrinsic still records
1497        // `AttributeValueIsLocked` when the document tries to reassign it.
1498        let mut parser = Parser::default().with_intrinsic_attribute(
1499            "backend",
1500            "html5",
1501            ModificationContext::ApiOnly,
1502        );
1503
1504        let doc = parser.parse(concat!("= Title\n", ":backend: docbook5\n"));
1505
1506        assert_eq!(
1507            doc.warnings().next().unwrap().warning,
1508            WarningType::AttributeValueIsLocked("backend".to_owned())
1509        );
1510        assert_eq!(
1511            parser.attribute_value("backend"),
1512            InterpretedValue::Value("html5")
1513        );
1514    }
1515
1516    #[test]
1517    fn catalog_transferred_to_document() {
1518        let mut parser = Parser::default();
1519        let doc = parser.parse("= Test Document\n\nSome content");
1520
1521        let catalog = doc.catalog();
1522        assert!(catalog.is_empty());
1523
1524        // The catalog was transferred to the document, leaving the parser with
1525        // an empty catalog.
1526        assert!(parser.catalog.borrow().is_empty());
1527    }
1528
1529    #[test]
1530    fn block_ids_registered_in_catalog() {
1531        let mut parser = Parser::default();
1532        let doc = parser.parse("= Test Document\n\n[#my-block]\nSome content with an ID");
1533
1534        let catalog = doc.catalog();
1535        assert!(!catalog.is_empty());
1536        assert!(catalog.contains_id("my-block"));
1537
1538        let entry = catalog.get_ref("my-block").unwrap();
1539        assert_eq!(entry.id, "my-block");
1540        assert_eq!(entry.ref_type, crate::document::RefType::Anchor);
1541    }
1542
1543    /// A simple test renderer that modifies special characters differently
1544    /// from the default HTML renderer.
1545    #[derive(Debug)]
1546    struct TestRenderer;
1547
1548    impl InlineSubstitutionRenderer for TestRenderer {
1549        fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
1550            // Custom rendering: wrap special characters in brackets.
1551            match type_ {
1552                SpecialCharacter::Lt => dest.push_str("[LT]"),
1553                SpecialCharacter::Gt => dest.push_str("[GT]"),
1554                SpecialCharacter::Ampersand => dest.push_str("[AMP]"),
1555            }
1556        }
1557
1558        fn render_quoted_substitition(
1559            &self,
1560            _type_: QuoteType,
1561            _scope: QuoteScope,
1562            _attrlist: Option<Attrlist<'_>>,
1563            _id: Option<String>,
1564            body: &str,
1565            dest: &mut String,
1566        ) {
1567            dest.push_str(body);
1568        }
1569
1570        fn render_character_replacement(
1571            &self,
1572            _type_: CharacterReplacementType,
1573            dest: &mut String,
1574        ) {
1575            dest.push_str("[CHAR]");
1576        }
1577
1578        fn render_line_break(&self, dest: &mut String) {
1579            dest.push_str("[BR]");
1580        }
1581
1582        fn render_image(&self, _params: &ImageRenderParams, dest: &mut String) {
1583            dest.push_str("[IMAGE]");
1584        }
1585
1586        fn image_uri(
1587            &self,
1588            target_image_path: &str,
1589            _parser: &Parser,
1590            _asset_dir_key: Option<&str>,
1591        ) -> String {
1592            target_image_path.to_string()
1593        }
1594
1595        fn render_icon(&self, _params: &IconRenderParams, dest: &mut String) {
1596            dest.push_str("[ICON]");
1597        }
1598
1599        fn render_link(&self, _params: &LinkRenderParams, dest: &mut String) {
1600            dest.push_str("[LINK]");
1601        }
1602
1603        fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
1604            dest.push_str(&format!("[ANCHOR:{}]", id));
1605        }
1606
1607        fn render_xref(&self, params: &crate::parser::XrefRenderParams, dest: &mut String) {
1608            dest.push_str(&format!("[XREF:{}]", params.target));
1609        }
1610
1611        fn render_callout(&self, params: &crate::parser::CalloutRenderParams, dest: &mut String) {
1612            dest.push_str(&format!("[CALLOUT:{}]", params.number));
1613        }
1614
1615        fn render_index_term(
1616            &self,
1617            params: &crate::parser::IndexTermRenderParams,
1618            dest: &mut String,
1619        ) {
1620            match params.visible_term {
1621                Some(term) => dest.push_str(&format!("[INDEXTERM:{term}]")),
1622                None => dest.push_str("[INDEXTERM]"),
1623            }
1624        }
1625
1626        fn render_button(&self, text: &str, dest: &mut String) {
1627            dest.push_str(&format!("[BUTTON:{text}]"));
1628        }
1629
1630        fn render_keyboard(&self, keys: &[String], dest: &mut String) {
1631            dest.push_str(&format!("[KBD:{}]", keys.join("+")));
1632        }
1633
1634        fn render_menu(&self, params: &crate::parser::MenuRenderParams, dest: &mut String) {
1635            dest.push_str(&format!("[MENU:{}]", params.menu));
1636        }
1637
1638        fn render_footnote(&self, params: &crate::parser::FootnoteRenderParams, dest: &mut String) {
1639            match params.index {
1640                Some(index) => dest.push_str(&format!("[FOOTNOTE:{index}]")),
1641                None => dest.push_str(&format!("[FOOTNOTE:{}]", params.text)),
1642            }
1643        }
1644    }
1645
1646    #[test]
1647    fn with_inline_substitution_renderer() {
1648        let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
1649
1650        // Parse a simple document with special characters and a footnote.
1651        let doc = parser.parse("Hello & goodbye < world > test footnote:[a note]");
1652
1653        // The document should parse successfully.
1654        assert_eq!(doc.warnings().count(), 0);
1655
1656        // Get the first block from the document.
1657        let block = doc.nested_blocks().next().unwrap();
1658
1659        let Block::Simple(simple_block) = block else {
1660            panic!("Expected simple block, got: {block:?}");
1661        };
1662
1663        // Our custom renderer should show [AMP], [LT], and [GT] instead of HTML
1664        // entities, and a resolved footnote as [FOOTNOTE:<index>].
1665        assert_eq!(
1666            simple_block.content().rendered(),
1667            "Hello [AMP] goodbye [LT] world [GT] test [FOOTNOTE:1]"
1668        );
1669    }
1670
1671    #[test]
1672    fn custom_renderer_renders_unresolved_footnote() {
1673        let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
1674
1675        // An unresolved footnote reference exercises the renderer's `None`
1676        // (no index) branch, which our custom renderer shows as
1677        // [FOOTNOTE:<text>].
1678        let doc = parser.parse("test.footnote:missing[]");
1679
1680        let block = doc.nested_blocks().next().unwrap();
1681        let Block::Simple(simple_block) = block else {
1682            panic!("Expected simple block, got: {block:?}");
1683        };
1684
1685        assert_eq!(simple_block.content().rendered(), "test.[FOOTNOTE:missing]");
1686    }
1687
1688    mod resolve_show_title {
1689        use crate::parser::{ModificationContext, Parser};
1690
1691        fn with(name: &str, set: bool) -> Parser {
1692            Parser::default().with_intrinsic_attribute_bool(
1693                name,
1694                set,
1695                ModificationContext::Anywhere,
1696            )
1697        }
1698
1699        #[test]
1700        fn neither_present_uses_default() {
1701            assert!(Parser::default().resolve_show_title(true));
1702            assert!(!Parser::default().resolve_show_title(false));
1703        }
1704
1705        #[test]
1706        fn showtitle_takes_precedence_and_decides() {
1707            // Present and set -> shown; present and unset -> hidden, regardless
1708            // of the default.
1709            assert!(with("showtitle", true).resolve_show_title(false));
1710            assert!(!with("showtitle", false).resolve_show_title(true));
1711        }
1712
1713        #[test]
1714        fn notitle_is_the_complement_when_showtitle_absent() {
1715            // notitle set -> hidden; notitle unset -> shown.
1716            assert!(!with("notitle", true).resolve_show_title(true));
1717            assert!(with("notitle", false).resolve_show_title(false));
1718        }
1719    }
1720
1721    mod refresh_doctype_derived_attr {
1722        use crate::{document::InterpretedValue, parser::Parser};
1723
1724        #[test]
1725        fn tracks_the_active_doctype() {
1726            let mut parser = Parser::default();
1727
1728            // The default doctype is `article`, so only its derived attribute is
1729            // defined (to an empty value).
1730            assert_eq!(
1731                parser.attribute_value("backend-html5-doctype-article"),
1732                InterpretedValue::Value(String::new())
1733            );
1734            assert_eq!(
1735                parser.attribute_value("backend-html5-doctype-book"),
1736                InterpretedValue::Unset
1737            );
1738
1739            // Forcing a new doctype moves the derived attribute with it.
1740            parser.force_doctype("book");
1741            assert_eq!(
1742                parser.attribute_value("backend-html5-doctype-book"),
1743                InterpretedValue::Value(String::new())
1744            );
1745            assert_eq!(
1746                parser.attribute_value("backend-html5-doctype-article"),
1747                InterpretedValue::Unset
1748            );
1749        }
1750
1751        #[test]
1752        fn defines_no_derived_attr_when_doctype_is_not_a_value() {
1753            let mut parser = Parser::default();
1754
1755            // The default article derived attribute starts out defined.
1756            assert_eq!(
1757                parser.attribute_value("backend-html5-doctype-article"),
1758                InterpretedValue::Value(String::new())
1759            );
1760
1761            // With `doctype` unset (no `Value`), a refresh clears any existing
1762            // derived attribute and defines none.
1763            std::sync::Arc::make_mut(&mut parser.attribute_values).remove("doctype");
1764            parser.refresh_doctype_derived_attr();
1765
1766            assert_eq!(parser.attribute_value("doctype"), InterpretedValue::Unset);
1767            assert_eq!(
1768                parser.attribute_value("backend-html5-doctype-article"),
1769                InterpretedValue::Unset
1770            );
1771        }
1772    }
1773
1774    mod docname {
1775        use crate::Parser;
1776
1777        #[test]
1778        fn none_without_primary_file_name() {
1779            assert_eq!(Parser::default().docname(), None);
1780        }
1781
1782        #[test]
1783        fn strips_directory_and_extension() {
1784            assert_eq!(
1785                Parser::default()
1786                    .with_primary_file_name("mydoc.adoc")
1787                    .docname()
1788                    .as_deref(),
1789                Some("mydoc")
1790            );
1791            assert_eq!(
1792                Parser::default()
1793                    .with_primary_file_name("docs/guide/mydoc.adoc")
1794                    .docname()
1795                    .as_deref(),
1796                Some("mydoc")
1797            );
1798            // A Windows-style separator is handled too, since the primary file
1799            // name may be supplied on either platform.
1800            assert_eq!(
1801                Parser::default()
1802                    .with_primary_file_name(r"docs\guide\mydoc.adoc")
1803                    .docname()
1804                    .as_deref(),
1805                Some("mydoc")
1806            );
1807        }
1808
1809        #[test]
1810        fn keeps_name_with_no_extension() {
1811            assert_eq!(
1812                Parser::default()
1813                    .with_primary_file_name("README")
1814                    .docname()
1815                    .as_deref(),
1816                Some("README")
1817            );
1818        }
1819
1820        #[test]
1821        fn none_when_path_has_no_file_component() {
1822            // A primary file name that ends in a separator has an empty base
1823            // name, which yields no document name.
1824            assert_eq!(
1825                Parser::default()
1826                    .with_primary_file_name("docs/guide/")
1827                    .docname(),
1828                None
1829            );
1830        }
1831
1832        #[test]
1833        fn leading_dot_name_is_kept_whole() {
1834            // A leading-dot name (e.g. `.adoc`) is treated as a dotfile with no
1835            // extension and kept whole, matching Ruby's
1836            // `File.basename(".adoc", ".*")`.
1837            assert_eq!(
1838                Parser::default()
1839                    .with_primary_file_name(".adoc")
1840                    .docname()
1841                    .as_deref(),
1842                Some(".adoc")
1843            );
1844        }
1845    }
1846
1847    mod counter {
1848        use super::super::next_counter_value;
1849        use crate::{document::InterpretedValue, tests::prelude::*};
1850
1851        #[test]
1852        fn next_counter_value_integer() {
1853            assert_eq!(next_counter_value("1"), "2");
1854            assert_eq!(next_counter_value("9"), "10");
1855            assert_eq!(next_counter_value("0"), "1");
1856            assert_eq!(next_counter_value("-1"), "0");
1857        }
1858
1859        #[test]
1860        fn next_counter_value_non_canonical_integer_is_advanced_as_a_string() {
1861            // A leading zero (or sign) does not round-trip through integer
1862            // parsing, so it is advanced like a string instead.
1863            assert_eq!(next_counter_value("07"), "08");
1864            assert_eq!(next_counter_value("+5"), "+6");
1865            // A leading-zero value still carries digit-to-digit like a string.
1866            assert_eq!(next_counter_value("09"), "10");
1867            assert_eq!(next_counter_value("099"), "100");
1868        }
1869
1870        #[test]
1871        fn next_counter_value_saturates_at_i64_max() {
1872            // A counter pinned at `i64::MAX` stays there rather than panicking
1873            // (debug) or wrapping (release).
1874            let max = i64::MAX.to_string();
1875            assert_eq!(next_counter_value(&max), max);
1876        }
1877
1878        #[test]
1879        fn next_counter_value_characters() {
1880            assert_eq!(next_counter_value("a"), "b");
1881            assert_eq!(next_counter_value("A"), "B");
1882            assert_eq!(next_counter_value("z"), "aa");
1883            assert_eq!(next_counter_value("Z"), "AA");
1884            assert_eq!(next_counter_value("az"), "ba");
1885            assert_eq!(next_counter_value("zz"), "aaa");
1886            assert_eq!(next_counter_value("Zz"), "AAa");
1887        }
1888
1889        #[test]
1890        fn next_counter_value_trailing_non_alphanumeric() {
1891            // The right-most alphanumeric is incremented; trailing punctuation is
1892            // left in place.
1893            assert_eq!(next_counter_value("a)"), "b)");
1894        }
1895
1896        #[test]
1897        fn next_counter_value_no_alphanumeric() {
1898            // With nothing alphanumeric to carry, the final code point advances.
1899            assert_eq!(next_counter_value("{"), "|");
1900        }
1901
1902        #[test]
1903        fn counter_defaults_to_one() {
1904            let p = Parser::default();
1905            assert_eq!(p.counter("x", None), "1");
1906            assert_eq!(p.counter("x", None), "2");
1907            assert_eq!(
1908                p.attribute_value("x"),
1909                InterpretedValue::Value("2".to_string())
1910            );
1911            assert!(p.has_attribute("x"));
1912            assert!(p.is_attribute_set("x"));
1913        }
1914
1915        #[test]
1916        fn counter_seed_used_only_while_unset() {
1917            let p = Parser::default();
1918            assert_eq!(p.counter("c", Some("A")), "A");
1919            // Once set, a later seed is ignored.
1920            assert_eq!(p.counter("c", Some("Q")), "B");
1921        }
1922
1923        #[test]
1924        fn counter_empty_seed_falls_back_to_one() {
1925            let p = Parser::default();
1926            assert_eq!(p.counter("c", Some("")), "1");
1927        }
1928    }
1929}