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