Skip to main content

asciidoc_parser/attributes/
attrlist.rs

1use crate::{
2    HasSpan, Parser, Span,
3    attributes::{ElementAttribute, element_attribute::ParseShorthand},
4    content::{Content, SubstitutionStep},
5    internal::{debug::DebugSliceReference, opaque_iter::opaque_slice_iter},
6    span::MatchedItem,
7    strings::CowStr,
8    warnings::{MatchAndWarnings, Warning, WarningType},
9};
10
11opaque_slice_iter! {
12    /// An iterator over the [`ElementAttribute`]s in an [`Attrlist`], returned
13    /// by [`Attrlist::attributes`].
14    pub struct ElementAttributes<'a> yielding ElementAttribute<'a>;
15}
16
17/// The source text that’s used to define attributes for an element is referred
18/// to as an **attrlist.** An attrlist is always enclosed in a pair of square
19/// brackets. This applies for block attributes as well as attributes on a block
20/// or inline macro. The processor splits the attrlist into individual attribute
21/// entries, determines whether each entry is a positional or named attribute,
22/// parses the entry accordingly, and assigns the result as an attribute on the
23/// node.
24#[derive(Clone, Eq, Hash, PartialEq, Default)]
25pub struct Attrlist<'src> {
26    attributes: Vec<ElementAttribute<'src>>,
27    anchor: Option<CowStr<'src>>,
28    source: Span<'src>,
29}
30
31impl<'src> Attrlist<'src> {
32    /// **IMPORTANT:** This `source` span passed to this function should NOT
33    /// include the opening or closing square brackets for the attrlist.
34    /// This is because the rules for closing brackets differ when parsing
35    /// inline, macro, and block elements.
36    pub(crate) fn parse(
37        source: Span<'src>,
38        parser: &Parser,
39        attrlist_context: AttrlistContext,
40    ) -> MatchAndWarnings<'src, MatchedItem<'src, Self>> {
41        let mut attributes: Vec<ElementAttribute> = vec![];
42        let mut parse_shorthand_items = true;
43        let mut warnings: Vec<Warning<'src>> = vec![];
44
45        // Apply attribute value substitutions before parsing attrlist content.
46        let source_cow = if source.contains('{') && source.contains('}') {
47            let mut content = Content::from(source);
48            SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
49            CowStr::from(content.rendered.to_string())
50        } else {
51            CowStr::from(source.data())
52        };
53
54        if source_cow.starts_with('[') && source_cow.ends_with(']') {
55            let anchor = source_cow[1..source_cow.len() - 1].to_owned();
56
57            return MatchAndWarnings {
58                item: MatchedItem {
59                    item: Self {
60                        attributes,
61                        anchor: Some(CowStr::from(anchor)),
62                        source,
63                    },
64                    after: source.discard_all(),
65                },
66                warnings,
67            };
68        }
69
70        let mut index = 0;
71
72        // 1-based counter over every comma-delimited entry, incremented per
73        // entry – named attributes and blank (`nil`) slots included – so that
74        // positional attributes are numbered the way Asciidoctor numbers them
75        // (see `nth_attribute`).
76        let mut entry_number = 0usize;
77
78        let after_index = loop {
79            entry_number += 1;
80
81            let (mut attr, new_index, warning_types) = ElementAttribute::parse(
82                &source_cow,
83                index,
84                parser,
85                ParseShorthand(parse_shorthand_items),
86                attrlist_context,
87            );
88
89            // Because we do attribute value substitution early on in parsing, we can't
90            // pinpoint the exact location of warnings in an attribute list. For that
91            // reason, individual attribute parsing only returns the warning type and we
92            // then map it back to the entire attrlist source.
93            for warning_type in warning_types {
94                warnings.push(Warning {
95                    source,
96                    warning: warning_type,
97                    origin: None,
98                });
99            }
100
101            // Shorthand items (the `#id`, `.role`, and `%option` entries) are
102            // only recognized in the first attribute position. Once the first
103            // attribute has been parsed – whether it was positional or named –
104            // disable shorthand parsing so that, for example, a `%header`
105            // entered after a named `cols` attribute is not mistaken for an
106            // option (the processor ignores it).
107            parse_shorthand_items = false;
108
109            let mut after = Span::new(source_cow.as_ref()).discard(new_index);
110
111            // A completely empty (or whitespace-only) attribute list: the first
112            // entry is an empty, *unquoted* positional with nothing after it.
113            // Yield no attributes. An explicit empty *quoted* positional
114            // (`""` / `''`) carries a value and is kept below, so it is excluded
115            // here by `!attr.value_is_quoted()`.
116            if attr.name().is_none()
117                && attr.value().is_empty()
118                && !attr.value_is_quoted()
119                && after.is_empty()
120                && attributes.is_empty()
121            {
122                break index;
123            }
124
125            if attr.name().is_some() {
126                // A named attribute whose value is the literal `None` unsets the
127                // attribute (Asciidoctor semantics); it still consumes a
128                // position but is not stored.
129                if attr.value() != "None" {
130                    attributes.push(attr);
131                }
132            } else if !attr.value().is_empty() || attr.value_is_quoted() {
133                // A positional attribute – including an explicit empty quoted
134                // value (`""` / `''`). Record its position so later positionals
135                // stay aligned across named and blank entries.
136                attr.set_positional_index(entry_number);
137                attributes.push(attr);
138            }
139
140            // Otherwise this is an empty, unquoted positional: a blank (`nil`)
141            // slot. It consumes `entry_number` (already incremented) but is not
142            // stored, so a later positional keeps its Asciidoctor position.
143
144            after = after.take_whitespace().after;
145
146            match after.take_prefix(",") {
147                Some(comma) => {
148                    after = comma.after.take_whitespace().after;
149
150                    if after.starts_with(',') {
151                        warnings.push(Warning {
152                            source,
153                            warning: WarningType::EmptyAttributeValue,
154                            origin: None,
155                        });
156
157                        // Consume the blank slot between consecutive commas here,
158                        // advancing the position counter past it.
159                        entry_number += 1;
160                        after = after.discard(1);
161                        index = after.byte_offset();
162                        continue;
163                    }
164
165                    index = after.byte_offset();
166                }
167                None => {
168                    break after.byte_offset();
169                }
170            }
171        };
172
173        if after_index < source_cow.len() {
174            warnings.push(Warning {
175                source,
176                warning: WarningType::MissingCommaAfterQuotedAttributeValue,
177                origin: None,
178            });
179        }
180
181        MatchAndWarnings {
182            item: MatchedItem {
183                item: Self {
184                    attributes,
185                    anchor: None,
186                    source,
187                },
188                after: source.discard_all(),
189            },
190            warnings,
191        }
192    }
193
194    /// Build the attribute list implied by a language-aware fenced code block
195    /// (`` ```lang ``).
196    ///
197    /// A fenced code block whose opening fence carries a language is shorthand
198    /// for a source block: it is equivalent to a `[source,<language>]`
199    /// attribute list applied to a listing block. The synthesized list
200    /// therefore carries the `source` block style in the first position and
201    /// the language in the second, so that downstream consumers resolve the
202    /// block to a source (highlighted listing) block and can read the
203    /// language via [`nth_attribute(2)`](Self::nth_attribute) – without
204    /// this parser performing any syntax highlighting itself.
205    ///
206    /// The `source` span is set to the language span, since that is the only
207    /// portion of the synthesized list that appears in the document source.
208    pub(crate) fn source_with_language(language: Span<'src>) -> Self {
209        Self {
210            attributes: vec![
211                ElementAttribute::synthesized_source_style(),
212                ElementAttribute::positional_from_span(language),
213            ],
214            anchor: None,
215            source: language,
216        }
217    }
218
219    /// Merge a subsequent block attribute line into this one.
220    ///
221    /// A block can be preceded by more than one attribute list line (optionally
222    /// straddling the block title). Asciidoctor merges every such line into a
223    /// single set of attributes, where a later line wins on a name (or
224    /// position) conflict and otherwise accumulates. This method folds `later`
225    /// into `self` using those semantics:
226    ///
227    /// * **Named attributes** accumulate; a later attribute with the same name
228    ///   replaces the earlier one (in place, preserving order).
229    /// * **Positional attributes** are matched by position. A later positional
230    ///   replaces the earlier one at the same position. The first positional
231    ///   additionally carries the block style and shorthand items (`#id`,
232    ///   `.role`, `%option`), which are merged via
233    ///   [`ElementAttribute::merge_block_style_shorthand`].
234    /// * **Roles** follow Asciidoctor's running model once a formal `role=`
235    ///   entry is in play: a formal `role=` *replaces* every role accumulated
236    ///   so far, while shorthand `.role` entries *append*. Because a replacing
237    ///   `role=` on one line can sit between shorthand roles on earlier and
238    ///   later lines, the resolved (ordered) role list is folded into the
239    ///   formal `role` attribute and the first positional is left free of role
240    ///   shorthand, so [`roles`](Self::roles) reports the resolved list. When
241    ///   no formal `role=` is involved, shorthand roles simply accumulate as
242    ///   above.
243    /// * The **anchor** is taken from the later line if it specifies one.
244    ///
245    /// The `source` span is left pointing at the first line, since the merged
246    /// attributes no longer correspond to a single contiguous span.
247    pub(crate) fn merge_block_attribute_line(&mut self, later: Attrlist<'src>) {
248        // Roles only need the running-model treatment once a formal `role=`
249        // entry appears (on this line or an earlier, already-folded one).
250        // Otherwise shorthand roles accumulate through the ordinary first-
251        // positional merge below, unchanged.
252        let fold_roles =
253            self.named_attribute("role").is_some() || later.named_attribute("role").is_some();
254
255        let resolved_roles: Option<Vec<String>> = if fold_roles {
256            // The roles accumulated so far, in resolved order (a folded `role`
257            // attribute holds them; failing that, `self`'s shorthand/formal
258            // roles are read the ordinary way).
259            let current: Vec<String> = self.roles().iter().map(|r| r.to_string()).collect();
260
261            // A formal `role=` on the later line replaces the running list;
262            // otherwise it carries forward. The later line's shorthand roles
263            // then append (matching Asciidoctor, where a line's `role=` is set
264            // before its shorthand roles are appended).
265            let later_formal: Option<Vec<String>> = later
266                .named_attribute("role")
267                .map(|attr| split_role_value(attr.value()).map(str::to_string).collect());
268
269            let later_shorthand: Vec<String> = later
270                .nth_attribute(1)
271                .map(|attr| attr.roles().iter().map(|r| r.to_string()).collect())
272                .unwrap_or_default();
273
274            let mut resolved = later_formal.unwrap_or(current);
275            resolved.extend(later_shorthand);
276
277            // Strip role shorthand from our own first positional so the formal
278            // `role` attribute set below is the only remaining source of roles.
279            if let Some(existing) = self.nth_positional_mut(1) {
280                *existing = existing.without_shorthand_roles();
281            }
282
283            Some(resolved)
284        } else {
285            None
286        };
287
288        let Attrlist {
289            attributes: later_attributes,
290            anchor: later_anchor,
291            source: _,
292        } = later;
293
294        if later_anchor.is_some() {
295            self.anchor = later_anchor;
296        }
297
298        for attr in later_attributes {
299            // When folding roles, the resolved role list is applied afterward,
300            // so skip the later line's formal `role` attribute here (it is
301            // accounted for in `resolved_roles`).
302            if fold_roles && attr.name_str() == Some("role") {
303                continue;
304            }
305
306            // An attribute carries a positional index exactly when it is a
307            // positional (unnamed) attribute; a named attribute has `None`.
308            // Dispatching on the index keeps a positional at its Asciidoctor
309            // position – the same 1-based entry count `nth_attribute` uses,
310            // which includes named entries and blank slots – so positions stay
311            // aligned across lines even when a later line interleaves named
312            // attributes before a positional.
313            match attr.positional_index() {
314                // Named: accumulate, with a later attribute replacing the
315                // earlier one of the same name in place.
316                None => {
317                    if let Some(existing) = self
318                        .attributes
319                        .iter_mut()
320                        .find(|a| a.name_str() == attr.name_str())
321                    {
322                        *existing = attr;
323                    } else {
324                        self.attributes.push(attr);
325                    }
326                }
327
328                // The first positional additionally carries the block style and
329                // shorthand items (`#id`, `.role`, `%option`), which are merged.
330                // While folding roles, its role shorthand is dropped so roles
331                // flow solely through the formal `role` attribute.
332                Some(1) => {
333                    let attr = if fold_roles {
334                        attr.without_shorthand_roles()
335                    } else {
336                        attr
337                    };
338
339                    if let Some(existing) = self.nth_positional_mut(1) {
340                        *existing = ElementAttribute::merge_block_style_shorthand(existing, &attr);
341                    } else {
342                        self.attributes.push(attr);
343                    }
344                }
345
346                // A later positional replaces the earlier one at the same
347                // position, otherwise it extends the list.
348                Some(position) => {
349                    if let Some(existing) = self.nth_positional_mut(position) {
350                        *existing = attr;
351                    } else {
352                        self.attributes.push(attr);
353                    }
354                }
355            }
356        }
357
358        // Record the resolved roles in the formal `role` attribute.
359        if let Some(resolved) = resolved_roles {
360            self.set_role_attribute(resolved);
361        }
362    }
363
364    /// Replace (or clear) the formal `role` attribute with the given resolved
365    /// role list. Called by
366    /// [`merge_block_attribute_line`](Self::merge_block_attribute_line) once
367    /// roles have been resolved under Asciidoctor's running model. An empty
368    /// list removes any existing `role` attribute.
369    fn set_role_attribute(&mut self, roles: Vec<String>) {
370        if roles.is_empty() {
371            self.attributes.retain(|a| a.name_str() != Some("role"));
372            return;
373        }
374
375        let attr = ElementAttribute::synthesized_role(roles.join(" "));
376        if let Some(existing) = self
377            .attributes
378            .iter_mut()
379            .find(|a| a.name_str() == Some("role"))
380        {
381            *existing = attr;
382        } else {
383            self.attributes.push(attr);
384        }
385    }
386
387    /// Return a mutable reference to the positional attribute at (1-based)
388    /// Asciidoctor position `n` – the position recorded on each attribute, not
389    /// its ordinal among stored positionals (the two differ once named entries
390    /// or blank slots consume positions). See
391    /// [`nth_attribute`](Self::nth_attribute).
392    ///
393    /// `n` must be 1 or greater; the only caller is
394    /// [`merge_block_attribute_line`](Self::merge_block_attribute_line), which
395    /// always passes a positive position.
396    fn nth_positional_mut(&mut self, n: usize) -> Option<&mut ElementAttribute<'src>> {
397        debug_assert!(n >= 1, "nth_positional_mut requires a 1-based position");
398
399        self.attributes
400            .iter_mut()
401            .find(|attr| attr.positional_index() == Some(n))
402    }
403
404    /// Returns an iterator over the attributes contained within
405    /// this attrlist.
406    pub fn attributes(&'src self) -> ElementAttributes<'src> {
407        ElementAttributes::new(&self.attributes)
408    }
409
410    /// Returns the anchor found in this attribute list, if any.
411    pub fn anchor(&'src self) -> Option<&'src str> {
412        self.anchor.as_deref()
413    }
414
415    /// Returns the `title=` attribute's value and whether the normal
416    /// substitution group has already been applied to it, if the attribute is
417    /// present.
418    ///
419    /// Unlike [`named_attribute`](Self::named_attribute), this borrows for the
420    /// duration of the call only, so it can be read from an `Attrlist` that is
421    /// about to be moved (as when block metadata derives a block title from a
422    /// `title=` attribute before storing the attribute list on the block). The
423    /// returned flag (see [`ElementAttribute::value_is_substituted`]) lets the
424    /// caller avoid substituting an already-substituted (single-quoted) value a
425    /// second time.
426    pub(crate) fn title_attribute(&self) -> Option<(&str, bool)> {
427        self.attributes
428            .iter()
429            .find(|attr| attr.name_str() == Some("title"))
430            .map(|attr| (attr.value_str(), attr.value_is_substituted()))
431    }
432
433    /// Returns the first attribute with the given name.
434    pub fn named_attribute(&'src self, name: &str) -> Option<&'src ElementAttribute<'src>> {
435        self.attributes.iter().find(|attr| {
436            if let Some(attr_name) = attr.name() {
437                attr_name == name
438            } else {
439                false
440            }
441        })
442    }
443
444    /// Returns the given (1-based) positional attribute.
445    ///
446    /// **IMPORTANT:** Positions are numbered the way Asciidoctor numbers them:
447    /// every comma-delimited entry consumes a position, including named entries
448    /// and blank (`nil`) slots. A later positional therefore keeps its position
449    /// even when an earlier entry is named or left blank (e.g.
450    /// `image::x[Alt,,3]` has `Alt` at position 1 and `3` at position 3,
451    /// with position 2 empty). A position that is empty, or that is
452    /// occupied by a named attribute, yields `None`.
453    pub fn nth_attribute(&'src self, n: usize) -> Option<&'src ElementAttribute<'src>> {
454        if n == 0 {
455            None
456        } else {
457            self.attributes
458                .iter()
459                .find(|attr| attr.positional_index() == Some(n))
460        }
461    }
462
463    /// Returns the first attribute with the given name or (1-based) index.
464    ///
465    /// Some block and macro types provide implicit mappings between attribute
466    /// names and positions to permit a shorthand syntax.
467    ///
468    /// This method will search by name first, and fall back to positional
469    /// indexing if the name doesn't yield a match.
470    pub fn named_or_positional_attribute(
471        &'src self,
472        name: &str,
473        index: usize,
474    ) -> Option<&'src ElementAttribute<'src>> {
475        self.named_attribute(name)
476            .or_else(|| self.nth_attribute(index))
477    }
478
479    /// Returns the ID attribute (if any).
480    ///
481    /// You can assign an ID to a block using the shorthand syntax, the longhand
482    /// syntax, or a legacy block anchor.
483    ///
484    /// In the shorthand syntax, you prefix the name with a hash (`#`) in the
485    /// first position attribute:
486    ///
487    /// ```asciidoc
488    /// [#goals]
489    /// * Goal 1
490    /// * Goal 2
491    /// ```
492    ///
493    /// In the longhand syntax, you use a standard named attribute:
494    ///
495    /// ```asciidoc
496    /// [id=goals]
497    /// * Goal 1
498    /// * Goal 2
499    /// ```
500    ///
501    /// In the legacy block anchor syntax, you surround the name with double
502    /// square brackets:
503    ///
504    /// ```asciidoc
505    /// [[goals]]
506    /// * Goal 1
507    /// * Goal 2
508    /// ```
509    pub fn id(&'src self) -> Option<&'src str> {
510        self.anchor().or_else(|| {
511            self.nth_attribute(1)
512                .and_then(|attr1| attr1.id())
513                .or_else(|| self.named_attribute("id").map(|attr| attr.value()))
514        })
515    }
516
517    /// Returns any role attributes that were found.
518    ///
519    /// You can assign one or more roles to blocks and most inline elements
520    /// using the `role` attribute. The `role` attribute is a [named attribute].
521    /// Even though the attribute name is singular, it may contain multiple
522    /// (space-separated) roles. Roles may also be defined using a shorthand
523    /// (dot-prefixed) syntax.
524    ///
525    /// A role:
526    /// 1. adds additional semantics to an element
527    /// 2. can be used to apply additional styling to a group of elements (e.g.,
528    ///    via a CSS class selector)
529    /// 3. may activate additional behavior if recognized by the converter
530    ///
531    /// **TIP:** The `role` attribute in AsciiDoc always get mapped to the
532    /// `class` attribute in the HTML output. In other words, role names are
533    /// synonymous with HTML class names, thus allowing output elements to be
534    /// identified and styled in CSS using class selectors (e.g.,
535    /// `sidebarblock.role1`).
536    ///
537    /// [named attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/positional-and-named-attributes/#named
538    pub fn roles(&'src self) -> Vec<&'src str> {
539        let mut roles = self
540            .nth_attribute(1)
541            .map(|attr1| attr1.roles())
542            .unwrap_or_default();
543
544        if let Some(role_attr) = self.named_attribute("role") {
545            roles.extend(split_role_value(role_attr.value()));
546        }
547
548        roles
549    }
550
551    /// Recovers the role from a quote-delimited first positional attribute (for
552    /// example `['role']`) in a quoted-text attribute list.
553    ///
554    /// This mirrors the `else` branch of Asciidoctor's
555    /// `parse_quoted_text_attributes`: when the first positional attribute is
556    /// not shorthand (it does not begin with `.` or `#`), Asciidoctor treats
557    /// the entire first positional – verbatim, quote characters included –
558    /// as the role. The shorthand parser used for the general attribute
559    /// list instead strips the surrounding quotes and records no role or
560    /// block style for such a value, so a quoted role would otherwise be
561    /// dropped.
562    ///
563    /// Returns the verbatim role only when the first positional attribute was
564    /// genuinely quote-delimited; otherwise the normal shorthand path already
565    /// produced the role, id, and block style, so this returns `None`.
566    pub(crate) fn quoted_text_fallback_role(&'src self) -> Option<&'src str> {
567        if !self.nth_attribute(1)?.value_is_quoted() {
568            return None;
569        }
570
571        // Asciidoctor's `parse_quoted_text_attributes` considers only the first
572        // positional attribute – the source up to the first comma – and uses it
573        // verbatim (quote characters included) as the role. The comma split is on
574        // the raw source, matching Asciidoctor's `str.slice 0, (str.index ',')`,
575        // so a comma *inside* the quotes truncates the role there too (e.g.
576        // `['a,b']` yields the role `'a`) rather than being treated as quoted
577        // content. A quote-delimited first positional always leaves at least its
578        // opening quote here, so the slice is never empty.
579        let raw = self.source.data();
580        Some(raw.split_once(',').map_or(raw, |(first, _)| first).trim())
581    }
582
583    /// Returns any option attributes that were found.
584    ///
585    /// The `options` attribute (often abbreviated as `opts`) is a versatile
586    /// [named attribute] that can be assigned one or more values. It can be
587    /// defined globally as document attribute as well as a block attribute on
588    /// an individual block.
589    ///
590    /// There is no strict schema for options. Any options which are not
591    /// recognized are ignored.
592    ///
593    /// You can assign one or more options to a block using the shorthand or
594    /// formal syntax for the options attribute.
595    ///
596    /// # Shorthand options syntax for blocks
597    ///
598    /// To assign an option to a block, prefix the value with a percent sign
599    /// (`%`) in an attribute list. The percent sign implicitly sets the
600    /// `options` attribute.
601    ///
602    /// ## Example 1: Sidebar block with an option assigned using the shorthand dot
603    ///
604    /// ```asciidoc
605    /// [%option]
606    /// ****
607    /// This is a sidebar with an option assigned to it, named option.
608    /// ****
609    /// ```
610    ///
611    /// You can assign multiple options to a block by prefixing each value with
612    /// a percent sign (`%`).
613    ///
614    /// ## Example 2: Sidebar with two options assigned using the shorthand dot
615    /// ```asciidoc
616    /// [%option1%option2]
617    /// ****
618    /// This is a sidebar with two options assigned to it, named option1 and option2.
619    /// ****
620    /// ```
621    ///
622    /// # Formal options syntax for blocks
623    ///
624    /// Explicitly set `options` or `opts`, followed by the equals sign (`=`),
625    /// and then the value in an attribute list.
626    ///
627    /// ## Example 3. Sidebar block with an option assigned using the formal syntax
628    /// ```asciidoc
629    /// [opts=option]
630    /// ****
631    /// This is a sidebar with an option assigned to it, named option.
632    /// ****
633    /// ```
634    ///
635    /// Separate multiple option values with commas (`,`).
636    ///
637    /// ## Example 4. Sidebar with three options assigned using the formal syntax
638    /// ```asciidoc
639    /// [opts="option1,option2"]
640    /// ****
641    /// This is a sidebar with two options assigned to it, option1 and option2.
642    /// ****
643    /// ```
644    ///
645    /// [named attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/positional-and-named-attributes/#named
646    pub fn options(&'src self) -> Vec<&'src str> {
647        let mut options = self
648            .nth_attribute(1)
649            .map(|attr1| attr1.options())
650            .unwrap_or_default();
651
652        if let Some(option_attr) = self.named_attribute("opts") {
653            options.append(&mut split_options(option_attr.value()));
654        }
655
656        if let Some(option_attr) = self.named_attribute("options") {
657            options.append(&mut split_options(option_attr.value()));
658        }
659
660        options
661    }
662
663    /// Returns `true` if this attribute list has the named option.
664    ///
665    /// See [`options()`] for a description of option syntax.
666    ///
667    /// [`options()`]: Self::options
668    pub fn has_option<N: AsRef<str>>(&'src self, name: N) -> bool {
669        // PERF: Might help to optimize away the construction of the options Vec.
670        let options = self.options();
671        let name = name.as_ref();
672        options.contains(&name)
673    }
674
675    /// Return the block style name from shorthand syntax.
676    pub fn block_style(&'src self) -> Option<&'src str> {
677        self.nth_attribute(1).and_then(|a| a.block_style())
678    }
679}
680
681impl<'src> HasSpan<'src> for Attrlist<'src> {
682    fn span(&self) -> Span<'src> {
683        self.source
684    }
685}
686
687impl std::fmt::Debug for Attrlist<'_> {
688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689        f.debug_struct("Attrlist")
690            .field("attributes", &DebugSliceReference(&self.attributes))
691            .field("anchor", &self.anchor)
692            .field("source", &self.source)
693            .finish()
694    }
695}
696
697/// Split an `opts`/`options` attribute value into individual option tokens,
698/// matching Asciidoctor: split on commas, trim surrounding whitespace from each
699/// token, and drop empty tokens. So `'opt1,,opt2 , opt3'` yields `opt1`,
700/// `opt2`, `opt3`.
701fn split_options(value: &str) -> Vec<&str> {
702    value
703        .split(',')
704        .map(str::trim)
705        .filter(|opt| !opt.is_empty())
706        .collect()
707}
708
709/// Split a formal `role` attribute value into its individual role names: split
710/// on ASCII spaces and drop empty tokens. So `'role1  role2'` yields `role1`,
711/// `role2`.
712///
713/// This is the single source of truth for how a `role=` value is tokenized,
714/// shared by [`Attrlist::roles`] (which borrows the names) and the
715/// block-attribute merge (which owns them), so the two can never diverge.
716fn split_role_value(value: &str) -> impl Iterator<Item = &str> {
717    value.split(' ').filter(|role| !role.is_empty())
718}
719
720/// Context for attribute list parsing.
721#[derive(Clone, Copy, Debug, Eq, PartialEq)]
722pub(crate) enum AttrlistContext {
723    Block,
724    Inline,
725}
726
727#[cfg(test)]
728mod tests {
729    #![allow(clippy::unwrap_used)]
730
731    use crate::{attributes::AttrlistContext, tests::prelude::*};
732
733    #[test]
734    fn impl_clone() {
735        // Silly test to mark the #[derive(...)] line as covered.
736        let p = Parser::default();
737        let b1 = crate::attributes::Attrlist::parse(
738            crate::Span::new("abc"),
739            &p,
740            AttrlistContext::Inline,
741        )
742        .unwrap_if_no_warnings();
743
744        let b2 = b1.item.clone();
745        assert_eq!(b1.item, b2);
746    }
747
748    #[test]
749    fn impl_default() {
750        let attrlist = crate::attributes::Attrlist::default();
751
752        assert_eq!(
753            attrlist,
754            Attrlist {
755                attributes: &[],
756                anchor: None,
757                source: Span {
758                    data: "",
759                    line: 1,
760                    col: 1,
761                    offset: 0
762                }
763            }
764        );
765
766        assert!(attrlist.named_attribute("foo").is_none());
767
768        assert!(attrlist.nth_attribute(0).is_none());
769        assert!(attrlist.nth_attribute(1).is_none());
770        assert!(attrlist.nth_attribute(42).is_none());
771
772        assert!(attrlist.named_or_positional_attribute("foo", 0).is_none());
773        assert!(attrlist.named_or_positional_attribute("foo", 1).is_none());
774        assert!(attrlist.named_or_positional_attribute("foo", 42).is_none());
775
776        assert!(attrlist.id().is_none());
777        assert!(attrlist.roles().is_empty());
778        assert!(attrlist.block_style().is_none());
779
780        assert_eq!(
781            attrlist.span(),
782            Span {
783                data: "",
784                line: 1,
785                col: 1,
786                offset: 0,
787            }
788        );
789    }
790
791    #[test]
792    fn empty_source() {
793        let p = Parser::default();
794
795        let mi =
796            crate::attributes::Attrlist::parse(crate::Span::default(), &p, AttrlistContext::Inline)
797                .unwrap_if_no_warnings();
798
799        assert_eq!(
800            mi.item,
801            Attrlist {
802                attributes: &[],
803                anchor: None,
804                source: Span {
805                    data: "",
806                    line: 1,
807                    col: 1,
808                    offset: 0
809                }
810            }
811        );
812
813        assert!(mi.item.named_attribute("foo").is_none());
814
815        assert!(mi.item.nth_attribute(0).is_none());
816        assert!(mi.item.nth_attribute(1).is_none());
817        assert!(mi.item.nth_attribute(42).is_none());
818
819        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
820        assert!(mi.item.named_or_positional_attribute("foo", 1).is_none());
821        assert!(mi.item.named_or_positional_attribute("foo", 42).is_none());
822
823        assert!(mi.item.id().is_none());
824        assert!(mi.item.roles().is_empty());
825        assert!(mi.item.block_style().is_none());
826
827        assert_eq!(
828            mi.item.span(),
829            Span {
830                data: "",
831                line: 1,
832                col: 1,
833                offset: 0,
834            }
835        );
836
837        assert_eq!(
838            mi.after,
839            Span {
840                data: "",
841                line: 1,
842                col: 1,
843                offset: 0
844            }
845        );
846    }
847
848    #[test]
849    fn empty_positional_attributes() {
850        let p = Parser::default();
851
852        let mi = crate::attributes::Attrlist::parse(
853            crate::Span::new(",300,400"),
854            &p,
855            AttrlistContext::Inline,
856        )
857        .unwrap_if_no_warnings();
858
859        // A leading comma leaves position 1 blank (a `nil` slot, as in
860        // Asciidoctor): it consumes the position but stores no attribute, so
861        // `300` and `400` remain at positions 2 and 3.
862        assert_eq!(
863            mi.item,
864            Attrlist {
865                attributes: &[
866                    ElementAttribute {
867                        name: None,
868                        shorthand_items: &[],
869                        value: "300"
870                    },
871                    ElementAttribute {
872                        name: None,
873                        shorthand_items: &[],
874                        value: "400"
875                    }
876                ],
877                anchor: None,
878                source: Span {
879                    data: ",300,400",
880                    line: 1,
881                    col: 1,
882                    offset: 0
883                }
884            }
885        );
886
887        assert!(mi.item.named_attribute("foo").is_none());
888        assert!(mi.item.nth_attribute(0).is_none());
889        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
890
891        assert!(mi.item.id().is_none());
892        assert!(mi.item.roles().is_empty());
893        assert!(mi.item.block_style().is_none());
894
895        // Position 1 is the blank slot: no attribute there.
896        assert!(mi.item.nth_attribute(1).is_none());
897        assert!(mi.item.named_or_positional_attribute("alt", 1).is_none());
898
899        assert_eq!(
900            mi.item.nth_attribute(2).unwrap(),
901            ElementAttribute {
902                name: None,
903                shorthand_items: &[],
904                value: "300"
905            }
906        );
907
908        assert_eq!(
909            mi.item.named_or_positional_attribute("width", 2).unwrap(),
910            ElementAttribute {
911                name: None,
912                shorthand_items: &[],
913                value: "300"
914            }
915        );
916
917        assert_eq!(
918            mi.item.nth_attribute(3).unwrap(),
919            ElementAttribute {
920                name: None,
921                shorthand_items: &[],
922                value: "400"
923            }
924        );
925
926        assert_eq!(
927            mi.item.named_or_positional_attribute("height", 3).unwrap(),
928            ElementAttribute {
929                name: None,
930                shorthand_items: &[],
931                value: "400"
932            }
933        );
934
935        assert!(mi.item.nth_attribute(4).is_none());
936        assert!(mi.item.named_or_positional_attribute("height", 4).is_none());
937        assert!(mi.item.nth_attribute(42).is_none());
938
939        assert_eq!(
940            mi.item.span(),
941            Span {
942                data: ",300,400",
943                line: 1,
944                col: 1,
945                offset: 0,
946            }
947        );
948
949        assert_eq!(
950            mi.after,
951            Span {
952                data: "",
953                line: 1,
954                col: 9,
955                offset: 8
956            }
957        );
958    }
959
960    #[test]
961    fn only_positional_attributes() {
962        let p = Parser::default();
963
964        let mi = crate::attributes::Attrlist::parse(
965            crate::Span::new("Sunset,300,400"),
966            &p,
967            AttrlistContext::Inline,
968        )
969        .unwrap_if_no_warnings();
970
971        assert_eq!(
972            mi.item,
973            Attrlist {
974                attributes: &[
975                    ElementAttribute {
976                        name: None,
977                        shorthand_items: &["Sunset"],
978                        value: "Sunset"
979                    },
980                    ElementAttribute {
981                        name: None,
982                        shorthand_items: &[],
983                        value: "300"
984                    },
985                    ElementAttribute {
986                        name: None,
987                        shorthand_items: &[],
988                        value: "400"
989                    }
990                ],
991                anchor: None,
992                source: Span {
993                    data: "Sunset,300,400",
994                    line: 1,
995                    col: 1,
996                    offset: 0
997                }
998            }
999        );
1000
1001        assert!(mi.item.named_attribute("foo").is_none());
1002        assert!(mi.item.nth_attribute(0).is_none());
1003        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
1004
1005        assert!(mi.item.id().is_none());
1006        assert!(mi.item.roles().is_empty());
1007        assert_eq!(mi.item.block_style().unwrap(), "Sunset");
1008
1009        assert_eq!(
1010            mi.item.nth_attribute(1).unwrap(),
1011            ElementAttribute {
1012                name: None,
1013                shorthand_items: &["Sunset"],
1014                value: "Sunset"
1015            }
1016        );
1017
1018        assert_eq!(
1019            mi.item.named_or_positional_attribute("alt", 1).unwrap(),
1020            ElementAttribute {
1021                name: None,
1022                shorthand_items: &["Sunset"],
1023                value: "Sunset"
1024            }
1025        );
1026
1027        assert_eq!(
1028            mi.item.nth_attribute(2).unwrap(),
1029            ElementAttribute {
1030                name: None,
1031                shorthand_items: &[],
1032                value: "300"
1033            }
1034        );
1035
1036        assert_eq!(
1037            mi.item.named_or_positional_attribute("width", 2).unwrap(),
1038            ElementAttribute {
1039                name: None,
1040                shorthand_items: &[],
1041                value: "300"
1042            }
1043        );
1044
1045        assert_eq!(
1046            mi.item.nth_attribute(3).unwrap(),
1047            ElementAttribute {
1048                name: None,
1049                shorthand_items: &[],
1050                value: "400"
1051            }
1052        );
1053
1054        assert_eq!(
1055            mi.item.named_or_positional_attribute("height", 3).unwrap(),
1056            ElementAttribute {
1057                name: None,
1058                shorthand_items: &[],
1059                value: "400"
1060            }
1061        );
1062
1063        assert!(mi.item.nth_attribute(4).is_none());
1064        assert!(mi.item.named_or_positional_attribute("height", 4).is_none());
1065        assert!(mi.item.nth_attribute(42).is_none());
1066
1067        assert_eq!(
1068            mi.item.span(),
1069            Span {
1070                data: "Sunset,300,400",
1071                line: 1,
1072                col: 1,
1073                offset: 0,
1074            }
1075        );
1076
1077        assert_eq!(
1078            mi.after,
1079            Span {
1080                data: "",
1081                line: 1,
1082                col: 15,
1083                offset: 14
1084            }
1085        );
1086    }
1087
1088    #[test]
1089    fn trim_trailing_space() {
1090        let p = Parser::default();
1091
1092        let mi = crate::attributes::Attrlist::parse(
1093            crate::Span::new("Sunset ,300 , 400"),
1094            &p,
1095            AttrlistContext::Inline,
1096        )
1097        .unwrap_if_no_warnings();
1098
1099        assert_eq!(
1100            mi.item,
1101            Attrlist {
1102                attributes: &[
1103                    ElementAttribute {
1104                        name: None,
1105                        shorthand_items: &["Sunset"],
1106                        value: "Sunset"
1107                    },
1108                    ElementAttribute {
1109                        name: None,
1110                        shorthand_items: &[],
1111                        value: "300"
1112                    },
1113                    ElementAttribute {
1114                        name: None,
1115                        shorthand_items: &[],
1116                        value: "400"
1117                    }
1118                ],
1119                anchor: None,
1120                source: Span {
1121                    data: "Sunset ,300 , 400",
1122                    line: 1,
1123                    col: 1,
1124                    offset: 0
1125                }
1126            }
1127        );
1128
1129        assert!(mi.item.named_attribute("foo").is_none());
1130        assert!(mi.item.nth_attribute(0).is_none());
1131        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
1132
1133        assert!(mi.item.id().is_none());
1134        assert!(mi.item.roles().is_empty());
1135        assert_eq!(mi.item.block_style().unwrap(), "Sunset");
1136
1137        assert_eq!(
1138            mi.item.nth_attribute(1).unwrap(),
1139            ElementAttribute {
1140                name: None,
1141                shorthand_items: &["Sunset"],
1142                value: "Sunset"
1143            }
1144        );
1145
1146        assert_eq!(
1147            mi.item.named_or_positional_attribute("alt", 1).unwrap(),
1148            ElementAttribute {
1149                name: None,
1150                shorthand_items: &["Sunset"],
1151                value: "Sunset"
1152            }
1153        );
1154
1155        assert_eq!(
1156            mi.item.nth_attribute(2).unwrap(),
1157            ElementAttribute {
1158                name: None,
1159                shorthand_items: &[],
1160                value: "300"
1161            }
1162        );
1163
1164        assert_eq!(
1165            mi.item.named_or_positional_attribute("width", 2).unwrap(),
1166            ElementAttribute {
1167                name: None,
1168                shorthand_items: &[],
1169                value: "300"
1170            }
1171        );
1172
1173        assert_eq!(
1174            mi.item.nth_attribute(3).unwrap(),
1175            ElementAttribute {
1176                name: None,
1177                shorthand_items: &[],
1178                value: "400"
1179            }
1180        );
1181
1182        assert_eq!(
1183            mi.item.named_or_positional_attribute("height", 3).unwrap(),
1184            ElementAttribute {
1185                name: None,
1186                shorthand_items: &[],
1187                value: "400"
1188            }
1189        );
1190
1191        assert!(mi.item.nth_attribute(4).is_none());
1192        assert!(mi.item.named_or_positional_attribute("height", 4).is_none());
1193        assert!(mi.item.nth_attribute(42).is_none());
1194
1195        assert_eq!(
1196            mi.item.span(),
1197            Span {
1198                data: "Sunset ,300 , 400",
1199                line: 1,
1200                col: 1,
1201                offset: 0,
1202            }
1203        );
1204
1205        assert_eq!(
1206            mi.after,
1207            Span {
1208                data: "",
1209                line: 1,
1210                col: 18,
1211                offset: 17
1212            }
1213        );
1214    }
1215
1216    #[test]
1217    fn only_named_attributes() {
1218        let p = Parser::default();
1219
1220        let mi = crate::attributes::Attrlist::parse(
1221            crate::Span::new("alt=Sunset,width=300,height=400"),
1222            &p,
1223            AttrlistContext::Inline,
1224        )
1225        .unwrap_if_no_warnings();
1226
1227        assert_eq!(
1228            mi.item,
1229            Attrlist {
1230                attributes: &[
1231                    ElementAttribute {
1232                        name: Some("alt"),
1233                        shorthand_items: &[],
1234                        value: "Sunset"
1235                    },
1236                    ElementAttribute {
1237                        name: Some("width"),
1238                        shorthand_items: &[],
1239                        value: "300"
1240                    },
1241                    ElementAttribute {
1242                        name: Some("height"),
1243                        shorthand_items: &[],
1244                        value: "400"
1245                    }
1246                ],
1247                anchor: None,
1248                source: Span {
1249                    data: "alt=Sunset,width=300,height=400",
1250                    line: 1,
1251                    col: 1,
1252                    offset: 0
1253                }
1254            }
1255        );
1256
1257        assert!(mi.item.named_attribute("foo").is_none());
1258        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
1259
1260        assert_eq!(
1261            mi.item.named_attribute("alt").unwrap(),
1262            ElementAttribute {
1263                name: Some("alt"),
1264                shorthand_items: &[],
1265                value: "Sunset"
1266            }
1267        );
1268
1269        assert_eq!(
1270            mi.item.named_or_positional_attribute("alt", 1).unwrap(),
1271            ElementAttribute {
1272                name: Some("alt"),
1273                shorthand_items: &[],
1274                value: "Sunset"
1275            }
1276        );
1277
1278        assert_eq!(
1279            mi.item.named_attribute("width").unwrap(),
1280            ElementAttribute {
1281                name: Some("width"),
1282                shorthand_items: &[],
1283                value: "300"
1284            }
1285        );
1286
1287        assert_eq!(
1288            mi.item.named_or_positional_attribute("width", 2).unwrap(),
1289            ElementAttribute {
1290                name: Some("width"),
1291                shorthand_items: &[],
1292                value: "300"
1293            }
1294        );
1295
1296        assert_eq!(
1297            mi.item.named_attribute("height").unwrap(),
1298            ElementAttribute {
1299                name: Some("height"),
1300                shorthand_items: &[],
1301                value: "400"
1302            }
1303        );
1304
1305        assert_eq!(
1306            mi.item.named_or_positional_attribute("height", 3).unwrap(),
1307            ElementAttribute {
1308                name: Some("height"),
1309                shorthand_items: &[],
1310                value: "400"
1311            }
1312        );
1313
1314        assert!(mi.item.nth_attribute(0).is_none());
1315        assert!(mi.item.nth_attribute(1).is_none());
1316        assert!(mi.item.nth_attribute(2).is_none());
1317        assert!(mi.item.nth_attribute(3).is_none());
1318        assert!(mi.item.nth_attribute(4).is_none());
1319        assert!(mi.item.nth_attribute(42).is_none());
1320
1321        assert!(mi.item.id().is_none());
1322        assert!(mi.item.roles().is_empty());
1323        assert!(mi.item.block_style().is_none());
1324
1325        assert_eq!(
1326            mi.item.span(),
1327            Span {
1328                data: "alt=Sunset,width=300,height=400",
1329                line: 1,
1330                col: 1,
1331                offset: 0
1332            }
1333        );
1334
1335        assert_eq!(
1336            mi.after,
1337            Span {
1338                data: "",
1339                line: 1,
1340                col: 32,
1341                offset: 31
1342            }
1343        );
1344    }
1345
1346    #[test]
1347    fn ignore_named_attribute_with_none_value() {
1348        let p = Parser::default();
1349        let mi = crate::attributes::Attrlist::parse(
1350            crate::Span::new("alt=Sunset,width=None,height=400"),
1351            &p,
1352            AttrlistContext::Inline,
1353        )
1354        .unwrap_if_no_warnings();
1355
1356        assert_eq!(
1357            mi.item,
1358            Attrlist {
1359                attributes: &[
1360                    ElementAttribute {
1361                        name: Some("alt"),
1362                        shorthand_items: &[],
1363                        value: "Sunset"
1364                    },
1365                    ElementAttribute {
1366                        name: Some("height"),
1367                        shorthand_items: &[],
1368                        value: "400"
1369                    }
1370                ],
1371                anchor: None,
1372                source: Span {
1373                    data: "alt=Sunset,width=None,height=400",
1374                    line: 1,
1375                    col: 1,
1376                    offset: 0
1377                }
1378            }
1379        );
1380
1381        assert!(mi.item.named_attribute("foo").is_none());
1382        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
1383
1384        assert_eq!(
1385            mi.item.named_attribute("alt").unwrap(),
1386            ElementAttribute {
1387                name: Some("alt"),
1388                shorthand_items: &[],
1389                value: "Sunset"
1390            }
1391        );
1392
1393        assert_eq!(
1394            mi.item.named_or_positional_attribute("alt", 1).unwrap(),
1395            ElementAttribute {
1396                name: Some("alt"),
1397                shorthand_items: &[],
1398                value: "Sunset"
1399            }
1400        );
1401
1402        assert!(mi.item.named_attribute("width").is_none());
1403        assert!(mi.item.named_or_positional_attribute("width", 2).is_none());
1404
1405        assert_eq!(
1406            mi.item.named_attribute("height").unwrap(),
1407            ElementAttribute {
1408                name: Some("height"),
1409                shorthand_items: &[],
1410                value: "400"
1411            }
1412        );
1413
1414        assert_eq!(
1415            mi.item.named_or_positional_attribute("height", 2).unwrap(),
1416            ElementAttribute {
1417                name: Some("height"),
1418                shorthand_items: &[],
1419                value: "400"
1420            }
1421        );
1422
1423        assert!(mi.item.nth_attribute(0).is_none());
1424        assert!(mi.item.nth_attribute(1).is_none());
1425        assert!(mi.item.nth_attribute(2).is_none());
1426        assert!(mi.item.nth_attribute(3).is_none());
1427        assert!(mi.item.nth_attribute(4).is_none());
1428        assert!(mi.item.nth_attribute(42).is_none());
1429
1430        assert!(mi.item.id().is_none());
1431        assert!(mi.item.roles().is_empty());
1432        assert!(mi.item.block_style().is_none());
1433
1434        assert_eq!(
1435            mi.item.span(),
1436            Span {
1437                data: "alt=Sunset,width=None,height=400",
1438                line: 1,
1439                col: 1,
1440                offset: 0
1441            }
1442        );
1443
1444        assert_eq!(
1445            mi.after,
1446            Span {
1447                data: "",
1448                line: 1,
1449                col: 33,
1450                offset: 32
1451            }
1452        );
1453    }
1454
1455    #[test]
1456    fn err_unparsed_remainder_after_value() {
1457        let p = Parser::default();
1458
1459        let maw = crate::attributes::Attrlist::parse(
1460            crate::Span::new("alt=\"Sunset\"width=300"),
1461            &p,
1462            AttrlistContext::Inline,
1463        );
1464
1465        let mi = maw.item.clone();
1466
1467        assert_eq!(
1468            mi.item,
1469            Attrlist {
1470                attributes: &[ElementAttribute {
1471                    name: Some("alt"),
1472                    shorthand_items: &[],
1473                    value: "Sunset"
1474                }],
1475                anchor: None,
1476                source: Span {
1477                    data: "alt=\"Sunset\"width=300",
1478                    line: 1,
1479                    col: 1,
1480                    offset: 0
1481                }
1482            }
1483        );
1484
1485        assert_eq!(
1486            mi.after,
1487            Span {
1488                data: "",
1489                line: 1,
1490                col: 22,
1491                offset: 21
1492            }
1493        );
1494
1495        assert_eq!(
1496            maw.warnings,
1497            vec![Warning {
1498                source: Span {
1499                    data: "alt=\"Sunset\"width=300",
1500                    line: 1,
1501                    col: 1,
1502                    offset: 0,
1503                },
1504                warning: WarningType::MissingCommaAfterQuotedAttributeValue,
1505            }]
1506        );
1507    }
1508
1509    #[test]
1510    fn propagates_error_from_element_attribute() {
1511        let p = Parser::default();
1512
1513        let maw = crate::attributes::Attrlist::parse(
1514            crate::Span::new("foo%#id"),
1515            &p,
1516            AttrlistContext::Inline,
1517        );
1518
1519        let mi = maw.item.clone();
1520
1521        assert_eq!(
1522            mi.item,
1523            Attrlist {
1524                attributes: &[ElementAttribute {
1525                    name: None,
1526                    shorthand_items: &["foo", "#id"],
1527                    value: "foo%#id"
1528                }],
1529                anchor: None,
1530                source: Span {
1531                    data: "foo%#id",
1532                    line: 1,
1533                    col: 1,
1534                    offset: 0
1535                }
1536            }
1537        );
1538
1539        assert_eq!(
1540            mi.after,
1541            Span {
1542                data: "",
1543                line: 1,
1544                col: 8,
1545                offset: 7
1546            }
1547        );
1548
1549        assert_eq!(
1550            maw.warnings,
1551            vec![Warning {
1552                source: Span {
1553                    data: "foo%#id",
1554                    line: 1,
1555                    col: 1,
1556                    offset: 0,
1557                },
1558                warning: WarningType::EmptyShorthandName,
1559            }]
1560        );
1561    }
1562
1563    #[test]
1564    fn merge_block_attribute_line_anchor_later_wins() {
1565        let p = Parser::default();
1566
1567        let mut first = crate::attributes::Attrlist::parse(
1568            crate::Span::new("[id1]"),
1569            &p,
1570            AttrlistContext::Block,
1571        )
1572        .unwrap_if_no_warnings()
1573        .item;
1574
1575        let later = crate::attributes::Attrlist::parse(
1576            crate::Span::new("[id2]"),
1577            &p,
1578            AttrlistContext::Block,
1579        )
1580        .unwrap_if_no_warnings()
1581        .item;
1582
1583        assert_eq!(first.anchor(), Some("id1"));
1584        first.merge_block_attribute_line(later);
1585        assert_eq!(first.anchor(), Some("id2"));
1586    }
1587
1588    #[test]
1589    fn merge_block_attribute_line_anchor_retained_when_later_has_none() {
1590        let p = Parser::default();
1591
1592        let mut first = crate::attributes::Attrlist::parse(
1593            crate::Span::new("[id1]"),
1594            &p,
1595            AttrlistContext::Block,
1596        )
1597        .unwrap_if_no_warnings()
1598        .item;
1599
1600        let later = crate::attributes::Attrlist::parse(
1601            crate::Span::new("foo=bar"),
1602            &p,
1603            AttrlistContext::Block,
1604        )
1605        .unwrap_if_no_warnings()
1606        .item;
1607
1608        first.merge_block_attribute_line(later);
1609        assert_eq!(first.anchor(), Some("id1"));
1610        assert_eq!(first.named_attribute("foo").unwrap().value(), "bar");
1611    }
1612
1613    #[test]
1614    fn merge_block_attribute_line_positions_account_for_named_entries() {
1615        // A later line whose positional is preceded by a named attribute must
1616        // merge at its Asciidoctor position (which counts the named entry), not
1617        // at its ordinal among unnamed entries. Here `Author2` is the later
1618        // line's *second* entry, so it replaces position 2 (`Author1`) rather
1619        // than merging into position 1 (`quote`); `Extra` is position 3.
1620        let p = Parser::default();
1621
1622        let mut first = crate::attributes::Attrlist::parse(
1623            crate::Span::new("quote,Author1"),
1624            &p,
1625            AttrlistContext::Block,
1626        )
1627        .unwrap_if_no_warnings()
1628        .item;
1629
1630        let later = crate::attributes::Attrlist::parse(
1631            crate::Span::new("width=300,Author2,Extra"),
1632            &p,
1633            AttrlistContext::Block,
1634        )
1635        .unwrap_if_no_warnings()
1636        .item;
1637
1638        first.merge_block_attribute_line(later);
1639
1640        assert_eq!(first.nth_attribute(1).unwrap().value(), "quote");
1641        assert_eq!(first.nth_attribute(2).unwrap().value(), "Author2");
1642        assert_eq!(first.nth_attribute(3).unwrap().value(), "Extra");
1643        assert_eq!(first.named_attribute("width").unwrap().value(), "300");
1644    }
1645
1646    #[test]
1647    fn anchor_syntax() {
1648        let p = Parser::default();
1649
1650        let maw = crate::attributes::Attrlist::parse(
1651            crate::Span::new("[notice]"),
1652            &p,
1653            AttrlistContext::Inline,
1654        );
1655
1656        let mi = maw.item.clone();
1657
1658        assert_eq!(
1659            mi.item,
1660            Attrlist {
1661                attributes: &[],
1662                anchor: Some("notice"),
1663                source: Span {
1664                    data: "[notice]",
1665                    line: 1,
1666                    col: 1,
1667                    offset: 0
1668                }
1669            }
1670        );
1671
1672        assert_eq!(
1673            mi.after,
1674            Span {
1675                data: "",
1676                line: 1,
1677                col: 9,
1678                offset: 8
1679            }
1680        );
1681
1682        assert!(maw.warnings.is_empty());
1683    }
1684
1685    mod id {
1686        use crate::{attributes::AttrlistContext, tests::prelude::*};
1687
1688        #[test]
1689        fn via_shorthand_syntax() {
1690            let p = Parser::default();
1691
1692            let mi = crate::attributes::Attrlist::parse(
1693                crate::Span::new("#goals"),
1694                &p,
1695                AttrlistContext::Inline,
1696            )
1697            .unwrap_if_no_warnings();
1698
1699            assert_eq!(
1700                mi.item,
1701                Attrlist {
1702                    attributes: &[ElementAttribute {
1703                        name: None,
1704                        shorthand_items: &["#goals"],
1705                        value: "#goals"
1706                    }],
1707                    anchor: None,
1708                    source: Span {
1709                        data: "#goals",
1710                        line: 1,
1711                        col: 1,
1712                        offset: 0
1713                    }
1714                }
1715            );
1716
1717            assert!(mi.item.named_attribute("foo").is_none());
1718            assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
1719            assert_eq!(mi.item.id().unwrap(), "goals");
1720            assert!(mi.item.roles().is_empty());
1721            assert!(mi.item.block_style().is_none());
1722
1723            assert_eq!(
1724                mi.item.span(),
1725                Span {
1726                    data: "#goals",
1727                    line: 1,
1728                    col: 1,
1729                    offset: 0
1730                }
1731            );
1732
1733            assert_eq!(
1734                mi.after,
1735                Span {
1736                    data: "",
1737                    line: 1,
1738                    col: 7,
1739                    offset: 6
1740                }
1741            );
1742        }
1743
1744        #[test]
1745        fn via_named_attribute() {
1746            let p = Parser::default();
1747
1748            let mi = crate::attributes::Attrlist::parse(
1749                crate::Span::new("foo=bar,id=goals"),
1750                &p,
1751                AttrlistContext::Inline,
1752            )
1753            .unwrap_if_no_warnings();
1754
1755            assert_eq!(
1756                mi.item,
1757                Attrlist {
1758                    attributes: &[
1759                        ElementAttribute {
1760                            name: Some("foo"),
1761                            shorthand_items: &[],
1762                            value: "bar"
1763                        },
1764                        ElementAttribute {
1765                            name: Some("id"),
1766                            shorthand_items: &[],
1767                            value: "goals"
1768                        },
1769                    ],
1770                    anchor: None,
1771                    source: Span {
1772                        data: "foo=bar,id=goals",
1773                        line: 1,
1774                        col: 1,
1775                        offset: 0
1776                    }
1777                }
1778            );
1779
1780            assert_eq!(
1781                mi.item.named_attribute("foo").unwrap(),
1782                ElementAttribute {
1783                    name: Some("foo"),
1784                    shorthand_items: &[],
1785                    value: "bar"
1786                }
1787            );
1788
1789            assert_eq!(
1790                mi.item.named_attribute("id").unwrap(),
1791                ElementAttribute {
1792                    name: Some("id"),
1793                    shorthand_items: &[],
1794                    value: "goals"
1795                }
1796            );
1797
1798            assert_eq!(mi.item.id().unwrap(), "goals");
1799            assert!(mi.item.roles().is_empty());
1800            assert!(mi.item.block_style().is_none());
1801
1802            assert_eq!(
1803                mi.after,
1804                Span {
1805                    data: "",
1806                    line: 1,
1807                    col: 17,
1808                    offset: 16
1809                }
1810            );
1811        }
1812
1813        #[test]
1814        fn via_block_anchor_syntax() {
1815            let p = Parser::default();
1816
1817            let mi = crate::attributes::Attrlist::parse(
1818                crate::Span::new("[goals]"),
1819                &p,
1820                AttrlistContext::Inline,
1821            )
1822            .unwrap_if_no_warnings();
1823
1824            assert_eq!(
1825                mi.item,
1826                Attrlist {
1827                    attributes: &[],
1828                    anchor: Some("goals"),
1829                    source: Span {
1830                        data: "[goals]",
1831                        line: 1,
1832                        col: 1,
1833                        offset: 0
1834                    }
1835                }
1836            );
1837
1838            assert_eq!(mi.item.id().unwrap(), "goals");
1839
1840            assert_eq!(
1841                mi.after,
1842                Span {
1843                    data: "",
1844                    line: 1,
1845                    col: 8,
1846                    offset: 7
1847                }
1848            );
1849        }
1850
1851        #[test]
1852        fn shorthand_only_first_attribute() {
1853            let p = Parser::default();
1854
1855            let mi = crate::attributes::Attrlist::parse(
1856                crate::Span::new("foo,blah#goals"),
1857                &p,
1858                AttrlistContext::Inline,
1859            )
1860            .unwrap_if_no_warnings();
1861
1862            assert_eq!(
1863                mi.item,
1864                Attrlist {
1865                    attributes: &[
1866                        ElementAttribute {
1867                            name: None,
1868                            shorthand_items: &["foo"],
1869                            value: "foo"
1870                        },
1871                        ElementAttribute {
1872                            name: None,
1873                            shorthand_items: &[],
1874                            value: "blah#goals"
1875                        },
1876                    ],
1877                    anchor: None,
1878                    source: Span {
1879                        data: "foo,blah#goals",
1880                        line: 1,
1881                        col: 1,
1882                        offset: 0
1883                    }
1884                }
1885            );
1886
1887            assert!(mi.item.id().is_none());
1888            assert!(mi.item.roles().is_empty());
1889            assert_eq!(mi.item.block_style().unwrap(), "foo");
1890
1891            assert_eq!(
1892                mi.after,
1893                Span {
1894                    data: "",
1895                    line: 1,
1896                    col: 15,
1897                    offset: 14
1898                }
1899            );
1900        }
1901    }
1902
1903    mod roles {
1904        use crate::{attributes::AttrlistContext, tests::prelude::*};
1905
1906        #[test]
1907        fn via_shorthand_syntax() {
1908            let p = Parser::default();
1909
1910            let mi = crate::attributes::Attrlist::parse(
1911                crate::Span::new(".rolename"),
1912                &p,
1913                AttrlistContext::Inline,
1914            )
1915            .unwrap_if_no_warnings();
1916
1917            assert_eq!(
1918                mi.item,
1919                Attrlist {
1920                    attributes: &[ElementAttribute {
1921                        name: None,
1922                        shorthand_items: &[".rolename"],
1923                        value: ".rolename"
1924                    }],
1925                    anchor: None,
1926                    source: Span {
1927                        data: ".rolename",
1928                        line: 1,
1929                        col: 1,
1930                        offset: 0
1931                    }
1932                }
1933            );
1934
1935            assert!(mi.item.named_attribute("foo").is_none());
1936            assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
1937
1938            let roles = mi.item.roles();
1939            let mut roles = roles.iter();
1940            assert_eq!(roles.next().unwrap(), &"rolename");
1941            assert!(roles.next().is_none());
1942
1943            assert!(mi.item.block_style().is_none());
1944
1945            assert_eq!(
1946                mi.item.span(),
1947                Span {
1948                    data: ".rolename",
1949                    line: 1,
1950                    col: 1,
1951                    offset: 0
1952                }
1953            );
1954
1955            assert_eq!(
1956                mi.after,
1957                Span {
1958                    data: "",
1959                    line: 1,
1960                    col: 10,
1961                    offset: 9
1962                }
1963            );
1964        }
1965
1966        #[test]
1967        fn via_shorthand_syntax_trim_trailing_whitespace() {
1968            let p = Parser::default();
1969
1970            let mi = crate::attributes::Attrlist::parse(
1971                crate::Span::new(".rolename "),
1972                &p,
1973                AttrlistContext::Inline,
1974            )
1975            .unwrap_if_no_warnings();
1976
1977            assert_eq!(
1978                mi.item,
1979                Attrlist {
1980                    attributes: &[ElementAttribute {
1981                        name: None,
1982                        shorthand_items: &[".rolename"],
1983                        value: ".rolename"
1984                    }],
1985                    anchor: None,
1986                    source: Span {
1987                        data: ".rolename ",
1988                        line: 1,
1989                        col: 1,
1990                        offset: 0
1991                    }
1992                }
1993            );
1994
1995            assert!(mi.item.named_attribute("foo").is_none());
1996            assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
1997
1998            let roles = mi.item.roles();
1999            let mut roles = roles.iter();
2000
2001            assert_eq!(roles.next().unwrap(), &"rolename");
2002            assert!(roles.next().is_none());
2003
2004            assert!(mi.item.block_style().is_none());
2005
2006            assert_eq!(
2007                mi.item.span(),
2008                Span {
2009                    data: ".rolename ",
2010                    line: 1,
2011                    col: 1,
2012                    offset: 0
2013                }
2014            );
2015
2016            assert_eq!(
2017                mi.after,
2018                Span {
2019                    data: "",
2020                    line: 1,
2021                    col: 11,
2022                    offset: 10
2023                }
2024            );
2025        }
2026
2027        #[test]
2028        fn multiple_roles_via_shorthand_syntax() {
2029            let p = Parser::default();
2030
2031            let mi = crate::attributes::Attrlist::parse(
2032                crate::Span::new(".role1.role2.role3"),
2033                &p,
2034                AttrlistContext::Inline,
2035            )
2036            .unwrap_if_no_warnings();
2037
2038            assert_eq!(
2039                mi.item,
2040                Attrlist {
2041                    attributes: &[ElementAttribute {
2042                        name: None,
2043                        shorthand_items: &[".role1", ".role2", ".role3"],
2044                        value: ".role1.role2.role3"
2045                    }],
2046                    anchor: None,
2047                    source: Span {
2048                        data: ".role1.role2.role3",
2049                        line: 1,
2050                        col: 1,
2051                        offset: 0
2052                    }
2053                }
2054            );
2055
2056            assert!(mi.item.named_attribute("foo").is_none());
2057            assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
2058
2059            let roles = mi.item.roles();
2060            let mut roles = roles.iter();
2061            assert_eq!(roles.next().unwrap(), &"role1");
2062            assert_eq!(roles.next().unwrap(), &"role2");
2063            assert_eq!(roles.next().unwrap(), &"role3");
2064            assert!(roles.next().is_none());
2065
2066            assert!(mi.item.block_style().is_none());
2067
2068            assert_eq!(
2069                mi.item.span(),
2070                Span {
2071                    data: ".role1.role2.role3",
2072                    line: 1,
2073                    col: 1,
2074                    offset: 0
2075                }
2076            );
2077
2078            assert_eq!(
2079                mi.after,
2080                Span {
2081                    data: "",
2082                    line: 1,
2083                    col: 19,
2084                    offset: 18
2085                }
2086            );
2087        }
2088
2089        #[test]
2090        fn multiple_roles_via_shorthand_syntax_trim_whitespace() {
2091            let p = Parser::default();
2092
2093            let mi = crate::attributes::Attrlist::parse(
2094                crate::Span::new(".role1 .role2 .role3 "),
2095                &p,
2096                AttrlistContext::Inline,
2097            )
2098            .unwrap_if_no_warnings();
2099
2100            assert_eq!(
2101                mi.item,
2102                Attrlist {
2103                    attributes: &[ElementAttribute {
2104                        name: None,
2105                        shorthand_items: &[".role1", ".role2", ".role3"],
2106                        value: ".role1 .role2 .role3"
2107                    }],
2108                    anchor: None,
2109                    source: Span {
2110                        data: ".role1 .role2 .role3 ",
2111                        line: 1,
2112                        col: 1,
2113                        offset: 0
2114                    }
2115                }
2116            );
2117
2118            assert!(mi.item.named_attribute("foo").is_none());
2119            assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
2120
2121            let roles = mi.item.roles();
2122            let mut roles = roles.iter();
2123            assert_eq!(roles.next().unwrap(), &"role1");
2124            assert_eq!(roles.next().unwrap(), &"role2");
2125            assert_eq!(roles.next().unwrap(), &"role3");
2126            assert!(roles.next().is_none());
2127
2128            assert!(mi.item.block_style().is_none());
2129
2130            assert_eq!(
2131                mi.item.span(),
2132                Span {
2133                    data: ".role1 .role2 .role3 ",
2134                    line: 1,
2135                    col: 1,
2136                    offset: 0
2137                }
2138            );
2139
2140            assert_eq!(
2141                mi.after,
2142                Span {
2143                    data: "",
2144                    line: 1,
2145                    col: 22,
2146                    offset: 21
2147                }
2148            );
2149        }
2150
2151        #[test]
2152        fn via_named_attribute() {
2153            let p = Parser::default();
2154
2155            let mi = crate::attributes::Attrlist::parse(
2156                crate::Span::new("foo=bar,role=role1"),
2157                &p,
2158                AttrlistContext::Inline,
2159            )
2160            .unwrap_if_no_warnings();
2161
2162            assert_eq!(
2163                mi.item,
2164                Attrlist {
2165                    attributes: &[
2166                        ElementAttribute {
2167                            name: Some("foo"),
2168                            shorthand_items: &[],
2169                            value: "bar"
2170                        },
2171                        ElementAttribute {
2172                            name: Some("role"),
2173                            shorthand_items: &[],
2174                            value: "role1"
2175                        },
2176                    ],
2177                    anchor: None,
2178                    source: Span {
2179                        data: "foo=bar,role=role1",
2180                        line: 1,
2181                        col: 1,
2182                        offset: 0
2183                    }
2184                }
2185            );
2186
2187            assert_eq!(
2188                mi.item.named_attribute("foo").unwrap(),
2189                ElementAttribute {
2190                    name: Some("foo"),
2191                    shorthand_items: &[],
2192                    value: "bar"
2193                }
2194            );
2195
2196            assert_eq!(
2197                mi.item.named_attribute("role").unwrap(),
2198                ElementAttribute {
2199                    name: Some("role"),
2200                    shorthand_items: &[],
2201                    value: "role1"
2202                }
2203            );
2204
2205            let roles = mi.item.roles();
2206            let mut roles = roles.iter();
2207            assert_eq!(roles.next().unwrap(), &"role1");
2208            assert!(roles.next().is_none());
2209
2210            assert!(mi.item.block_style().is_none());
2211
2212            assert_eq!(
2213                mi.after,
2214                Span {
2215                    data: "",
2216                    line: 1,
2217                    col: 19,
2218                    offset: 18
2219                }
2220            );
2221        }
2222
2223        #[test]
2224        fn multiple_roles_via_named_attribute() {
2225            let p = Parser::default();
2226
2227            let mi = crate::attributes::Attrlist::parse(
2228                crate::Span::new("foo=bar,role=role1 role2   role3 "),
2229                &p,
2230                AttrlistContext::Inline,
2231            )
2232            .unwrap_if_no_warnings();
2233
2234            assert_eq!(
2235                mi.item,
2236                Attrlist {
2237                    attributes: &[
2238                        ElementAttribute {
2239                            name: Some("foo"),
2240                            shorthand_items: &[],
2241                            value: "bar"
2242                        },
2243                        ElementAttribute {
2244                            name: Some("role"),
2245                            shorthand_items: &[],
2246                            value: "role1 role2   role3"
2247                        },
2248                    ],
2249                    anchor: None,
2250                    source: Span {
2251                        data: "foo=bar,role=role1 role2   role3 ",
2252                        line: 1,
2253                        col: 1,
2254                        offset: 0
2255                    }
2256                }
2257            );
2258
2259            assert_eq!(
2260                mi.item.named_attribute("foo").unwrap(),
2261                ElementAttribute {
2262                    name: Some("foo"),
2263                    shorthand_items: &[],
2264                    value: "bar"
2265                }
2266            );
2267
2268            assert_eq!(
2269                mi.item.named_attribute("role").unwrap(),
2270                ElementAttribute {
2271                    name: Some("role"),
2272                    shorthand_items: &[],
2273                    value: "role1 role2   role3"
2274                }
2275            );
2276
2277            let roles = mi.item.roles();
2278            let mut roles = roles.iter();
2279            assert_eq!(roles.next().unwrap(), &"role1");
2280            assert_eq!(roles.next().unwrap(), &"role2");
2281            assert_eq!(roles.next().unwrap(), &"role3");
2282            assert!(roles.next().is_none());
2283
2284            assert!(mi.item.block_style().is_none());
2285
2286            assert_eq!(
2287                mi.after,
2288                Span {
2289                    data: "",
2290                    line: 1,
2291                    col: 34,
2292                    offset: 33
2293                }
2294            );
2295        }
2296
2297        #[test]
2298        fn shorthand_role_and_named_attribute_role() {
2299            let p = Parser::default();
2300
2301            let mi = crate::attributes::Attrlist::parse(
2302                crate::Span::new("#foo.sh1.sh2,role=na1 na2   na3 "),
2303                &p,
2304                AttrlistContext::Inline,
2305            )
2306            .unwrap_if_no_warnings();
2307
2308            assert_eq!(
2309                mi.item,
2310                Attrlist {
2311                    attributes: &[
2312                        ElementAttribute {
2313                            name: None,
2314                            shorthand_items: &["#foo", ".sh1", ".sh2"],
2315                            value: "#foo.sh1.sh2"
2316                        },
2317                        ElementAttribute {
2318                            name: Some("role"),
2319                            shorthand_items: &[],
2320                            value: "na1 na2   na3"
2321                        },
2322                    ],
2323                    anchor: None,
2324                    source: Span {
2325                        data: "#foo.sh1.sh2,role=na1 na2   na3 ",
2326                        line: 1,
2327                        col: 1,
2328                        offset: 0
2329                    }
2330                }
2331            );
2332
2333            assert!(mi.item.named_attribute("foo").is_none());
2334
2335            assert_eq!(
2336                mi.item.named_attribute("role").unwrap(),
2337                ElementAttribute {
2338                    name: Some("role"),
2339                    shorthand_items: &[],
2340                    value: "na1 na2   na3"
2341                }
2342            );
2343
2344            let roles = mi.item.roles();
2345            let mut roles = roles.iter();
2346            assert_eq!(roles.next().unwrap(), &"sh1");
2347            assert_eq!(roles.next().unwrap(), &"sh2");
2348            assert_eq!(roles.next().unwrap(), &"na1");
2349            assert_eq!(roles.next().unwrap(), &"na2");
2350            assert_eq!(roles.next().unwrap(), &"na3");
2351            assert!(roles.next().is_none());
2352
2353            assert!(mi.item.block_style().is_none());
2354
2355            assert_eq!(
2356                mi.after,
2357                Span {
2358                    data: "",
2359                    line: 1,
2360                    col: 33,
2361                    offset: 32
2362                }
2363            );
2364        }
2365
2366        #[test]
2367        fn shorthand_only_first_attribute() {
2368            let p = Parser::default();
2369
2370            let mi = crate::attributes::Attrlist::parse(
2371                crate::Span::new("foo,blah.rolename"),
2372                &p,
2373                AttrlistContext::Inline,
2374            )
2375            .unwrap_if_no_warnings();
2376
2377            assert_eq!(
2378                mi.item,
2379                Attrlist {
2380                    attributes: &[
2381                        ElementAttribute {
2382                            name: None,
2383                            shorthand_items: &["foo"],
2384                            value: "foo"
2385                        },
2386                        ElementAttribute {
2387                            name: None,
2388                            shorthand_items: &[],
2389                            value: "blah.rolename"
2390                        },
2391                    ],
2392                    anchor: None,
2393                    source: Span {
2394                        data: "foo,blah.rolename",
2395                        line: 1,
2396                        col: 1,
2397                        offset: 0
2398                    }
2399                }
2400            );
2401
2402            let roles = mi.item.roles();
2403            assert_eq!(roles.iter().len(), 0);
2404
2405            assert_eq!(mi.item.block_style().unwrap(), "foo");
2406
2407            assert_eq!(
2408                mi.after,
2409                Span {
2410                    data: "",
2411                    line: 1,
2412                    col: 18,
2413                    offset: 17
2414                }
2415            );
2416        }
2417
2418        #[test]
2419        fn quoted_first_positional_becomes_verbatim_role() {
2420            let p = Parser::default();
2421
2422            // A quote-delimited first positional carries no shorthand role or
2423            // block style, so `quoted_text_fallback_role` recovers it verbatim
2424            // (quotes included), mirroring Asciidoctor's
2425            // `parse_quoted_text_attributes`.
2426            let mi = crate::attributes::Attrlist::parse(
2427                crate::Span::new("'role'"),
2428                &p,
2429                AttrlistContext::Inline,
2430            )
2431            .unwrap_if_no_warnings();
2432
2433            assert!(mi.item.roles().is_empty());
2434            assert!(mi.item.block_style().is_none());
2435            assert_eq!(mi.item.quoted_text_fallback_role().unwrap(), "'role'");
2436
2437            // Only the first positional (the source up to the first comma) is
2438            // considered, mirroring Asciidoctor's `parse_quoted_text_attributes`.
2439            let mi = crate::attributes::Attrlist::parse(
2440                crate::Span::new("'role',keep=dropped"),
2441                &p,
2442                AttrlistContext::Inline,
2443            )
2444            .unwrap_if_no_warnings();
2445
2446            assert_eq!(mi.item.quoted_text_fallback_role().unwrap(), "'role'");
2447
2448            // The comma boundary is applied to the raw source, matching
2449            // Asciidoctor's `str.slice 0, (str.index ',')`, so a comma inside the
2450            // quotes truncates the role there too rather than being kept as
2451            // quoted content.
2452            let mi = crate::attributes::Attrlist::parse(
2453                crate::Span::new("'a,b'"),
2454                &p,
2455                AttrlistContext::Inline,
2456            )
2457            .unwrap_if_no_warnings();
2458
2459            assert_eq!(mi.item.quoted_text_fallback_role().unwrap(), "'a");
2460
2461            // An unquoted positional is handled by the normal block-style path,
2462            // so there is no fallback role to recover.
2463            let mi = crate::attributes::Attrlist::parse(
2464                crate::Span::new("role"),
2465                &p,
2466                AttrlistContext::Inline,
2467            )
2468            .unwrap_if_no_warnings();
2469
2470            assert!(mi.item.quoted_text_fallback_role().is_none());
2471
2472            // A named-only or otherwise position-1-less attribute list has no
2473            // first positional attribute, so there is nothing to recover.
2474            let mi = crate::attributes::Attrlist::parse(
2475                crate::Span::new("id=x"),
2476                &p,
2477                AttrlistContext::Inline,
2478            )
2479            .unwrap_if_no_warnings();
2480
2481            assert!(mi.item.quoted_text_fallback_role().is_none());
2482        }
2483    }
2484
2485    mod options {
2486        use crate::{attributes::AttrlistContext, tests::prelude::*};
2487
2488        #[test]
2489        fn via_shorthand_syntax() {
2490            let p = Parser::default();
2491
2492            let mi = crate::attributes::Attrlist::parse(
2493                crate::Span::new("%option"),
2494                &p,
2495                AttrlistContext::Inline,
2496            )
2497            .unwrap_if_no_warnings();
2498
2499            assert_eq!(
2500                mi.item,
2501                Attrlist {
2502                    attributes: &[ElementAttribute {
2503                        name: None,
2504                        shorthand_items: &["%option"],
2505                        value: "%option"
2506                    }],
2507                    anchor: None,
2508                    source: Span {
2509                        data: "%option",
2510                        line: 1,
2511                        col: 1,
2512                        offset: 0
2513                    }
2514                }
2515            );
2516
2517            assert!(mi.item.named_attribute("foo").is_none());
2518            assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
2519
2520            let options = mi.item.options();
2521            let mut options = options.iter();
2522            assert_eq!(options.next().unwrap(), &"option",);
2523            assert!(options.next().is_none());
2524
2525            assert!(mi.item.has_option("option"));
2526            assert!(!mi.item.has_option("option1"));
2527
2528            assert_eq!(
2529                mi.item.span(),
2530                Span {
2531                    data: "%option",
2532                    line: 1,
2533                    col: 1,
2534                    offset: 0
2535                }
2536            );
2537
2538            assert_eq!(
2539                mi.after,
2540                Span {
2541                    data: "",
2542                    line: 1,
2543                    col: 8,
2544                    offset: 7
2545                }
2546            );
2547        }
2548
2549        #[test]
2550        fn multiple_options_via_shorthand_syntax() {
2551            let p = Parser::default();
2552
2553            let mi = crate::attributes::Attrlist::parse(
2554                crate::Span::new("%option1%option2%option3"),
2555                &p,
2556                AttrlistContext::Inline,
2557            )
2558            .unwrap_if_no_warnings();
2559
2560            assert_eq!(
2561                mi.item,
2562                Attrlist {
2563                    attributes: &[ElementAttribute {
2564                        name: None,
2565                        shorthand_items: &["%option1", "%option2", "%option3",],
2566                        value: "%option1%option2%option3"
2567                    }],
2568                    anchor: None,
2569                    source: Span {
2570                        data: "%option1%option2%option3",
2571                        line: 1,
2572                        col: 1,
2573                        offset: 0
2574                    }
2575                }
2576            );
2577
2578            assert!(mi.item.named_attribute("foo").is_none());
2579            assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
2580
2581            let options = mi.item.options();
2582            let mut options = options.iter();
2583            assert_eq!(options.next().unwrap(), &"option1");
2584            assert_eq!(options.next().unwrap(), &"option2");
2585            assert_eq!(options.next().unwrap(), &"option3");
2586            assert!(options.next().is_none());
2587
2588            assert!(mi.item.has_option("option1"));
2589            assert!(mi.item.has_option("option2"));
2590            assert!(mi.item.has_option("option3"));
2591            assert!(!mi.item.has_option("option4"));
2592
2593            assert_eq!(
2594                mi.item.span(),
2595                Span {
2596                    data: "%option1%option2%option3",
2597                    line: 1,
2598                    col: 1,
2599                    offset: 0
2600                }
2601            );
2602
2603            assert_eq!(
2604                mi.after,
2605                Span {
2606                    data: "",
2607                    line: 1,
2608                    col: 25,
2609                    offset: 24
2610                }
2611            );
2612        }
2613
2614        #[test]
2615        fn via_options_attribute() {
2616            let p = Parser::default();
2617
2618            let mi = crate::attributes::Attrlist::parse(
2619                crate::Span::new("foo=bar,options=option1"),
2620                &p,
2621                AttrlistContext::Inline,
2622            )
2623            .unwrap_if_no_warnings();
2624
2625            assert_eq!(
2626                mi.item,
2627                Attrlist {
2628                    attributes: &[
2629                        ElementAttribute {
2630                            name: Some("foo"),
2631                            shorthand_items: &[],
2632                            value: "bar"
2633                        },
2634                        ElementAttribute {
2635                            name: Some("options"),
2636                            shorthand_items: &[],
2637                            value: "option1"
2638                        },
2639                    ],
2640                    anchor: None,
2641                    source: Span {
2642                        data: "foo=bar,options=option1",
2643                        line: 1,
2644                        col: 1,
2645                        offset: 0
2646                    }
2647                }
2648            );
2649
2650            assert_eq!(
2651                mi.item.named_attribute("foo").unwrap(),
2652                ElementAttribute {
2653                    name: Some("foo"),
2654                    shorthand_items: &[],
2655                    value: "bar"
2656                }
2657            );
2658
2659            assert_eq!(
2660                mi.item.named_attribute("options").unwrap(),
2661                ElementAttribute {
2662                    name: Some("options"),
2663                    shorthand_items: &[],
2664                    value: "option1"
2665                }
2666            );
2667
2668            let options = mi.item.options();
2669            let mut options = options.iter();
2670            assert_eq!(options.next().unwrap(), &"option1");
2671            assert!(options.next().is_none());
2672
2673            assert!(mi.item.has_option("option1"));
2674            assert!(!mi.item.has_option("option2"));
2675
2676            assert_eq!(
2677                mi.after,
2678                Span {
2679                    data: "",
2680                    line: 1,
2681                    col: 24,
2682                    offset: 23
2683                }
2684            );
2685        }
2686
2687        #[test]
2688        fn via_opts_attribute() {
2689            let p = Parser::default();
2690
2691            let mi = crate::attributes::Attrlist::parse(
2692                crate::Span::new("foo=bar,opts=option1"),
2693                &p,
2694                AttrlistContext::Inline,
2695            )
2696            .unwrap_if_no_warnings();
2697
2698            assert_eq!(
2699                mi.item,
2700                Attrlist {
2701                    attributes: &[
2702                        ElementAttribute {
2703                            name: Some("foo"),
2704                            shorthand_items: &[],
2705                            value: "bar"
2706                        },
2707                        ElementAttribute {
2708                            name: Some("opts"),
2709                            shorthand_items: &[],
2710                            value: "option1"
2711                        },
2712                    ],
2713                    anchor: None,
2714                    source: Span {
2715                        data: "foo=bar,opts=option1",
2716                        line: 1,
2717                        col: 1,
2718                        offset: 0
2719                    }
2720                }
2721            );
2722
2723            assert_eq!(
2724                mi.item.named_attribute("foo").unwrap(),
2725                ElementAttribute {
2726                    name: Some("foo"),
2727                    shorthand_items: &[],
2728                    value: "bar"
2729                }
2730            );
2731
2732            assert_eq!(
2733                mi.item.named_attribute("opts").unwrap(),
2734                ElementAttribute {
2735                    name: Some("opts"),
2736                    shorthand_items: &[],
2737                    value: "option1"
2738                }
2739            );
2740
2741            let options = mi.item.options();
2742            let mut options = options.iter();
2743            assert_eq!(options.next().unwrap(), &"option1");
2744            assert!(options.next().is_none());
2745
2746            assert!(!mi.item.has_option("option"));
2747            assert!(mi.item.has_option("option1"));
2748            assert!(!mi.item.has_option("option2"));
2749
2750            assert_eq!(
2751                mi.after,
2752                Span {
2753                    data: "",
2754                    line: 1,
2755                    col: 21,
2756                    offset: 20
2757                }
2758            );
2759        }
2760
2761        #[test]
2762        fn multiple_options_via_named_attribute() {
2763            let p = Parser::default();
2764
2765            let mi = crate::attributes::Attrlist::parse(
2766                crate::Span::new("foo=bar,options=\"option1,option2,option3\""),
2767                &p,
2768                AttrlistContext::Inline,
2769            )
2770            .unwrap_if_no_warnings();
2771
2772            assert_eq!(
2773                mi.item,
2774                Attrlist {
2775                    attributes: &[
2776                        ElementAttribute {
2777                            name: Some("foo"),
2778                            shorthand_items: &[],
2779                            value: "bar"
2780                        },
2781                        ElementAttribute {
2782                            name: Some("options"),
2783                            shorthand_items: &[],
2784                            value: "option1,option2,option3"
2785                        },
2786                    ],
2787                    anchor: None,
2788                    source: Span {
2789                        data: "foo=bar,options=\"option1,option2,option3\"",
2790                        line: 1,
2791                        col: 1,
2792                        offset: 0
2793                    }
2794                }
2795            );
2796
2797            assert_eq!(
2798                mi.item.named_attribute("foo").unwrap(),
2799                ElementAttribute {
2800                    name: Some("foo"),
2801                    shorthand_items: &[],
2802                    value: "bar"
2803                }
2804            );
2805
2806            assert_eq!(
2807                mi.item.named_attribute("options").unwrap(),
2808                ElementAttribute {
2809                    name: Some("options"),
2810                    shorthand_items: &[],
2811                    value: "option1,option2,option3"
2812                }
2813            );
2814
2815            let options = mi.item.options();
2816            let mut options = options.iter();
2817            assert_eq!(options.next().unwrap(), &"option1");
2818            assert_eq!(options.next().unwrap(), &"option2");
2819            assert_eq!(options.next().unwrap(), &"option3");
2820            assert!(options.next().is_none());
2821
2822            assert!(mi.item.has_option("option1"));
2823            assert!(mi.item.has_option("option2"));
2824            assert!(mi.item.has_option("option3"));
2825            assert!(!mi.item.has_option("option4"));
2826
2827            assert_eq!(
2828                mi.after,
2829                Span {
2830                    data: "",
2831                    line: 1,
2832                    col: 42,
2833                    offset: 41
2834                }
2835            );
2836        }
2837
2838        #[test]
2839        fn shorthand_option_and_named_attribute_option() {
2840            let p = Parser::default();
2841
2842            let mi = crate::attributes::Attrlist::parse(
2843                crate::Span::new("#foo%sh1%sh2,options=\"na1,na2,na3\""),
2844                &p,
2845                AttrlistContext::Inline,
2846            )
2847            .unwrap_if_no_warnings();
2848
2849            assert_eq!(
2850                mi.item,
2851                Attrlist {
2852                    attributes: &[
2853                        ElementAttribute {
2854                            name: None,
2855                            shorthand_items: &["#foo", "%sh1", "%sh2"],
2856                            value: "#foo%sh1%sh2"
2857                        },
2858                        ElementAttribute {
2859                            name: Some("options"),
2860                            shorthand_items: &[],
2861                            value: "na1,na2,na3"
2862                        },
2863                    ],
2864                    anchor: None,
2865                    source: Span {
2866                        data: "#foo%sh1%sh2,options=\"na1,na2,na3\"",
2867                        line: 1,
2868                        col: 1,
2869                        offset: 0
2870                    }
2871                }
2872            );
2873
2874            assert!(mi.item.named_attribute("foo").is_none(),);
2875
2876            assert_eq!(
2877                mi.item.named_attribute("options").unwrap(),
2878                ElementAttribute {
2879                    name: Some("options"),
2880                    shorthand_items: &[],
2881                    value: "na1,na2,na3"
2882                }
2883            );
2884
2885            let options = mi.item.options();
2886            let mut options = options.iter();
2887            assert_eq!(options.next().unwrap(), &"sh1");
2888            assert_eq!(options.next().unwrap(), &"sh2");
2889            assert_eq!(options.next().unwrap(), &"na1");
2890            assert_eq!(options.next().unwrap(), &"na2");
2891            assert_eq!(options.next().unwrap(), &"na3");
2892            assert!(options.next().is_none(),);
2893
2894            assert!(mi.item.has_option("sh1"));
2895            assert!(mi.item.has_option("sh2"));
2896            assert!(!mi.item.has_option("sh3"));
2897            assert!(mi.item.has_option("na1"));
2898            assert!(mi.item.has_option("na2"));
2899            assert!(mi.item.has_option("na3"));
2900            assert!(!mi.item.has_option("na4"));
2901
2902            assert_eq!(
2903                mi.after,
2904                Span {
2905                    data: "",
2906                    line: 1,
2907                    col: 35,
2908                    offset: 34
2909                }
2910            );
2911        }
2912
2913        #[test]
2914        fn shorthand_only_first_attribute() {
2915            let p = Parser::default();
2916
2917            let mi = crate::attributes::Attrlist::parse(
2918                crate::Span::new("foo,blah%option"),
2919                &p,
2920                AttrlistContext::Inline,
2921            )
2922            .unwrap_if_no_warnings();
2923
2924            assert_eq!(
2925                mi.item,
2926                Attrlist {
2927                    attributes: &[
2928                        ElementAttribute {
2929                            name: None,
2930                            shorthand_items: &["foo"],
2931                            value: "foo"
2932                        },
2933                        ElementAttribute {
2934                            name: None,
2935                            shorthand_items: &[],
2936                            value: "blah%option"
2937                        },
2938                    ],
2939                    anchor: None,
2940                    source: Span {
2941                        data: "foo,blah%option",
2942                        line: 1,
2943                        col: 1,
2944                        offset: 0
2945                    }
2946                }
2947            );
2948
2949            let options = mi.item.options();
2950            assert_eq!(options.iter().len(), 0);
2951
2952            assert!(!mi.item.has_option("option"));
2953
2954            assert_eq!(
2955                mi.after,
2956                Span {
2957                    data: "",
2958                    line: 1,
2959                    col: 16,
2960                    offset: 15
2961                }
2962            );
2963        }
2964    }
2965
2966    #[test]
2967    fn block_style() {
2968        let p = Parser::default();
2969
2970        let mi = crate::attributes::Attrlist::parse(
2971            crate::Span::new("blah#goals"),
2972            &p,
2973            AttrlistContext::Inline,
2974        )
2975        .unwrap_if_no_warnings();
2976
2977        let attrlist = mi.item;
2978        assert_eq!(attrlist.block_style().unwrap(), "blah");
2979    }
2980
2981    #[test]
2982    fn err_double_comma() {
2983        let p = Parser::default();
2984
2985        let maw = crate::attributes::Attrlist::parse(
2986            crate::Span::new("alt=Sunset,width=300,,height=400"),
2987            &p,
2988            AttrlistContext::Inline,
2989        );
2990
2991        let mi = maw.item.clone();
2992
2993        assert_eq!(
2994            mi.item,
2995            Attrlist {
2996                attributes: &[
2997                    ElementAttribute {
2998                        name: Some("alt"),
2999                        shorthand_items: &[],
3000                        value: "Sunset"
3001                    },
3002                    ElementAttribute {
3003                        name: Some("width"),
3004                        shorthand_items: &[],
3005                        value: "300"
3006                    },
3007                    ElementAttribute {
3008                        name: Some("height"),
3009                        shorthand_items: &[],
3010                        value: "400"
3011                    },
3012                ],
3013                anchor: None,
3014                source: Span {
3015                    data: "alt=Sunset,width=300,,height=400",
3016                    line: 1,
3017                    col: 1,
3018                    offset: 0,
3019                }
3020            }
3021        );
3022
3023        assert_eq!(
3024            mi.after,
3025            Span {
3026                data: "",
3027                line: 1,
3028                col: 33,
3029                offset: 32,
3030            }
3031        );
3032
3033        assert_eq!(
3034            maw.warnings,
3035            vec![Warning {
3036                source: Span {
3037                    data: "alt=Sunset,width=300,,height=400",
3038                    line: 1,
3039                    col: 1,
3040                    offset: 0,
3041                },
3042                warning: WarningType::EmptyAttributeValue,
3043            }]
3044        );
3045    }
3046
3047    #[test]
3048    fn applies_attribute_substitution_before_parsing() {
3049        let p = Parser::default().with_intrinsic_attribute(
3050            "sunset_dimensions",
3051            "300,400",
3052            ModificationContext::Anywhere,
3053        );
3054
3055        let mi = crate::attributes::Attrlist::parse(
3056            crate::Span::new("Sunset,{sunset_dimensions}"),
3057            &p,
3058            AttrlistContext::Inline,
3059        )
3060        .unwrap_if_no_warnings();
3061
3062        assert_eq!(
3063            mi.item,
3064            Attrlist {
3065                attributes: &[
3066                    ElementAttribute {
3067                        name: None,
3068                        shorthand_items: &["Sunset"],
3069                        value: "Sunset"
3070                    },
3071                    ElementAttribute {
3072                        name: None,
3073                        shorthand_items: &[],
3074                        value: "300"
3075                    },
3076                    ElementAttribute {
3077                        name: None,
3078                        shorthand_items: &[],
3079                        value: "400"
3080                    }
3081                ],
3082                anchor: None,
3083                source: Span {
3084                    data: "Sunset,{sunset_dimensions}",
3085                    line: 1,
3086                    col: 1,
3087                    offset: 0
3088                }
3089            }
3090        );
3091
3092        assert!(mi.item.named_attribute("foo").is_none());
3093        assert!(mi.item.nth_attribute(0).is_none());
3094        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
3095
3096        assert!(mi.item.id().is_none());
3097        assert!(mi.item.roles().is_empty());
3098        assert_eq!(mi.item.block_style().unwrap(), "Sunset");
3099
3100        assert_eq!(
3101            mi.item.nth_attribute(1).unwrap(),
3102            ElementAttribute {
3103                name: None,
3104                shorthand_items: &["Sunset"],
3105                value: "Sunset"
3106            }
3107        );
3108
3109        assert_eq!(
3110            mi.item.named_or_positional_attribute("alt", 1).unwrap(),
3111            ElementAttribute {
3112                name: None,
3113                shorthand_items: &["Sunset"],
3114                value: "Sunset"
3115            }
3116        );
3117
3118        assert_eq!(
3119            mi.item.nth_attribute(2).unwrap(),
3120            ElementAttribute {
3121                name: None,
3122                shorthand_items: &[],
3123                value: "300"
3124            }
3125        );
3126
3127        assert_eq!(
3128            mi.item.named_or_positional_attribute("width", 2).unwrap(),
3129            ElementAttribute {
3130                name: None,
3131                shorthand_items: &[],
3132                value: "300"
3133            }
3134        );
3135
3136        assert_eq!(
3137            mi.item.nth_attribute(3).unwrap(),
3138            ElementAttribute {
3139                name: None,
3140                shorthand_items: &[],
3141                value: "400"
3142            }
3143        );
3144
3145        assert_eq!(
3146            mi.item.named_or_positional_attribute("height", 3).unwrap(),
3147            ElementAttribute {
3148                name: None,
3149                shorthand_items: &[],
3150                value: "400"
3151            }
3152        );
3153
3154        assert!(mi.item.nth_attribute(4).is_none());
3155        assert!(mi.item.named_or_positional_attribute("height", 4).is_none());
3156        assert!(mi.item.nth_attribute(42).is_none());
3157
3158        assert_eq!(
3159            mi.item.span(),
3160            Span {
3161                data: "Sunset,{sunset_dimensions}",
3162                line: 1,
3163                col: 1,
3164                offset: 0,
3165            }
3166        );
3167
3168        assert_eq!(
3169            mi.after,
3170            Span {
3171                data: "",
3172                line: 1,
3173                col: 27,
3174                offset: 26,
3175            }
3176        );
3177    }
3178
3179    #[test]
3180    fn ignores_unknown_attribute_when_applying_attribution_substitution() {
3181        let p = Parser::default().with_intrinsic_attribute(
3182            "sunset_dimensions",
3183            "300,400",
3184            ModificationContext::Anywhere,
3185        );
3186
3187        let mi = crate::attributes::Attrlist::parse(
3188            crate::Span::new("Sunset,{not_sunset_dimensions}"),
3189            &p,
3190            AttrlistContext::Inline,
3191        )
3192        .unwrap_if_no_warnings();
3193
3194        assert_eq!(
3195            mi.item,
3196            Attrlist {
3197                attributes: &[
3198                    ElementAttribute {
3199                        name: None,
3200                        shorthand_items: &["Sunset"],
3201                        value: "Sunset"
3202                    },
3203                    ElementAttribute {
3204                        name: None,
3205                        shorthand_items: &[],
3206                        value: "{not_sunset_dimensions}"
3207                    },
3208                ],
3209                anchor: None,
3210                source: Span {
3211                    data: "Sunset,{not_sunset_dimensions}",
3212                    line: 1,
3213                    col: 1,
3214                    offset: 0
3215                }
3216            }
3217        );
3218
3219        assert!(mi.item.named_attribute("foo").is_none());
3220        assert!(mi.item.nth_attribute(0).is_none());
3221        assert!(mi.item.named_or_positional_attribute("foo", 0).is_none());
3222
3223        assert!(mi.item.id().is_none());
3224        assert!(mi.item.roles().is_empty());
3225        assert_eq!(mi.item.block_style().unwrap(), "Sunset");
3226
3227        assert_eq!(
3228            mi.item.nth_attribute(1).unwrap(),
3229            ElementAttribute {
3230                name: None,
3231                shorthand_items: &["Sunset"],
3232                value: "Sunset"
3233            }
3234        );
3235
3236        assert_eq!(
3237            mi.item.named_or_positional_attribute("alt", 1).unwrap(),
3238            ElementAttribute {
3239                name: None,
3240                shorthand_items: &["Sunset"],
3241                value: "Sunset"
3242            }
3243        );
3244
3245        assert_eq!(
3246            mi.item.nth_attribute(2).unwrap(),
3247            ElementAttribute {
3248                name: None,
3249                shorthand_items: &[],
3250                value: "{not_sunset_dimensions}"
3251            }
3252        );
3253
3254        assert_eq!(
3255            mi.item.named_or_positional_attribute("width", 2).unwrap(),
3256            ElementAttribute {
3257                name: None,
3258                shorthand_items: &[],
3259                value: "{not_sunset_dimensions}"
3260            }
3261        );
3262
3263        assert!(mi.item.nth_attribute(3).is_none());
3264        assert!(mi.item.named_or_positional_attribute("height", 3).is_none());
3265        assert!(mi.item.nth_attribute(42).is_none());
3266
3267        assert_eq!(
3268            mi.item.span(),
3269            Span {
3270                data: "Sunset,{not_sunset_dimensions}",
3271                line: 1,
3272                col: 1,
3273                offset: 0,
3274            }
3275        );
3276
3277        assert_eq!(
3278            mi.after,
3279            Span {
3280                data: "",
3281                line: 1,
3282                col: 31,
3283                offset: 30,
3284            }
3285        );
3286    }
3287
3288    #[test]
3289    fn impl_debug() {
3290        let p = Parser::default();
3291
3292        let mi = crate::attributes::Attrlist::parse(
3293            crate::Span::new("Sunset,300,400"),
3294            &p,
3295            AttrlistContext::Inline,
3296        )
3297        .unwrap_if_no_warnings();
3298
3299        let attrlist = mi.item;
3300
3301        assert_eq!(
3302            format!("{attrlist:#?}"),
3303            r#"Attrlist {
3304    attributes: &[
3305        ElementAttribute {
3306            name: None,
3307            value: "Sunset",
3308            shorthand_item_indices: [
3309                0,
3310            ],
3311        },
3312        ElementAttribute {
3313            name: None,
3314            value: "300",
3315            shorthand_item_indices: [],
3316        },
3317        ElementAttribute {
3318            name: None,
3319            value: "400",
3320            shorthand_item_indices: [],
3321        },
3322    ],
3323    anchor: None,
3324    source: Span {
3325        data: "Sunset,300,400",
3326        line: 1,
3327        col: 1,
3328        offset: 0,
3329    },
3330}"#
3331        );
3332    }
3333}