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        built_in_attrs::{built_in_attrs, built_in_default_values},
16        preprocessor::preprocess,
17    },
18    warnings::{Warning, WarningType},
19};
20
21/// The [`Parser`] struct and its related structs allow a caller to configure
22/// how AsciiDoc parsing occurs and then to initiate the parsing process.
23#[derive(Clone, Debug)]
24pub struct Parser {
25    /// Attribute values at current state of parsing.
26    ///
27    /// Shared (copy-on-write via [`Arc`]) with the immutable built-in attribute
28    /// table, so creating or cloning a parser does not deep-copy the table; the
29    /// map is only copied the first time this parser modifies an attribute.
30    pub(crate) attribute_values: Arc<HashMap<String, AttributeValue>>,
31
32    /// Default values for attributes if "set." Immutable after construction and
33    /// shared via [`Arc`] (never copied per parser).
34    default_attribute_values: Arc<HashMap<String, String>>,
35
36    /// Specifies how the basic raw text of a simple block will be converted to
37    /// the format which will ultimately be presented in the final output.
38    ///
39    /// Typically this is an [`HtmlSubstitutionRenderer`] but clients may
40    /// provide alternative implementations.
41    pub(crate) renderer: Rc<dyn InlineSubstitutionRenderer>,
42
43    /// Specifies the name of the primary file to be parsed.
44    pub(crate) primary_file_name: Option<String>,
45
46    /// Specifies how to generate clean and secure paths relative to the parsing
47    /// context.
48    pub path_resolver: PathResolver,
49
50    /// Handler for resolving include:: directives.
51    pub(crate) include_file_handler: Option<Rc<dyn IncludeFileHandler>>,
52
53    /// Handler for resolving docinfo files. If absent, no docinfo content is
54    /// resolved.
55    pub(crate) docinfo_file_handler: Option<Rc<dyn DocinfoFileHandler>>,
56
57    /// Document catalog for tracking referenceable elements during parsing.
58    /// This is created during parsing and transferred to the Document when
59    /// complete.
60    ///
61    /// Wrapped in a [`RefCell`] so that anchors and references discovered deep
62    /// inside inline substitution (where only a shared `&Parser` is available,
63    /// e.g. within a regex [`Replacer`](regex::Replacer)) can still be
64    /// registered.
65    catalog: RefCell<Catalog>,
66
67    /// Most recently-assigned section number.
68    pub(crate) last_section_number: SectionNumber,
69
70    /// Most recently-assigned appendix section number.
71    pub(crate) last_appendix_section_number: SectionNumber,
72
73    /// Saved copy of sectnumlevels at end of document header.
74    pub(crate) sectnumlevels: usize,
75
76    /// Section type of outermost section. (Used to determine whether to number
77    /// child sections as a normal section or appendix.)
78    pub(crate) topmost_section_type: SectionType,
79
80    /// True while parsing the direct block children of a section that carries
81    /// the `bibliography` style.
82    ///
83    /// A top-level unordered list parsed in this scope implicitly inherits the
84    /// `bibliography` style (matching Asciidoctor), even without its own
85    /// `[bibliography]` attribute. The flag is saved and restored around each
86    /// section body, so a non-bibliography subsection clears it for its own
87    /// children (the style does not propagate into subsections).
88    pub(crate) parsing_bibliography_section_body: bool,
89
90    /// True while the principal text of a bibliography list item is being
91    /// substituted.
92    ///
93    /// Read through a shared `&Parser` by the macros substitution step so it
94    /// recognizes a leading bibliography anchor (`[[[id]]]`). It is wrapped in
95    /// a [`Cell`] because the substitution code paths (e.g. a regex
96    /// [`Replacer`](regex::Replacer)) only hold a shared reference to the
97    /// parser.
98    pub(crate) in_bibliography_list_item: Cell<bool>,
99
100    /// Live values of [counter] attributes, keyed by counter name (e.g.
101    /// `index`, `example-number`, `table-number`).
102    ///
103    /// A counter is a specialized document attribute: its value is *also* the
104    /// value of the document attribute of the same name. Counters are resolved
105    /// (and advanced) deep inside the attribute-reference substitution step,
106    /// where only a shared `&Parser` is available, so the new value is recorded
107    /// here through a [`RefCell`] and read back as an attribute by
108    /// [`attribute_value()`]. An explicit attribute assignment to a counter's
109    /// name supersedes this overlay (and is what allows `:!name:` to reset a
110    /// counter), so every attribute setter clears the matching entry.
111    ///
112    /// Captioned blocks (example, table, …) are numbered with this same
113    /// mechanism: each context's caption number is the counter named
114    /// `<context>-number`, mirroring Asciidoctor's `Document#counter`.
115    ///
116    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
117    /// [`attribute_value()`]: Self::attribute_value
118    pub(crate) counter_values: RefCell<HashMap<String, String>>,
119
120    /// Canonical names of attributes that are locked against modification from
121    /// the document body for the current scope.
122    ///
123    /// An AsciiDoc table cell creates a nested document that inherits the
124    /// parent document's attributes. An attribute that is *set* in the
125    /// parent _cannot_ be modified inside the cell (matching Asciidoctor,
126    /// which here diverges from the spec's "set or explicitly unset" wording),
127    /// so while a cell is being parsed every inherited attribute name
128    /// (other than a handful of exceptions) is recorded here and a body
129    /// attribute assignment to such a name is silently ignored. The set is
130    /// saved and restored around each cell, so the lock applies only within
131    /// the cell (and nests correctly).
132    pub(crate) locked_attribute_names: HashSet<String>,
133
134    /// Number of AsciiDoc table cells currently being parsed in the call stack.
135    ///
136    /// An AsciiDoc table cell creates a nested, standalone AsciiDoc document.
137    /// While that document is being parsed this counter is greater than zero,
138    /// which (matching Asciidoctor's `Document#nested?`) changes the default
139    /// cell separator of any table found inside from the vertical bar (`|`) to
140    /// the exclamation mark (`!`), so a nested table needs no explicit
141    /// `separator` attribute. The counter is incremented and decremented around
142    /// each AsciiDoc cell, so it nests correctly.
143    pub(crate) nested_document_depth: usize,
144
145    /// Catalog of callout numbers registered by verbatim blocks, used to
146    /// validate the callout lists that annotate them.
147    ///
148    /// Wrapped in a [`RefCell`] because callouts are registered deep inside the
149    /// callouts substitution step, where only a shared `&Parser` is available.
150    callouts: RefCell<CalloutCatalog>,
151
152    /// Warnings produced while replacing attribute references (e.g. a reference
153    /// to a missing attribute when `attribute-missing` is `warn`).
154    ///
155    /// Wrapped in a [`RefCell`] because attribute references are replaced deep
156    /// inside the attributes substitution step, where only a shared `&Parser`
157    /// is available. Each entry stores the byte offset and length of the source
158    /// span the warning refers to (rather than a borrowed
159    /// [`Span`](crate::Span), which the lifetime-free `Parser` cannot
160    /// hold), so the warnings can be turned into
161    /// spanned [`Warning`]s once the document's owned source is available.
162    substitution_warnings: RefCell<Vec<SubstitutionWarning>>,
163}
164
165/// A warning recorded while replacing attribute references, stored in a form
166/// that does not borrow the source so it can live on the [`Parser`].
167///
168/// The `offset`/`len` pair locates the relevant text within the (preprocessed)
169/// document source; [`Parser::take_substitution_warnings`] reconstitutes a
170/// spanned [`Warning`] from it.
171#[derive(Clone, Debug)]
172pub(crate) struct SubstitutionWarning {
173    /// Byte offset into the document source of the span this warning refers to.
174    pub(crate) offset: usize,
175
176    /// Byte length of the span this warning refers to.
177    pub(crate) len: usize,
178
179    /// The type of warning, already carrying any owned data it needs (such as
180    /// the missing attribute's name).
181    pub(crate) warning: WarningType,
182}
183
184/// Tracks the callout numbers defined by verbatim blocks so that a callout list
185/// can be validated against the callouts it annotates.
186///
187/// This mirrors the relevant behavior of Asciidoctor's `Callouts` catalog: each
188/// verbatim block registers the callout numbers it defines into the current
189/// list, and each callout list checks its items against that list (warning
190/// about any item with no matching callout) before the list is closed.
191#[derive(Clone, Debug, Default)]
192struct CalloutCatalog {
193    /// Callout numbers registered (in document order) since the last callout
194    /// list was closed.
195    current: Vec<u32>,
196}
197
198impl Default for Parser {
199    fn default() -> Self {
200        Self {
201            attribute_values: built_in_attrs(),
202            default_attribute_values: built_in_default_values(),
203            renderer: Rc::new(HtmlSubstitutionRenderer {}),
204            primary_file_name: None,
205            path_resolver: PathResolver::default(),
206            include_file_handler: None,
207            docinfo_file_handler: None,
208            catalog: RefCell::new(Catalog::new()),
209            last_section_number: SectionNumber::default(),
210            last_appendix_section_number: SectionNumber {
211                section_type: SectionType::Appendix,
212                components: vec![],
213            },
214            sectnumlevels: 3,
215            topmost_section_type: SectionType::Normal,
216            parsing_bibliography_section_body: false,
217            in_bibliography_list_item: Cell::new(false),
218            counter_values: RefCell::new(HashMap::new()),
219            locked_attribute_names: HashSet::new(),
220            nested_document_depth: 0,
221            callouts: RefCell::new(CalloutCatalog::default()),
222            substitution_warnings: RefCell::new(vec![]),
223        }
224    }
225}
226
227impl Parser {
228    /// Parse a UTF-8 string as an AsciiDoc document.
229    ///
230    /// The [`Document`] data structure returned by this call has a '`static`
231    /// lifetime; this is an implementation detail. It retains a copy of the
232    /// `source` string that was passed in, but it is not tied to the lifetime
233    /// of that string.
234    ///
235    /// Nearly all of the data structures contained within the [`Document`]
236    /// structure are tied to the lifetime of the document and have a `'src`
237    /// lifetime to signal their dependency on the source document.
238    ///
239    /// **IMPORTANT:** The AsciiDoc language documentation states that UTF-16
240    /// encoding is allowed if a byte-order-mark (BOM) is present at the
241    /// start of a file. This format is not directly supported by the
242    /// `asciidoc-parser` crate. Any UTF-16 content must be re-encoded as
243    /// UTF-8 prior to parsing.
244    ///
245    /// The `Parser` struct will be updated with document attribute values
246    /// discovered during parsing. These values may be inspected using
247    /// [`attribute_value()`].
248    ///
249    /// # Warnings, not errors
250    ///
251    /// Any UTF-8 string is a valid AsciiDoc document, so this function does not
252    /// return an [`Option`] or [`Result`] data type. There may be any number of
253    /// character sequences that have ambiguous or potentially unintended
254    /// meanings. For that reason, a caller is advised to review the warnings
255    /// provided via the [`warnings()`] iterator.
256    ///
257    /// [`warnings()`]: Document::warnings
258    /// [`attribute_value()`]: Self::attribute_value
259    pub fn parse(&mut self, source: &str) -> Document<'static> {
260        let mut document = self.parse_deferred(source);
261
262        // Resolve cross-references against this document's own catalog. For
263        // multi-document workflows, use `parse_deferred` and resolve later with
264        // a caller-supplied resolver via `Document::resolve_references`.
265        document.resolve_against_own_catalog(&*self.renderer);
266
267        document
268    }
269
270    /// Parse a UTF-8 string as an AsciiDoc document, leaving cross-references
271    /// unresolved.
272    ///
273    /// This behaves like [`parse()`], except it does not resolve
274    /// cross-references (`<<id>>`, `xref:id[…]`). The returned [`Document`]
275    /// carries its references in a deferred state; resolve them later with
276    /// [`Document::resolve_references`].
277    ///
278    /// This is the entry point for multi-document workflows (e.g. Antora-style
279    /// site generation): parse every document with this method, build a
280    /// combined index from each document's [`catalog()`], then resolve each
281    /// document against that index. This crate does not merge catalogs
282    /// itself.
283    ///
284    /// [`parse()`]: Self::parse
285    /// [`catalog()`]: Document::catalog
286    pub fn parse_deferred(&mut self, source: &str) -> Document<'static> {
287        let (preprocessed_source, source_map) = preprocess(source, self);
288
289        // NOTE: `Document::parse` will transfer the catalog to itself at the end of the
290        // parsing operation. Start each parse with a fresh catalog.
291        *self.catalog.borrow_mut() = Catalog::new();
292
293        // Start each parse with an empty callout catalog.
294        *self.callouts.borrow_mut() = CalloutCatalog::default();
295
296        // Start each parse with no pending substitution warnings.
297        self.substitution_warnings.borrow_mut().clear();
298
299        // Reset section numbering for each new document.
300        self.last_section_number = SectionNumber::default();
301
302        // Reset counter (and captioned-block) numbering for each new document.
303        self.counter_values.borrow_mut().clear();
304
305        Document::parse(&preprocessed_source, source_map, self)
306    }
307
308    /// Retrieves the current interpreted value of a [document attribute].
309    ///
310    /// Each document holds a set of name-value pairs called document
311    /// attributes. These attributes provide a means of configuring the AsciiDoc
312    /// processor, declaring document metadata, and defining reusable content.
313    /// This page introduces document attributes and answers some questions
314    /// about the terminology used when referring to them.
315    ///
316    /// ## What are document attributes?
317    ///
318    /// Document attributes are effectively document-scoped variables for the
319    /// AsciiDoc language. The AsciiDoc language defines a set of built-in
320    /// attributes, and also allows the author (or extensions) to define
321    /// additional document attributes, which may replace built-in attributes
322    /// when permitted.
323    ///
324    /// Built-in attributes either provide access to read-only information about
325    /// the document and its environment or allow the author to configure
326    /// behavior of the AsciiDoc processor for a whole document or select
327    /// regions. Built-in attributes are effectively unordered. User-defined
328    /// attribute serve as a powerful text replacement tool. User-defined
329    /// attributes are stored in the order in which they are defined.
330    ///
331    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
332    pub fn attribute_value<N: AsRef<str>>(&self, name: N) -> InterpretedValue {
333        // A counter's current value lives in the overlay and supersedes any
334        // earlier value of the attribute of the same name (see
335        // [`counter_values`](Self::counter_values)).
336        if let Some(value) = self.counter_values.borrow().get(name.as_ref()) {
337            return InterpretedValue::Value(value.clone());
338        }
339
340        self.attribute_values
341            .get(name.as_ref())
342            .map(|av| av.value.clone())
343            .map(|av| {
344                if let InterpretedValue::Set = av
345                    && let Some(default) = self.default_attribute_values.get(name.as_ref())
346                {
347                    InterpretedValue::Value(default.clone())
348                } else {
349                    av
350                }
351            })
352            .unwrap_or(InterpretedValue::Unset)
353    }
354
355    /// Returns `true` if the parser has a [document attribute] by this name.
356    ///
357    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
358    pub fn has_attribute<N: AsRef<str>>(&self, name: N) -> bool {
359        self.counter_values.borrow().contains_key(name.as_ref())
360            || self.attribute_values.contains_key(name.as_ref())
361    }
362
363    /// Returns `true` if the parser has a [document attribute] by this name
364    /// which has been set (i.e. is present and not [unset]).
365    ///
366    /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
367    /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
368    pub fn is_attribute_set<N: AsRef<str>>(&self, name: N) -> bool {
369        // A counter always holds a concrete (set) value.
370        if self.counter_values.borrow().contains_key(name.as_ref()) {
371            return true;
372        }
373
374        self.attribute_values
375            .get(name.as_ref())
376            .map(|a| a.value != InterpretedValue::Unset)
377            .unwrap_or(false)
378    }
379
380    /// Resolves whether a document title should be displayed, from the
381    /// `showtitle`/`notitle` attribute pair (which are complements).
382    ///
383    /// `showtitle` takes precedence: if present, the title shows precisely when
384    /// it is set. Otherwise `notitle`, if present, hides the title when set.
385    /// When neither attribute is present, `default_shown` decides — a
386    /// standalone document (such as a nested AsciiDoc table cell) shows its
387    /// title, while an embedded document does not.
388    pub(crate) fn resolve_show_title(&self, default_shown: bool) -> bool {
389        if self.has_attribute("showtitle") {
390            self.is_attribute_set("showtitle")
391        } else if self.has_attribute("notitle") {
392            !self.is_attribute_set("notitle")
393        } else {
394            default_shown
395        }
396    }
397
398    /// Forces the `doctype` attribute to `value`, refreshing the derived
399    /// `backend-html5-doctype-*` attribute.
400    ///
401    /// Used when a nested AsciiDoc table cell resets its doctype to the default
402    /// (a cell does not inherit the parent's doctype). The value stays
403    /// modifiable from the document body so the cell may still set its own
404    /// doctype.
405    pub(crate) fn force_doctype(&mut self, value: &str) {
406        Arc::make_mut(&mut self.attribute_values).insert(
407            "doctype".to_string(),
408            AttributeValue {
409                allowable_value: AllowableValue::Any,
410                modification_context: ModificationContext::ApiOrDocumentBody,
411                value: InterpretedValue::Value(value.to_string()),
412            },
413        );
414        self.refresh_doctype_derived_attr();
415    }
416
417    /// Recomputes the `backend-html5-doctype-{doctype}` intrinsic attribute so
418    /// exactly one exists — for the active doctype — resolving to an empty
419    /// (defined) value. References to any other doctype stay undefined and so
420    /// render literally.
421    pub(crate) fn refresh_doctype_derived_attr(&mut self) {
422        Arc::make_mut(&mut self.attribute_values)
423            .retain(|name, _| !name.starts_with("backend-html5-doctype-"));
424
425        if let InterpretedValue::Value(doctype) = self.attribute_value("doctype") {
426            Arc::make_mut(&mut self.attribute_values).insert(
427                format!("backend-html5-doctype-{doctype}"),
428                AttributeValue {
429                    allowable_value: AllowableValue::Any,
430                    modification_context: ModificationContext::Anywhere,
431                    value: InterpretedValue::Value(String::new()),
432                },
433            );
434        }
435    }
436
437    /// Sets the value of an [intrinsic attribute].
438    ///
439    /// Intrinsic attributes are set automatically by the processor. These
440    /// attributes provide information about the document being processed (e.g.,
441    /// `docfile`), the security mode under which the processor is running
442    /// (e.g., `safe-mode-name`), and information about the user’s environment
443    /// (e.g., `user-home`).
444    ///
445    /// The [`modification_context`](ModificationContext) establishes whether
446    /// the value can be subsequently modified by the document header and/or in
447    /// the document body.
448    ///
449    /// Subsequent calls to this function or [`with_intrinsic_attribute_bool()`]
450    /// are always permitted. The last such call for any given attribute name
451    /// takes precendence.
452    ///
453    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
454    ///
455    /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
456    pub fn with_intrinsic_attribute<N: AsRef<str>, V: AsRef<str>>(
457        mut self,
458        name: N,
459        value: V,
460        modification_context: ModificationContext,
461    ) -> Self {
462        let attribute_value = AttributeValue {
463            allowable_value: AllowableValue::Any,
464            modification_context,
465            value: InterpretedValue::Value(value.as_ref().to_string()),
466        };
467
468        Arc::make_mut(&mut self.attribute_values)
469            .insert(name.as_ref().to_lowercase(), attribute_value);
470
471        self
472    }
473
474    /// Register a referenceable element (anchor, section, bibliography entry)
475    /// in the document catalog.
476    ///
477    /// This takes `&self` (rather than `&mut self`) so that it can be called
478    /// from inline-substitution code paths that only hold a shared reference to
479    /// the parser, such as a regex [`Replacer`](regex::Replacer).
480    pub(crate) fn register_ref(
481        &self,
482        id: &str,
483        reftext: Option<&str>,
484        ref_type: RefType,
485    ) -> Result<(), crate::document::DuplicateIdError> {
486        self.catalog
487            .borrow_mut()
488            .register_ref(id, reftext, ref_type)
489    }
490
491    /// Registers a callout number defined by a verbatim block.
492    ///
493    /// Takes `&self` so it can be called from the callouts substitution step,
494    /// which only holds a shared reference to the parser.
495    pub(crate) fn register_callout(&self, number: u32) {
496        self.callouts.borrow_mut().current.push(number);
497    }
498
499    /// Returns `true` if a callout numbered `number` was registered for the
500    /// current (not-yet-closed) callout list.
501    pub(crate) fn callout_defined(&self, number: u32) -> bool {
502        self.callouts.borrow().current.contains(&number)
503    }
504
505    /// Closes the current callout list, so callouts registered afterward belong
506    /// to the next list.
507    pub(crate) fn close_callout_list(&self) {
508        self.callouts.borrow_mut().current.clear();
509    }
510
511    /// Records a warning produced while replacing attribute references.
512    ///
513    /// Takes `&self` so it can be called from the attributes substitution step,
514    /// which only holds a shared reference to the parser. `source` locates the
515    /// text the warning refers to; its byte offset and length are stored so a
516    /// spanned [`Warning`] can be reconstructed later (see
517    /// [`take_substitution_warnings`](Self::take_substitution_warnings)).
518    pub(crate) fn record_substitution_warning(
519        &self,
520        source: crate::Span<'_>,
521        warning: WarningType,
522    ) {
523        self.substitution_warnings
524            .borrow_mut()
525            .push(SubstitutionWarning {
526                offset: source.byte_offset(),
527                len: source.len(),
528                warning,
529            });
530    }
531
532    /// Returns the number of substitution warnings recorded so far.
533    ///
534    /// Used together with [`truncate_substitution_warnings`] to discard
535    /// warnings recorded while parsing an owned (e.g. include-expanded) source,
536    /// whose offsets do not refer to the primary document source.
537    ///
538    /// [`truncate_substitution_warnings`]: Self::truncate_substitution_warnings
539    pub(crate) fn substitution_warnings_len(&self) -> usize {
540        self.substitution_warnings.borrow().len()
541    }
542
543    /// Discards any substitution warnings recorded since the buffer held `len`
544    /// entries.
545    pub(crate) fn truncate_substitution_warnings(&self, len: usize) {
546        self.substitution_warnings.borrow_mut().truncate(len);
547    }
548
549    /// Takes the substitution warnings recorded during parsing, leaving the
550    /// buffer empty.
551    pub(crate) fn take_substitution_warnings(&self) -> Vec<SubstitutionWarning> {
552        std::mem::take(&mut *self.substitution_warnings.borrow_mut())
553    }
554
555    /// Generate a unique ID derived from `base_id` and register it in the
556    /// document catalog, returning the ID that was assigned.
557    pub(crate) fn generate_and_register_unique_id(
558        &self,
559        base_id: &str,
560        reftext: Option<&str>,
561        ref_type: RefType,
562    ) -> String {
563        self.catalog
564            .borrow_mut()
565            .generate_and_register_unique_id(base_id, reftext, ref_type)
566    }
567
568    /// Takes the catalog from the parser, transferring ownership and leaving an
569    /// empty catalog in its place.
570    ///
571    /// This is used by `Document::parse` to transfer the catalog from the
572    /// parser to the document at the end of parsing.
573    pub(crate) fn take_catalog(&mut self) -> Catalog {
574        std::mem::take(&mut *self.catalog.borrow_mut())
575    }
576
577    /* Comment out until we're prepared to use and test this.
578        /// Sets the default value for an [intrinsic attribute].
579        ///
580        /// Default values for attributes are provided automatically by the
581        /// processor. These values provide a falllback textual value for an
582        /// attribute when it is merely "set" by the document via API, header, or
583        /// document body.
584        ///
585        /// Calling this does not imply that the value is set automatically by
586        /// default, nor does it establish any policy for where the value may be
587        /// modified. For that, please use [`with_intrinsic_attribute`].
588        ///
589        /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
590        /// [`with_intrinsic_attribute`]: Self::with_intrinsic_attribute
591        pub fn with_default_attribute_value<N: AsRef<str>, V: AsRef<str>>(
592            mut self,
593            name: N,
594            value: V,
595        ) -> Self {
596            self.default_attribute_values
597                .insert(name.as_ref().to_string(), value.as_ref().to_string());
598
599            self
600        }
601    */
602
603    /// Sets the value of an [intrinsic attribute] from a boolean flag.
604    ///
605    /// A boolean `true` is interpreted as "set." A boolean `false` is
606    /// interpreted as "unset."
607    ///
608    /// Intrinsic attributes are set automatically by the processor. These
609    /// attributes provide information about the document being processed (e.g.,
610    /// `docfile`), the security mode under which the processor is running
611    /// (e.g., `safe-mode-name`), and information about the user’s environment
612    /// (e.g., `user-home`).
613    ///
614    /// The [`modification_context`](ModificationContext) establishes whether
615    /// the value can be subsequently modified by the document header and/or in
616    /// the document body.
617    ///
618    /// Subsequent calls to this function or [`with_intrinsic_attribute()`] are
619    /// always permitted. The last such call for any given attribute name takes
620    /// precendence.
621    ///
622    /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
623    ///
624    /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
625    pub fn with_intrinsic_attribute_bool<N: AsRef<str>>(
626        mut self,
627        name: N,
628        value: bool,
629        modification_context: ModificationContext,
630    ) -> Self {
631        let attribute_value = AttributeValue {
632            allowable_value: AllowableValue::Any,
633            modification_context,
634            value: if value {
635                InterpretedValue::Set
636            } else {
637                InterpretedValue::Unset
638            },
639        };
640
641        Arc::make_mut(&mut self.attribute_values)
642            .insert(name.as_ref().to_lowercase(), attribute_value);
643
644        self
645    }
646
647    /// Replace the default [`InlineSubstitutionRenderer`] for this parser.
648    ///
649    /// The default implementation of [`InlineSubstitutionRenderer`] that is
650    /// provided is suitable for HTML5 rendering. If you are targeting a
651    /// different back-end rendering, you will need to provide your own
652    /// implementation and set it using this call before parsing.
653    pub fn with_inline_substitution_renderer<ISR: InlineSubstitutionRenderer + 'static>(
654        mut self,
655        renderer: ISR,
656    ) -> Self {
657        self.renderer = Rc::new(renderer);
658        self
659    }
660
661    /// Sets the name of the primary file to be parsed when [`parse()`] is
662    /// called.
663    ///
664    /// This name will be used for any error messages detected in this file and
665    /// also will be passed to [`IncludeFileHandler::resolve_target()`] as the
666    /// `source` argument for any `include::` file resolution requests from this
667    /// file.
668    ///
669    /// [`parse()`]: Self::parse
670    /// [`IncludeFileHandler::resolve_target()`]: crate::parser::IncludeFileHandler::resolve_target
671    pub fn with_primary_file_name<S: AsRef<str>>(mut self, name: S) -> Self {
672        self.primary_file_name = Some(name.as_ref().to_owned());
673        self
674    }
675
676    /// Sets the [`IncludeFileHandler`] for this parser.
677    ///
678    /// The include file handler is responsible for resolving `include::`
679    /// directives encountered during preprocessing. If no handler is provided,
680    /// include directives will be ignored.
681    ///
682    /// [`IncludeFileHandler`]: crate::parser::IncludeFileHandler
683    pub fn with_include_file_handler<IFH: IncludeFileHandler + 'static>(
684        mut self,
685        handler: IFH,
686    ) -> Self {
687        self.include_file_handler = Some(Rc::new(handler));
688        self
689    }
690
691    /// Sets the [`DocinfoFileHandler`] for this parser.
692    ///
693    /// The docinfo file handler is responsible for providing the content of
694    /// [docinfo files] requested while resolving a document's docinfo (see the
695    /// `docinfo` attribute). If no handler is provided, no docinfo content is
696    /// resolved and [`Document::docinfo`] returns an empty string for every
697    /// location.
698    ///
699    /// [`DocinfoFileHandler`]: crate::parser::DocinfoFileHandler
700    /// [docinfo files]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
701    /// [`Document::docinfo`]: crate::Document::docinfo
702    pub fn with_docinfo_file_handler<DFH: DocinfoFileHandler + 'static>(
703        mut self,
704        handler: DFH,
705    ) -> Self {
706        self.docinfo_file_handler = Some(Rc::new(handler));
707        self
708    }
709
710    /// Returns the document name (`docname`): the base name of the primary
711    /// file, stripped of its directory and final extension.
712    ///
713    /// This is the `<docname>` used to build private docinfo file names (e.g.
714    /// `mydoc-docinfo.html` for `mydoc.adoc`). Returns `None` when no primary
715    /// file name has been set, in which case private docinfo files cannot be
716    /// resolved.
717    pub(crate) fn docname(&self) -> Option<String> {
718        let primary = self.primary_file_name.as_deref()?;
719
720        // Strip the directory portion (handling both separators, since the
721        // primary file name may have been supplied on either platform).
722        let base = primary.rsplit(['/', '\\']).next().unwrap_or(primary);
723
724        // Strip a single trailing extension, if present. A leading-dot name
725        // (e.g. `.adoc`) is treated as having no extension and is kept whole as
726        // the stem, matching Ruby's `File.basename(".adoc", ".*")`.
727        let stem = match base.rfind('.') {
728            Some(0) | None => base,
729            Some(idx) => &base[..idx],
730        };
731
732        if stem.is_empty() {
733            None
734        } else {
735            Some(stem.to_string())
736        }
737    }
738
739    /// Called from [`Header::parse()`] to accept or reject an attribute value.
740    ///
741    /// [`Header::parse()`]: crate::document::Header::parse
742    pub(crate) fn set_attribute_from_header<'src>(
743        &mut self,
744        attr: &Attribute<'src>,
745        warnings: &mut Vec<Warning<'src>>,
746    ) {
747        let attr_name = remap_attr_name(attr.name().data());
748
749        let existing_attr = self.attribute_values.get(&attr_name);
750
751        // Verify that we have permission to overwrite any existing attribute value.
752        if let Some(existing_attr) = existing_attr
753            && (existing_attr.modification_context == ModificationContext::ApiOnly
754                || existing_attr.modification_context == ModificationContext::ApiOrDocumentBody)
755        {
756            warnings.push(Warning {
757                source: attr.span(),
758                warning: WarningType::AttributeValueIsLocked(attr_name),
759            });
760            return;
761        }
762
763        let mut value = attr.value().clone();
764
765        if let InterpretedValue::Set = value
766            && let Some(default_value) = self.default_attribute_values.get(&attr_name)
767        {
768            value = InterpretedValue::Value(default_value.clone());
769        }
770
771        let attribute_value = AttributeValue {
772            allowable_value: AllowableValue::Any,
773            modification_context: ModificationContext::Anywhere,
774            value,
775        };
776
777        // An explicit assignment supersedes (and resets) any counter of the same
778        // name.
779        self.counter_values.borrow_mut().remove(&attr_name);
780
781        let is_doctype = attr_name == "doctype";
782        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
783        if is_doctype {
784            self.refresh_doctype_derived_attr();
785        }
786    }
787
788    /// Called from [`Header::parse()`] for a value that is derived from parsing
789    /// the header (except for attribute lines).
790    ///
791    /// [`Header::parse()`]: crate::document::Header::parse
792    pub(crate) fn set_attribute_by_value_from_header<N: AsRef<str>, V: AsRef<str>>(
793        &mut self,
794        name: N,
795        value: V,
796    ) {
797        let attr_name = remap_attr_name(name);
798
799        let attribute_value = AttributeValue {
800            allowable_value: AllowableValue::Any,
801            modification_context: ModificationContext::Anywhere,
802            value: InterpretedValue::Value(value.as_ref().to_owned()),
803        };
804
805        self.counter_values.borrow_mut().remove(&attr_name);
806        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
807    }
808
809    /// Called while parsing a block (see [`Block::parse_with_outcome()`]) to
810    /// accept or reject an attribute value from a document (body) attribute.
811    ///
812    /// [`Block::parse_with_outcome()`]: crate::blocks::Block::parse_with_outcome
813    pub(crate) fn set_attribute_from_body<'src>(
814        &mut self,
815        attr: &Attribute<'src>,
816        warnings: &mut Vec<Warning<'src>>,
817    ) {
818        let attr_name = remap_attr_name(attr.name().data());
819
820        // An attribute inherited from the parent document of an AsciiDoc table
821        // cell is locked for the duration of that cell: a body assignment to it
822        // is silently ignored (no warning), matching Asciidoctor.
823        if self.locked_attribute_names.contains(&attr_name) {
824            return;
825        }
826
827        // Verify that we have permission to overwrite any existing attribute value.
828        if let Some(existing_attr) = self.attribute_values.get(&attr_name)
829            && (existing_attr.modification_context != ModificationContext::Anywhere
830                && existing_attr.modification_context != ModificationContext::ApiOrDocumentBody)
831        {
832            warnings.push(Warning {
833                source: attr.span(),
834                warning: WarningType::AttributeValueIsLocked(attr_name),
835            });
836            return;
837        }
838
839        let attribute_value = AttributeValue {
840            allowable_value: AllowableValue::Any,
841            modification_context: ModificationContext::Anywhere,
842            value: attr.value().clone(),
843        };
844
845        // An explicit assignment supersedes (and resets) any counter of the same
846        // name. This is what lets `:!name:` reset a counter.
847        self.counter_values.borrow_mut().remove(&attr_name);
848
849        let is_doctype = attr_name == "doctype";
850        Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
851        if is_doctype {
852            self.refresh_doctype_derived_attr();
853        }
854    }
855
856    /// Assign the next section number for a given level.
857    pub(crate) fn assign_section_number(&mut self, level: usize) -> SectionNumber {
858        match self.topmost_section_type {
859            SectionType::Normal => {
860                self.last_section_number.assign_next_number(level);
861                self.last_section_number.clone()
862            }
863            SectionType::Appendix => {
864                self.last_appendix_section_number.assign_next_number(level);
865                self.last_appendix_section_number.clone()
866            }
867            SectionType::Discrete => {
868                // Shouldn't happen, but ignore if it does.
869                self.last_section_number.clone()
870            }
871        }
872    }
873
874    /// Resolves a [counter] of the given `name`, advancing it to the next value
875    /// in its sequence and returning that value.
876    ///
877    /// A counter is a specialized document attribute: its value is stored as
878    /// (and read back from) the attribute of the same name, so a later
879    /// `{name}` reference shows the current value and an attribute assignment
880    /// such as `:!name:` resets it. Each resolution advances the counter:
881    ///
882    /// * an integer value is incremented (`1` -> `2`);
883    /// * any other value is advanced like Ruby's `String#succ` (`a` -> `b`, `z`
884    ///   -> `aa`, `Az` -> `Ba`), matching Asciidoctor.
885    ///
886    /// `seed` (from the `{counter:name:seed}` form) supplies the first value,
887    /// but only when the counter is currently unset; otherwise it is ignored.
888    /// With no seed the sequence starts at `1`.
889    ///
890    /// This mirrors Asciidoctor's `Document#counter`.
891    ///
892    /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
893    pub(crate) fn counter(&self, name: &str, seed: Option<&str>) -> String {
894        let next = match self.attribute_value(name) {
895            InterpretedValue::Value(current) if !current.is_empty() => next_counter_value(&current),
896            _ => match seed {
897                Some(seed) if !seed.is_empty() => seed.to_string(),
898                _ => "1".to_string(),
899            },
900        };
901
902        self.counter_values
903            .borrow_mut()
904            .insert(name.to_string(), next.clone());
905
906        next
907    }
908}
909
910/// Advances a counter value to the next value in its sequence, mirroring
911/// Asciidoctor's `Helpers.nextval`.
912///
913/// A canonical integer string (one that round-trips through integer parsing,
914/// e.g. `7` but not `07` or `+7`) is incremented numerically. Anything else is
915/// advanced with [`string_succ`].
916fn next_counter_value(current: &str) -> String {
917    if let Ok(n) = current.parse::<i64>()
918        && n.to_string() == current
919    {
920        // `saturating_add` keeps a counter that has somehow reached `i64::MAX`
921        // pinned there rather than panicking (debug) or wrapping (release).
922        return n.saturating_add(1).to_string();
923    }
924
925    string_succ(current)
926}
927
928/// Returns the successor of a string, mirroring Ruby's `String#succ` for the
929/// ASCII cases that AsciiDoc counters can produce.
930///
931/// The right-most alphanumeric character is incremented within its own class
932/// (digits, lowercase letters, uppercase letters), carrying leftward on
933/// wrap-around (`9` -> `0`, `z` -> `a`, `Z` -> `A`) and prepending a fresh
934/// leading character (`1`, `a`, or `A`) when the carry runs off the front
935/// (`z` -> `aa`, `Zz` -> `AAa`). A string with no alphanumeric characters has
936/// the code point of its last character incremented.
937fn string_succ(current: &str) -> String {
938    let chars: Vec<char> = current.chars().collect();
939
940    // Without an alphanumeric to carry through, Ruby increments the code point
941    // of the final character.
942    if !chars.iter().any(char::is_ascii_alphanumeric) {
943        let mut chars = chars;
944        if let Some(last) = chars.last_mut() {
945            *last = char::from_u32(*last as u32 + 1).unwrap_or(*last);
946        }
947        return chars.into_iter().collect();
948    }
949
950    // Walk right to left. `carrying` stays true while we are still looking for
951    // (or carrying through) the alphanumeric run: trailing non-alphanumeric
952    // characters are passed over unchanged, then the right-most alphanumeric is
953    // incremented within its class and any wrap-around carries leftward to the
954    // next alphanumeric. When the carry runs off the front, a fresh leading
955    // character of the same class is prepended (`z` -> `aa`, `9` -> `10`).
956    let mut out_rev: Vec<char> = Vec::with_capacity(chars.len() + 1);
957    let mut carrying = true;
958    let mut lead = '1';
959
960    for &c in chars.iter().rev() {
961        if carrying && c.is_ascii_alphanumeric() {
962            // Increment within the character's class, carrying on wrap-around.
963            // The arms are exhaustive over ASCII alphanumerics, so the catch-all
964            // can only be `Z` (the one value not matched above).
965            let (next, carry) = match c {
966                '0'..='8' | 'a'..='y' | 'A'..='Y' => ((c as u8 + 1) as char, false),
967                '9' => ('0', true),
968                'z' => ('a', true),
969                _ => ('A', true),
970            };
971            out_rev.push(next);
972            carrying = carry;
973            // On a carry, remember the class of leading character to prepend if
974            // the carry runs off the front; `next` is `0`, `a`, or `A` here.
975            lead = match next {
976                '0' => '1',
977                'a' => 'a',
978                _ => 'A',
979            };
980        } else {
981            // Either the carry is spent, or this is a trailing non-alphanumeric
982            // we pass over while still searching for the run to increment.
983            out_rev.push(c);
984        }
985    }
986
987    if carrying {
988        out_rev.push(lead);
989    }
990
991    out_rev.into_iter().rev().collect()
992}
993
994fn remap_attr_name<N: AsRef<str>>(raw_attr_name: N) -> String {
995    let attr_name = raw_attr_name.as_ref().to_lowercase();
996
997    // Some attribute names have aliases. Remap to the primary name.
998    match attr_name.as_str() {
999        "hardbreaks" => "hardbreaks-option".to_string(),
1000        _ => attr_name,
1001    }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    #![allow(clippy::panic)]
1007    #![allow(clippy::unwrap_used)]
1008
1009    use crate::{
1010        attributes::Attrlist,
1011        blocks::Block,
1012        parser::{
1013            CharacterReplacementType, IconRenderParams, ImageRenderParams,
1014            InlineSubstitutionRenderer, LinkRenderParams, QuoteScope, QuoteType, SpecialCharacter,
1015        },
1016        tests::prelude::*,
1017    };
1018
1019    #[test]
1020    fn default_is_unset() {
1021        let p = Parser::default();
1022        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
1023    }
1024
1025    #[test]
1026    fn creates_catalog_if_needed() {
1027        let mut p = Parser::default();
1028        let doc = p.parse("= Hello, World!\n\n== First Section Title");
1029        let cat = doc.catalog();
1030        assert!(cat.refs.contains_key("_first_section_title"));
1031
1032        let doc = p.parse("= Hello, World!\n\n== Second Section Title");
1033        let cat = doc.catalog();
1034        assert!(!cat.refs.contains_key("_first_section_title"));
1035        assert!(cat.refs.contains_key("_second_section_title"));
1036    }
1037
1038    #[test]
1039    fn with_intrinsic_attribute() {
1040        let p =
1041            Parser::default().with_intrinsic_attribute("foo", "bar", ModificationContext::Anywhere);
1042
1043        assert_eq!(p.attribute_value("foo"), InterpretedValue::Value("bar"));
1044        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1045
1046        assert!(p.is_attribute_set("foo"));
1047        assert!(!p.is_attribute_set("foo2"));
1048        assert!(!p.is_attribute_set("xyz"));
1049    }
1050
1051    #[test]
1052    fn with_intrinsic_attribute_set() {
1053        let p = Parser::default().with_intrinsic_attribute_bool(
1054            "foo",
1055            true,
1056            ModificationContext::Anywhere,
1057        );
1058
1059        assert_eq!(p.attribute_value("foo"), InterpretedValue::Set);
1060        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1061
1062        assert!(p.is_attribute_set("foo"));
1063        assert!(!p.is_attribute_set("foo2"));
1064        assert!(!p.is_attribute_set("xyz"));
1065    }
1066
1067    #[test]
1068    fn with_intrinsic_attribute_unset() {
1069        let p = Parser::default().with_intrinsic_attribute_bool(
1070            "foo",
1071            false,
1072            ModificationContext::Anywhere,
1073        );
1074
1075        assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
1076        assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
1077
1078        assert!(!p.is_attribute_set("foo"));
1079        assert!(!p.is_attribute_set("foo2"));
1080        assert!(!p.is_attribute_set("xyz"));
1081    }
1082
1083    #[test]
1084    fn can_not_override_locked_default_value() {
1085        let mut parser = Parser::default();
1086
1087        let doc = parser.parse(":sp: not a space!");
1088
1089        assert_eq!(
1090            doc.warnings().next().unwrap().warning,
1091            WarningType::AttributeValueIsLocked("sp".to_owned())
1092        );
1093
1094        assert_eq!(parser.attribute_value("sp"), InterpretedValue::Value(" "));
1095    }
1096
1097    #[test]
1098    fn catalog_transferred_to_document() {
1099        let mut parser = Parser::default();
1100        let doc = parser.parse("= Test Document\n\nSome content");
1101
1102        let catalog = doc.catalog();
1103        assert!(catalog.is_empty());
1104
1105        // The catalog was transferred to the document, leaving the parser with
1106        // an empty catalog.
1107        assert!(parser.catalog.borrow().is_empty());
1108    }
1109
1110    #[test]
1111    fn block_ids_registered_in_catalog() {
1112        let mut parser = Parser::default();
1113        let doc = parser.parse("= Test Document\n\n[#my-block]\nSome content with an ID");
1114
1115        let catalog = doc.catalog();
1116        assert!(!catalog.is_empty());
1117        assert!(catalog.contains_id("my-block"));
1118
1119        let entry = catalog.get_ref("my-block").unwrap();
1120        assert_eq!(entry.id, "my-block");
1121        assert_eq!(entry.ref_type, crate::document::RefType::Anchor);
1122    }
1123
1124    /// A simple test renderer that modifies special characters differently
1125    /// from the default HTML renderer.
1126    #[derive(Debug)]
1127    struct TestRenderer;
1128
1129    impl InlineSubstitutionRenderer for TestRenderer {
1130        fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
1131            // Custom rendering: wrap special characters in brackets.
1132            match type_ {
1133                SpecialCharacter::Lt => dest.push_str("[LT]"),
1134                SpecialCharacter::Gt => dest.push_str("[GT]"),
1135                SpecialCharacter::Ampersand => dest.push_str("[AMP]"),
1136            }
1137        }
1138
1139        fn render_quoted_substitition(
1140            &self,
1141            _type_: QuoteType,
1142            _scope: QuoteScope,
1143            _attrlist: Option<Attrlist<'_>>,
1144            _id: Option<String>,
1145            body: &str,
1146            dest: &mut String,
1147        ) {
1148            dest.push_str(body);
1149        }
1150
1151        fn render_character_replacement(
1152            &self,
1153            _type_: CharacterReplacementType,
1154            dest: &mut String,
1155        ) {
1156            dest.push_str("[CHAR]");
1157        }
1158
1159        fn render_line_break(&self, dest: &mut String) {
1160            dest.push_str("[BR]");
1161        }
1162
1163        fn render_image(&self, _params: &ImageRenderParams, dest: &mut String) {
1164            dest.push_str("[IMAGE]");
1165        }
1166
1167        fn image_uri(
1168            &self,
1169            target_image_path: &str,
1170            _parser: &Parser,
1171            _asset_dir_key: Option<&str>,
1172        ) -> String {
1173            target_image_path.to_string()
1174        }
1175
1176        fn render_icon(&self, _params: &IconRenderParams, dest: &mut String) {
1177            dest.push_str("[ICON]");
1178        }
1179
1180        fn render_link(&self, _params: &LinkRenderParams, dest: &mut String) {
1181            dest.push_str("[LINK]");
1182        }
1183
1184        fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
1185            dest.push_str(&format!("[ANCHOR:{}]", id));
1186        }
1187
1188        fn render_xref(&self, params: &crate::parser::XrefRenderParams, dest: &mut String) {
1189            dest.push_str(&format!("[XREF:{}]", params.target));
1190        }
1191
1192        fn render_callout(&self, params: &crate::parser::CalloutRenderParams, dest: &mut String) {
1193            dest.push_str(&format!("[CALLOUT:{}]", params.number));
1194        }
1195    }
1196
1197    #[test]
1198    fn with_inline_substitution_renderer() {
1199        let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
1200
1201        // Parse a simple document with special characters.
1202        let doc = parser.parse("Hello & goodbye < world > test");
1203
1204        // The document should parse successfully.
1205        assert_eq!(doc.warnings().count(), 0);
1206
1207        // Get the first block from the document.
1208        let block = doc.nested_blocks().next().unwrap();
1209
1210        let Block::Simple(simple_block) = block else {
1211            panic!("Expected simple block, got: {block:?}");
1212        };
1213
1214        // Our custom renderer should show [AMP], [LT], and [GT] instead of HTML
1215        // entities.
1216        assert_eq!(
1217            simple_block.content().rendered(),
1218            "Hello [AMP] goodbye [LT] world [GT] test"
1219        );
1220    }
1221
1222    mod resolve_show_title {
1223        use crate::parser::{ModificationContext, Parser};
1224
1225        fn with(name: &str, set: bool) -> Parser {
1226            Parser::default().with_intrinsic_attribute_bool(
1227                name,
1228                set,
1229                ModificationContext::Anywhere,
1230            )
1231        }
1232
1233        #[test]
1234        fn neither_present_uses_default() {
1235            assert!(Parser::default().resolve_show_title(true));
1236            assert!(!Parser::default().resolve_show_title(false));
1237        }
1238
1239        #[test]
1240        fn showtitle_takes_precedence_and_decides() {
1241            // Present and set -> shown; present and unset -> hidden, regardless
1242            // of the default.
1243            assert!(with("showtitle", true).resolve_show_title(false));
1244            assert!(!with("showtitle", false).resolve_show_title(true));
1245        }
1246
1247        #[test]
1248        fn notitle_is_the_complement_when_showtitle_absent() {
1249            // notitle set -> hidden; notitle unset -> shown.
1250            assert!(!with("notitle", true).resolve_show_title(true));
1251            assert!(with("notitle", false).resolve_show_title(false));
1252        }
1253    }
1254
1255    mod refresh_doctype_derived_attr {
1256        use crate::{document::InterpretedValue, parser::Parser};
1257
1258        #[test]
1259        fn tracks_the_active_doctype() {
1260            let mut parser = Parser::default();
1261
1262            // The default doctype is `article`, so only its derived attribute is
1263            // defined (to an empty value).
1264            assert_eq!(
1265                parser.attribute_value("backend-html5-doctype-article"),
1266                InterpretedValue::Value(String::new())
1267            );
1268            assert_eq!(
1269                parser.attribute_value("backend-html5-doctype-book"),
1270                InterpretedValue::Unset
1271            );
1272
1273            // Forcing a new doctype moves the derived attribute with it.
1274            parser.force_doctype("book");
1275            assert_eq!(
1276                parser.attribute_value("backend-html5-doctype-book"),
1277                InterpretedValue::Value(String::new())
1278            );
1279            assert_eq!(
1280                parser.attribute_value("backend-html5-doctype-article"),
1281                InterpretedValue::Unset
1282            );
1283        }
1284
1285        #[test]
1286        fn defines_no_derived_attr_when_doctype_is_not_a_value() {
1287            let mut parser = Parser::default();
1288
1289            // The default article derived attribute starts out defined.
1290            assert_eq!(
1291                parser.attribute_value("backend-html5-doctype-article"),
1292                InterpretedValue::Value(String::new())
1293            );
1294
1295            // With `doctype` unset (no `Value`), a refresh clears any existing
1296            // derived attribute and defines none.
1297            std::sync::Arc::make_mut(&mut parser.attribute_values).remove("doctype");
1298            parser.refresh_doctype_derived_attr();
1299
1300            assert_eq!(parser.attribute_value("doctype"), InterpretedValue::Unset);
1301            assert_eq!(
1302                parser.attribute_value("backend-html5-doctype-article"),
1303                InterpretedValue::Unset
1304            );
1305        }
1306    }
1307
1308    mod docname {
1309        use crate::Parser;
1310
1311        #[test]
1312        fn none_without_primary_file_name() {
1313            assert_eq!(Parser::default().docname(), None);
1314        }
1315
1316        #[test]
1317        fn strips_directory_and_extension() {
1318            assert_eq!(
1319                Parser::default()
1320                    .with_primary_file_name("mydoc.adoc")
1321                    .docname()
1322                    .as_deref(),
1323                Some("mydoc")
1324            );
1325            assert_eq!(
1326                Parser::default()
1327                    .with_primary_file_name("docs/guide/mydoc.adoc")
1328                    .docname()
1329                    .as_deref(),
1330                Some("mydoc")
1331            );
1332            // A Windows-style separator is handled too, since the primary file
1333            // name may be supplied on either platform.
1334            assert_eq!(
1335                Parser::default()
1336                    .with_primary_file_name(r"docs\guide\mydoc.adoc")
1337                    .docname()
1338                    .as_deref(),
1339                Some("mydoc")
1340            );
1341        }
1342
1343        #[test]
1344        fn keeps_name_with_no_extension() {
1345            assert_eq!(
1346                Parser::default()
1347                    .with_primary_file_name("README")
1348                    .docname()
1349                    .as_deref(),
1350                Some("README")
1351            );
1352        }
1353
1354        #[test]
1355        fn none_when_path_has_no_file_component() {
1356            // A primary file name that ends in a separator has an empty base
1357            // name, which yields no document name.
1358            assert_eq!(
1359                Parser::default()
1360                    .with_primary_file_name("docs/guide/")
1361                    .docname(),
1362                None
1363            );
1364        }
1365
1366        #[test]
1367        fn leading_dot_name_is_kept_whole() {
1368            // A leading-dot name (e.g. `.adoc`) is treated as a dotfile with no
1369            // extension and kept whole, matching Ruby's
1370            // `File.basename(".adoc", ".*")`.
1371            assert_eq!(
1372                Parser::default()
1373                    .with_primary_file_name(".adoc")
1374                    .docname()
1375                    .as_deref(),
1376                Some(".adoc")
1377            );
1378        }
1379    }
1380
1381    mod counter {
1382        use super::super::next_counter_value;
1383        use crate::{document::InterpretedValue, tests::prelude::*};
1384
1385        #[test]
1386        fn next_counter_value_integer() {
1387            assert_eq!(next_counter_value("1"), "2");
1388            assert_eq!(next_counter_value("9"), "10");
1389            assert_eq!(next_counter_value("0"), "1");
1390            assert_eq!(next_counter_value("-1"), "0");
1391        }
1392
1393        #[test]
1394        fn next_counter_value_non_canonical_integer_is_advanced_as_a_string() {
1395            // A leading zero (or sign) does not round-trip through integer
1396            // parsing, so it is advanced like a string instead.
1397            assert_eq!(next_counter_value("07"), "08");
1398            assert_eq!(next_counter_value("+5"), "+6");
1399            // A leading-zero value still carries digit-to-digit like a string.
1400            assert_eq!(next_counter_value("09"), "10");
1401            assert_eq!(next_counter_value("099"), "100");
1402        }
1403
1404        #[test]
1405        fn next_counter_value_saturates_at_i64_max() {
1406            // A counter pinned at `i64::MAX` stays there rather than panicking
1407            // (debug) or wrapping (release).
1408            let max = i64::MAX.to_string();
1409            assert_eq!(next_counter_value(&max), max);
1410        }
1411
1412        #[test]
1413        fn next_counter_value_characters() {
1414            assert_eq!(next_counter_value("a"), "b");
1415            assert_eq!(next_counter_value("A"), "B");
1416            assert_eq!(next_counter_value("z"), "aa");
1417            assert_eq!(next_counter_value("Z"), "AA");
1418            assert_eq!(next_counter_value("az"), "ba");
1419            assert_eq!(next_counter_value("zz"), "aaa");
1420            assert_eq!(next_counter_value("Zz"), "AAa");
1421        }
1422
1423        #[test]
1424        fn next_counter_value_trailing_non_alphanumeric() {
1425            // The right-most alphanumeric is incremented; trailing punctuation is
1426            // left in place.
1427            assert_eq!(next_counter_value("a)"), "b)");
1428        }
1429
1430        #[test]
1431        fn next_counter_value_no_alphanumeric() {
1432            // With nothing alphanumeric to carry, the final code point advances.
1433            assert_eq!(next_counter_value("{"), "|");
1434        }
1435
1436        #[test]
1437        fn counter_defaults_to_one() {
1438            let p = Parser::default();
1439            assert_eq!(p.counter("x", None), "1");
1440            assert_eq!(p.counter("x", None), "2");
1441            assert_eq!(
1442                p.attribute_value("x"),
1443                InterpretedValue::Value("2".to_string())
1444            );
1445            assert!(p.has_attribute("x"));
1446            assert!(p.is_attribute_set("x"));
1447        }
1448
1449        #[test]
1450        fn counter_seed_used_only_while_unset() {
1451            let p = Parser::default();
1452            assert_eq!(p.counter("c", Some("A")), "A");
1453            // Once set, a later seed is ignored.
1454            assert_eq!(p.counter("c", Some("Q")), "B");
1455        }
1456
1457        #[test]
1458        fn counter_empty_seed_falls_back_to_one() {
1459            let p = Parser::default();
1460            assert_eq!(p.counter("c", Some("")), "1");
1461        }
1462    }
1463}