asciidoc_parser/parser/parser.rs
1use std::{
2 cell::{Cell, RefCell},
3 collections::{HashMap, HashSet},
4 rc::Rc,
5 sync::{Arc, LazyLock},
6};
7
8use regex::Regex;
9
10use crate::{
11 Document, HasSpan,
12 blocks::{SectionNumber, SectionType},
13 document::{Attribute, Catalog, InterpretedValue, RefType},
14 parser::{
15 AllowableValue, AttributeValue, DatetimeContext, DocinfoFileHandler,
16 HtmlSubstitutionRenderer, ImageFileHandler, IncludeFileHandler, InlineSubstitutionRenderer,
17 ModificationContext, PathResolver, ReferenceTime, ResolvedAttributes, SafeMode, SourceLine,
18 SourceMap, SvgFileHandler,
19 built_in_attrs::{
20 built_in_attr, built_in_default_values, derived_backend_value,
21 is_derived_backend_value, max_attribute_value_size_default, synthesized_attr,
22 user_home_default,
23 },
24 is_datetime_attribute,
25 preprocessor::preprocess,
26 safe_mode::masked_doc_path,
27 },
28 warnings::{Warning, WarningType},
29};
30
31/// The [`Parser`] struct and its related structs allow a caller to configure
32/// how AsciiDoc parsing occurs and then to initiate the parsing process.
33#[derive(Clone, Debug)]
34pub struct Parser {
35 /// Per-parser attribute values: **only** the attributes this parser has
36 /// defined, overridden, or explicitly unset. The large set of built-in
37 /// defaults is *not* copied in here; [`attribute_value`] falls back to the
38 /// shared built-in table (see [`built_in_attrs`]) on a lookup miss, so
39 /// creating or cloning a parser allocates nothing per built-in attribute.
40 ///
41 /// A per-parser entry always shadows the built-in default of the same name,
42 /// including an [`Unset`](InterpretedValue::Unset) tombstone that records a
43 /// built-in having been unset. The map is wrapped in an [`Arc`] so a parser
44 /// clone (e.g. for a nested AsciiDoc table cell) shares it copy-on-write
45 /// and only copies these (few) entries when it next modifies an
46 /// attribute.
47 ///
48 /// [`attribute_value`]: Self::attribute_value
49 /// [`built_in_attrs`]: super::built_in_attrs
50 pub(crate) attribute_values: Arc<HashMap<String, AttributeValue>>,
51
52 /// Default values for attributes if "set." Immutable after construction and
53 /// shared via [`Arc`] (never copied per parser).
54 default_attribute_values: Arc<HashMap<String, String>>,
55
56 /// Specifies how the basic raw text of a simple block will be converted to
57 /// the format which will ultimately be presented in the final output.
58 ///
59 /// Typically this is an [`HtmlSubstitutionRenderer`] but clients may
60 /// provide alternative implementations.
61 pub(crate) renderer: Rc<dyn InlineSubstitutionRenderer>,
62
63 /// Specifies the name of the primary file to be parsed.
64 pub(crate) primary_file_name: Option<String>,
65
66 /// Specifies how to generate clean and secure paths relative to the parsing
67 /// context.
68 pub path_resolver: PathResolver,
69
70 /// Handler for resolving include:: directives.
71 pub(crate) include_file_handler: Option<Rc<dyn IncludeFileHandler>>,
72
73 /// Handler for resolving docinfo files. If absent, no docinfo content is
74 /// resolved.
75 pub(crate) docinfo_file_handler: Option<Rc<dyn DocinfoFileHandler>>,
76
77 /// Handler for reading the contents of an SVG file requested by an inline
78 /// image with the `inline` option. If absent, inline SVG images fall back
79 /// to rendering their alt text.
80 pub(crate) svg_file_handler: Option<Rc<dyn SvgFileHandler>>,
81
82 /// Handler for reading the bytes of an image file that must be embedded as
83 /// a `data:` URI (when the `data-uri` attribute is set below
84 /// [`SafeMode::Secure`]). If absent, such images fall back to an ordinary
85 /// web path.
86 pub(crate) image_file_handler: Option<Rc<dyn ImageFileHandler>>,
87
88 /// Whether referenced images are recorded in the document catalog as they
89 /// are encountered (Asciidoctor's `catalog_assets` API option). When
90 /// `false` (the default), the `image:`/`image::` macros do not populate
91 /// [`Catalog::images`](crate::document::Catalog::images).
92 pub(crate) catalog_assets: bool,
93
94 /// The safe mode under which the document is parsed and rendered. Controls
95 /// security-sensitive rendering behavior (such as whether an interactive
96 /// SVG image is rendered as an `<object>` element). Defaults to
97 /// [`SafeMode::Secure`].
98 pub(crate) safe: SafeMode,
99
100 /// Document catalog for tracking referenceable elements during parsing.
101 /// This is created during parsing and transferred to the Document when
102 /// complete.
103 ///
104 /// Wrapped in a [`RefCell`] so that anchors and references discovered deep
105 /// inside inline substitution (where only a shared `&Parser` is available,
106 /// e.g. within a regex [`Replacer`](regex::Replacer)) can still be
107 /// registered.
108 catalog: RefCell<Catalog>,
109
110 /// Most recently-assigned section number.
111 pub(crate) last_section_number: SectionNumber,
112
113 /// Most recently-assigned appendix section number.
114 pub(crate) last_appendix_section_number: SectionNumber,
115
116 /// Saved copy of sectnumlevels at end of document header.
117 pub(crate) sectnumlevels: usize,
118
119 /// Section type of outermost section. (Used to determine whether to number
120 /// child sections as a normal section or appendix.)
121 pub(crate) topmost_section_type: SectionType,
122
123 /// True while parsing the direct block children of a section that carries
124 /// the `bibliography` style.
125 ///
126 /// A top-level unordered list parsed in this scope implicitly inherits the
127 /// `bibliography` style (matching Asciidoctor), even without its own
128 /// `[bibliography]` attribute. The flag is saved and restored around each
129 /// section body, so a non-bibliography subsection clears it for its own
130 /// children (the style does not propagate into subsections).
131 pub(crate) parsing_bibliography_section_body: bool,
132
133 /// True while the principal text of a bibliography list item is being
134 /// substituted.
135 ///
136 /// Read through a shared `&Parser` by the macros substitution step so it
137 /// recognizes a leading bibliography anchor (`[[[id]]]`). It is wrapped in
138 /// a [`Cell`] because the substitution code paths (e.g. a regex
139 /// [`Replacer`](regex::Replacer)) only hold a shared reference to the
140 /// parser.
141 pub(crate) in_bibliography_list_item: Cell<bool>,
142
143 /// True while a section title is being substituted, so each `footnote:[…]`
144 /// macro's rendered marker is bracketed with
145 /// [`FOOTNOTE_MARKER_START`](crate::content::FOOTNOTE_MARKER_START) /
146 /// [`FOOTNOTE_MARKER_END`](crate::content::FOOTNOTE_MARKER_END) sentinels.
147 /// The footnote is still defined and numbered in document order; the
148 /// sentinels merely let the marker be excised from the section's reference
149 /// text and auto-generated ID without a second substitution pass (see
150 /// `SectionBlock::parse`).
151 ///
152 /// Wrapped in a [`Cell`] for the same reason as
153 /// [`in_bibliography_list_item`](Self::in_bibliography_list_item): the
154 /// substitution code paths hold only a shared reference to the parser.
155 pub(crate) mark_footnote_spans: Cell<bool>,
156
157 /// A block title carried over from a section heading to the first block
158 /// parsed after it.
159 ///
160 /// A block title above a section heading does not title the section; it is
161 /// carried over to the first block inside the section (matching
162 /// Asciidoctor, where the block-attribute hash holding the title is passed
163 /// through to the section body's first block). `SectionBlock::parse`
164 /// stashes the rendered title here and the next block parsed claims it —
165 /// which may be a nested section, re-stashing it for *its* first block, or
166 /// (when the section body is empty) a sibling section reached after the
167 /// stashing section ends. A block with a title of its own wins over the
168 /// carried title, which is then discarded.
169 ///
170 /// The title is carried as an owned snapshot (this struct is lifetime-free
171 /// and cannot hold the `.Title` line's source span), so a block claiming a
172 /// carried title has no `title_source` — the same shape as a title
173 /// supplied via a `title=` attribute. The snapshot keeps any deferred
174 /// cross-references, so an embedded `<<id>>` in a carried title still
175 /// resolves once the catalog is complete.
176 pub(crate) pending_block_title: Option<crate::content::OwnedTitle>,
177
178 /// Live values of [counter] attributes, keyed by counter name (e.g.
179 /// `index`, `example-number`, `table-number`).
180 ///
181 /// A counter is a specialized document attribute: its value is *also* the
182 /// value of the document attribute of the same name. Counters are resolved
183 /// (and advanced) deep inside the attribute-reference substitution step,
184 /// where only a shared `&Parser` is available, so the new value is recorded
185 /// here through a [`RefCell`] and read back as an attribute by
186 /// [`attribute_value()`]. An explicit attribute assignment to a counter's
187 /// name supersedes this overlay (and is what allows `:!name:` to reset a
188 /// counter), so every attribute setter clears the matching entry.
189 ///
190 /// Captioned blocks (example, table, …) are numbered with this same
191 /// mechanism: each context's caption number is the counter named
192 /// `<context>-number`, mirroring Asciidoctor's `Document#counter`.
193 ///
194 /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
195 /// [`attribute_value()`]: Self::attribute_value
196 pub(crate) counter_values: RefCell<HashMap<String, String>>,
197
198 /// Running state for inline `{counter:…}` / `{counter2:…}` counters whose
199 /// target attribute is *locked* (API-set or a locked built-in).
200 ///
201 /// Such a counter must keep advancing across repeated references, but it
202 /// must not overwrite the locked attribute's readable value — so its
203 /// sequence is tracked here rather than in the readable
204 /// [`counter_values`](Self::counter_values) overlay. This mirrors
205 /// Asciidoctor's `Document#counter`, which advances `@counters` while
206 /// leaving `@attributes` untouched for a locked attribute (see
207 /// [`counter_impl`](Self::counter_impl)). A captioning counter is exempt:
208 /// its value is committed to the readable overlay even when locked.
209 ///
210 /// Accepted limitation: a locked inline counter tracks its sequence here
211 /// while a captioning counter tracks it in
212 /// [`counter_values`](Self::counter_values), so the two sequences diverge
213 /// when the *same* locked attribute is advanced both ways in one document
214 /// (e.g. an API-locked `example-number` driven by both example blocks and
215 /// inline `{counter:example-number}` references) — Asciidoctor keeps a
216 /// single `@counters` sequence shared across both. This is left unmatched
217 /// deliberately. The scenario — API-locking a `<context>-number` attribute
218 /// *and* mixing caption and inline use — is pathological, and exact parity
219 /// is unreachable regardless: this crate resolves inline counters during
220 /// parsing, whereas Asciidoctor advances captions during parsing but inline
221 /// references during conversion, so the two sequences interleave
222 /// differently no matter how the state is stored. Unifying the maps would
223 /// therefore add read-path complexity (the gate would have to be threaded
224 /// through [`ResolvedAttributes`] too) without actually matching
225 /// Asciidoctor's output here.
226 pub(crate) locked_counter_values: RefCell<HashMap<String, String>>,
227
228 /// Canonical names of attributes that are locked against modification from
229 /// the document body for the current scope.
230 ///
231 /// An AsciiDoc table cell creates a nested document that inherits the
232 /// parent document's attributes. An attribute that is *set* in the
233 /// parent _cannot_ be modified inside the cell (matching Asciidoctor,
234 /// which here diverges from the spec's "set or explicitly unset" wording),
235 /// so while a cell is being parsed every inherited attribute name
236 /// (other than a handful of exceptions) is recorded here and a body
237 /// attribute assignment to such a name is silently ignored. The set is
238 /// saved and restored around each cell, so the lock applies only within
239 /// the cell (and nests correctly).
240 pub(crate) locked_attribute_names: HashSet<String>,
241
242 /// Number of AsciiDoc table cells currently being parsed in the call stack.
243 ///
244 /// An AsciiDoc table cell creates a nested, standalone AsciiDoc document.
245 /// While that document is being parsed this counter is greater than zero,
246 /// which (matching Asciidoctor's `Document#nested?`) changes the default
247 /// cell separator of any table found inside from the vertical bar (`|`) to
248 /// the exclamation mark (`!`), so a nested table needs no explicit
249 /// `separator` attribute. The counter is incremented and decremented around
250 /// each AsciiDoc cell, so it nests correctly.
251 pub(crate) nested_document_depth: usize,
252
253 /// Number of privately-owned sub-sources currently being parsed in the call
254 /// stack.
255 ///
256 /// A Markdown-style blockquote and an AsciiDoc table cell parse their
257 /// blocks from an owned source string whose byte offsets do not map to
258 /// the primary document source. A footnote defined while such a
259 /// sub-source is being substituted therefore cannot record a
260 /// document-relative location for its cross-reference warning;
261 /// `define_footnote` reads this counter to detect that case and leave
262 /// the location unset (see
263 /// [`Footnote::location`](crate::document::Footnote)). It is incremented
264 /// and decremented around each owned sub-source parse, so it nests
265 /// correctly.
266 ///
267 /// Unlike [`nested_document_depth`](Self::nested_document_depth), this also
268 /// counts Markdown-style blockquotes (which are not nested documents), so
269 /// the two cannot be merged.
270 pub(crate) owned_subsource_depth: usize,
271
272 /// Source map of the document currently being parsed, populated by
273 /// [`Document::parse`] for the duration of the parse (and `None` outside
274 /// it).
275 ///
276 /// Block parsing works from the *preprocessed* source, so a span's line
277 /// number is relative to that flattened source rather than to the original
278 /// input file(s). An AsciiDoc table cell whose first line is an `include::`
279 /// directive re-runs the preprocessor over the cell's content: to report an
280 /// unresolved directive against the file and line where it *originally*
281 /// appeared (rather than "(root file)"), the cell must map its position in
282 /// the preprocessed source back through this map. It is only consulted
283 /// while parsing the top-level document (`nested_document_depth == 0`),
284 /// where a cell's span still refers to that source.
285 ///
286 /// [`Document::parse`]: crate::Document
287 pub(crate) source_map: Option<Rc<SourceMap>>,
288
289 /// Stack of source maps for the include-expanded (owned) AsciiDoc table
290 /// cells currently being parsed in the call stack (innermost last).
291 ///
292 /// An AsciiDoc cell whose first line is an `include::` directive is parsed
293 /// from a private, preprocessor-expanded copy of its content rather than
294 /// from the document source. While that owned copy is being parsed a span's
295 /// line number no longer indexes the document
296 /// [`source_map`](Self::source_map); it indexes the owned copy instead.
297 /// Each owned cell's own source map (produced by re-running the
298 /// preprocessor over its content) is pushed here for the duration of
299 /// its parse, so a directive buried inside the owned content can still
300 /// be mapped back to the file and line it *originally* came from —
301 /// needed to name that file in the "Unresolved directive" message and
302 /// to report the warning's true cursor via
303 /// [`Warning::origin`](crate::warnings::Warning::origin).
304 ///
305 /// The stack is empty while parsing the top-level document (and while
306 /// parsing a *borrowed* cell, which keeps document spans). It is non-empty
307 /// exactly when a span's line indexes an owned copy rather than the
308 /// document, which the source-map lookup uses to decide which map to
309 /// consult. It is pushed and popped around each owned-cell parse, so it
310 /// nests correctly.
311 pub(crate) owned_cell_source_maps: Vec<Rc<SourceMap>>,
312
313 /// Warnings raised by an `include::` directive buried inside an owned
314 /// (include-expanded) AsciiDoc table cell, each already resolved to the
315 /// `(file, line)` where the directive originally appeared.
316 ///
317 /// Such a warning is raised deep inside the owned cell's parse, where the
318 /// only spans available borrow the owned copy and cannot escape it, and no
319 /// document span maps to the directive. It is therefore recorded here (in a
320 /// lifetime-free, pre-resolved form) and drained once an enclosing
321 /// document-level cell can anchor it to a real document span while carrying
322 /// its true origin (see
323 /// [`Warning::origin`](crate::warnings::Warning::origin)).
324 ///
325 /// Wrapped in a [`RefCell`] only for symmetry with the other deferred
326 /// warning buffers; it is mutated through `&mut self`-free helpers so the
327 /// owned-cell parse (which holds the parser mutably) can still record into
328 /// it from within a `self_cell` construction closure.
329 owned_cell_warnings: RefCell<Vec<ResolvedWarning>>,
330
331 /// Catalog of callout numbers registered by verbatim blocks, used to
332 /// validate the callout lists that annotate them.
333 ///
334 /// Wrapped in a [`RefCell`] because callouts are registered deep inside the
335 /// callouts substitution step, where only a shared `&Parser` is available.
336 callouts: RefCell<CalloutCatalog>,
337
338 /// Warnings produced while replacing attribute references (e.g. a reference
339 /// to a missing attribute when `attribute-missing` is `warn`).
340 ///
341 /// Wrapped in a [`RefCell`] because attribute references are replaced deep
342 /// inside the attributes substitution step, where only a shared `&Parser`
343 /// is available. Each entry stores the byte offset and length of the source
344 /// span the warning refers to (rather than a borrowed
345 /// [`Span`](crate::Span), which the lifetime-free `Parser` cannot
346 /// hold), so the warnings can be turned into
347 /// spanned [`Warning`]s once the document's owned source is available.
348 substitution_warnings: RefCell<Vec<DeferredWarning>>,
349
350 /// An optional fixed reference time that pins the clock used to compute the
351 /// time-dependent document attributes (`docdate`, `doctime`, `docdatetime`,
352 /// `docyear`, and their `local*` siblings), for reproducible output.
353 ///
354 /// When set, it supersedes both the real wall clock and the
355 /// `SOURCE_DATE_EPOCH` environment variable as the value of "now" (which
356 /// drives the `local*` attributes and, absent an [`input_mtime`], the
357 /// `doc*` attributes too). See [`with_reference_time`] and
358 /// [`resolve_datetime_attribute`].
359 ///
360 /// [`input_mtime`]: Self::input_mtime
361 /// [`with_reference_time`]: Self::with_reference_time
362 /// [`resolve_datetime_attribute`]: Self::resolve_datetime_attribute
363 reference_time: Option<ReferenceTime>,
364
365 /// An optional fixed modification time of the source document, pinning the
366 /// clock that drives the `doc*` attributes (`docdate`, `doctime`,
367 /// `docdatetime`, `docyear`) specifically.
368 ///
369 /// This mirrors Asciidoctor's `input_mtime` option: the `local*` attributes
370 /// continue to track "now", while the `doc*` attributes reflect this source
371 /// modification time. See [`with_input_mtime`] and
372 /// [`resolve_datetime_attribute`].
373 ///
374 /// [`with_input_mtime`]: Self::with_input_mtime
375 /// [`resolve_datetime_attribute`]: Self::resolve_datetime_attribute
376 input_mtime: Option<ReferenceTime>,
377
378 /// The reference instants used to compute the time-dependent document
379 /// attributes, captured lazily the first time one of those attributes is
380 /// read during a parse (and reset at the start of each parse).
381 ///
382 /// The time-dependent attributes are *not* materialized into
383 /// [`attribute_values`](Self::attribute_values); instead they are resolved
384 /// on demand from this context (see
385 /// [`resolve_datetime_attribute`](Self::resolve_datetime_attribute)). A
386 /// parse that never references one therefore does no clock, environment, or
387 /// allocation work for them, and repeated reads within a parse observe a
388 /// single consistent instant.
389 ///
390 /// Wrapped in a [`RefCell`] because the capture happens through the shared
391 /// `&Parser` attribute readers (which the substitution code paths reach
392 /// with only a shared reference).
393 datetime_context: RefCell<Option<DatetimeContext>>,
394}
395
396/// A warning recorded in a form that does not borrow the source so it can live
397/// on the [`Parser`] (or be returned from preprocessing), to be reconstituted
398/// into a spanned [`Warning`] once the document's owned source is available.
399///
400/// This is used both for warnings raised while replacing attribute references
401/// and for warnings raised during preprocessing (e.g. an unresolved include
402/// directive). The `offset`/`len` pair locates the relevant text within the
403/// (preprocessed) document source.
404#[derive(Clone, Debug)]
405pub(crate) struct DeferredWarning {
406 /// Byte offset into the document source of the span this warning refers to.
407 pub(crate) offset: usize,
408
409 /// Byte length of the span this warning refers to.
410 pub(crate) len: usize,
411
412 /// The type of warning, already carrying any owned data it needs (such as
413 /// the missing attribute's name).
414 pub(crate) warning: WarningType,
415
416 /// A pre-resolved originating `(file, line)` for this warning, carried
417 /// through to [`Warning::origin`].
418 ///
419 /// This is `None` for warnings that point at real (emitted) output: their
420 /// [`offset`](Self::offset)/[`len`](Self::len) span resolves the location
421 /// through the document source map. It is `Some` for a preprocessor
422 /// directive that produces no output of its own — a malformed or
423 /// unterminated conditional directive — where there is no output span to
424 /// resolve against, so the directive's own file and line are recorded here
425 /// directly (the `offset`/`len` span is then only a best-effort anchor).
426 ///
427 /// [`Warning::origin`]: crate::warnings::Warning::origin
428 pub(crate) origin: Option<SourceLine>,
429}
430
431/// A warning whose location is already resolved to an originating
432/// `(file, line)`, independent of any source map.
433///
434/// Used for a warning raised inside an owned (include-expanded) AsciiDoc table
435/// cell, whose directive never appears in the document source: it is resolved
436/// against the owning cell's own source map when raised, then carried in this
437/// form until an enclosing document-level cell can surface it (see
438/// [`Parser::owned_cell_warnings`]).
439#[derive(Clone, Debug)]
440pub(crate) struct ResolvedWarning {
441 /// The originating file and line where the directive appeared.
442 pub(crate) origin: SourceLine,
443
444 /// The type of warning, already carrying any owned data it needs (such as
445 /// the missing include target).
446 pub(crate) warning: WarningType,
447}
448
449/// Tracks the callout numbers defined by verbatim blocks so that a callout list
450/// can be validated against the callouts it annotates.
451///
452/// This mirrors the relevant behavior of Asciidoctor's `Callouts` catalog: each
453/// verbatim block registers the callout numbers it defines into the current
454/// list, and each callout list checks its items against that list (warning
455/// about any item with no matching callout) before the list is closed.
456#[derive(Clone, Debug, Default)]
457struct CalloutCatalog {
458 /// Callout numbers registered (in document order) since the last callout
459 /// list was closed.
460 current: Vec<u32>,
461}
462
463impl Default for Parser {
464 fn default() -> Self {
465 Self {
466 // Starts empty: built-in defaults are resolved on the fly via the
467 // shared table (see `attribute_value`), not copied in per parser.
468 attribute_values: Arc::new(HashMap::new()),
469 default_attribute_values: built_in_default_values(),
470 renderer: Rc::new(HtmlSubstitutionRenderer {}),
471 primary_file_name: None,
472 path_resolver: PathResolver::default(),
473 include_file_handler: None,
474 docinfo_file_handler: None,
475 svg_file_handler: None,
476 image_file_handler: None,
477 catalog_assets: false,
478 safe: SafeMode::default(),
479 catalog: RefCell::new(Catalog::new()),
480 last_section_number: SectionNumber::default(),
481 last_appendix_section_number: SectionNumber {
482 section_type: SectionType::Appendix,
483 components: vec![],
484 appendix_letter: None,
485 },
486 sectnumlevels: 3,
487 topmost_section_type: SectionType::Normal,
488 parsing_bibliography_section_body: false,
489 in_bibliography_list_item: Cell::new(false),
490 mark_footnote_spans: Cell::new(false),
491 pending_block_title: None,
492 counter_values: RefCell::new(HashMap::new()),
493 locked_counter_values: RefCell::new(HashMap::new()),
494 locked_attribute_names: HashSet::new(),
495 nested_document_depth: 0,
496 owned_subsource_depth: 0,
497 source_map: None,
498 owned_cell_source_maps: vec![],
499 owned_cell_warnings: RefCell::new(vec![]),
500 callouts: RefCell::new(CalloutCatalog::default()),
501 substitution_warnings: RefCell::new(vec![]),
502 reference_time: None,
503 input_mtime: None,
504 datetime_context: RefCell::new(None),
505 }
506 }
507}
508
509impl Parser {
510 /// Parse a UTF-8 string as an AsciiDoc document.
511 ///
512 /// The [`Document`] data structure returned by this call has a '`static`
513 /// lifetime; this is an implementation detail. It retains a copy of the
514 /// `source` string that was passed in, but it is not tied to the lifetime
515 /// of that string.
516 ///
517 /// Nearly all of the data structures contained within the [`Document`]
518 /// structure are tied to the lifetime of the document and have a `'src`
519 /// lifetime to signal their dependency on the source document.
520 ///
521 /// **IMPORTANT:** The AsciiDoc language documentation states that UTF-16
522 /// encoding is allowed if a byte-order-mark (BOM) is present at the
523 /// start of a file. This format is not directly supported by the
524 /// `asciidoc-parser` crate. Any UTF-16 content must be re-encoded as
525 /// UTF-8 prior to parsing.
526 ///
527 /// The `Parser` struct will be updated with document attribute values
528 /// discovered during parsing. These values may be inspected using
529 /// [`attribute_value()`].
530 ///
531 /// # Warnings, not errors
532 ///
533 /// Any UTF-8 string is a valid AsciiDoc document, so this function does not
534 /// return an [`Option`] or [`Result`] data type. There may be any number of
535 /// character sequences that have ambiguous or potentially unintended
536 /// meanings. For that reason, a caller is advised to review the warnings
537 /// provided via the [`warnings()`] iterator.
538 ///
539 /// [`warnings()`]: Document::warnings
540 /// [`attribute_value()`]: Self::attribute_value
541 pub fn parse(&mut self, source: &str) -> Document<'static> {
542 let mut document = self.parse_deferred(source);
543
544 // Resolve cross-references against this document's own catalog. For
545 // multi-document workflows, use `parse_deferred` and resolve later with
546 // a caller-supplied resolver via `Document::resolve_references`.
547 document.resolve_against_own_catalog(&*self.renderer);
548
549 document
550 }
551
552 /// Parse a UTF-8 string as an AsciiDoc document, leaving cross-references
553 /// unresolved.
554 ///
555 /// This behaves like [`parse()`], except it does not resolve
556 /// cross-references (`<<id>>`, `xref:id[…]`). The returned [`Document`]
557 /// carries its references in a deferred state; resolve them later with
558 /// [`Document::resolve_references`].
559 ///
560 /// This is the entry point for multi-document workflows (e.g. Antora-style
561 /// site generation): parse every document with this method, build a
562 /// combined index from each document's [`catalog()`], then resolve each
563 /// document against that index. This crate does not merge catalogs
564 /// itself.
565 ///
566 /// [`parse()`]: Self::parse
567 /// [`catalog()`]: Document::catalog
568 pub fn parse_deferred(&mut self, source: &str) -> Document<'static> {
569 // The time-dependent document attributes (docdate, doctime, docdatetime,
570 // docyear, and their local* siblings) are resolved lazily from a
571 // reference instant captured the first time one is read (see
572 // `resolve_datetime_attribute`). Reset that capture so each parse sees a
573 // fresh "now"; a parse that never references one does no datetime work.
574 *self.datetime_context.borrow_mut() = None;
575
576 // Drop leading YAML-style front matter (and record it in the
577 // `front-matter` attribute) when `skip-front-matter` is set, before the
578 // source reaches the preprocessor or header parser.
579 let stripped_source = self.skip_front_matter(source);
580 let source = stripped_source.as_deref().unwrap_or(source);
581
582 let (preprocessed_source, source_map, preprocessor_warnings, includes) =
583 preprocess(source, self);
584
585 // NOTE: `Document::parse` will transfer the catalog to itself at the end of the
586 // parsing operation. Start each parse with a fresh catalog.
587 *self.catalog.borrow_mut() = Catalog::new();
588
589 // Seed the fresh catalog with the files the preprocessor just expanded,
590 // so an inter-document cross reference to an included file can resolve
591 // to an internal anchor while the document is parsed. Replaying each
592 // event lets the catalog resolve a file that was included both fully and
593 // partially to a full include.
594 {
595 let mut catalog = self.catalog.borrow_mut();
596 for (key, full) in includes {
597 catalog.register_include(&key, full);
598 }
599 }
600
601 // Start each parse with an empty callout catalog.
602 *self.callouts.borrow_mut() = CalloutCatalog::default();
603
604 // Start each parse with no pending substitution warnings.
605 self.substitution_warnings.borrow_mut().clear();
606
607 // Reset section numbering for each new document.
608 self.last_section_number = SectionNumber::default();
609
610 // Start each parse with no block title carried over from a section
611 // heading.
612 self.pending_block_title = None;
613
614 // Reset counter (and captioned-block) numbering for each new document.
615 self.counter_values.borrow_mut().clear();
616 self.locked_counter_values.borrow_mut().clear();
617
618 Document::parse(
619 &preprocessed_source,
620 source_map,
621 preprocessor_warnings,
622 self,
623 )
624 }
625
626 /// Drops leading YAML-style front matter from `source`, mirroring
627 /// Asciidoctor's `Reader#skip_front_matter!`.
628 ///
629 /// Front matter is a block opened by a line of exactly `---` at the very
630 /// start of the document and closed by a matching `---` line. Only this
631 /// `---` fence (the YAML convention) is recognized – a `+++` TOML fence is
632 /// not – and the captured content is stored verbatim, never parsed. It is
633 /// only removed when the `skip-front-matter` attribute is set
634 /// (typically via the API); otherwise `---` retains its ordinary
635 /// meaning and this is a no-op. When a well-formed block is found, its
636 /// content (the lines between the delimiters, joined by LF and with the
637 /// delimiters excluded) is stored in the `front-matter` document
638 /// attribute, and a rewritten copy of the source is returned in which
639 /// every removed line – both delimiters and the content – is replaced
640 /// by a blank line. Preserving the line *count* keeps every following
641 /// line at its original line number, matching Asciidoctor (whose reader
642 /// advances `lineno` past the skipped block); the leading blank lines
643 /// are ignored by the header parser.
644 ///
645 /// Returns `None` (leaving the source untouched and setting no attribute)
646 /// when `skip-front-matter` is not set, when the first line is not `---`,
647 /// or when no closing `---` delimiter is found.
648 fn skip_front_matter(&mut self, source: &str) -> Option<String> {
649 if !self.is_attribute_set("skip-front-matter") {
650 return None;
651 }
652
653 // Strip a line's trailing end-of-line sequence (LF or CRLF) so the
654 // delimiter comparison and the captured content match Asciidoctor's
655 // chomped lines.
656 fn line_content(line: &str) -> &str {
657 let line = line.strip_suffix('\n').unwrap_or(line);
658 line.strip_suffix('\r').unwrap_or(line)
659 }
660
661 let mut lines = source.split_inclusive('\n');
662
663 // Front matter must open on the very first line, which must be exactly
664 // `---`.
665 let first = lines.next()?;
666 if line_content(first) != "---" {
667 return None;
668 }
669
670 // Byte offset just past the region being consumed, and the number of
671 // physical lines consumed (starting with the opening delimiter).
672 let mut consumed_end = first.len();
673 let mut consumed_lines = 1usize;
674
675 let mut front_matter = String::new();
676 let mut closed = false;
677
678 for line in lines {
679 consumed_end += line.len();
680 consumed_lines += 1;
681
682 if line_content(line) == "---" {
683 closed = true;
684 break;
685 }
686
687 if !front_matter.is_empty() {
688 front_matter.push('\n');
689 }
690
691 front_matter.push_str(line_content(line));
692 }
693
694 // Without a closing delimiter the block is malformed; leave the source
695 // (and attributes) untouched, matching Asciidoctor.
696 if !closed {
697 return None;
698 }
699
700 self.set_attribute_by_value_from_header("front-matter", &front_matter);
701
702 // Replace the consumed region with one blank line per removed physical
703 // line so the remaining content keeps its original line numbers.
704 let mut rewritten = String::with_capacity(source.len());
705 for _ in 0..consumed_lines {
706 rewritten.push('\n');
707 }
708
709 rewritten.push_str(&source[consumed_end..]);
710
711 Some(rewritten)
712 }
713
714 /// Retrieves the current interpreted value of a [document attribute].
715 ///
716 /// Each document holds a set of name-value pairs called document
717 /// attributes. These attributes provide a means of configuring the AsciiDoc
718 /// processor, declaring document metadata, and defining reusable content.
719 /// This page introduces document attributes and answers some questions
720 /// about the terminology used when referring to them.
721 ///
722 /// ## What are document attributes?
723 ///
724 /// Document attributes are effectively document-scoped variables for the
725 /// AsciiDoc language. The AsciiDoc language defines a set of built-in
726 /// attributes, and also allows the author (or extensions) to define
727 /// additional document attributes, which may replace built-in attributes
728 /// when permitted.
729 ///
730 /// Built-in attributes either provide access to read-only information about
731 /// the document and its environment or allow the author to configure
732 /// behavior of the AsciiDoc processor for a whole document or select
733 /// regions. Built-in attributes are effectively unordered. User-defined
734 /// attribute serve as a powerful text replacement tool. User-defined
735 /// attributes are stored in the order in which they are defined.
736 ///
737 /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
738 pub fn attribute_value<N: AsRef<str>>(&self, name: N) -> InterpretedValue {
739 let name = name.as_ref();
740
741 // A counter's current value lives in the overlay and supersedes any
742 // earlier value of the attribute of the same name (see
743 // [`counter_values`](Self::counter_values)).
744 if let Some(value) = self.counter_values.borrow().get(name) {
745 return InterpretedValue::Value(value.clone());
746 }
747
748 // An unset `relfilesuffix` reads as the *effective* value of
749 // `outfilesuffix` — routed through this same reader so an
750 // `outfilesuffix` counter overlay is honored too (see
751 // [`tracks_outfilesuffix`](Self::tracks_outfilesuffix)).
752 if self.tracks_outfilesuffix(name) {
753 return self.attribute_value("outfilesuffix");
754 }
755
756 // Under `SafeMode::Server` or greater, `docdir` reads as empty and
757 // `docfile` is relativized (its `docdir` prefix stripped), matching
758 // Ruby Asciidoctor's document-init masking. Resolved here at read time
759 // (like `relfilesuffix` above and `max-attribute-value-size`) so the
760 // API-provided values are never rewritten and builder-call order does
761 // not matter (see [`masked_doc_path`]).
762 //
763 // [`masked_doc_path`]: crate::parser::safe_mode::masked_doc_path
764 if self.safe >= SafeMode::Server
765 && let Some(masked) = masked_doc_path(name, |n| self.raw_set_value(n))
766 {
767 return masked;
768 }
769
770 // `basebackend` / `filetype` are derived on the fly from the current
771 // `backend` (see [`derived_backend_value`]) rather than stored; they are
772 // read-only intrinsics, so no per-parser entry ever shadows this.
773 if let Some(value) = derived_backend_value(name, &self.attribute_values) {
774 return value;
775 }
776
777 match self.effective_attribute(name) {
778 Some(av) => {
779 if let InterpretedValue::Set = av.value
780 && let Some(default) = self.default_attribute_values.get(name)
781 {
782 InterpretedValue::Value(default.clone())
783 } else {
784 av.value.clone()
785 }
786 }
787 // A time-dependent attribute is not materialized in either table; it
788 // is resolved on demand from the captured reference instant.
789 None => self
790 .resolve_datetime_attribute(name)
791 .unwrap_or(InterpretedValue::Unset),
792 }
793 }
794
795 /// Returns the raw stored string value of `name` if it currently resolves
796 /// to a plain [`Value`](InterpretedValue::Value), *before* any
797 /// safe-mode masking is applied. Returns `None` when the attribute is unset
798 /// or resolves to a non-value form.
799 ///
800 /// This feeds [`masked_doc_path`], which must compute the `docfile`
801 /// relativization from the *original* API-provided `docdir` rather than its
802 /// masked (blanked) form.
803 fn raw_set_value(&self, name: &str) -> Option<String> {
804 match self.effective_attribute(name)?.value {
805 InterpretedValue::Value(ref v) => Some(v.clone()),
806 _ => None,
807 }
808 }
809
810 /// Returns the effective attribute definition for `name`: a per-parser
811 /// entry (an override or an explicit [unset] tombstone) shadows the
812 /// shared built-in default, which in turn shadows an on-the-fly
813 /// synthesized attribute (the active `backend-html5-doctype-*` and
814 /// `safe-mode-*` flags). The synthesized attributes are never
815 /// materialized in either table.
816 ///
817 /// This is the *raw* lookup used by the attribute writers to decide whether
818 /// a name is locked against modification. The attribute *readers*
819 /// additionally resolve the read-only default of `relfilesuffix` (see
820 /// [`tracks_outfilesuffix`](Self::tracks_outfilesuffix)).
821 ///
822 /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
823 pub(crate) fn effective_attribute(&self, name: &str) -> Option<&AttributeValue> {
824 if let Some(av) = self.attribute_values.get(name) {
825 return Some(av);
826 }
827 // `max-attribute-value-size` carries its `4096` default only under
828 // Secure, so it is resolved as a mode-aware synthesized attribute rather
829 // than a fixed built-in. It is consulted here *after* the per-parser map
830 // so a caller-supplied value (always API-only, hence always in that map)
831 // wins regardless of builder-call order, and a `with_safe_mode` change
832 // never rewrites it.
833 if name == "max-attribute-value-size" {
834 return Some(max_attribute_value_size_default(
835 self.safe == SafeMode::Secure,
836 ));
837 }
838 // `user-home` is the user's home directory below `SafeMode::Server` and
839 // the masking `.` at `Server`/`Secure`, so it too is resolved as a
840 // mode-aware synthesized attribute. Consulted here *after* the
841 // per-parser map so a caller-supplied `user-home` (always API-only,
842 // hence always in that map) wins regardless of builder-call order.
843 if name == "user-home" {
844 return Some(user_home_default(self.safe < SafeMode::Server));
845 }
846 if let Some(av) = built_in_attr(name) {
847 return Some(av);
848 }
849 synthesized_attr(name, &self.attribute_values)
850 }
851
852 /// Reports whether `name` is `relfilesuffix` in its unset state, in which
853 /// case a *read* resolves it to the current value of `outfilesuffix` (the
854 /// two diverge for non-HTML backends, e.g. `.xml` for DocBook — see
855 /// [issue #657](https://github.com/asciidoc-rs/asciidoc-parser/issues/657)).
856 ///
857 /// Returns `false` once `relfilesuffix` is explicitly set or unset (an
858 /// entry — a value or an [unset] tombstone — then lives in the
859 /// per-parser map), and for every other name. The redirect is
860 /// deliberately confined to the value *readers*: the attribute
861 /// *writers* consult [`effective_attribute`](Self::effective_attribute)
862 /// directly, so `relfilesuffix` stays modifiable anywhere rather than
863 /// inheriting the header-only modification context of `outfilesuffix`.
864 /// Callers must apply a like-named counter overlay first, so a
865 /// `{counter:relfilesuffix}` still wins over the tracked default.
866 ///
867 /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
868 pub(crate) fn tracks_outfilesuffix(&self, name: &str) -> bool {
869 name == "relfilesuffix" && !self.attribute_values.contains_key(name)
870 }
871
872 /// Returns `true` if the parser has a [document attribute] by this name.
873 ///
874 /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
875 pub fn has_attribute<N: AsRef<str>>(&self, name: N) -> bool {
876 let name = name.as_ref();
877 if self.counter_values.borrow().contains_key(name) {
878 return true;
879 }
880 if self.tracks_outfilesuffix(name) {
881 return self.has_attribute("outfilesuffix");
882 }
883 // A derived `basebackend` / `filetype` is present only while `backend`
884 // resolves to a non-empty value (see [`derived_backend_value`]).
885 if derived_backend_value(name, &self.attribute_values).is_some() {
886 return true;
887 }
888 self.effective_attribute(name).is_some() || self.resolve_datetime_attribute(name).is_some()
889 }
890
891 /// Returns `true` if the parser has a [document attribute] by this name
892 /// which has been set (i.e. is present and not [unset]).
893 ///
894 /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
895 /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
896 pub fn is_attribute_set<N: AsRef<str>>(&self, name: N) -> bool {
897 let name = name.as_ref();
898
899 // A counter always holds a concrete (set) value.
900 if self.counter_values.borrow().contains_key(name) {
901 return true;
902 }
903
904 if self.tracks_outfilesuffix(name) {
905 return self.is_attribute_set("outfilesuffix");
906 }
907
908 // A derived `basebackend` / `filetype` holds a concrete (set) value
909 // whenever it is present, i.e. while `backend` is non-empty (see
910 // [`derived_backend_value`]).
911 if derived_backend_value(name, &self.attribute_values).is_some() {
912 return true;
913 }
914
915 self.effective_attribute(name)
916 .map(|a| a.value != InterpretedValue::Unset)
917 .unwrap_or_else(|| self.resolve_datetime_attribute(name).is_some())
918 }
919
920 /// Returns the current `leveloffset` document attribute as a signed
921 /// integer.
922 ///
923 /// The `leveloffset` attribute shifts the effective level of every section
924 /// heading in scope (see the include directive's `leveloffset` option and
925 /// the `:leveloffset:` attribute entry). Relative assignments (`+N` / `-N`)
926 /// are resolved to an absolute value when the attribute is set (see
927 /// [`resolve_leveloffset_assignment`](Self::resolve_leveloffset_assignment)),
928 /// so the stored value is always a plain integer; a non-integer or unset
929 /// value yields an offset of `0`.
930 pub(crate) fn level_offset(&self) -> i32 {
931 match self.attribute_value("leveloffset") {
932 InterpretedValue::Value(v) => v.trim().parse::<i32>().unwrap_or(0),
933 _ => 0,
934 }
935 }
936
937 /// Returns the effective `max-attribute-value-size`: the byte limit applied
938 /// to a resolved attribute-entry value, or `None` when no limit is in
939 /// force.
940 ///
941 /// The value is coerced as Ruby's `String#to_i` would (matching
942 /// Asciidoctor); a non-positive result — including an explicit unset or `0`
943 /// — disables the limit. The `4096` default only exists under
944 /// `SafeMode::Secure` (see
945 /// [`apply_safe_mode_attributes`](Self::apply_safe_mode_attributes)), so in
946 /// a relaxed safe mode this resolves to `None` unless the caller sets an
947 /// explicit positive value.
948 fn max_attribute_value_size(&self) -> Option<usize> {
949 match self.attribute_value("max-attribute-value-size") {
950 InterpretedValue::Value(value) => {
951 let size = super::preprocessor::ruby_to_i(&value);
952 (size > 0).then(|| usize::try_from(size).unwrap_or(usize::MAX))
953 }
954 _ => None,
955 }
956 }
957
958 /// Applies the [`max-attribute-value-size`](Self::max_attribute_value_size)
959 /// limit to a freshly resolved attribute-entry `value`, truncating it (on a
960 /// character boundary, so a multibyte character is never split) when it
961 /// exceeds the limit. Values that already fit, and non-`Value` variants,
962 /// are returned unchanged.
963 fn limit_attribute_value_size(&self, value: InterpretedValue) -> InterpretedValue {
964 let Some(max) = self.max_attribute_value_size() else {
965 return value;
966 };
967
968 let InterpretedValue::Value(text) = value else {
969 return value;
970 };
971
972 if text.len() <= max {
973 return InterpretedValue::Value(text);
974 }
975
976 // Back up to the nearest character boundary at or below `max` so a
977 // multibyte character straddling the limit is dropped whole rather than
978 // split (which would otherwise yield invalid UTF-8).
979 let mut end = max;
980 while end > 0 && !text.is_char_boundary(end) {
981 end -= 1;
982 }
983
984 let mut text = text;
985 text.truncate(end);
986 InterpretedValue::Value(text)
987 }
988
989 /// Resolves a `leveloffset` assignment value, converting a relative form
990 /// (`+N` / `-N`) into the absolute value it produces given the
991 /// `leveloffset` currently in effect. Absolute values, and values that
992 /// aren't a signed integer, are returned unchanged.
993 ///
994 /// This mirrors Asciidoctor, where a relative `leveloffset` accumulates on
995 /// top of the offset already in effect. That accumulation is what lets the
996 /// offsets of nested includes compose: each `include::[leveloffset=+1]`
997 /// (and its `:leveloffset: +1` wrapper) shifts headings one level further
998 /// down relative to wherever the surrounding content already sits.
999 fn resolve_leveloffset_assignment(&self, value: InterpretedValue) -> InterpretedValue {
1000 let InterpretedValue::Value(ref v) = value else {
1001 return value;
1002 };
1003
1004 // Only a leading `+`/`-` marks a relative assignment; anything else
1005 // (an absolute value, or a non-numeric value) is stored unchanged.
1006 let trimmed = v.trim();
1007 if !trimmed.starts_with(['+', '-']) {
1008 return value;
1009 }
1010
1011 // Parse the whole signed value as `i64` so the extreme relative delta
1012 // `-2147483648` (whose magnitude exceeds `i32::MAX`) is still read as
1013 // itself rather than failing and being stored as an absolute value.
1014 match trimmed.parse::<i64>() {
1015 // The running offset is a valid `i32`, so widening it to `i64`
1016 // makes the accumulation itself infallible; `saturating_add` then
1017 // guards the (already absurd) case of a delta near `i64::MIN/MAX`,
1018 // and the result is clamped back into the `i32` the attribute
1019 // stores. This keeps a pathological offset from overflowing —
1020 // which would panic in debug builds and wrap in release builds —
1021 // rather than imposing a real bound the syntax does not otherwise
1022 // impose.
1023 Ok(delta) => InterpretedValue::Value(
1024 (self.level_offset() as i64)
1025 .saturating_add(delta)
1026 .clamp(i32::MIN as i64, i32::MAX as i64)
1027 .to_string(),
1028 ),
1029 Err(_) => value,
1030 }
1031 }
1032
1033 /// Resolves a `leveloffset` assignment (see
1034 /// [`resolve_leveloffset_assignment`](Self::resolve_leveloffset_assignment))
1035 /// and, if the resulting absolute offset is so large or small that *every*
1036 /// heading would be shifted outside the supported 1..=5 section-level
1037 /// range, records a [`LeveloffsetExcludesAllHeadingLevels`] warning
1038 /// against `span`.
1039 ///
1040 /// [`LeveloffsetExcludesAllHeadingLevels`]:
1041 /// crate::warnings::WarningType::LeveloffsetExcludesAllHeadingLevels
1042 fn resolve_leveloffset_and_warn<'src>(
1043 &self,
1044 value: InterpretedValue,
1045 span: crate::Span<'src>,
1046 warnings: &mut Vec<Warning<'src>>,
1047 ) -> InterpretedValue {
1048 let value = self.resolve_leveloffset_assignment(value);
1049
1050 if let InterpretedValue::Value(ref v) = value
1051 && let Ok(offset) = v.trim().parse::<i32>()
1052 && !leveloffset_admits_any_heading(offset)
1053 {
1054 warnings.push(Warning {
1055 source: span,
1056 warning: WarningType::LeveloffsetExcludesAllHeadingLevels(offset),
1057 origin: None,
1058 });
1059 }
1060
1061 value
1062 }
1063
1064 /// Captures the parser's fully-resolved document-attribute state so it can
1065 /// outlive the parser — for example, retained on a [`Document`] to answer
1066 /// [`attribute_value`]/[`has_attribute`]/[`is_attribute_set`] without a
1067 /// parser in hand (the embed path a renderer uses for `convert_document`).
1068 ///
1069 /// This shares the parser's attribute tables by [`Arc`] rather than copying
1070 /// them, so it is cheap to take on every parse (the large built-in table is
1071 /// never deep-cloned). The time-dependent attributes are not materialized;
1072 /// the snapshot carries the reference-time configuration so it can resolve
1073 /// them on demand exactly as the parser does. See [`ResolvedAttributes`].
1074 ///
1075 /// [`Document`]: crate::Document
1076 /// [`attribute_value`]: Self::attribute_value
1077 /// [`has_attribute`]: Self::has_attribute
1078 /// [`is_attribute_set`]: Self::is_attribute_set
1079 pub(crate) fn snapshot_attributes(&self) -> ResolvedAttributes {
1080 ResolvedAttributes::new(
1081 Arc::clone(&self.attribute_values),
1082 Arc::clone(&self.default_attribute_values),
1083 self.counter_values.borrow().clone(),
1084 self.safe,
1085 self.reference_time.clone(),
1086 self.input_mtime.clone(),
1087 )
1088 }
1089
1090 /// Resolves whether a document title should be displayed, from the
1091 /// `showtitle`/`notitle` attribute pair (which are complements).
1092 ///
1093 /// `showtitle` takes precedence: if present, the title shows precisely when
1094 /// it is set. Otherwise `notitle`, if present, hides the title when set.
1095 /// When neither attribute is present, `default_shown` decides — a
1096 /// standalone document (such as a nested AsciiDoc table cell) shows its
1097 /// title, while an embedded document does not.
1098 pub(crate) fn resolve_show_title(&self, default_shown: bool) -> bool {
1099 if self.has_attribute("showtitle") {
1100 self.is_attribute_set("showtitle")
1101 } else if self.has_attribute("notitle") {
1102 !self.is_attribute_set("notitle")
1103 } else {
1104 default_shown
1105 }
1106 }
1107
1108 /// Applies the `notitle` ⇔ `showtitle` inverse-toggle linkage
1109 /// (Asciidoctor asciidoctor/asciidoctor#3804).
1110 ///
1111 /// `notitle` and `showtitle` are two spellings of a single "show the
1112 /// document title" switch, wired as opposites: assigning either attribute
1113 /// updates the other so the resolved document reflects one consistent
1114 /// toggle. This yields last-assignment-wins semantics for free, since each
1115 /// assignment rewrites the partner left by the previous one.
1116 ///
1117 /// `attr_name` is the attribute just assigned and `value` its stored value;
1118 /// the call is a no-op for any other name. Turning the toggle *off* (an
1119 /// explicit [unset], e.g. `:!notitle:`) turns the partner *on* — it is
1120 /// stored [set] with the same `modification_context` and
1121 /// `silent_when_locked` flag as the triggering assignment. Turning the
1122 /// toggle *on* (an empty `Set` or an explicit value, e.g. `:notitle:`)
1123 /// *removes* the partner entirely.
1124 ///
1125 /// The partner is removed — rather than left as an explicit unset
1126 /// tombstone — to mirror Asciidoctor's attribute-hash semantics, where an
1127 /// "off" attribute is simply absent. That keeps every observer consistent:
1128 /// `has_attribute`, `ifdef`/`ifndef`, and `{partner}` reference
1129 /// substitution all see the same absence Asciidoctor does (so, e.g., a
1130 /// `{showtitle}` reference stays literal after `:notitle:` rather than
1131 /// silently resolving to an empty string).
1132 ///
1133 /// [set]: https://docs.asciidoctor.org/asciidoc/latest/attributes/set-attributes/
1134 /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
1135 fn apply_title_visibility_linkage(
1136 &mut self,
1137 attr_name: &str,
1138 value: &InterpretedValue,
1139 modification_context: ModificationContext,
1140 silent_when_locked: bool,
1141 ) {
1142 let partner = match attr_name {
1143 "notitle" => "showtitle",
1144 "showtitle" => "notitle",
1145 _ => return,
1146 };
1147
1148 // Either way the partner supersedes (and resets) any counter of the
1149 // same name, mirroring a direct assignment.
1150 self.counter_values.borrow_mut().remove(partner);
1151
1152 if let InterpretedValue::Unset = value {
1153 // The toggle is off, so the partner turns on.
1154 Arc::make_mut(&mut self.attribute_values).insert(
1155 partner.to_string(),
1156 AttributeValue {
1157 allowable_value: AllowableValue::Any,
1158 modification_context,
1159 silent_when_locked,
1160 value: InterpretedValue::Set,
1161 },
1162 );
1163 } else if self.attribute_values.contains_key(partner) {
1164 // The toggle is on, so the partner turns off — and, matching
1165 // Asciidoctor, "off" means absent. (Guarded so the common case of
1166 // no prior partner entry does not clone the shared map.)
1167 Arc::make_mut(&mut self.attribute_values).remove(partner);
1168 }
1169 }
1170
1171 /// Forces the `doctype` attribute to `value`.
1172 ///
1173 /// Used when a nested AsciiDoc table cell resets its doctype to the default
1174 /// (a cell does not inherit the parent's doctype). The value stays
1175 /// modifiable from the document body so the cell may still set its own
1176 /// doctype.
1177 ///
1178 /// The derived `backend-html5-doctype-{doctype}` attribute needs no
1179 /// explicit refresh: it is synthesized on the fly for whatever
1180 /// `doctype` currently resolves to (see
1181 /// [`attribute_value`](Self::attribute_value)).
1182 pub(crate) fn force_doctype(&mut self, value: &str) {
1183 Arc::make_mut(&mut self.attribute_values).insert(
1184 "doctype".to_string(),
1185 AttributeValue {
1186 allowable_value: AllowableValue::Any,
1187 modification_context: ModificationContext::ApiOrDocumentBody,
1188 silent_when_locked: false,
1189 value: InterpretedValue::Value(value.to_string()),
1190 },
1191 );
1192 }
1193
1194 /// Sets the value of an [intrinsic attribute].
1195 ///
1196 /// Intrinsic attributes are set automatically by the processor. These
1197 /// attributes provide information about the document being processed (e.g.,
1198 /// `docfile`), the security mode under which the processor is running
1199 /// (e.g., `safe-mode-name`), and information about the user’s environment
1200 /// (e.g., `user-home`).
1201 ///
1202 /// The [`modification_context`](ModificationContext) establishes whether
1203 /// the value can be subsequently modified by the document header and/or in
1204 /// the document body.
1205 ///
1206 /// Subsequent calls to this function or [`with_intrinsic_attribute_bool()`]
1207 /// are always permitted. The last such call for any given attribute name
1208 /// takes precendence.
1209 ///
1210 /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1211 ///
1212 /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
1213 pub fn with_intrinsic_attribute<N: AsRef<str>, V: AsRef<str>>(
1214 mut self,
1215 name: N,
1216 value: V,
1217 modification_context: ModificationContext,
1218 ) -> Self {
1219 let name = name.as_ref().to_lowercase();
1220 let value = InterpretedValue::Value(value.as_ref().to_string());
1221 let attribute_value = AttributeValue {
1222 allowable_value: AllowableValue::Any,
1223 modification_context,
1224 silent_when_locked: false,
1225 value: value.clone(),
1226 };
1227
1228 Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1229
1230 self.apply_title_visibility_linkage(&name, &value, modification_context, false);
1231
1232 self
1233 }
1234
1235 /// Sets the value of an [intrinsic attribute], rejecting any disallowed
1236 /// subsequent write *silently*.
1237 ///
1238 /// This behaves exactly like [`with_intrinsic_attribute()`] except that a
1239 /// document header or body assignment that the
1240 /// [`modification_context`](ModificationContext) does not permit is dropped
1241 /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
1242 /// The rejected write is otherwise handled identically (the value is left
1243 /// unchanged).
1244 ///
1245 /// This reproduces Asciidoctor's *silent* safe-mode attribute restrictions:
1246 /// under `SERVER`/`SECURE`, a document assignment of a restricted
1247 /// conversion attribute (`backend`, `doctype`, `docinfo`,
1248 /// `source-highlighter`) is simply dropped, with no diagnostic. Seed
1249 /// such an attribute as an [`ApiOnly`](ModificationContext::ApiOnly)
1250 /// silent intrinsic to lock it against document assignment without
1251 /// warning.
1252 ///
1253 /// Subsequent calls to this function or the other
1254 /// `with_intrinsic_attribute` variants are always permitted. The last
1255 /// such call for any given attribute name takes precedence.
1256 ///
1257 /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1258 ///
1259 /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
1260 pub fn with_intrinsic_attribute_silent<N: AsRef<str>, V: AsRef<str>>(
1261 mut self,
1262 name: N,
1263 value: V,
1264 modification_context: ModificationContext,
1265 ) -> Self {
1266 let name = name.as_ref().to_lowercase();
1267 let value = InterpretedValue::Value(value.as_ref().to_string());
1268 let attribute_value = AttributeValue {
1269 allowable_value: AllowableValue::Any,
1270 modification_context,
1271 silent_when_locked: true,
1272 value: value.clone(),
1273 };
1274
1275 Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1276
1277 self.apply_title_visibility_linkage(&name, &value, modification_context, true);
1278
1279 self
1280 }
1281
1282 /// Register a referenceable element (anchor, section, bibliography entry)
1283 /// in the document catalog.
1284 ///
1285 /// This takes `&self` (rather than `&mut self`) so that it can be called
1286 /// from inline-substitution code paths that only hold a shared reference to
1287 /// the parser, such as a regex [`Replacer`](regex::Replacer).
1288 pub(crate) fn register_ref(
1289 &self,
1290 id: &str,
1291 reftext: Option<&str>,
1292 ref_type: RefType,
1293 ) -> Result<(), crate::document::DuplicateIdError> {
1294 self.catalog
1295 .borrow_mut()
1296 .register_ref(id, reftext, ref_type)
1297 }
1298
1299 /// Attaches an [`XrefSignifier`](crate::parser::XrefSignifier) to an
1300 /// already-registered catalog element, so a cross-reference to it can build
1301 /// `full`/`short` [`xrefstyle`](crate::parser::XrefStyle) text.
1302 ///
1303 /// Takes `&self` for the same reason as
1304 /// [`register_ref`](Self::register_ref).
1305 pub(crate) fn set_ref_signifier(&self, id: &str, signifier: crate::parser::XrefSignifier) {
1306 self.catalog.borrow_mut().set_signifier(id, signifier);
1307 }
1308
1309 /// Records a referenced image in the document catalog when
1310 /// [`catalog_assets`](Self::with_catalog_assets) is enabled. A no-op
1311 /// otherwise.
1312 ///
1313 /// `target` is the (already attribute-substituted) image target as written
1314 /// in the macro; `imagesdir` is the value of the document `imagesdir`
1315 /// attribute at the point of reference, or `None` when it is unset.
1316 ///
1317 /// Takes `&self` so it can be called from the macros substitution step,
1318 /// which only holds a shared reference to the parser.
1319 pub(crate) fn register_image(&self, target: String, imagesdir: Option<String>) {
1320 if self.catalog_assets {
1321 self.catalog.borrow_mut().register_image(target, imagesdir);
1322 }
1323 }
1324
1325 /// Registers a callout number defined by a verbatim block.
1326 ///
1327 /// Takes `&self` so it can be called from the callouts substitution step,
1328 /// which only holds a shared reference to the parser.
1329 pub(crate) fn register_callout(&self, number: u32) {
1330 self.callouts.borrow_mut().current.push(number);
1331 }
1332
1333 /// Returns `true` if a callout numbered `number` was registered for the
1334 /// current (not-yet-closed) callout list.
1335 pub(crate) fn callout_defined(&self, number: u32) -> bool {
1336 self.callouts.borrow().current.contains(&number)
1337 }
1338
1339 /// Closes the current callout list, so callouts registered afterward belong
1340 /// to the next list.
1341 pub(crate) fn close_callout_list(&self) {
1342 self.callouts.borrow_mut().current.clear();
1343 }
1344
1345 /// Returns the number of an already-defined footnote with the given ID, if
1346 /// one exists in the current document's footnote registry.
1347 ///
1348 /// Takes `&self` so it can be called from the macros substitution step,
1349 /// which only holds a shared reference to the parser.
1350 pub(crate) fn footnote_index_for_id(&self, id: &str) -> Option<String> {
1351 self.catalog
1352 .borrow()
1353 .footnote_with_id(id)
1354 .map(|f| f.index.clone())
1355 }
1356
1357 /// Defines a new footnote, advancing the `footnote-number` counter and
1358 /// registering the footnote in the current document's registry. Returns the
1359 /// number assigned to the footnote.
1360 ///
1361 /// `source` is the span of the content the defining `footnote:[…]` macro
1362 /// was written in; its offset into the document source is recorded so a
1363 /// cross-reference warning can be anchored at the footnote rather than at
1364 /// the whole document. When the footnote is defined while substituting a
1365 /// privately-owned sub-source (a Markdown-style blockquote or an AsciiDoc
1366 /// table cell — see
1367 /// [`owned_subsource_depth`](Self::owned_subsource_depth)), that offset
1368 /// does not map to the document, so no location is recorded and
1369 /// resolution falls back to the whole-document span.
1370 ///
1371 /// Takes `&self` so it can be called from the macros substitution step.
1372 pub(crate) fn define_footnote(
1373 &self,
1374 id: Option<&str>,
1375 text: String,
1376 xrefs: Vec<crate::content::XrefSegment>,
1377 source: crate::Span<'_>,
1378 ) -> String {
1379 // A footnote's text is extracted out of the block during macro
1380 // substitution, so any cross-reference inside it never reaches the
1381 // document-level resolution pass over block content. Those
1382 // cross-references are captured (as placeholders in `text` plus the
1383 // `xrefs` segments) so they can be resolved alongside the block
1384 // references. The stored `text` is the unresolved fallback rendering
1385 // until then, so it is always clean.
1386 let (text, deferred) = if xrefs.is_empty() {
1387 (text, None)
1388 } else {
1389 let deferred = crate::content::FootnoteDeferred::new(text, xrefs);
1390 let rendered = deferred.render(&*self.renderer);
1391 (rendered, Some(Box::new(deferred)))
1392 };
1393
1394 // Footnotes are numbered consecutively throughout the document via the
1395 // `footnote-number` counter, which is seeded to `0` so the first
1396 // footnote is numbered `1`. The counter is a document-wide attribute, so
1397 // numbering continues across nested documents (AsciiDoc table cells)
1398 // even though the footnote *list* does not. The counter honors any seed
1399 // the document sets, so a non-integer seed yields a non-integer number
1400 // (matching Asciidoctor); the value is therefore kept as a string.
1401 let index = self.counter("footnote-number", None);
1402
1403 // Record the defining occurrence's location only when it is locatable in
1404 // the document source. A footnote defined inside an owned sub-source
1405 // indexes that private source, whose offset would misplace the warning,
1406 // so it is left unrecorded (resolution then falls back to the
1407 // whole-document span).
1408 let location = if self.owned_subsource_depth == 0 {
1409 Some((source.byte_offset(), source.data().len()))
1410 } else {
1411 None
1412 };
1413
1414 self.catalog
1415 .borrow_mut()
1416 .register_footnote(crate::document::Footnote {
1417 index: index.clone(),
1418 id: id.map(|s| s.to_owned()),
1419 text,
1420 deferred,
1421 location,
1422 });
1423
1424 index
1425 }
1426
1427 /// Removes and returns the current document's footnote list, leaving an
1428 /// empty list behind. Used to give a nested document (an AsciiDoc table
1429 /// cell) its own footnote registry; see [`restore_footnotes`].
1430 ///
1431 /// [`restore_footnotes`]: Self::restore_footnotes
1432 pub(crate) fn take_footnotes(&self) -> Vec<crate::document::Footnote> {
1433 self.catalog.borrow_mut().take_footnotes()
1434 }
1435
1436 /// Restores a previously-[taken](Self::take_footnotes) footnote list,
1437 /// discarding any footnotes registered in the meantime (i.e. those defined
1438 /// inside the nested document).
1439 pub(crate) fn restore_footnotes(&self, footnotes: Vec<crate::document::Footnote>) {
1440 self.catalog.borrow_mut().restore_footnotes(footnotes);
1441 }
1442
1443 /// Records a warning produced while replacing attribute references.
1444 ///
1445 /// Takes `&self` so it can be called from the attributes substitution step,
1446 /// which only holds a shared reference to the parser. `source` locates the
1447 /// text the warning refers to; its byte offset and length are stored so a
1448 /// spanned [`Warning`] can be reconstructed later (see
1449 /// [`take_substitution_warnings`](Self::take_substitution_warnings)).
1450 pub(crate) fn record_substitution_warning(
1451 &self,
1452 source: crate::Span<'_>,
1453 warning: WarningType,
1454 ) {
1455 self.substitution_warnings
1456 .borrow_mut()
1457 .push(DeferredWarning {
1458 offset: source.byte_offset(),
1459 len: source.len(),
1460 warning,
1461 origin: None,
1462 });
1463 }
1464
1465 /// Returns the number of substitution warnings recorded so far.
1466 ///
1467 /// Used together with [`truncate_substitution_warnings`] to discard
1468 /// warnings recorded while parsing an owned (e.g. include-expanded) source,
1469 /// whose offsets do not refer to the primary document source.
1470 ///
1471 /// [`truncate_substitution_warnings`]: Self::truncate_substitution_warnings
1472 pub(crate) fn substitution_warnings_len(&self) -> usize {
1473 self.substitution_warnings.borrow().len()
1474 }
1475
1476 /// Discards any substitution warnings recorded since the buffer held `len`
1477 /// entries.
1478 pub(crate) fn truncate_substitution_warnings(&self, len: usize) {
1479 self.substitution_warnings.borrow_mut().truncate(len);
1480 }
1481
1482 /// Removes and returns any substitution warnings recorded since the buffer
1483 /// held `len` entries.
1484 pub(crate) fn drain_substitution_warnings_since(&self, len: usize) -> Vec<DeferredWarning> {
1485 self.substitution_warnings.borrow_mut().split_off(len)
1486 }
1487
1488 /// Takes the substitution warnings recorded during parsing, leaving the
1489 /// buffer empty.
1490 pub(crate) fn take_substitution_warnings(&self) -> Vec<DeferredWarning> {
1491 std::mem::take(&mut *self.substitution_warnings.borrow_mut())
1492 }
1493
1494 /// Returns `true` while the parser is parsing the content of an owned
1495 /// (include-expanded) AsciiDoc table cell, i.e. when a span's line indexes
1496 /// an owned copy rather than the document source.
1497 pub(crate) fn is_in_owned_cell_source(&self) -> bool {
1498 !self.owned_cell_source_maps.is_empty()
1499 }
1500
1501 /// Pushes an owned cell's source map for the duration of its parse. Paired
1502 /// with [`pop_owned_cell_source_map`](Self::pop_owned_cell_source_map).
1503 pub(crate) fn push_owned_cell_source_map(&mut self, source_map: Rc<SourceMap>) {
1504 self.owned_cell_source_maps.push(source_map);
1505 }
1506
1507 /// Pops the source map pushed by the matching
1508 /// [`push_owned_cell_source_map`](Self::push_owned_cell_source_map).
1509 pub(crate) fn pop_owned_cell_source_map(&mut self) {
1510 self.owned_cell_source_maps.pop();
1511 }
1512
1513 /// Resolves a line number in the innermost owned cell's source back to the
1514 /// file and line it originally came from, using that cell's source map.
1515 ///
1516 /// Returns `None` when not inside an owned cell source.
1517 pub(crate) fn owned_cell_original_file_and_line(&self, line: usize) -> Option<SourceLine> {
1518 self.owned_cell_source_maps
1519 .last()
1520 .and_then(|sm| sm.original_file_and_line(line))
1521 }
1522
1523 /// Records a warning raised by a directive at `line` in the innermost owned
1524 /// cell's source, resolving `line` to the file and line it originally came
1525 /// from so the warning can be surfaced later with a real cursor (see
1526 /// [`take_owned_cell_warnings`]).
1527 ///
1528 /// A no-op when not inside an owned cell source (the line does not resolve
1529 /// to an owned origin) — the caller only reaches this from an owned-cell
1530 /// parse, but the guard keeps a stray call from recording an unanchorable
1531 /// warning.
1532 ///
1533 /// Takes `&self`: an owned-cell parse holds the parser mutably behind a
1534 /// `self_cell` construction closure, so recording goes through interior
1535 /// mutability.
1536 ///
1537 /// [`take_owned_cell_warnings`]: Self::take_owned_cell_warnings
1538 pub(crate) fn record_owned_cell_warning(
1539 &self,
1540 line: usize,
1541 warning: WarningType,
1542 origin_override: Option<SourceLine>,
1543 ) {
1544 // A no-output directive that originated in a file the cell *included*
1545 // carries a true `(file, line)` origin already; prefer it. Otherwise
1546 // resolve the cell's own directive line through the enclosing owned
1547 // cell's source map.
1548 let origin = origin_override.or_else(|| self.owned_cell_original_file_and_line(line));
1549 if let Some(origin) = origin {
1550 self.owned_cell_warnings
1551 .borrow_mut()
1552 .push(ResolvedWarning { origin, warning });
1553 }
1554 }
1555
1556 /// Takes the owned-cell warnings recorded during parsing, leaving the
1557 /// buffer empty.
1558 pub(crate) fn take_owned_cell_warnings(&self) -> Vec<ResolvedWarning> {
1559 std::mem::take(&mut *self.owned_cell_warnings.borrow_mut())
1560 }
1561
1562 /// Generate a unique ID derived from `base_id` and register it in the
1563 /// document catalog, returning the ID that was assigned.
1564 pub(crate) fn generate_and_register_unique_id(
1565 &self,
1566 base_id: &str,
1567 reftext: Option<&str>,
1568 ref_type: RefType,
1569 ) -> String {
1570 // A synthetic ID that collides with an existing one is enumerated using
1571 // the `idseparator` (e.g. `_section_one`, `_section_one_2`), matching
1572 // Ruby Asciidoctor — not a hardcoded hyphen. Mirrors the separator
1573 // resolution in `generate_section_id`.
1574 let separator = self
1575 .attribute_value("idseparator")
1576 .as_maybe_str()
1577 .unwrap_or_default()
1578 .chars()
1579 .next()
1580 .map(|c| c.to_string())
1581 .unwrap_or_default();
1582
1583 self.catalog
1584 .borrow_mut()
1585 .generate_and_register_unique_id(base_id, reftext, ref_type, &separator)
1586 }
1587
1588 /// Takes the catalog from the parser, transferring ownership and leaving an
1589 /// empty catalog in its place.
1590 ///
1591 /// This is used by `Document::parse` to transfer the catalog from the
1592 /// parser to the document at the end of parsing.
1593 pub(crate) fn take_catalog(&mut self) -> Catalog {
1594 std::mem::take(&mut *self.catalog.borrow_mut())
1595 }
1596
1597 /* Comment out until we're prepared to use and test this.
1598 /// Sets the default value for an [intrinsic attribute].
1599 ///
1600 /// Default values for attributes are provided automatically by the
1601 /// processor. These values provide a falllback textual value for an
1602 /// attribute when it is merely "set" by the document via API, header, or
1603 /// document body.
1604 ///
1605 /// Calling this does not imply that the value is set automatically by
1606 /// default, nor does it establish any policy for where the value may be
1607 /// modified. For that, please use [`with_intrinsic_attribute`].
1608 ///
1609 /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1610 /// [`with_intrinsic_attribute`]: Self::with_intrinsic_attribute
1611 pub fn with_default_attribute_value<N: AsRef<str>, V: AsRef<str>>(
1612 mut self,
1613 name: N,
1614 value: V,
1615 ) -> Self {
1616 self.default_attribute_values
1617 .insert(name.as_ref().to_string(), value.as_ref().to_string());
1618
1619 self
1620 }
1621 */
1622
1623 /// Sets the value of an [intrinsic attribute] from a boolean flag.
1624 ///
1625 /// A boolean `true` is interpreted as "set." A boolean `false` is
1626 /// interpreted as "unset."
1627 ///
1628 /// Intrinsic attributes are set automatically by the processor. These
1629 /// attributes provide information about the document being processed (e.g.,
1630 /// `docfile`), the security mode under which the processor is running
1631 /// (e.g., `safe-mode-name`), and information about the user’s environment
1632 /// (e.g., `user-home`).
1633 ///
1634 /// The [`modification_context`](ModificationContext) establishes whether
1635 /// the value can be subsequently modified by the document header and/or in
1636 /// the document body.
1637 ///
1638 /// Subsequent calls to this function or [`with_intrinsic_attribute()`] are
1639 /// always permitted. The last such call for any given attribute name takes
1640 /// precendence.
1641 ///
1642 /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1643 ///
1644 /// [`with_intrinsic_attribute()`]: Self::with_intrinsic_attribute
1645 pub fn with_intrinsic_attribute_bool<N: AsRef<str>>(
1646 mut self,
1647 name: N,
1648 value: bool,
1649 modification_context: ModificationContext,
1650 ) -> Self {
1651 let name = name.as_ref().to_lowercase();
1652 let value = if value {
1653 InterpretedValue::Set
1654 } else {
1655 InterpretedValue::Unset
1656 };
1657 let attribute_value = AttributeValue {
1658 allowable_value: AllowableValue::Any,
1659 modification_context,
1660 silent_when_locked: false,
1661 value: value.clone(),
1662 };
1663
1664 Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1665
1666 self.apply_title_visibility_linkage(&name, &value, modification_context, false);
1667
1668 self
1669 }
1670
1671 /// Sets the value of an [intrinsic attribute] from a boolean flag,
1672 /// rejecting any disallowed subsequent write *silently*.
1673 ///
1674 /// This behaves exactly like [`with_intrinsic_attribute_bool()`] except
1675 /// that a document header or body assignment that the
1676 /// [`modification_context`](ModificationContext) does not permit is dropped
1677 /// with **no** `AttributeValueIsLocked` warning, instead of recording one.
1678 /// See [`with_intrinsic_attribute_silent()`] for the motivating use case
1679 /// (Asciidoctor's silent safe-mode attribute restrictions).
1680 ///
1681 /// A boolean `true` is interpreted as "set." A boolean `false` is
1682 /// interpreted as "unset."
1683 ///
1684 /// Subsequent calls to this function or the other
1685 /// `with_intrinsic_attribute` variants are always permitted. The last
1686 /// such call for any given attribute name takes precedence.
1687 ///
1688 /// [intrinsic attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1689 ///
1690 /// [`with_intrinsic_attribute_bool()`]: Self::with_intrinsic_attribute_bool
1691 /// [`with_intrinsic_attribute_silent()`]: Self::with_intrinsic_attribute_silent
1692 pub fn with_intrinsic_attribute_bool_silent<N: AsRef<str>>(
1693 mut self,
1694 name: N,
1695 value: bool,
1696 modification_context: ModificationContext,
1697 ) -> Self {
1698 let name = name.as_ref().to_lowercase();
1699 let value = if value {
1700 InterpretedValue::Set
1701 } else {
1702 InterpretedValue::Unset
1703 };
1704 let attribute_value = AttributeValue {
1705 allowable_value: AllowableValue::Any,
1706 modification_context,
1707 silent_when_locked: true,
1708 value: value.clone(),
1709 };
1710
1711 Arc::make_mut(&mut self.attribute_values).insert(name.clone(), attribute_value);
1712
1713 self.apply_title_visibility_linkage(&name, &value, modification_context, true);
1714
1715 self
1716 }
1717
1718 /// Pins the reference time (the value of "now") used to compute the
1719 /// time-dependent document attributes, for reproducible output.
1720 ///
1721 /// AsciiDoc derives `localdate`, `localtime`, `localdatetime`, and
1722 /// `localyear` from the current wall-clock time, and `docdate`, `doctime`,
1723 /// `docdatetime`, and `docyear` from the source file's modification time
1724 /// (falling back to "now" when no modification time is known). Because
1725 /// those values change from run to run, any output that embeds them is
1726 /// not reproducible. Supplying a [`ReferenceTime`] pins "now" to a
1727 /// fixed instant so the computed attributes are stable.
1728 ///
1729 /// This is the API counterpart of the `SOURCE_DATE_EPOCH` environment
1730 /// variable; a value set here takes precedence over that variable. To pin
1731 /// only the source-modification time that drives the `doc*` attributes
1732 /// (leaving `local*` on the real clock), use [`with_input_mtime`] instead;
1733 /// an [`with_input_mtime`] value takes precedence over this one for the
1734 /// `doc*` attributes.
1735 ///
1736 /// A value set via the document header or body (e.g. an explicit
1737 /// `:docdate:`) still wins over the computed default.
1738 ///
1739 /// [`with_input_mtime`]: Self::with_input_mtime
1740 pub fn with_reference_time(mut self, reference_time: ReferenceTime) -> Self {
1741 self.reference_time = Some(reference_time);
1742 self
1743 }
1744
1745 /// Pins the modification time of the source document, which drives the
1746 /// `docdate`, `doctime`, `docdatetime`, and `docyear` attributes.
1747 ///
1748 /// This mirrors Asciidoctor's `input_mtime` option: the `local*` attributes
1749 /// continue to reflect "now" (the real clock, a [`with_reference_time`]
1750 /// value, or `SOURCE_DATE_EPOCH`), while the `doc*` attributes reflect the
1751 /// supplied source modification time. A value set here takes precedence
1752 /// over a [`with_reference_time`] value for the `doc*` attributes.
1753 ///
1754 /// A value set via the document header or body (e.g. an explicit
1755 /// `:docdate:`) still wins over the computed default.
1756 ///
1757 /// [`with_reference_time`]: Self::with_reference_time
1758 pub fn with_input_mtime(mut self, input_mtime: ReferenceTime) -> Self {
1759 self.input_mtime = Some(input_mtime);
1760 self
1761 }
1762
1763 /// Resolves a time-dependent document attribute (`docdate`, `doctime`,
1764 /// `docdatetime`, `docyear`, or a `local*` sibling) on demand, returning
1765 /// `None` for any other name (or when the attribute resolves to no value,
1766 /// as `docyear` / `localyear` do for an explicit date without a `YYYY-`
1767 /// prefix).
1768 ///
1769 /// The reference instant is captured lazily on the first such read of a
1770 /// parse and cached (see [`datetime_context`](Self::datetime_context)), so
1771 /// a parse that never references a time-dependent attribute does no
1772 /// clock, environment, or allocation work, and repeated reads observe
1773 /// one consistent instant. An explicit value assigned via the API,
1774 /// header, or body always wins; the derived `*year` / `*datetime` are
1775 /// computed from whichever value each sibling resolves to. See
1776 /// [`DatetimeContext`].
1777 ///
1778 /// Takes `&self` so it can be called from the shared-reference attribute
1779 /// readers (which the substitution code paths reach with only a `&Parser`);
1780 /// the lazy capture goes through the [`RefCell`].
1781 fn resolve_datetime_attribute(&self, name: &str) -> Option<InterpretedValue> {
1782 if !is_datetime_attribute(name) {
1783 return None;
1784 }
1785
1786 let context = {
1787 let mut slot = self.datetime_context.borrow_mut();
1788 slot.get_or_insert_with(|| {
1789 DatetimeContext::capture(self.reference_time.as_ref(), self.input_mtime.as_ref())
1790 })
1791 .clone()
1792 };
1793
1794 context
1795 .resolve(name, |sibling| self.stored_datetime_override(sibling))
1796 .map(InterpretedValue::Value)
1797 }
1798
1799 /// Returns the *explicitly-set* value of `name` from the per-parser
1800 /// attribute map, as an owned string (a value-less "set" reads as an empty
1801 /// string), or `None` when it has no such entry.
1802 ///
1803 /// This reads only the stored overrides — never the on-the-fly datetime
1804 /// resolution — so it can supply the explicit sibling values
1805 /// [`resolve_datetime_attribute`](Self::resolve_datetime_attribute) needs
1806 /// without recursing. It mirrors the Ruby truthiness the datetime
1807 /// computation relies on (`attrs['docdate']`), where any present value —
1808 /// including an empty string — counts as explicitly supplied.
1809 fn stored_datetime_override(&self, name: &str) -> Option<String> {
1810 self.attribute_values
1811 .get(name)
1812 .and_then(|av| match &av.value {
1813 InterpretedValue::Value(value) => Some(value.clone()),
1814 InterpretedValue::Set => Some(String::new()),
1815 InterpretedValue::Unset => None,
1816 })
1817 }
1818
1819 /// Replace the default [`InlineSubstitutionRenderer`] for this parser.
1820 ///
1821 /// The default implementation of [`InlineSubstitutionRenderer`] that is
1822 /// provided is suitable for HTML5 rendering. If you are targeting a
1823 /// different back-end rendering, you will need to provide your own
1824 /// implementation and set it using this call before parsing.
1825 pub fn with_inline_substitution_renderer<ISR: InlineSubstitutionRenderer + 'static>(
1826 mut self,
1827 renderer: ISR,
1828 ) -> Self {
1829 self.renderer = Rc::new(renderer);
1830 self
1831 }
1832
1833 /// Sets the name of the primary file to be parsed when [`parse()`] is
1834 /// called.
1835 ///
1836 /// This name will be used for any error messages detected in this file and
1837 /// also will be passed to [`IncludeFileHandler::resolve_target()`] as the
1838 /// `source` argument for any `include::` file resolution requests from this
1839 /// file.
1840 ///
1841 /// [`parse()`]: Self::parse
1842 /// [`IncludeFileHandler::resolve_target()`]: crate::parser::IncludeFileHandler::resolve_target
1843 pub fn with_primary_file_name<S: AsRef<str>>(mut self, name: S) -> Self {
1844 self.primary_file_name = Some(name.as_ref().to_owned());
1845 self
1846 }
1847
1848 /// Sets the [`IncludeFileHandler`] for this parser.
1849 ///
1850 /// The include file handler is responsible for resolving `include::`
1851 /// directives encountered during preprocessing. If no handler is provided,
1852 /// include directives will be ignored.
1853 ///
1854 /// [`IncludeFileHandler`]: crate::parser::IncludeFileHandler
1855 pub fn with_include_file_handler<IFH: IncludeFileHandler + 'static>(
1856 mut self,
1857 handler: IFH,
1858 ) -> Self {
1859 self.include_file_handler = Some(Rc::new(handler));
1860 self
1861 }
1862
1863 /// Sets the [`DocinfoFileHandler`] for this parser.
1864 ///
1865 /// The docinfo file handler is responsible for providing the content of
1866 /// [docinfo files] requested while resolving a document's docinfo (see the
1867 /// `docinfo` attribute). If no handler is provided, no docinfo content is
1868 /// resolved and [`Document::docinfo`] returns an empty string for every
1869 /// location.
1870 ///
1871 /// [`DocinfoFileHandler`]: crate::parser::DocinfoFileHandler
1872 /// [docinfo files]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
1873 /// [`Document::docinfo`]: crate::Document::docinfo
1874 pub fn with_docinfo_file_handler<DFH: DocinfoFileHandler + 'static>(
1875 mut self,
1876 handler: DFH,
1877 ) -> Self {
1878 self.docinfo_file_handler = Some(Rc::new(handler));
1879 self
1880 }
1881
1882 /// Sets the [`SvgFileHandler`] for this parser.
1883 ///
1884 /// The SVG file handler is responsible for providing the raw contents of an
1885 /// SVG file requested by an inline image with the `inline` option (e.g.
1886 /// `image:diagram.svg[opts=inline]`). If no handler is provided, inline SVG
1887 /// images fall back to rendering their alt text.
1888 ///
1889 /// [`SvgFileHandler`]: crate::parser::SvgFileHandler
1890 pub fn with_svg_file_handler<SFH: SvgFileHandler + 'static>(mut self, handler: SFH) -> Self {
1891 self.svg_file_handler = Some(Rc::new(handler));
1892 self
1893 }
1894
1895 /// Sets the [`ImageFileHandler`] for this parser.
1896 ///
1897 /// The image file handler is responsible for providing the raw bytes of an
1898 /// image that must be embedded as a `data:` URI – i.e. when the `data-uri`
1899 /// document attribute is set and the safe mode is below
1900 /// [`SafeMode::Secure`]. If no handler is provided (or it cannot find the
1901 /// file), such images fall back to an ordinary web path, exactly as if
1902 /// `data-uri` were not set.
1903 ///
1904 /// [`ImageFileHandler`]: crate::parser::ImageFileHandler
1905 pub fn with_image_file_handler<IFH: ImageFileHandler + 'static>(
1906 mut self,
1907 handler: IFH,
1908 ) -> Self {
1909 self.image_file_handler = Some(Rc::new(handler));
1910 self
1911 }
1912
1913 /// Enables or disables cataloging of referenced image assets.
1914 ///
1915 /// When enabled (Asciidoctor's `catalog_assets` API option), each image
1916 /// referenced by an `image:`/`image::` macro is recorded in the document
1917 /// catalog and can be retrieved afterward via
1918 /// [`Catalog::images`](crate::document::Catalog::images). The default is
1919 /// disabled, in which case no image references are recorded.
1920 pub fn with_catalog_assets(mut self, catalog_assets: bool) -> Self {
1921 self.catalog_assets = catalog_assets;
1922 self
1923 }
1924
1925 /// Sets the [`SafeMode`] under which the document is parsed and rendered.
1926 ///
1927 /// The default is [`SafeMode::Secure`], the most conservative setting.
1928 /// Relaxing the safe mode enables security-sensitive rendering behavior,
1929 /// such as rendering an interactive SVG image as an `<object>` element.
1930 ///
1931 /// [`SafeMode`]: crate::SafeMode
1932 pub fn with_safe_mode(mut self, safe: SafeMode) -> Self {
1933 self.safe = safe;
1934 self.apply_safe_mode_attributes();
1935 self
1936 }
1937
1938 /// Overrides the `safe-mode-*` family of [intrinsic attributes] from the
1939 /// current safe mode.
1940 ///
1941 /// These attributes let a document (or a downstream converter) inspect the
1942 /// security mode under which it is being processed:
1943 ///
1944 /// * `safe-mode-level` — the numeric level (`0`, `1`, `10`, or `20`).
1945 /// * `safe-mode-name` — the lowercase mode name (`unsafe`, `safe`,
1946 /// `server`, or `secure`).
1947 /// * `safe-mode-<name>` — a single flag attribute (set to an empty value)
1948 /// naming the active mode; the flags for the other modes are absent so
1949 /// that a reference to them resolves literally.
1950 ///
1951 /// Only `safe-mode-level` and `safe-mode-name` are stored here (shadowing
1952 /// their built-in Secure-mode defaults). The active `safe-mode-<name>` flag
1953 /// is synthesized on the fly from `safe-mode-name` (see
1954 /// [`synthesized_attr`]), so exactly one flag is ever defined and the
1955 /// inactive flags stay absent without any per-mode bookkeeping here.
1956 ///
1957 /// All of these are read-only from the document's perspective (they can
1958 /// only be established via the API), matching Ruby Asciidoctor.
1959 ///
1960 /// [intrinsic attributes]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-ref/#intrinsic-attributes
1961 fn apply_safe_mode_attributes(&mut self) {
1962 let intrinsic = |value: InterpretedValue| AttributeValue {
1963 allowable_value: AllowableValue::Any,
1964 modification_context: ModificationContext::ApiOnly,
1965 silent_when_locked: false,
1966 value,
1967 };
1968
1969 let attrs = Arc::make_mut(&mut self.attribute_values);
1970 attrs.insert(
1971 "safe-mode-level".to_string(),
1972 intrinsic(InterpretedValue::Value(self.safe.level().to_string())),
1973 );
1974 attrs.insert(
1975 "safe-mode-name".to_string(),
1976 intrinsic(InterpretedValue::Value(self.safe.name().to_string())),
1977 );
1978
1979 // NOTE: `max-attribute-value-size` is deliberately *not* touched here.
1980 // Its Secure-only default is resolved as a mode-aware synthesized
1981 // attribute in [`effective_attribute`](Self::effective_attribute), so a
1982 // caller's explicit limit (which lives in `attribute_values`) is never
1983 // clobbered by a safe-mode change, whatever the builder-call order.
1984 }
1985
1986 /// Returns the [`SafeMode`] under which this parser operates.
1987 ///
1988 /// [`SafeMode`]: crate::SafeMode
1989 pub fn safe_mode(&self) -> SafeMode {
1990 self.safe
1991 }
1992
1993 /// Returns the document name (`docname`): the base name of the primary
1994 /// file, stripped of its directory and final extension.
1995 ///
1996 /// This is the `<docname>` used to build private docinfo file names (e.g.
1997 /// `mydoc-docinfo.html` for `mydoc.adoc`). Returns `None` when no primary
1998 /// file name has been set, in which case private docinfo files cannot be
1999 /// resolved.
2000 pub(crate) fn docname(&self) -> Option<String> {
2001 let primary = self.primary_file_name.as_deref()?;
2002
2003 // Strip the directory portion (handling both separators, since the
2004 // primary file name may have been supplied on either platform).
2005 let base = primary.rsplit(['/', '\\']).next().unwrap_or(primary);
2006
2007 // Strip a single trailing extension, if present. A leading-dot name
2008 // (e.g. `.adoc`) is treated as having no extension and is kept whole as
2009 // the stem, matching Ruby's `File.basename(".adoc", ".*")`.
2010 let stem = match base.rfind('.') {
2011 Some(0) | None => base,
2012 Some(idx) => &base[..idx],
2013 };
2014
2015 if stem.is_empty() {
2016 None
2017 } else {
2018 Some(stem.to_string())
2019 }
2020 }
2021
2022 /// Returns `true` if the AsciiDoc file named by `key` (an inter-document
2023 /// xref path — relative to this document, AsciiDoc extension removed) was
2024 /// included into this document *in full* by the preprocessor.
2025 ///
2026 /// A cross reference to such a file collapses to a same-document reference,
2027 /// since the file's anchors are now part of this document. Takes `&self` so
2028 /// it can be called from an inline-substitution
2029 /// [`Replacer`](regex::Replacer) that holds only a shared reference to
2030 /// the parser. See
2031 /// [`Catalog::include_is_full`](crate::document::Catalog::include_is_full).
2032 pub(crate) fn catalog_include_is_full(&self, key: &str) -> bool {
2033 self.catalog.borrow().include_is_full(key)
2034 }
2035
2036 /// Records an included AsciiDoc file in the document catalog's include
2037 /// registry, mid-parse.
2038 ///
2039 /// `Parser::parse_deferred` seeds the registry with the outermost
2040 /// document's own includes before parsing begins; this entry point is for
2041 /// an include performed while a nested scope with a shared catalog — an
2042 /// AsciiDoc table cell — is parsed. Takes `&self` for the same reason as
2043 /// [`catalog_include_is_full`](Self::catalog_include_is_full). See
2044 /// [`Catalog::register_include`](crate::document::Catalog::register_include).
2045 pub(crate) fn register_include(&self, key: &str, full: bool) {
2046 self.catalog.borrow_mut().register_include(key, full);
2047 }
2048
2049 /// Called from [`Header::parse()`] to accept or reject an attribute value.
2050 ///
2051 /// [`Header::parse()`]: crate::document::Header::parse
2052 pub(crate) fn set_attribute_from_header<'src>(
2053 &mut self,
2054 attr: &Attribute<'src>,
2055 warnings: &mut Vec<Warning<'src>>,
2056 ) {
2057 let attr_name = remap_attr_name(attr.name().data());
2058
2059 // The derived backend-family namespace is a read-only synthesized
2060 // intrinsic; a document must not write any of it (see
2061 // [`is_reserved_derived_attr`]).
2062 if is_reserved_derived_attr(&attr_name) {
2063 return;
2064 }
2065
2066 // Verify that we have permission to overwrite any existing attribute
2067 // value, considering both a per-parser entry and the shared built-in
2068 // default it would shadow (a built-in such as `sp` is `ApiOnly`).
2069 if let Some(existing_attr) = self.effective_attribute(&attr_name)
2070 && (existing_attr.modification_context == ModificationContext::ApiOnly
2071 || existing_attr.modification_context == ModificationContext::ApiOrDocumentBody)
2072 {
2073 // A silently-locked intrinsic rejects the write without recording a
2074 // warning (see `AttributeValue::silent_when_locked`).
2075 if !existing_attr.silent_when_locked {
2076 warnings.push(Warning {
2077 source: attr.span(),
2078 warning: WarningType::AttributeValueIsLocked(attr_name),
2079 origin: None,
2080 });
2081 }
2082 return;
2083 }
2084
2085 let mut value = attr.value().clone();
2086
2087 if let InterpretedValue::Set = value
2088 && let Some(default_value) = self.default_attribute_values.get(&attr_name)
2089 {
2090 value = InterpretedValue::Value(default_value.clone());
2091 }
2092
2093 // A relative `leveloffset` (`+N` / `-N`) accumulates on top of the
2094 // offset already in effect; resolve it to an absolute value so the
2095 // stored attribute is always a plain integer, and warn if the result is
2096 // so extreme that no heading could ever land in the valid level range.
2097 if attr_name == "leveloffset" {
2098 value = self.resolve_leveloffset_and_warn(value, attr.span(), warnings);
2099 }
2100
2101 // Cap the resolved value at `max-attribute-value-size` bytes (a no-op
2102 // unless that limit is in force — by default, only under Secure).
2103 value = self.limit_attribute_value_size(value);
2104
2105 // `notitle` and `showtitle` are inverse spellings of one title-
2106 // visibility toggle; keep the partner in sync (see
2107 // [`apply_title_visibility_linkage`](Self::apply_title_visibility_linkage)).
2108 self.apply_title_visibility_linkage(
2109 &attr_name,
2110 &value,
2111 ModificationContext::Anywhere,
2112 false,
2113 );
2114
2115 let attribute_value = AttributeValue {
2116 allowable_value: AllowableValue::Any,
2117 modification_context: ModificationContext::Anywhere,
2118 silent_when_locked: false,
2119 value,
2120 };
2121
2122 // An explicit assignment supersedes (and resets) any counter of the same
2123 // name.
2124 self.counter_values.borrow_mut().remove(&attr_name);
2125
2126 // The derived `backend-html5-doctype-*` attribute tracks `doctype`
2127 // automatically (it is synthesized on lookup), so no refresh is needed.
2128 Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
2129 }
2130
2131 /// Called from [`Header::parse()`] for a value that is derived from parsing
2132 /// the header (except for attribute lines).
2133 ///
2134 /// [`Header::parse()`]: crate::document::Header::parse
2135 pub(crate) fn set_attribute_by_value_from_header<N: AsRef<str>, V: AsRef<str>>(
2136 &mut self,
2137 name: N,
2138 value: V,
2139 ) {
2140 let attr_name = remap_attr_name(name);
2141
2142 let attribute_value = AttributeValue {
2143 allowable_value: AllowableValue::Any,
2144 modification_context: ModificationContext::Anywhere,
2145 silent_when_locked: false,
2146 value: InterpretedValue::Value(value.as_ref().to_owned()),
2147 };
2148
2149 self.counter_values.borrow_mut().remove(&attr_name);
2150 Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
2151 }
2152
2153 /// Applies the `imagesdir`-relative default for the `iconsdir` attribute.
2154 ///
2155 /// The `iconsdir` attribute defaults to `{imagesdir}/icons`; when
2156 /// `imagesdir` is left empty this resolves to the built-in
2157 /// [`DEFAULT_ICONSDIR`] (`./images/icons`). When `imagesdir` is set to a
2158 /// non-empty value and `iconsdir` was left at its built-in default, the
2159 /// icons directory is derived as `{imagesdir}/icons`.
2160 ///
2161 /// The derivation is skipped — so an explicit `iconsdir` wins — when either
2162 /// the attribute was set in the header (`iconsdir_set_in_header`) or its
2163 /// resolved value differs from [`DEFAULT_ICONSDIR`] (which is how an
2164 /// override applied any other way, e.g. via the API, is detected). The one
2165 /// case this cannot detect is a non-header override whose value happens to
2166 /// equal the built-in default (e.g. an API caller setting `iconsdir` to
2167 /// exactly `./images/icons`): it is indistinguishable from the default and
2168 /// so is re-derived. That combination is contradictory in practice (it
2169 /// pins `iconsdir` to the value it would take were `imagesdir` unset) and
2170 /// is not worth a dedicated provenance flag.
2171 ///
2172 /// This is called once, after the document header is parsed, mirroring
2173 /// Asciidoctor's document-initialization timing (a later `imagesdir` change
2174 /// in the document body does not retroactively re-derive `iconsdir`). See
2175 /// icons-image.adoc.
2176 ///
2177 /// [`DEFAULT_ICONSDIR`]: super::built_in_attrs::DEFAULT_ICONSDIR
2178 pub(crate) fn apply_iconsdir_default(&mut self, iconsdir_set_in_header: bool) {
2179 if iconsdir_set_in_header {
2180 return;
2181 }
2182
2183 // Preserve any override whose value differs from the built-in default
2184 // (e.g. one applied via the API); only the built-in default itself is
2185 // eligible for `imagesdir`-relative derivation. See the doc comment for
2186 // the one indistinguishable corner case.
2187 if self.attribute_value("iconsdir").as_maybe_str()
2188 != Some(super::built_in_attrs::DEFAULT_ICONSDIR)
2189 {
2190 return;
2191 }
2192
2193 let imagesdir = self.attribute_value("imagesdir");
2194 let derived = match imagesdir.as_maybe_str().filter(|d| !d.is_empty()) {
2195 Some(dir) => format!("{}/icons", dir.trim_end_matches('/')),
2196 None => return,
2197 };
2198
2199 self.set_attribute_by_value_from_header("iconsdir", derived);
2200 }
2201
2202 /// Called while parsing a block (see [`Block::parse_with_outcome()`]) to
2203 /// accept or reject an attribute value from a document (body) attribute.
2204 ///
2205 /// [`Block::parse_with_outcome()`]: crate::blocks::Block::parse_with_outcome
2206 pub(crate) fn set_attribute_from_body<'src>(
2207 &mut self,
2208 attr: &Attribute<'src>,
2209 warnings: &mut Vec<Warning<'src>>,
2210 ) {
2211 let attr_name = remap_attr_name(attr.name().data());
2212
2213 // The derived backend-family namespace is a read-only synthesized
2214 // intrinsic; a document must not write any of it (see
2215 // [`is_reserved_derived_attr`]).
2216 if is_reserved_derived_attr(&attr_name) {
2217 return;
2218 }
2219
2220 // An attribute inherited from the parent document of an AsciiDoc table
2221 // cell is locked for the duration of that cell: a body assignment to it
2222 // is silently ignored (no warning), matching Asciidoctor.
2223 if self.locked_attribute_names.contains(&attr_name) {
2224 return;
2225 }
2226
2227 // Verify that we have permission to overwrite any existing attribute
2228 // value, considering both a per-parser entry and the shared built-in
2229 // default it would shadow.
2230 if let Some(existing_attr) = self.effective_attribute(&attr_name)
2231 && (existing_attr.modification_context != ModificationContext::Anywhere
2232 && existing_attr.modification_context != ModificationContext::ApiOrDocumentBody)
2233 {
2234 // A silently-locked intrinsic rejects the write without recording a
2235 // warning (see `AttributeValue::silent_when_locked`).
2236 if !existing_attr.silent_when_locked {
2237 warnings.push(Warning {
2238 source: attr.span(),
2239 warning: WarningType::AttributeValueIsLocked(attr_name),
2240 origin: None,
2241 });
2242 }
2243 return;
2244 }
2245
2246 let mut value = attr.value().clone();
2247
2248 // A relative `leveloffset` (`+N` / `-N`) accumulates on top of the
2249 // offset already in effect; resolve it to an absolute value so the
2250 // stored attribute is always a plain integer, and warn if the result is
2251 // so extreme that no heading could ever land in the valid level range.
2252 if attr_name == "leveloffset" {
2253 value = self.resolve_leveloffset_and_warn(value, attr.span(), warnings);
2254 }
2255
2256 // Cap the resolved value at `max-attribute-value-size` bytes (a no-op
2257 // unless that limit is in force — by default, only under Secure).
2258 value = self.limit_attribute_value_size(value);
2259
2260 // `notitle` and `showtitle` are inverse spellings of one title-
2261 // visibility toggle; keep the partner in sync (see
2262 // [`apply_title_visibility_linkage`](Self::apply_title_visibility_linkage)).
2263 self.apply_title_visibility_linkage(
2264 &attr_name,
2265 &value,
2266 ModificationContext::Anywhere,
2267 false,
2268 );
2269
2270 let attribute_value = AttributeValue {
2271 allowable_value: AllowableValue::Any,
2272 modification_context: ModificationContext::Anywhere,
2273 silent_when_locked: false,
2274 value,
2275 };
2276
2277 // An explicit assignment supersedes (and resets) any counter of the same
2278 // name. This is what lets `:!name:` reset a counter.
2279 self.counter_values.borrow_mut().remove(&attr_name);
2280
2281 // The derived `backend-html5-doctype-*` attribute tracks `doctype`
2282 // automatically (it is synthesized on lookup), so no refresh is needed.
2283 Arc::make_mut(&mut self.attribute_values).insert(attr_name, attribute_value);
2284 }
2285
2286 /// Assign the next section number for a given level.
2287 pub(crate) fn assign_section_number(&mut self, level: usize) -> SectionNumber {
2288 match self.topmost_section_type {
2289 SectionType::Appendix => {
2290 self.last_appendix_section_number.assign_next_number(level);
2291 self.last_appendix_section_number.clone()
2292 }
2293
2294 // `topmost_section_type` is only ever `Normal` or `Appendix`: a
2295 // discrete heading never becomes the topmost section type (see
2296 // `SectionBlock::parse`). `Discrete` therefore cannot reach this
2297 // point, so it is folded in with `Normal` rather than carried as a
2298 // separate, untestable arm.
2299 SectionType::Normal | SectionType::Discrete => {
2300 self.last_section_number.assign_next_number(level);
2301 self.last_section_number.clone()
2302 }
2303 }
2304 }
2305
2306 /// Resolves a [counter] of the given `name`, advancing it to the next value
2307 /// in its sequence and returning that value.
2308 ///
2309 /// A counter is a specialized document attribute: its value is stored as
2310 /// (and read back from) the attribute of the same name, so a later
2311 /// `{name}` reference shows the current value and an attribute assignment
2312 /// such as `:!name:` resets it. Each resolution advances the counter:
2313 ///
2314 /// * an integer value is incremented (`1` -> `2`);
2315 /// * any other value is advanced like Ruby's `String#succ` (`a` -> `b`, `z`
2316 /// -> `aa`, `Az` -> `Ba`), matching Asciidoctor.
2317 ///
2318 /// `seed` (from the `{counter:name:seed}` form) supplies the first value,
2319 /// but only when the counter is currently unset; otherwise it is ignored.
2320 /// With no seed the sequence starts at `1`.
2321 ///
2322 /// This mirrors Asciidoctor's `Document#counter`.
2323 ///
2324 /// [counter]: https://docs.asciidoctor.org/asciidoc/latest/attributes/counters/
2325 pub(crate) fn counter(&self, name: &str, seed: Option<&str>) -> String {
2326 self.counter_impl(name, seed, false)
2327 }
2328
2329 /// Like [`counter`](Self::counter), but the advanced value stays readable
2330 /// as the attribute of the same name even when that attribute is
2331 /// *locked* (API-set or a locked built-in). This is the captioning
2332 /// counter, used for the `<context>-number` of a numbered block.
2333 ///
2334 /// Mirrors Asciidoctor's `increment_and_store_counter`: it too advances a
2335 /// locked counter, and its block attribute entry is replayed onto the
2336 /// document attributes during conversion, so a locked `example-number`
2337 /// reads back as its latest counter value (unlike a plain inline
2338 /// `{counter:…}`, which leaves the locked value in place).
2339 pub(crate) fn counter_for_caption(&self, name: &str, seed: Option<&str>) -> String {
2340 self.counter_impl(name, seed, true)
2341 }
2342
2343 /// Advances the `name` counter and returns its new value. `seed` supplies
2344 /// the starting value when the counter has no current value to advance
2345 /// from.
2346 ///
2347 /// A counter reads the current value to produce (and display) the next one.
2348 /// For an unlocked attribute the advanced value is stored in the readable
2349 /// [`counter_values`](Self::counter_values) overlay, so a later reference
2350 /// reads it. For a *locked* attribute — one set via the API, or a locked
2351 /// built-in such as `max-include-depth` — the write path depends on the
2352 /// caller:
2353 ///
2354 /// * `commit_when_locked` (the captioning counter): the value is stored in
2355 /// the readable overlay anyway, matching Asciidoctor's
2356 /// `increment_and_store_counter`, whose block attribute entry is replayed
2357 /// onto the document attributes during conversion.
2358 /// * otherwise (an inline `{counter:…}` / `{counter2:…}` directive): the
2359 /// running value is kept in the private
2360 /// [`locked_counter_values`](Self::locked_counter_values) map instead, so
2361 /// the sequence still advances across repeated references while a plain
2362 /// reference to the attribute continues to read the locked value. This
2363 /// mirrors Asciidoctor's `Document#counter`, which advances `@counters`
2364 /// but leaves `@attributes` untouched while the attribute is
2365 /// `attribute_locked?`.
2366 fn counter_impl(&self, name: &str, seed: Option<&str>, commit_when_locked: bool) -> String {
2367 let use_private_state = !commit_when_locked && self.attribute_is_locked(name);
2368
2369 // The value to advance from: the private running state first (only
2370 // populated when it is in use), otherwise the current readable value of
2371 // the attribute (which, for an unlocked counter, already reflects the
2372 // overlay).
2373 let current = if use_private_state {
2374 self.locked_counter_values.borrow().get(name).cloned()
2375 } else {
2376 None
2377 }
2378 .or_else(|| match self.attribute_value(name) {
2379 InterpretedValue::Value(current) if !current.is_empty() => Some(current),
2380 _ => None,
2381 });
2382
2383 let next = match current {
2384 Some(current) => next_counter_value(¤t),
2385 None => match seed {
2386 Some(seed) if !seed.is_empty() => seed.to_string(),
2387 _ => "1".to_string(),
2388 },
2389 };
2390
2391 if use_private_state {
2392 self.locked_counter_values
2393 .borrow_mut()
2394 .insert(name.to_string(), next.clone());
2395 } else {
2396 self.counter_values
2397 .borrow_mut()
2398 .insert(name.to_string(), next.clone());
2399 }
2400
2401 next
2402 }
2403
2404 /// Reports whether `name` currently resolves to an attribute that is
2405 /// *locked* against modification by a counter: it has an effective value
2406 /// whose [`ModificationContext`] is
2407 /// [`ApiOnly`](ModificationContext::ApiOnly) — an API-set override or a
2408 /// locked built-in such as `max-include-depth`.
2409 ///
2410 /// This mirrors Asciidoctor's `Document#attribute_locked?`, which is `true`
2411 /// exactly for an attribute supplied through the API (its
2412 /// `@attribute_overrides`). It is deliberately *narrower* than the
2413 /// write-permission check in
2414 /// [`set_attribute_from_body`](Self::set_attribute_from_body): a
2415 /// header-only attribute such as an unset `outfilesuffix`
2416 /// ([`ApiOrHeader`](ModificationContext::ApiOrHeader)) cannot be assigned
2417 /// from the body, yet a counter *may* advance it (matching Asciidoctor,
2418 /// where `{counter:outfilesuffix}` moves it while it is not API-locked).
2419 fn attribute_is_locked(&self, name: &str) -> bool {
2420 self.effective_attribute(name)
2421 .is_some_and(|a| a.modification_context == ModificationContext::ApiOnly)
2422 }
2423}
2424
2425/// Whether a `leveloffset` of `offset` leaves at least one syntactic heading
2426/// level able to land inside the supported section-level range.
2427///
2428/// Syntactic heading levels run 0 (`=`) through 5 (`======`) and valid section
2429/// levels run 1 through 5, so an offset keeps some heading in range only while
2430/// it stays within `1 - 5 ..= 5 - 0`, i.e. `-4..=5`. Outside that window every
2431/// heading is clamped, so the offset can never place a heading at its intended
2432/// level.
2433fn leveloffset_admits_any_heading(offset: i32) -> bool {
2434 (-4..=5).contains(&offset)
2435}
2436
2437/// Advances a counter value to the next value in its sequence, mirroring
2438/// Asciidoctor's `Helpers.nextval`.
2439///
2440/// A canonical integer string (one that round-trips through integer parsing,
2441/// e.g. `7` but not `07` or `+7`) is incremented numerically. Anything else is
2442/// advanced with [`string_succ`].
2443fn next_counter_value(current: &str) -> String {
2444 if let Ok(n) = current.parse::<i64>()
2445 && n.to_string() == current
2446 {
2447 // `saturating_add` keeps a counter that has somehow reached `i64::MAX`
2448 // pinned there rather than panicking (debug) or wrapping (release).
2449 return n.saturating_add(1).to_string();
2450 }
2451
2452 string_succ(current)
2453}
2454
2455/// Returns the successor of a string, mirroring Ruby's `String#succ` for the
2456/// ASCII cases that AsciiDoc counters can produce.
2457///
2458/// The right-most alphanumeric character is incremented within its own class
2459/// (digits, lowercase letters, uppercase letters), carrying leftward on
2460/// wrap-around (`9` -> `0`, `z` -> `a`, `Z` -> `A`) and prepending a fresh
2461/// leading character (`1`, `a`, or `A`) when the carry runs off the front
2462/// (`z` -> `aa`, `Zz` -> `AAa`). A string with no alphanumeric characters has
2463/// the code point of its last character incremented.
2464fn string_succ(current: &str) -> String {
2465 let chars: Vec<char> = current.chars().collect();
2466
2467 // Without an alphanumeric to carry through, Ruby increments the code point
2468 // of the final character.
2469 if !chars.iter().any(char::is_ascii_alphanumeric) {
2470 let mut chars = chars;
2471 if let Some(last) = chars.last_mut() {
2472 *last = char::from_u32(*last as u32 + 1).unwrap_or(*last);
2473 }
2474 return chars.into_iter().collect();
2475 }
2476
2477 // Walk right to left. `carrying` stays true while we are still looking for
2478 // (or carrying through) the alphanumeric run: trailing non-alphanumeric
2479 // characters are passed over unchanged, then the right-most alphanumeric is
2480 // incremented within its class and any wrap-around carries leftward to the
2481 // next alphanumeric. When the carry runs off the front, a fresh leading
2482 // character of the same class is prepended (`z` -> `aa`, `9` -> `10`).
2483 let mut out_rev: Vec<char> = Vec::with_capacity(chars.len() + 1);
2484 let mut carrying = true;
2485 let mut lead = '1';
2486
2487 for &c in chars.iter().rev() {
2488 if carrying && c.is_ascii_alphanumeric() {
2489 // Increment within the character's class, carrying on wrap-around.
2490 // The arms are exhaustive over ASCII alphanumerics, so the catch-all
2491 // can only be `Z` (the one value not matched above).
2492 let (next, carry) = match c {
2493 '0'..='8' | 'a'..='y' | 'A'..='Y' => ((c as u8 + 1) as char, false),
2494 '9' => ('0', true),
2495 'z' => ('a', true),
2496 _ => ('A', true),
2497 };
2498 out_rev.push(next);
2499 carrying = carry;
2500 // On a carry, remember the class of leading character to prepend if
2501 // the carry runs off the front; `next` is `0`, `a`, or `A` here.
2502 lead = match next {
2503 '0' => '1',
2504 'a' => 'a',
2505 _ => 'A',
2506 };
2507 } else {
2508 // Either the carry is spent, or this is a trailing non-alphanumeric
2509 // we pass over while still searching for the run to increment.
2510 out_rev.push(c);
2511 }
2512 }
2513
2514 if carrying {
2515 out_rev.push(lead);
2516 }
2517
2518 out_rev.into_iter().rev().collect()
2519}
2520
2521/// Matches every character that Asciidoctor's `sanitize_attribute_name` strips
2522/// from an attribute name: anything that is not a [word character] (`\w`, i.e.
2523/// `\p{Word}`) or a hyphen. Mirrors Asciidoctor's `InvalidAttributeNameCharsRx`
2524/// (`/[^#{CC_WORD}-]/`).
2525///
2526/// [word character]: crate::internal::is_word_char
2527static INVALID_ATTR_NAME_CHARS: LazyLock<Regex> = LazyLock::new(|| {
2528 #[allow(clippy::unwrap_used)]
2529 Regex::new(r"[^\w-]").unwrap()
2530});
2531
2532fn remap_attr_name<N: AsRef<str>>(raw_attr_name: N) -> String {
2533 // Sanitize the name the way Asciidoctor's `sanitize_attribute_name` does:
2534 // drop every character that is not a word character or a hyphen, then
2535 // lower-case the result. This is what lets an attribute entry written as
2536 // `:Author Initials:` set the `authorinitials` attribute, `:Foo 3^ # -
2537 // Bar[:` set `foo3-bar`, and `:My frog:` set `myfrog`. Unicode word
2538 // characters are preserved, so `:café:` sets `café` and `:سمن:` sets `سمن`.
2539 //
2540 // The full Unicode case fold (Asciidoctor's `downcase`, not merely ASCII)
2541 // is what makes an attribute reference case-insensitive: an entry written
2542 // `:He-Man:` is reachable as `{he-man}` or `{HE-MAN}`. A reference is folded
2543 // through this same `to_lowercase()` before lookup (see `AttributeReplacer`
2544 // in `content::substitution_step`), so definition and reference stay
2545 // symmetric even when a fold expands a character (e.g. `İ` -> `i` + combining
2546 // dot): both sides land on the identical key.
2547 let attr_name: String = INVALID_ATTR_NAME_CHARS
2548 .replace_all(raw_attr_name.as_ref(), "")
2549 .to_lowercase();
2550
2551 // Some attribute names have aliases. Remap to the primary name.
2552 //
2553 // `numbered` is a legacy alias for `sectnums`, so setting `numbered` sets
2554 // `sectnums` (and `numbered!` unsets it). Mirrors Asciidoctor's
2555 // `Parser.store_attribute`, which renames the attribute before storing it.
2556 match attr_name.as_str() {
2557 "hardbreaks" => "hardbreaks-option".to_string(),
2558 "numbered" => "sectnums".to_string(),
2559 _ => attr_name,
2560 }
2561}
2562
2563/// Returns `true` if `name` is a derived backend-family attribute whose
2564/// assignment must be rejected *even while it is inactive*, because the flag it
2565/// would name can become active later in the same parse and the stored override
2566/// would then shadow the read-only intrinsic:
2567///
2568/// * The bare derived values `basebackend` / `filetype` — always resolved on
2569/// the fly from `backend` (see [`derived_backend_value`]), never stored.
2570/// * The doctype-keyed flags `backend-<b>-doctype-<d>` /
2571/// `basebackend-<bb>-doctype-<d>` — the `doctype` component shifts mid-parse
2572/// (e.g. an AsciiDoc table cell that resets, then changes, its doctype), so
2573/// an assignment to an inactive one (`backend-html5-doctype-article` while
2574/// the doctype is `book`) must not be stored where it could shadow the
2575/// intrinsic once the doctype switches.
2576///
2577/// The remaining flag names (`backend-<b>`, `basebackend-<bb>`, `filetype-<f>`,
2578/// and bare `doctype-<d>`) are deliberately **not** reserved: rejecting them
2579/// would swallow author-defined attributes such as `:backend-custom:` or
2580/// `:doctype-draft:` (used as `ifdef` flags), which Asciidoctor keeps. The
2581/// *active* one of these is still write-protected by the normal permission
2582/// check, since [`synthesized_attr`] resolves it to a locked intrinsic.
2583fn is_reserved_derived_attr(name: &str) -> bool {
2584 is_derived_backend_value(name)
2585 || ((name.starts_with("backend-") || name.starts_with("basebackend-"))
2586 && name.contains("-doctype-"))
2587}
2588
2589#[cfg(test)]
2590mod tests {
2591 #![allow(clippy::panic)]
2592 #![allow(clippy::unwrap_used)]
2593
2594 use crate::{
2595 attributes::Attrlist,
2596 blocks::Block,
2597 parser::{
2598 CharacterReplacementType, IconRenderParams, ImageRenderParams,
2599 InlineSubstitutionRenderer, LinkRenderParams, QuoteScope, QuoteType, SpecialCharacter,
2600 },
2601 tests::prelude::*,
2602 };
2603
2604 #[test]
2605 fn default_is_unset() {
2606 let p = Parser::default();
2607 assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
2608 }
2609
2610 mod remap_attr_name {
2611 use super::super::remap_attr_name;
2612
2613 #[test]
2614 fn strips_non_word_and_lower_cases_ascii() {
2615 assert_eq!(remap_attr_name("Foo Bar"), "foobar");
2616 assert_eq!(remap_attr_name("Foo 3^ # - Bar["), "foo3-bar");
2617 assert_eq!(remap_attr_name("My frog"), "myfrog");
2618 }
2619
2620 #[test]
2621 fn preserves_unicode_word_characters() {
2622 // Unicode letters and digits are word characters, so they survive
2623 // sanitization; the `{café}` / `{سمن}` references then resolve.
2624 assert_eq!(remap_attr_name("café"), "café");
2625 assert_eq!(remap_attr_name("سمن"), "سمن");
2626 }
2627
2628 #[test]
2629 fn preserves_marks_and_join_controls() {
2630 // `\p{Word}` includes combining marks and join controls, so a
2631 // decomposed name and a name embedding a ZWNJ are not mangled.
2632 let decomposed = "cafe\u{301}";
2633 assert_eq!(remap_attr_name(decomposed), decomposed);
2634
2635 let with_zwnj = "\u{645}\u{200c}\u{646}";
2636 assert_eq!(remap_attr_name(with_zwnj), with_zwnj);
2637 }
2638
2639 #[test]
2640 fn folds_case_with_full_unicode() {
2641 // The name is folded with the full Unicode `to_lowercase()`, so a
2642 // reference lookup is case-insensitive. A fold that expands a
2643 // character (`İ` -> `i` + U+0307 combining dot above) is harmless:
2644 // an attribute reference is folded through the same function, so
2645 // definition and reference still land on the identical key.
2646 assert_eq!(remap_attr_name("He-Man"), "he-man");
2647 assert_eq!(remap_attr_name("İstanbul"), "i\u{307}stanbul");
2648 assert_eq!(remap_attr_name("İstanbul"), "İstanbul".to_lowercase());
2649 }
2650 }
2651
2652 #[test]
2653 fn attribute_reference_resolves_case_insensitively() {
2654 // A reference is folded with the same Unicode `to_lowercase()` used to
2655 // store the name, so any casing of the reference resolves the entry.
2656 let doc = Parser::default().parse(":He-Man: the foe\n\n{he-man} / {HE-MAN} / {He-Man}");
2657 assert_eq!(
2658 rendered_paragraphs(&doc),
2659 vec!["the foe / the foe / the foe"]
2660 );
2661 }
2662
2663 #[test]
2664 fn attribute_reference_case_fold_round_trips_when_it_expands() {
2665 // `İ` folds to `i` + U+0307 under `to_lowercase()`. Because both the
2666 // definition and the reference fold through that same function, the
2667 // entry stays reachable by its own spelling despite the expansion.
2668 let doc = Parser::default().parse(":İ: dotted\n\n{İ}");
2669 assert_eq!(rendered_paragraphs(&doc), vec!["dotted"]);
2670 }
2671
2672 #[test]
2673 fn unicode_attribute_reference_resolves_in_preprocessor() {
2674 // The preprocessor (conditional directives, include targets) resolves
2675 // `{name}` references with the same Unicode word-character class as the
2676 // main substitution pass, so a Unicode-named attribute drives an
2677 // `ifeval` condition. See #726.
2678 let doc = Parser::default()
2679 .parse(":café: yes\n\nifeval::[\"{café}\" == \"yes\"]\nShown.\nendif::[]");
2680 assert_eq!(rendered_paragraphs(&doc), vec!["Shown."]);
2681 }
2682
2683 #[test]
2684 fn case_insensitive_attribute_reference_resolves_in_preprocessor() {
2685 // The preprocessor folds a `{name}` reference the same way the main
2686 // substitution pass does, so a mismatched-case reference still drives an
2687 // `ifeval` condition.
2688 let doc = Parser::default()
2689 .parse(":Answer: yes\n\nifeval::[\"{answer}\" == \"yes\"]\nShown.\nendif::[]");
2690 assert_eq!(rendered_paragraphs(&doc), vec!["Shown."]);
2691 }
2692
2693 #[test]
2694 fn owned_cell_warning_is_recorded_only_inside_an_owned_cell_source() {
2695 use std::rc::Rc;
2696
2697 use crate::{
2698 parser::{SourceLine, SourceMap},
2699 warnings::WarningType,
2700 };
2701
2702 let mut p = Parser::default();
2703
2704 // Outside an owned cell source there is no map to resolve against, so a
2705 // recorded warning has no origin and is dropped rather than queued.
2706 assert!(!p.is_in_owned_cell_source());
2707 p.record_owned_cell_warning(
2708 1,
2709 WarningType::IncludeFileNotFound("x.adoc".to_owned()),
2710 None,
2711 );
2712 assert!(p.take_owned_cell_warnings().is_empty());
2713
2714 // An explicit origin override is queued even without a cell source map.
2715 p.record_owned_cell_warning(
2716 1,
2717 WarningType::UnterminatedConditionalDirective("ifdef::foo[]".to_owned()),
2718 Some(SourceLine(Some("inc.adoc".to_owned()), 3)),
2719 );
2720 let overridden = p.take_owned_cell_warnings();
2721 let [overridden] = overridden.as_slice() else {
2722 panic!("expected exactly one recorded warning, got {overridden:?}");
2723 };
2724 assert_eq!(
2725 overridden.origin,
2726 SourceLine(Some("inc.adoc".to_owned()), 3)
2727 );
2728
2729 // Publish a cell source map (output line 1 came from `cell.adoc` line 2,
2730 // the way the preprocessor would record an include-expanded cell).
2731 let mut sm = SourceMap::default();
2732 sm.append(1, SourceLine(Some("cell.adoc".to_owned()), 2));
2733 p.push_owned_cell_source_map(Rc::new(sm));
2734 assert!(p.is_in_owned_cell_source());
2735
2736 // Now the same call resolves the line to its origin and queues the
2737 // warning with that pre-resolved (file, line).
2738 p.record_owned_cell_warning(
2739 1,
2740 WarningType::IncludeFileNotFound("y.adoc".to_owned()),
2741 None,
2742 );
2743 let recorded = p.take_owned_cell_warnings();
2744 let [recorded] = recorded.as_slice() else {
2745 panic!("expected exactly one recorded warning, got {recorded:?}");
2746 };
2747 assert_eq!(recorded.origin, SourceLine(Some("cell.adoc".to_owned()), 2));
2748 assert_eq!(
2749 recorded.warning,
2750 WarningType::IncludeFileNotFound("y.adoc".to_owned())
2751 );
2752
2753 // Taking drains the buffer, and popping restores the not-in-owned-cell
2754 // state.
2755 assert!(p.take_owned_cell_warnings().is_empty());
2756 p.pop_owned_cell_source_map();
2757 assert!(!p.is_in_owned_cell_source());
2758 }
2759
2760 #[test]
2761 fn creates_catalog_if_needed() {
2762 let mut p = Parser::default();
2763 let doc = p.parse("= Hello, World!\n\n== First Section Title");
2764 let cat = doc.catalog();
2765 assert!(cat.refs.contains_key("_first_section_title"));
2766
2767 let doc = p.parse("= Hello, World!\n\n== Second Section Title");
2768 let cat = doc.catalog();
2769 assert!(!cat.refs.contains_key("_first_section_title"));
2770 assert!(cat.refs.contains_key("_second_section_title"));
2771 }
2772
2773 #[test]
2774 fn with_intrinsic_attribute() {
2775 let p =
2776 Parser::default().with_intrinsic_attribute("foo", "bar", ModificationContext::Anywhere);
2777
2778 assert_eq!(p.attribute_value("foo"), InterpretedValue::Value("bar"));
2779 assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
2780
2781 assert!(p.is_attribute_set("foo"));
2782 assert!(!p.is_attribute_set("foo2"));
2783 assert!(!p.is_attribute_set("xyz"));
2784 }
2785
2786 // Under `SafeMode::Server` or greater, `docdir` reads as empty and
2787 // `docfile` is relativized against `docdir`; see #735 and the ported
2788 // upstream tests in `tests/asciidoctor_rb/attributes_test.rs`. These cover
2789 // crate-specific edge cases not exercised by the single upstream test.
2790 #[test]
2791 fn masks_docdir_and_docfile_under_secure_mode() {
2792 // Secure (the default) is stricter than Server, so masking also applies.
2793 let p = Parser::default()
2794 .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2795 .with_intrinsic_attribute(
2796 "docfile",
2797 "/some/dir/sample.adoc",
2798 ModificationContext::ApiOnly,
2799 );
2800 assert_eq!(p.safe_mode(), SafeMode::Secure);
2801 assert_eq!(p.attribute_value("docdir"), InterpretedValue::Value(""));
2802 assert_eq!(
2803 p.attribute_value("docfile"),
2804 InterpretedValue::Value("sample.adoc")
2805 );
2806 // The masked `docdir` is still a *set* (present) attribute.
2807 assert!(p.is_attribute_set("docdir"));
2808 assert!(p.has_attribute("docfile"));
2809 }
2810
2811 #[test]
2812 fn relativizes_docfile_in_a_subdirectory_of_docdir() {
2813 // A `docfile` nested below `docdir` keeps its sub-path relative to
2814 // `docdir` (not merely its base name), matching Asciidoctor's
2815 // `docfile[(docdir.length + 1)..-1]` slice.
2816 let p = Parser::default()
2817 .with_safe_mode(SafeMode::Server)
2818 .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2819 .with_intrinsic_attribute(
2820 "docfile",
2821 "/some/dir/sub/sample.adoc",
2822 ModificationContext::ApiOnly,
2823 );
2824 assert_eq!(
2825 p.attribute_value("docfile"),
2826 InterpretedValue::Value("sub/sample.adoc")
2827 );
2828 }
2829
2830 #[test]
2831 fn relativizes_docfile_not_under_docdir_to_its_basename() {
2832 // An inconsistent `docdir` / `docfile` pairing (docfile not under
2833 // docdir) must not be truncated at an unrelated byte offset; it
2834 // relativizes to the base name instead (see #735 review feedback).
2835 let p = Parser::default()
2836 .with_safe_mode(SafeMode::Server)
2837 .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2838 .with_intrinsic_attribute(
2839 "docfile",
2840 "/some/different/file.adoc",
2841 ModificationContext::ApiOnly,
2842 );
2843 assert_eq!(
2844 p.attribute_value("docfile"),
2845 InterpretedValue::Value("file.adoc")
2846 );
2847 }
2848
2849 #[test]
2850 fn docfile_without_docdir_falls_back_to_basename_under_server_mode() {
2851 let p = Parser::default()
2852 .with_safe_mode(SafeMode::Server)
2853 .with_intrinsic_attribute(
2854 "docfile",
2855 "/some/dir/sample.adoc",
2856 ModificationContext::ApiOnly,
2857 );
2858 assert_eq!(
2859 p.attribute_value("docfile"),
2860 InterpretedValue::Value("sample.adoc")
2861 );
2862 }
2863
2864 #[test]
2865 fn does_not_mask_docdir_and_docfile_below_server_mode() {
2866 // Below Server, the API-provided values pass through verbatim.
2867 let p = Parser::default()
2868 .with_safe_mode(SafeMode::Safe)
2869 .with_intrinsic_attribute("docdir", "/some/dir", ModificationContext::ApiOnly)
2870 .with_intrinsic_attribute(
2871 "docfile",
2872 "/some/dir/sample.adoc",
2873 ModificationContext::ApiOnly,
2874 );
2875 assert_eq!(
2876 p.attribute_value("docdir"),
2877 InterpretedValue::Value("/some/dir")
2878 );
2879 assert_eq!(
2880 p.attribute_value("docfile"),
2881 InterpretedValue::Value("/some/dir/sample.adoc")
2882 );
2883 }
2884
2885 #[test]
2886 fn unset_docdir_and_docfile_stay_missing_under_server_mode() {
2887 // Masking never conjures a value for an attribute that was never set, so
2888 // a reference to an unset `docdir` / `docfile` still resolves as missing.
2889 let p = Parser::default().with_safe_mode(SafeMode::Server);
2890 assert_eq!(p.attribute_value("docdir"), InterpretedValue::Unset);
2891 assert_eq!(p.attribute_value("docfile"), InterpretedValue::Unset);
2892 assert!(!p.has_attribute("docdir"));
2893 assert!(!p.has_attribute("docfile"));
2894 }
2895
2896 #[test]
2897 fn leaves_non_string_docdir_and_docfile_untouched_under_server_mode() {
2898 // A `docdir` / `docfile` present as a boolean flag (not a path string)
2899 // carries no host path to leak, so the masking has nothing to blank or
2900 // relativize and leaves the (empty) `Set` value as-is.
2901 let p = Parser::default()
2902 .with_safe_mode(SafeMode::Server)
2903 .with_intrinsic_attribute_bool("docdir", true, ModificationContext::ApiOnly)
2904 .with_intrinsic_attribute_bool("docfile", true, ModificationContext::ApiOnly);
2905 assert_eq!(p.attribute_value("docdir"), InterpretedValue::Set);
2906 assert_eq!(p.attribute_value("docfile"), InterpretedValue::Set);
2907 }
2908
2909 #[test]
2910 fn with_intrinsic_attribute_set() {
2911 let p = Parser::default().with_intrinsic_attribute_bool(
2912 "foo",
2913 true,
2914 ModificationContext::Anywhere,
2915 );
2916
2917 assert_eq!(p.attribute_value("foo"), InterpretedValue::Set);
2918 assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
2919
2920 assert!(p.is_attribute_set("foo"));
2921 assert!(!p.is_attribute_set("foo2"));
2922 assert!(!p.is_attribute_set("xyz"));
2923 }
2924
2925 #[test]
2926 fn with_intrinsic_attribute_unset() {
2927 let p = Parser::default().with_intrinsic_attribute_bool(
2928 "foo",
2929 false,
2930 ModificationContext::Anywhere,
2931 );
2932
2933 assert_eq!(p.attribute_value("foo"), InterpretedValue::Unset);
2934 assert_eq!(p.attribute_value("foo2"), InterpretedValue::Unset);
2935
2936 assert!(!p.is_attribute_set("foo"));
2937 assert!(!p.is_attribute_set("foo2"));
2938 assert!(!p.is_attribute_set("xyz"));
2939 }
2940
2941 #[test]
2942 fn can_not_override_locked_default_value() {
2943 let mut parser = Parser::default();
2944
2945 let doc = parser.parse(":sp: not a space!");
2946
2947 assert_eq!(
2948 doc.warnings().next().unwrap().warning,
2949 WarningType::AttributeValueIsLocked("sp".to_owned())
2950 );
2951
2952 assert_eq!(parser.attribute_value("sp"), InterpretedValue::Value(" "));
2953 }
2954
2955 #[test]
2956 fn asciidoc_parser_version_is_predefined() {
2957 // The crate predefines `asciidoc-parser-version` with its own version
2958 // (the parser-specific counterpart of Ruby Asciidoctor's
2959 // `asciidoctor-version` intrinsic).
2960 let mut parser = Parser::default();
2961
2962 assert_eq!(
2963 parser.attribute_value("asciidoc-parser-version"),
2964 InterpretedValue::Value(env!("CARGO_PKG_VERSION"))
2965 );
2966
2967 // The value is available to attribute references and `ifeval`
2968 // expressions in document content.
2969 let doc = parser.parse(concat!(
2970 "= Title\n",
2971 "\n",
2972 "ifeval::['{asciidoc-parser-version}' >= '0.1.0']\n",
2973 "v{asciidoc-parser-version}\n",
2974 "endif::[]\n",
2975 ));
2976
2977 assert_eq!(
2978 rendered_paragraphs(&doc),
2979 vec![format!("v{}", env!("CARGO_PKG_VERSION"))]
2980 );
2981 }
2982
2983 #[test]
2984 fn asciidoc_parser_version_is_locked() {
2985 // The parser version describes the processor itself, so a document
2986 // assignment is rejected with a warning and the built-in value stays
2987 // in place.
2988 let mut parser = Parser::default();
2989
2990 let doc = parser.parse(":asciidoc-parser-version: 99.99.99");
2991
2992 assert_eq!(
2993 doc.warnings().next().unwrap().warning,
2994 WarningType::AttributeValueIsLocked("asciidoc-parser-version".to_owned())
2995 );
2996
2997 assert_eq!(
2998 parser.attribute_value("asciidoc-parser-version"),
2999 InterpretedValue::Value(env!("CARGO_PKG_VERSION"))
3000 );
3001 }
3002
3003 #[test]
3004 fn asciidoctor_version_is_predefined() {
3005 // The crate predefines `asciidoctor-version` with the Asciidoctor
3006 // release whose behavior it implements, so documents written against
3007 // Asciidoctor's own intrinsic behave the same here.
3008 let mut parser = Parser::default();
3009
3010 assert_eq!(
3011 parser.attribute_value("asciidoctor-version"),
3012 InterpretedValue::Value(crate::ASCIIDOCTOR_VERSION)
3013 );
3014
3015 // The value is available to `ifdef` gating and to attribute references
3016 // and `ifeval` expressions in document content.
3017 let doc = parser.parse(concat!(
3018 "= Title\n",
3019 "\n",
3020 "ifdef::asciidoctor-version[]\n",
3021 "ifeval::['{asciidoctor-version}' >= '0.1.0']\n",
3022 "v{asciidoctor-version}\n",
3023 "endif::[]\n",
3024 "endif::[]\n",
3025 ));
3026
3027 assert_eq!(
3028 rendered_paragraphs(&doc),
3029 vec![format!("v{}", crate::ASCIIDOCTOR_VERSION)]
3030 );
3031 }
3032
3033 #[test]
3034 fn asciidoctor_version_is_locked() {
3035 // Like its `asciidoc-parser-version` companion, this describes the
3036 // processor itself, so a document assignment is rejected with a warning
3037 // and the built-in value stays in place.
3038 let mut parser = Parser::default();
3039
3040 let doc = parser.parse(":asciidoctor-version: 99.99.99");
3041
3042 assert_eq!(
3043 doc.warnings().next().unwrap().warning,
3044 WarningType::AttributeValueIsLocked("asciidoctor-version".to_owned())
3045 );
3046
3047 assert_eq!(
3048 parser.attribute_value("asciidoctor-version"),
3049 InterpretedValue::Value(crate::ASCIIDOCTOR_VERSION)
3050 );
3051 }
3052
3053 #[test]
3054 fn asciidoctor_flag_is_predefined() {
3055 // The crate predefines the always-set `asciidoctor` boolean flag, so a
3056 // document guarding Asciidoctor-only content with `ifdef::asciidoctor[]`
3057 // behaves the same here. A `////` comment block containing a directive
3058 // that would corrupt it once the flag is defined must stay untouched
3059 // (see issue #810).
3060 let mut parser = Parser::default();
3061
3062 assert_eq!(parser.attribute_value("asciidoctor"), InterpretedValue::Set);
3063
3064 let doc = parser.parse(concat!(
3065 "= Title\n",
3066 "\n",
3067 "ifdef::asciidoctor[]\n",
3068 "shown when asciidoctor is set\n",
3069 "endif::[]\n",
3070 "\n",
3071 "////\n",
3072 "ifdef::asciidoctor[////]\n",
3073 "////\n",
3074 "\n",
3075 "line after comment block\n",
3076 ));
3077
3078 assert_eq!(
3079 rendered_paragraphs(&doc),
3080 vec![
3081 "shown when asciidoctor is set".to_owned(),
3082 "line after comment block".to_owned(),
3083 ]
3084 );
3085 }
3086
3087 #[test]
3088 fn asciidoctor_flag_is_locked() {
3089 // Like the version intrinsics, the flag describes the processor itself,
3090 // so a document assignment is rejected with a warning and the built-in
3091 // value stays in place.
3092 let mut parser = Parser::default();
3093
3094 let doc = parser.parse(":asciidoctor: 99.99.99");
3095
3096 assert_eq!(
3097 doc.warnings().next().unwrap().warning,
3098 WarningType::AttributeValueIsLocked("asciidoctor".to_owned())
3099 );
3100
3101 assert_eq!(parser.attribute_value("asciidoctor"), InterpretedValue::Set);
3102 }
3103
3104 #[test]
3105 fn asciidoc_parser_version_distinguishes_the_two_processors() {
3106 // Both version intrinsics are defined, so a document tells the
3107 // processors apart via `asciidoc-parser-version`, which Ruby
3108 // Asciidoctor does not define.
3109 let mut parser = Parser::default();
3110
3111 let doc = parser.parse(concat!(
3112 "= Title\n",
3113 "\n",
3114 "ifdef::asciidoc-parser-version[]\n",
3115 "This is asciidoc-parser.\n",
3116 "endif::[]\n",
3117 ));
3118
3119 assert_eq!(rendered_paragraphs(&doc), vec!["This is asciidoc-parser."]);
3120 }
3121
3122 #[test]
3123 fn silently_locked_intrinsic_rejects_header_and_body_without_warning() {
3124 // A silently-locked `ApiOnly` intrinsic (as a converter would seed a
3125 // safe-mode-restricted attribute) rejects both a header assignment and a
3126 // body assignment of the same name, leaving the value unchanged and
3127 // recording no warning.
3128 let mut parser = Parser::default().with_intrinsic_attribute_silent(
3129 "backend",
3130 "html5",
3131 ModificationContext::ApiOnly,
3132 );
3133
3134 let doc = parser.parse(concat!(
3135 "= Title\n",
3136 ":backend: docbook5\n",
3137 "\n",
3138 "Body paragraph.\n",
3139 "\n",
3140 ":backend: manpage\n",
3141 ));
3142
3143 assert_eq!(doc.warnings().count(), 0);
3144 assert_eq!(
3145 parser.attribute_value("backend"),
3146 InterpretedValue::Value("html5")
3147 );
3148 }
3149
3150 #[test]
3151 fn silently_locked_bool_intrinsic_rejects_without_warning() {
3152 let mut parser = Parser::default().with_intrinsic_attribute_bool_silent(
3153 "sectids",
3154 true,
3155 ModificationContext::ApiOnly,
3156 );
3157
3158 let doc = parser.parse(concat!("= Title\n", ":!sectids:\n"));
3159
3160 assert_eq!(doc.warnings().count(), 0);
3161 assert_eq!(parser.attribute_value("sectids"), InterpretedValue::Set);
3162 }
3163
3164 #[test]
3165 fn silently_locked_bool_intrinsic_false_is_unset() {
3166 // A `false` flag records an `Unset` tombstone, and a locked (`ApiOnly`)
3167 // attribute rejects a document body reassignment without warning.
3168 let mut parser = Parser::default().with_intrinsic_attribute_bool_silent(
3169 "sectids",
3170 false,
3171 ModificationContext::ApiOnly,
3172 );
3173
3174 let doc = parser.parse(concat!("= Title\n", ":sectids:\n"));
3175
3176 assert_eq!(doc.warnings().count(), 0);
3177 assert_eq!(parser.attribute_value("sectids"), InterpretedValue::Unset);
3178 }
3179
3180 #[test]
3181 fn normally_locked_intrinsic_still_warns() {
3182 // Regression: a non-silent `ApiOnly` intrinsic still records
3183 // `AttributeValueIsLocked` when the document tries to reassign it.
3184 let mut parser = Parser::default().with_intrinsic_attribute(
3185 "backend",
3186 "html5",
3187 ModificationContext::ApiOnly,
3188 );
3189
3190 let doc = parser.parse(concat!("= Title\n", ":backend: docbook5\n"));
3191
3192 assert_eq!(
3193 doc.warnings().next().unwrap().warning,
3194 WarningType::AttributeValueIsLocked("backend".to_owned())
3195 );
3196 assert_eq!(
3197 parser.attribute_value("backend"),
3198 InterpretedValue::Value("html5")
3199 );
3200 }
3201
3202 #[test]
3203 fn catalog_transferred_to_document() {
3204 let mut parser = Parser::default();
3205 let doc = parser.parse("= Test Document\n\nSome content");
3206
3207 let catalog = doc.catalog();
3208 assert!(catalog.is_empty());
3209
3210 // The catalog was transferred to the document, leaving the parser with
3211 // an empty catalog.
3212 assert!(parser.catalog.borrow().is_empty());
3213 }
3214
3215 #[test]
3216 fn block_ids_registered_in_catalog() {
3217 let mut parser = Parser::default();
3218 let doc = parser.parse("= Test Document\n\n[#my-block]\nSome content with an ID");
3219
3220 let catalog = doc.catalog();
3221 assert!(!catalog.is_empty());
3222 assert!(catalog.contains_id("my-block"));
3223
3224 let entry = catalog.get_ref("my-block").unwrap();
3225 assert_eq!(entry.id, "my-block");
3226 assert_eq!(entry.ref_type, crate::document::RefType::Anchor);
3227 }
3228
3229 /// A simple test renderer that modifies special characters differently
3230 /// from the default HTML renderer.
3231 #[derive(Debug)]
3232 struct TestRenderer;
3233
3234 impl InlineSubstitutionRenderer for TestRenderer {
3235 fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
3236 // Custom rendering: wrap special characters in brackets.
3237 match type_ {
3238 SpecialCharacter::Lt => dest.push_str("[LT]"),
3239 SpecialCharacter::Gt => dest.push_str("[GT]"),
3240 SpecialCharacter::Ampersand => dest.push_str("[AMP]"),
3241 }
3242 }
3243
3244 fn render_quoted_substitition(
3245 &self,
3246 _type_: QuoteType,
3247 _scope: QuoteScope,
3248 _attrlist: Option<Attrlist<'_>>,
3249 _id: Option<String>,
3250 body: &str,
3251 dest: &mut String,
3252 ) {
3253 dest.push_str(body);
3254 }
3255
3256 fn render_character_replacement(
3257 &self,
3258 _type_: CharacterReplacementType,
3259 dest: &mut String,
3260 ) {
3261 dest.push_str("[CHAR]");
3262 }
3263
3264 fn render_line_break(&self, dest: &mut String) {
3265 dest.push_str("[BR]");
3266 }
3267
3268 fn render_image(&self, _params: &ImageRenderParams, dest: &mut String) {
3269 dest.push_str("[IMAGE]");
3270 }
3271
3272 fn image_uri(
3273 &self,
3274 target_image_path: &str,
3275 _parser: &Parser,
3276 _asset_dir_key: Option<&str>,
3277 ) -> String {
3278 target_image_path.to_string()
3279 }
3280
3281 fn render_icon(&self, _params: &IconRenderParams, dest: &mut String) {
3282 dest.push_str("[ICON]");
3283 }
3284
3285 fn render_link(&self, _params: &LinkRenderParams, dest: &mut String) {
3286 dest.push_str("[LINK]");
3287 }
3288
3289 fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
3290 dest.push_str(&format!("[ANCHOR:{}]", id));
3291 }
3292
3293 fn render_xref(&self, params: &crate::parser::XrefRenderParams, dest: &mut String) {
3294 dest.push_str(&format!("[XREF:{}]", params.target));
3295 }
3296
3297 fn render_callout(&self, params: &crate::parser::CalloutRenderParams, dest: &mut String) {
3298 dest.push_str(&format!("[CALLOUT:{}]", params.number));
3299 }
3300
3301 fn render_index_term(
3302 &self,
3303 params: &crate::parser::IndexTermRenderParams,
3304 dest: &mut String,
3305 ) {
3306 match params.visible_term {
3307 Some(term) => dest.push_str(&format!("[INDEXTERM:{term}]")),
3308 None => dest.push_str("[INDEXTERM]"),
3309 }
3310 }
3311
3312 fn render_button(&self, text: &str, dest: &mut String) {
3313 dest.push_str(&format!("[BUTTON:{text}]"));
3314 }
3315
3316 fn render_keyboard(&self, keys: &[String], dest: &mut String) {
3317 dest.push_str(&format!("[KBD:{}]", keys.join("+")));
3318 }
3319
3320 fn render_menu(&self, params: &crate::parser::MenuRenderParams, dest: &mut String) {
3321 dest.push_str(&format!("[MENU:{}]", params.menu));
3322 }
3323
3324 fn render_footnote(&self, params: &crate::parser::FootnoteRenderParams, dest: &mut String) {
3325 match params.index {
3326 Some(index) => dest.push_str(&format!("[FOOTNOTE:{index}]")),
3327 None => dest.push_str(&format!("[FOOTNOTE:{}]", params.text)),
3328 }
3329 }
3330 }
3331
3332 #[test]
3333 fn with_inline_substitution_renderer() {
3334 let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
3335
3336 // Parse a simple document with special characters and a footnote.
3337 let doc = parser.parse("Hello & goodbye < world > test footnote:[a note]");
3338
3339 // The document should parse successfully.
3340 assert_eq!(doc.warnings().count(), 0);
3341
3342 // Get the first block from the document.
3343 let block = doc.nested_blocks().next().unwrap();
3344
3345 let Block::Simple(simple_block) = block else {
3346 panic!("Expected simple block, got: {block:?}");
3347 };
3348
3349 // Our custom renderer should show [AMP], [LT], and [GT] instead of HTML
3350 // entities, and a resolved footnote as [FOOTNOTE:<index>].
3351 assert_eq!(
3352 simple_block.content().rendered(),
3353 "Hello [AMP] goodbye [LT] world [GT] test [FOOTNOTE:1]"
3354 );
3355 }
3356
3357 #[test]
3358 fn custom_renderer_renders_unresolved_footnote() {
3359 let mut parser = Parser::default().with_inline_substitution_renderer(TestRenderer);
3360
3361 // An unresolved footnote reference exercises the renderer's `None`
3362 // (no index) branch, which our custom renderer shows as
3363 // [FOOTNOTE:<text>].
3364 let doc = parser.parse("test.footnote:missing[]");
3365
3366 let block = doc.nested_blocks().next().unwrap();
3367 let Block::Simple(simple_block) = block else {
3368 panic!("Expected simple block, got: {block:?}");
3369 };
3370
3371 assert_eq!(simple_block.content().rendered(), "test.[FOOTNOTE:missing]");
3372 }
3373
3374 mod resolve_show_title {
3375 use crate::parser::{ModificationContext, Parser};
3376
3377 fn with(name: &str, set: bool) -> Parser {
3378 Parser::default().with_intrinsic_attribute_bool(
3379 name,
3380 set,
3381 ModificationContext::Anywhere,
3382 )
3383 }
3384
3385 #[test]
3386 fn neither_present_uses_default() {
3387 assert!(Parser::default().resolve_show_title(true));
3388 assert!(!Parser::default().resolve_show_title(false));
3389 }
3390
3391 #[test]
3392 fn showtitle_takes_precedence_and_decides() {
3393 // Present and set -> shown; present and unset -> hidden, regardless
3394 // of the default.
3395 assert!(with("showtitle", true).resolve_show_title(false));
3396 assert!(!with("showtitle", false).resolve_show_title(true));
3397 }
3398
3399 #[test]
3400 fn notitle_is_the_complement_when_showtitle_absent() {
3401 // notitle set -> hidden; notitle unset -> shown.
3402 assert!(!with("notitle", true).resolve_show_title(true));
3403 assert!(with("notitle", false).resolve_show_title(false));
3404 }
3405 }
3406
3407 mod notitle_showtitle_linkage {
3408 use crate::{
3409 blocks::{Block, IsBlock},
3410 document::InterpretedValue,
3411 parser::{ModificationContext, Parser},
3412 };
3413
3414 // Asciidoctor asciidoctor/asciidoctor#3804: `notitle` and `showtitle`
3415 // are two spellings of one title-visibility toggle, wired as inverses.
3416 // Assigning either updates the partner so the resolved document carries
3417 // one consistent signal — following Asciidoctor's hash semantics, where
3418 // turning the toggle *on* sets one spelling and *removes* the other.
3419
3420 fn parse_header(entries: &str) -> Parser {
3421 let mut parser = Parser::default();
3422 parser.parse(&format!("= Title\n{entries}\n\nbody"));
3423 parser
3424 }
3425
3426 #[test]
3427 fn header_showtitle_set_unsets_notitle() {
3428 // `:showtitle:` => notitle removed (absent).
3429 let parser = parse_header(":showtitle:");
3430 assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3431 assert!(!parser.has_attribute("notitle"));
3432 }
3433
3434 #[test]
3435 fn header_showtitle_unset_sets_notitle() {
3436 // `:!showtitle:` => notitle set.
3437 let parser = parse_header(":!showtitle:");
3438 assert!(!parser.is_attribute_set("showtitle"));
3439 assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3440 assert!(parser.is_attribute_set("notitle"));
3441 }
3442
3443 #[test]
3444 fn header_notitle_set_unsets_showtitle() {
3445 // `:notitle:` => showtitle removed (absent).
3446 let parser = parse_header(":notitle:");
3447 assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3448 assert!(!parser.has_attribute("showtitle"));
3449 }
3450
3451 #[test]
3452 fn header_notitle_unset_sets_showtitle() {
3453 // `:!notitle:` => showtitle set. This is the case called out in the
3454 // issue: a consumer keying off `showtitle` now sees a signal.
3455 let parser = parse_header(":!notitle:");
3456 assert!(!parser.is_attribute_set("notitle"));
3457 assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3458 assert!(parser.is_attribute_set("showtitle"));
3459 }
3460
3461 #[test]
3462 fn last_assignment_wins() {
3463 // Each assignment rewrites the partner, so whichever is assigned
3464 // last decides the resolved toggle.
3465 let parser = parse_header(":notitle:\n:showtitle:");
3466 assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3467 assert!(!parser.has_attribute("notitle"));
3468
3469 let parser = parse_header(":showtitle:\n:notitle:");
3470 assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3471 assert!(!parser.has_attribute("showtitle"));
3472 }
3473
3474 #[test]
3475 fn body_assignment_is_linked() {
3476 // A body attribute entry links the partner just as a header entry
3477 // does.
3478 let mut parser = Parser::default();
3479 parser.parse("= Title\n\nintro\n\n:notitle:\n\nmore");
3480 assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3481 assert!(!parser.has_attribute("showtitle"));
3482 }
3483
3484 #[test]
3485 fn api_assignment_is_linked() {
3486 // Setting either attribute via the API links the partner, matching
3487 // Asciidoctor's `attributes: { 'notitle!' => '' }` etc.
3488 let parser = Parser::default().with_intrinsic_attribute_bool(
3489 "notitle",
3490 true,
3491 ModificationContext::Anywhere,
3492 );
3493 assert_eq!(parser.attribute_value("notitle"), InterpretedValue::Set);
3494 assert!(!parser.has_attribute("showtitle"));
3495
3496 let parser = Parser::default().with_intrinsic_attribute_bool(
3497 "notitle",
3498 false,
3499 ModificationContext::Anywhere,
3500 );
3501 assert!(!parser.is_attribute_set("notitle"));
3502 assert_eq!(parser.attribute_value("showtitle"), InterpretedValue::Set);
3503 }
3504
3505 #[test]
3506 fn turning_the_toggle_on_leaves_no_partner_tombstone() {
3507 // `:notitle:` removes `showtitle` outright rather than leaving an
3508 // unset tombstone, so a `{showtitle}` reference stays literal (as it
3509 // would with the attribute absent) instead of resolving to an empty
3510 // string. This guards the interaction flagged in review.
3511 let parser = parse_header(":notitle:");
3512 assert!(!parser.has_attribute("showtitle"));
3513
3514 let mut parser = Parser::default();
3515 let doc = parser.parse("= Title\n:notitle:\n\n{showtitle}");
3516 let block = doc.nested_blocks().next().unwrap();
3517 let Block::Simple(simple_block) = block else {
3518 panic!("expected a simple block");
3519 };
3520 assert_eq!(simple_block.content().rendered(), "{showtitle}");
3521 }
3522
3523 #[test]
3524 fn unrelated_attributes_are_untouched() {
3525 // A document that never assigns either spelling leaves both absent —
3526 // the linkage is a no-op for every other attribute.
3527 let parser = parse_header(":sectnums:");
3528 assert!(!parser.has_attribute("notitle"));
3529 assert!(!parser.has_attribute("showtitle"));
3530 }
3531 }
3532
3533 mod derived_backend_family_attrs {
3534 use crate::{
3535 document::InterpretedValue,
3536 parser::{AllowableValue, AttributeValue, ModificationContext, Parser},
3537 };
3538
3539 #[test]
3540 fn tracks_the_active_doctype() {
3541 let mut parser = Parser::default();
3542
3543 // The default doctype is `article`, so only its derived attribute is
3544 // defined (to an empty value).
3545 assert_eq!(
3546 parser.attribute_value("backend-html5-doctype-article"),
3547 InterpretedValue::Value(String::new())
3548 );
3549 assert_eq!(
3550 parser.attribute_value("backend-html5-doctype-book"),
3551 InterpretedValue::Unset
3552 );
3553
3554 // Forcing a new doctype moves the derived attribute with it.
3555 parser.force_doctype("book");
3556 assert_eq!(
3557 parser.attribute_value("backend-html5-doctype-book"),
3558 InterpretedValue::Value(String::new())
3559 );
3560 assert_eq!(
3561 parser.attribute_value("backend-html5-doctype-article"),
3562 InterpretedValue::Unset
3563 );
3564 }
3565
3566 #[test]
3567 fn defines_no_derived_attr_when_doctype_is_not_a_value() {
3568 let mut parser = Parser::default();
3569
3570 // The default article derived attribute starts out defined.
3571 assert_eq!(
3572 parser.attribute_value("backend-html5-doctype-article"),
3573 InterpretedValue::Value(String::new())
3574 );
3575
3576 // Shadow the built-in `doctype` default with an explicit unset
3577 // tombstone. With `doctype` no longer resolving to a `Value`, no
3578 // derived attribute is synthesized for any doctype.
3579 std::sync::Arc::make_mut(&mut parser.attribute_values).insert(
3580 "doctype".to_string(),
3581 AttributeValue {
3582 allowable_value: AllowableValue::Any,
3583 modification_context: ModificationContext::Anywhere,
3584 silent_when_locked: false,
3585 value: InterpretedValue::Unset,
3586 },
3587 );
3588
3589 assert_eq!(parser.attribute_value("doctype"), InterpretedValue::Unset);
3590 assert_eq!(
3591 parser.attribute_value("backend-html5-doctype-article"),
3592 InterpretedValue::Unset
3593 );
3594 }
3595
3596 #[test]
3597 fn document_header_cannot_assign_a_derived_doctype_flag() {
3598 // The `backend-html5-doctype-*` namespace is a read-only intrinsic,
3599 // so a document header assignment to it is ignored: the flag for the
3600 // (inactive) `book` doctype stays undefined rather than taking the
3601 // assigned value, so it cannot later shadow the intrinsic.
3602 let mut parser = Parser::default();
3603 let _doc = parser.parse("= Title\n:backend-html5-doctype-book: custom\n\nbody");
3604
3605 assert_eq!(
3606 parser.attribute_value("backend-html5-doctype-book"),
3607 InterpretedValue::Unset
3608 );
3609 }
3610
3611 #[test]
3612 fn default_backend_family_is_materialized() {
3613 let parser = Parser::default();
3614
3615 // The default backend is `html5`; its whole derived family resolves
3616 // to queryable document attributes (empty-valued flags plus the
3617 // `backend` / `basebackend` / `filetype` values).
3618 for (name, value) in [
3619 ("backend", "html5"),
3620 ("backend-html5", ""),
3621 ("basebackend", "html"),
3622 ("basebackend-html", ""),
3623 ("filetype", "html"),
3624 ("filetype-html", ""),
3625 ("doctype-article", ""),
3626 ("backend-html5-doctype-article", ""),
3627 ("basebackend-html-doctype-article", ""),
3628 ] {
3629 assert!(parser.has_attribute(name), "missing {name:?}");
3630 assert!(parser.is_attribute_set(name), "not set: {name:?}");
3631 assert_eq!(
3632 parser.attribute_value(name),
3633 InterpretedValue::Value(value.to_string()),
3634 "unexpected value for {name:?}"
3635 );
3636 }
3637 }
3638
3639 #[test]
3640 fn family_tracks_a_non_html_backend() {
3641 // Setting a different backend re-derives the whole family from it
3642 // (basebackend strips the trailing digits, filetype maps through the
3643 // Asciidoctor extension table), and the inactive `html5` flags fall
3644 // away.
3645 let doc = Parser::default().parse(":backend: docbook5\n\nbody");
3646
3647 assert_eq!(
3648 doc.attribute_value("backend"),
3649 InterpretedValue::Value("docbook5".to_string())
3650 );
3651 assert_eq!(
3652 doc.attribute_value("basebackend"),
3653 InterpretedValue::Value("docbook".to_string())
3654 );
3655 assert_eq!(
3656 doc.attribute_value("filetype"),
3657 InterpretedValue::Value("xml".to_string())
3658 );
3659 assert!(doc.has_attribute("backend-docbook5"));
3660 assert!(doc.has_attribute("basebackend-docbook"));
3661 assert!(doc.has_attribute("backend-docbook5-doctype-article"));
3662
3663 // The derived values report as set through the post-parse
3664 // `Document` (snapshot) reader, not just the live parser.
3665 assert!(doc.is_attribute_set("basebackend"));
3666 assert!(doc.is_attribute_set("filetype"));
3667
3668 // The html5 flags are no longer active.
3669 assert!(!doc.has_attribute("backend-html5"));
3670 assert!(!doc.has_attribute("basebackend-html"));
3671 assert!(!doc.has_attribute("backend-html5-doctype-article"));
3672 }
3673
3674 #[test]
3675 fn derived_value_and_flag_attributes_are_read_only() {
3676 // `basebackend` / `filetype` and the derived flag namespace are
3677 // read-only intrinsics; a document assignment is silently ignored and
3678 // the synthesized value stands.
3679 let doc = Parser::default().parse(
3680 ":basebackend: custom\n:filetype: custom\n:backend-html5: custom\n:doctype-article: custom\n\nbody",
3681 );
3682
3683 assert_eq!(
3684 doc.attribute_value("basebackend"),
3685 InterpretedValue::Value("html".to_string())
3686 );
3687 assert_eq!(
3688 doc.attribute_value("filetype"),
3689 InterpretedValue::Value("html".to_string())
3690 );
3691 assert_eq!(
3692 doc.attribute_value("backend-html5"),
3693 InterpretedValue::Value(String::new())
3694 );
3695 assert_eq!(
3696 doc.attribute_value("doctype-article"),
3697 InterpretedValue::Value(String::new())
3698 );
3699 }
3700
3701 #[test]
3702 fn custom_prefixed_flags_stay_assignable() {
3703 // Author-defined attributes that share a derived-family prefix but
3704 // name no active flag (and are not the doctype-keyed namespace) are
3705 // kept, not swallowed by the read-only reservation, so they stay
3706 // visible to `ifdef` / attribute references — matching Asciidoctor.
3707 let doc = Parser::default().parse(
3708 ":backend-custom: enabled\n:basebackend-custom: on\n:filetype-custom: yes\n:doctype-draft: 1\n\nbody",
3709 );
3710
3711 for (name, value) in [
3712 ("backend-custom", "enabled"),
3713 ("basebackend-custom", "on"),
3714 ("filetype-custom", "yes"),
3715 ("doctype-draft", "1"),
3716 ] {
3717 assert!(doc.has_attribute(name), "missing {name:?}");
3718 assert_eq!(
3719 doc.attribute_value(name),
3720 InterpretedValue::Value(value.to_string()),
3721 "unexpected value for {name:?}"
3722 );
3723 }
3724 }
3725
3726 #[test]
3727 fn unset_backend_makes_the_family_absent() {
3728 // Explicitly unsetting `backend` leaves nothing to derive from, so
3729 // `basebackend` / `filetype` and the backend-keyed flags are absent
3730 // rather than resolving to empty traits or degenerate `backend-` /
3731 // `filetype-` names.
3732 let doc = Parser::default().parse(":backend!:\n\nbody");
3733
3734 assert_eq!(doc.attribute_value("backend"), InterpretedValue::Unset);
3735 for name in ["basebackend", "filetype"] {
3736 assert!(!doc.has_attribute(name), "unexpectedly present: {name:?}");
3737 assert!(!doc.is_attribute_set(name), "unexpectedly set: {name:?}");
3738 assert_eq!(doc.attribute_value(name), InterpretedValue::Unset);
3739 }
3740
3741 // No degenerate empty-suffix flags, and the html5 flags are gone.
3742 for name in [
3743 "backend-",
3744 "basebackend-",
3745 "filetype-",
3746 "backend-html5",
3747 "basebackend-html",
3748 ] {
3749 assert!(!doc.has_attribute(name), "unexpectedly present: {name:?}");
3750 }
3751
3752 // The doctype-only flag does not depend on `backend`, so it remains.
3753 assert!(doc.has_attribute("doctype-article"));
3754 }
3755 }
3756
3757 mod docname {
3758 use crate::Parser;
3759
3760 #[test]
3761 fn none_without_primary_file_name() {
3762 assert_eq!(Parser::default().docname(), None);
3763 }
3764
3765 #[test]
3766 fn strips_directory_and_extension() {
3767 assert_eq!(
3768 Parser::default()
3769 .with_primary_file_name("mydoc.adoc")
3770 .docname()
3771 .as_deref(),
3772 Some("mydoc")
3773 );
3774 assert_eq!(
3775 Parser::default()
3776 .with_primary_file_name("docs/guide/mydoc.adoc")
3777 .docname()
3778 .as_deref(),
3779 Some("mydoc")
3780 );
3781 // A Windows-style separator is handled too, since the primary file
3782 // name may be supplied on either platform.
3783 assert_eq!(
3784 Parser::default()
3785 .with_primary_file_name(r"docs\guide\mydoc.adoc")
3786 .docname()
3787 .as_deref(),
3788 Some("mydoc")
3789 );
3790 }
3791
3792 #[test]
3793 fn keeps_name_with_no_extension() {
3794 assert_eq!(
3795 Parser::default()
3796 .with_primary_file_name("README")
3797 .docname()
3798 .as_deref(),
3799 Some("README")
3800 );
3801 }
3802
3803 #[test]
3804 fn none_when_path_has_no_file_component() {
3805 // A primary file name that ends in a separator has an empty base
3806 // name, which yields no document name.
3807 assert_eq!(
3808 Parser::default()
3809 .with_primary_file_name("docs/guide/")
3810 .docname(),
3811 None
3812 );
3813 }
3814
3815 #[test]
3816 fn leading_dot_name_is_kept_whole() {
3817 // A leading-dot name (e.g. `.adoc`) is treated as a dotfile with no
3818 // extension and kept whole, matching Ruby's
3819 // `File.basename(".adoc", ".*")`.
3820 assert_eq!(
3821 Parser::default()
3822 .with_primary_file_name(".adoc")
3823 .docname()
3824 .as_deref(),
3825 Some(".adoc")
3826 );
3827 }
3828 }
3829
3830 mod counter {
3831 use super::super::next_counter_value;
3832 use crate::{document::InterpretedValue, tests::prelude::*};
3833
3834 #[test]
3835 fn next_counter_value_integer() {
3836 assert_eq!(next_counter_value("1"), "2");
3837 assert_eq!(next_counter_value("9"), "10");
3838 assert_eq!(next_counter_value("0"), "1");
3839 assert_eq!(next_counter_value("-1"), "0");
3840 }
3841
3842 #[test]
3843 fn next_counter_value_non_canonical_integer_is_advanced_as_a_string() {
3844 // A leading zero (or sign) does not round-trip through integer
3845 // parsing, so it is advanced like a string instead.
3846 assert_eq!(next_counter_value("07"), "08");
3847 assert_eq!(next_counter_value("+5"), "+6");
3848 // A leading-zero value still carries digit-to-digit like a string.
3849 assert_eq!(next_counter_value("09"), "10");
3850 assert_eq!(next_counter_value("099"), "100");
3851 }
3852
3853 #[test]
3854 fn next_counter_value_saturates_at_i64_max() {
3855 // A counter pinned at `i64::MAX` stays there rather than panicking
3856 // (debug) or wrapping (release).
3857 let max = i64::MAX.to_string();
3858 assert_eq!(next_counter_value(&max), max);
3859 }
3860
3861 #[test]
3862 fn next_counter_value_characters() {
3863 assert_eq!(next_counter_value("a"), "b");
3864 assert_eq!(next_counter_value("A"), "B");
3865 assert_eq!(next_counter_value("z"), "aa");
3866 assert_eq!(next_counter_value("Z"), "AA");
3867 assert_eq!(next_counter_value("az"), "ba");
3868 assert_eq!(next_counter_value("zz"), "aaa");
3869 assert_eq!(next_counter_value("Zz"), "AAa");
3870 }
3871
3872 #[test]
3873 fn next_counter_value_trailing_non_alphanumeric() {
3874 // The right-most alphanumeric is incremented; trailing punctuation is
3875 // left in place.
3876 assert_eq!(next_counter_value("a)"), "b)");
3877 }
3878
3879 #[test]
3880 fn next_counter_value_no_alphanumeric() {
3881 // With nothing alphanumeric to carry, the final code point advances.
3882 assert_eq!(next_counter_value("{"), "|");
3883 }
3884
3885 #[test]
3886 fn counter_defaults_to_one() {
3887 let p = Parser::default();
3888 assert_eq!(p.counter("x", None), "1");
3889 assert_eq!(p.counter("x", None), "2");
3890 assert_eq!(
3891 p.attribute_value("x"),
3892 InterpretedValue::Value("2".to_string())
3893 );
3894 assert!(p.has_attribute("x"));
3895 assert!(p.is_attribute_set("x"));
3896 }
3897
3898 #[test]
3899 fn counter_seed_used_only_while_unset() {
3900 let p = Parser::default();
3901 assert_eq!(p.counter("c", Some("A")), "A");
3902 // Once set, a later seed is ignored.
3903 assert_eq!(p.counter("c", Some("Q")), "B");
3904 }
3905
3906 #[test]
3907 fn counter_empty_seed_falls_back_to_one() {
3908 let p = Parser::default();
3909 assert_eq!(p.counter("c", Some("")), "1");
3910 }
3911 }
3912
3913 /// Coverage for the time-dependent document attributes (`docdate`,
3914 /// `doctime`, `docdatetime`, `docyear`, and their `local*` siblings) that
3915 /// is *not* a direct port of Asciidoctor's Ruby tests: the injectable
3916 /// clock ([`Parser::with_reference_time`] /
3917 /// [`Parser::with_input_mtime`]), and resolution *during* a parse (a
3918 /// `{docdate}` reference or an `ifdef::docdate[]` directive) rather
3919 /// than off the finished document.
3920 ///
3921 /// The direct Ruby ports live alongside the vendored suite in
3922 /// `tests/asciidoctor_rb/document_test.rs`.
3923 mod datetime_attributes {
3924 use crate::{parser::ReferenceTime, tests::prelude::*};
3925
3926 #[test]
3927 fn pins_local_attributes_with_reference_time() {
3928 // The injectable clock (this crate's stable-output mechanism) pins
3929 // the `local*` attributes, which Asciidoctor derives from
3930 // `::Time.now`.
3931 let doc = Parser::default()
3932 .with_reference_time(ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 6 * 3600))
3933 .parse("");
3934
3935 assert_eq!(
3936 doc.attribute_value("localdate"),
3937 InterpretedValue::Value("2019-01-02")
3938 );
3939 assert_eq!(
3940 doc.attribute_value("localyear"),
3941 InterpretedValue::Value("2019")
3942 );
3943 assert_eq!(
3944 doc.attribute_value("localtime"),
3945 InterpretedValue::Value("03:04:05 +0600")
3946 );
3947 assert_eq!(
3948 doc.attribute_value("localdatetime"),
3949 InterpretedValue::Value("2019-01-02 03:04:05 +0600")
3950 );
3951 }
3952
3953 #[test]
3954 fn resolves_date_attributes_referenced_in_the_document_body() {
3955 // A `{docdate}` reference resolves the attribute on demand through
3956 // the parser (during substitution), not off the finished document
3957 // snapshot.
3958 let doc = Parser::default()
3959 .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
3960 .parse("docdate={docdate} docyear={docyear} docdatetime={docdatetime}");
3961
3962 assert_eq!(
3963 rendered_paragraphs(&doc),
3964 vec![
3965 "docdate=2015-01-01 docyear=2015 docdatetime=2015-01-01 10:00:00 UTC"
3966 .to_string()
3967 ]
3968 );
3969 }
3970
3971 #[test]
3972 fn conditional_directive_sees_a_computed_date_attribute() {
3973 // `ifdef` queries `is_attribute_set`, which must report the computed
3974 // `docdate` as set.
3975 let doc = Parser::default()
3976 .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
3977 .parse("ifdef::docdate[present]");
3978
3979 assert_eq!(rendered_paragraphs(&doc), vec!["present".to_string()]);
3980 }
3981
3982 #[test]
3983 fn an_explicit_doctime_feeds_the_computed_docdatetime() {
3984 // An explicit `doctime` (a stored value) supplies the time portion
3985 // of the computed `docdatetime`, both when referenced in the body
3986 // and when read off the document.
3987 let mut parser = Parser::default()
3988 .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
3989 .with_intrinsic_attribute(
3990 "doctime",
3991 "09:09:09-0500",
3992 ModificationContext::ApiOrHeader,
3993 );
3994 let doc = parser.parse("at {docdatetime}");
3995
3996 assert_eq!(
3997 rendered_paragraphs(&doc),
3998 vec!["at 2015-01-01 09:09:09-0500".to_string()]
3999 );
4000 assert_eq!(
4001 doc.attribute_value("docdatetime"),
4002 InterpretedValue::Value("2015-01-01 09:09:09-0500")
4003 );
4004 }
4005
4006 #[test]
4007 fn an_unset_doctime_falls_back_to_the_reference_time() {
4008 // An explicitly unset `doctime` is treated as absent, so
4009 // `docdatetime` falls back to the reference instant's time.
4010 let mut parser = Parser::default()
4011 .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
4012 .with_intrinsic_attribute_bool("doctime", false, ModificationContext::ApiOrHeader);
4013 let doc = parser.parse("at {docdatetime}");
4014
4015 assert_eq!(
4016 rendered_paragraphs(&doc),
4017 vec!["at 2015-01-01 10:00:00 UTC".to_string()]
4018 );
4019 assert_eq!(
4020 doc.attribute_value("docdatetime"),
4021 InterpretedValue::Value("2015-01-01 10:00:00 UTC")
4022 );
4023 }
4024
4025 #[test]
4026 fn a_value_less_doctime_reads_as_an_empty_time() {
4027 // A value-less `doctime` (set, but with no value) contributes an
4028 // empty time, leaving a trailing space in the computed
4029 // `docdatetime`.
4030 let mut parser = Parser::default()
4031 .with_reference_time(ReferenceTime::from_unix_timestamp(1_420_106_400))
4032 .with_intrinsic_attribute_bool("doctime", true, ModificationContext::ApiOrHeader);
4033 let doc = parser.parse("x{docdatetime}x");
4034
4035 assert_eq!(rendered_paragraphs(&doc), vec!["x2015-01-01 x".to_string()]);
4036 assert_eq!(
4037 doc.attribute_value("docdatetime"),
4038 InterpretedValue::Value("2015-01-01 ")
4039 );
4040 }
4041 }
4042
4043 // Crate-native `skip-front-matter` cases (no Asciidoctor analog) covering
4044 // edge conditions beyond the reader-suite ports in
4045 // `tests::asciidoctor_rb::reader_test`. See [`Parser::skip_front_matter`].
4046 mod skip_front_matter {
4047 use crate::tests::prelude::*;
4048
4049 #[test]
4050 fn crlf_line_endings() {
4051 // The front-matter delimiters are matched after a CRLF line ending
4052 // is stripped, and the captured `front-matter` value is likewise
4053 // chomped, so a document with `\r\n` line endings is handled the
4054 // same as one with bare `\n`.
4055 let doc = Parser::default()
4056 .with_intrinsic_attribute_bool(
4057 "skip-front-matter",
4058 true,
4059 ModificationContext::ApiOnly,
4060 )
4061 .parse("---\r\nlayout: post\r\ntitle: Document Title\r\n---\r\n= Document Title\r\nAuthor Name\r\n\r\npreamble\r\n");
4062
4063 assert_eq!(
4064 doc.attribute_value("front-matter"),
4065 InterpretedValue::Value("layout: post\ntitle: Document Title")
4066 );
4067 assert_eq!(doc.header().title(), Some("Document Title"));
4068 assert_eq!(doc.header().title_source().unwrap().line(), 5);
4069 }
4070
4071 #[test]
4072 fn first_line_is_not_a_delimiter() {
4073 // With `skip-front-matter` set but no opening `---` on the first
4074 // line, there is nothing to skip: the document parses normally and
4075 // no `front-matter` attribute is recorded.
4076 let doc = Parser::default()
4077 .with_intrinsic_attribute_bool(
4078 "skip-front-matter",
4079 true,
4080 ModificationContext::ApiOnly,
4081 )
4082 .parse("= Document Title\nAuthor Name\n\npreamble\n");
4083
4084 assert_eq!(doc.attribute_value("front-matter"), InterpretedValue::Unset);
4085 assert_eq!(doc.header().title(), Some("Document Title"));
4086 assert_eq!(doc.header().title_source().unwrap().line(), 1);
4087 }
4088 }
4089}