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