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