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