Skip to main content

asciidoc_parser/attributes/
element_attribute.rs

1use crate::{
2    Parser, Span,
3    attributes::AttrlistContext,
4    content::{Content, SubstitutionGroup},
5    span::MatchedItem,
6    strings::CowStr,
7    warnings::WarningType,
8};
9
10/// This struct represents a single element attribute.
11///
12/// Element attributes define the built-in and user-defined settings and
13/// metadata that can be applied to an individual block element or inline
14/// element in a document (including macros). Although the include directive is
15/// not technically an element, element attributes can also be defined on an
16/// include directive.
17#[derive(Clone, Eq, PartialEq)]
18pub struct ElementAttribute<'src> {
19    name: Option<CowStr<'src>>,
20    value: CowStr<'src>,
21    shorthand_item_indices: Vec<usize>,
22
23    /// The 1-based position of a *positional* (unnamed) attribute within its
24    /// attribute list, counting every comma-delimited entry — named entries and
25    /// blank slots included — the way Asciidoctor numbers positional keys.
26    /// `None` for a named attribute (or an attribute synthesized without a
27    /// known position). Consumed by [`Attrlist::nth_attribute`] so that
28    /// later positionals keep their index even when earlier entries are
29    /// named or blank.
30    ///
31    /// [`Attrlist::nth_attribute`]: crate::attributes::Attrlist::nth_attribute
32    positional_index: Option<usize>,
33
34    /// `true` when this attribute's `value` came from a genuinely
35    /// quote-delimited string (`"…"` or `'…'` with both delimiters
36    /// present), as opposed to an unquoted value or a lone leading quote.
37    /// This distinguishes an explicit empty quoted positional (`""` / `''`,
38    /// which is a real empty-valued attribute) from a blank slot, and gates
39    /// the quote-unescaping and single-quoted substitution that only apply
40    /// to genuinely quoted values.
41    value_is_quoted: bool,
42
43    /// `true` when the normal substitution group was already applied to `value`
44    /// while parsing this attribute (a single-quoted value in a block attribute
45    /// list). Consumers that would otherwise substitute the value themselves —
46    /// such as deriving a block title from a `title=` attribute — use this to
47    /// avoid substituting it a second time (which would, e.g., double-escape
48    /// special characters).
49    value_is_substituted: bool,
50}
51
52impl std::fmt::Debug for ElementAttribute<'_> {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        // `value_is_substituted` is an internal parsing detail and is
55        // deliberately omitted so the debug representation stays focused on the
56        // attribute's observable content.
57        f.debug_struct("ElementAttribute")
58            .field("name", &self.name)
59            .field("value", &self.value)
60            .field("shorthand_item_indices", &self.shorthand_item_indices)
61            .finish()
62    }
63}
64
65impl<'src> ElementAttribute<'src> {
66    pub(crate) fn parse(
67        source_text: &CowStr<'src>,
68        start_index: usize,
69        parser: &Parser,
70        mut parse_shorthand: ParseShorthand,
71        attrlist_context: AttrlistContext,
72    ) -> (Self, usize, Vec<WarningType>) {
73        let mut warnings: Vec<WarningType> = vec![];
74
75        let (name, value, shorthand_item_indices, value_is_quoted, value_is_substituted, offset) = {
76            let mut source = Span::new(source_text.as_ref());
77            source = source.discard(start_index);
78
79            // Skip any leading, non-semantic whitespace before this entry
80            // (Asciidoctor's `skip_blank`). Name detection has to run first, so
81            // without this a name with leading blanks — e.g. `[  first = value]`
82            // or the second/third entries once a comma is consumed — would fail
83            // to be recognized and fall through to a positional literal.
84            source = source.take_whitespace_with_newline().after;
85
86            let (name, after): (Option<Span<'_>>, Span) = match source.take_attr_name() {
87                Some(name) => {
88                    let space = name.after.take_whitespace_with_newline();
89                    match space.after.take_prefix("=") {
90                        Some(equals) => {
91                            let space = equals.after.take_whitespace_with_newline();
92                            // `name=` with nothing (or only a comma) after the `=`
93                            // is a named attribute with an empty value, not a
94                            // positional one. The empty value falls out of the
95                            // value scan below (`take_while(c != ',')` yields the
96                            // empty string), so the name is all we need to keep.
97                            (Some(name.item), space.after)
98                        }
99                        None => (None, source),
100                    }
101                }
102                None => (None, source),
103            };
104
105            let after = after.take_whitespace_with_newline().after;
106            let first_char = after.data().chars().next();
107
108            // `value_is_quoted` is set only when the value was genuinely
109            // delimited by a matching pair of quotes (`take_quoted_string`
110            // succeeded). A lone leading quote with no terminator is *not*
111            // quoted: Asciidoctor treats it as a literal (its `single_quoted`
112            // flag is set `unless value.start_with? APOS`), so it receives no
113            // unescaping and no substitution.
114            let mut value_is_quoted = false;
115            let value = match first_char {
116                Some('\'') | Some('"') => match after.take_quoted_string() {
117                    Some(v) => {
118                        parse_shorthand = ParseShorthand(false);
119                        value_is_quoted = true;
120                        v
121                    }
122                    None => {
123                        warnings.push(WarningType::AttributeValueMissingTerminatingQuote);
124                        after.take_while(|c| c != ',').trim_item_trailing_spaces()
125                    }
126                },
127                _ => after.take_while(|c| c != ',').trim_item_trailing_spaces(),
128            };
129
130            let after = value.after;
131            let mut value = cowstr_from_source_and_span(source_text, &value.item);
132            let mut value_is_substituted = false;
133
134            if value_is_quoted && let Some(first) = first_char {
135                let escaped_quote = format!("\\{first}");
136                let mut new_value = value.replace(&escaped_quote, &first.to_string());
137
138                if first == '\'' && attrlist_context == AttrlistContext::Block {
139                    // A single-quoted value in a block attribute list has the
140                    // normal substitution group applied at assignment time.
141                    value_is_substituted = true;
142
143                    let span = Span::new(&new_value);
144                    let mut content = Content::from(span);
145                    SubstitutionGroup::Normal.apply(&mut content, parser, None);
146
147                    if content.rendered.as_ref() != new_value {
148                        new_value = content.rendered.to_string();
149                    }
150                }
151
152                if new_value != *value {
153                    value = CowStr::from(new_value);
154                }
155            }
156
157            let shorthand_item_indices = if name.is_none() && parse_shorthand.0 {
158                parse_shorthand_items(&value, &mut warnings)
159            } else {
160                vec![]
161            };
162
163            let name = name.map(|name| cowstr_from_source_and_span(source_text, &name));
164
165            (
166                name,
167                value,
168                shorthand_item_indices,
169                value_is_quoted,
170                value_is_substituted,
171                after.byte_offset(),
172            )
173        };
174
175        (
176            Self {
177                name,
178                value,
179                shorthand_item_indices,
180                positional_index: None,
181                value_is_quoted,
182                value_is_substituted,
183            },
184            offset,
185            warnings,
186        )
187    }
188
189    /// Synthesize the `source` block-style attribute implied by a
190    /// language-aware fenced code block (`` ```lang ``). This is the first
191    /// positional attribute of the equivalent `[source,<lang>]` attribute list;
192    /// its shorthand items resolve the block style to `source`.
193    pub(crate) fn synthesized_source_style() -> Self {
194        const SOURCE: &str = "source";
195        let mut warnings: Vec<WarningType> = vec![];
196        let shorthand_item_indices = parse_shorthand_items(SOURCE, &mut warnings);
197
198        // `source` is a single shorthand item with no delimiters, so parsing it
199        // can never warn. Guard that invariant rather than plumb an always-empty
200        // list back to the caller.
201        debug_assert!(
202            warnings.is_empty(),
203            "synthesizing the `source` block style should not produce warnings, got: {warnings:?}"
204        );
205
206        Self {
207            name: None,
208            value: CowStr::from(SOURCE),
209            shorthand_item_indices,
210            positional_index: Some(1),
211            value_is_quoted: false,
212            value_is_substituted: false,
213        }
214    }
215
216    /// Construct a bare positional attribute whose value is drawn directly from
217    /// `span`, carrying no shorthand items. Used for the language on a
218    /// language-aware fenced code block (the second positional attribute of the
219    /// equivalent `[source,<lang>]` attribute list).
220    pub(crate) fn positional_from_span(span: Span<'src>) -> Self {
221        Self {
222            name: None,
223            value: CowStr::from(span.data()),
224            shorthand_item_indices: vec![],
225            positional_index: Some(2),
226            value_is_quoted: false,
227            value_is_substituted: false,
228        }
229    }
230
231    /// Return the attribute name, if one was found.
232    pub fn name(&'src self) -> Option<&'src str> {
233        self.name_str()
234    }
235
236    /// Return the attribute name, if one was found.
237    ///
238    /// Unlike [`name`](Self::name), this borrows for the duration of the call
239    /// only, so it can be used on temporary `ElementAttribute` values (for
240    /// example while merging multiple attribute lists).
241    pub(crate) fn name_str(&self) -> Option<&str> {
242        self.name.as_deref()
243    }
244
245    /// Return the shorthand items, if applicable.
246    ///
247    /// Shorthand items are only parsed for certain element attributes. If this
248    /// attribute is not of the appropriate kind, this will return an empty
249    /// list.
250    pub fn shorthand_items(&'src self) -> Vec<&'src str> {
251        self.shorthand_items_internal()
252    }
253
254    /// Same as [`shorthand_items`](Self::shorthand_items), but borrows for the
255    /// duration of the call only so it can be used on temporary values.
256    fn shorthand_items_internal(&self) -> Vec<&str> {
257        let mut result = vec![];
258        let value = self.value.as_ref();
259
260        let mut iter = self.shorthand_item_indices.iter().peekable();
261
262        while let Some(curr) = iter.next() {
263            let mut next_item = if let Some(next) = iter.peek() {
264                &value[*curr..**next]
265            } else {
266                &value[*curr..]
267            };
268
269            if next_item == "#" || next_item == "." || next_item == "%" {
270                continue;
271            }
272
273            next_item = next_item.trim_end();
274
275            if !next_item.is_empty() {
276                result.push(next_item);
277            }
278        }
279
280        result
281    }
282
283    /// Return the block style name from shorthand syntax.
284    pub fn block_style(&'src self) -> Option<&'src str> {
285        self.block_style_internal()
286    }
287
288    /// Same as [`block_style`](Self::block_style), but borrows for the duration
289    /// of the call only.
290    fn block_style_internal(&self) -> Option<&str> {
291        self.shorthand_items_internal()
292            .first()
293            .filter(|v| !v.chars().any(is_shorthand_delimiter))
294            .cloned()
295    }
296
297    /// Return the ID attribute from shorthand syntax.
298    ///
299    /// If multiple ID attributes were specified, only the first
300    /// match is returned. (Multiple IDs are not supported.)
301    ///
302    /// You can assign an ID to a block using the shorthand syntax, the longhand
303    /// syntax, or a legacy block anchor.
304    ///
305    /// In the shorthand syntax, you prefix the name with a hash (`#`) in the
306    /// first position attribute:
307    ///
308    /// ```asciidoc
309    /// [#goals]
310    /// * Goal 1
311    /// * Goal 2
312    /// ```
313    ///
314    /// In the longhand syntax, you use a standard named attribute:
315    ///
316    /// ```asciidoc
317    /// [id=goals]
318    /// * Goal 1
319    /// * Goal 2
320    /// ```
321    ///
322    /// In the legacy block anchor syntax, you surround the name with double
323    /// square brackets:
324    ///
325    /// ```asciidoc
326    /// [[goals]]
327    /// * Goal 1
328    /// * Goal 2
329    /// ```
330    pub fn id(&'src self) -> Option<&'src str> {
331        self.id_internal()
332    }
333
334    /// Same as [`id`](Self::id), but borrows for the duration of the call only.
335    fn id_internal(&self) -> Option<&str> {
336        self.shorthand_items_internal()
337            .into_iter()
338            .find(|v| v.starts_with('#'))
339            .map(|v| &v[1..])
340    }
341
342    /// Return any role attributes that were found in shorthand syntax.
343    ///
344    /// You can assign one or more roles to blocks and most inline elements
345    /// using the `role` attribute. The `role` attribute is a [named attribute].
346    /// Even though the attribute name is singular, it may contain multiple
347    /// (space-separated) roles. Roles may also be defined using a shorthand
348    /// (dot-prefixed) syntax.
349    ///
350    /// A role:
351    /// 1. adds additional semantics to an element
352    /// 2. can be used to apply additional styling to a group of elements (e.g.,
353    ///    via a CSS class selector)
354    /// 3. may activate additional behavior if recognized by the converter
355    ///
356    /// **TIP:** The `role` attribute in AsciiDoc always get mapped to the
357    /// `class` attribute in the HTML output. In other words, role names are
358    /// synonymous with HTML class names, thus allowing output elements to be
359    /// identified and styled in CSS using class selectors (e.g.,
360    /// `sidebarblock.role1`).
361    ///
362    /// [named attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/positional-and-named-attributes/#named
363    pub fn roles(&'src self) -> Vec<&'src str> {
364        self.roles_internal()
365    }
366
367    /// Same as [`roles`](Self::roles), but borrows for the duration of the call
368    /// only.
369    fn roles_internal(&self) -> Vec<&str> {
370        self.shorthand_items_internal()
371            .into_iter()
372            .filter(|span| span.starts_with('.'))
373            .map(|span| &span[1..])
374            .collect()
375    }
376
377    /// Return any option attributes that were found in shorthand syntax.
378    ///
379    /// The `options` attribute (often abbreviated as `opts`) is a versatile
380    /// [named attribute] that can be assigned one or more values. It can be
381    /// defined globally as document attribute as well as a block attribute on
382    /// an individual block.
383    ///
384    /// There is no strict schema for options. Any options which are not
385    /// recognized are ignored.
386    ///
387    /// You can assign one or more options to a block using the shorthand or
388    /// formal syntax for the options attribute.
389    ///
390    /// # Shorthand options syntax for blocks
391    ///
392    /// To assign an option to a block, prefix the value with a percent sign
393    /// (`%`) in an attribute list. The percent sign implicitly sets the
394    /// `options` attribute.
395    ///
396    /// ## Example 1: Sidebar block with an option assigned using the shorthand dot
397    ///
398    /// ```asciidoc
399    /// [%option]
400    /// ****
401    /// This is a sidebar with an option assigned to it, named option.
402    /// ****
403    /// ```
404    ///
405    /// You can assign multiple options to a block by prest
406    /// fixing each value with
407    /// a percent sign (`%`).
408    ///
409    /// ## Example 2: Sidebar with two options assigned using the shorthand dot
410    /// ```asciidoc
411    /// [%option1%option2]
412    /// ****
413    /// This is a sidebar with two options assigned to it, named option1 and option2.
414    /// ****
415    /// ```
416    ///
417    /// # Formal options syntax for blocks
418    ///
419    /// Explicitly set `options` or `opts`, followed by the equals sign (`=`),
420    /// and then the value in an attribute list.
421    ///
422    /// ## Example 3. Sidebar block with an option assigned using the formal syntax
423    /// ```asciidoc
424    /// [opts=option]
425    /// ****
426    /// This is a sidebar with an option assigned to it, named option.
427    /// ****
428    /// ```
429    ///
430    /// Separate multiple option values with commas (`,`).
431    ///
432    /// ## Example 4. Sidebar with three options assigned using the formal syntax
433    /// ```asciidoc
434    /// [opts="option1,option2"]
435    /// ****
436    /// This is a sidebar with two options assigned to it, option1 and option2.
437    /// ****
438    /// ```
439    ///
440    /// [named attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/positional-and-named-attributes/#named
441    pub fn options(&'src self) -> Vec<&'src str> {
442        self.options_internal()
443    }
444
445    /// Same as [`options`](Self::options), but borrows for the duration of the
446    /// call only.
447    fn options_internal(&self) -> Vec<&str> {
448        self.shorthand_items_internal()
449            .into_iter()
450            .filter(|v| v.starts_with('%'))
451            .map(|v| &v[1..])
452            .collect()
453    }
454
455    /// Merge the shorthand of two first-position (block style) attributes drawn
456    /// from separate block attribute lines, following Asciidoctor's semantics:
457    ///
458    /// * the block style and ID are taken from the later line if it specifies
459    ///   them, otherwise retained from the earlier line;
460    /// * roles and options accumulate, with the earlier line's values first.
461    ///
462    /// The result is a freshly synthesized positional attribute whose value
463    /// re-encodes the merged shorthand so that the usual accessors continue to
464    /// work.
465    pub(crate) fn merge_block_style_shorthand(earlier: &Self, later: &Self) -> Self {
466        let block_style = later
467            .block_style_internal()
468            .or(earlier.block_style_internal());
469        let id = later.id_internal().or(earlier.id_internal());
470
471        let mut roles = earlier.roles_internal();
472        roles.extend(later.roles_internal());
473
474        let mut options = earlier.options_internal();
475        options.extend(later.options_internal());
476
477        let mut value = String::new();
478        if let Some(block_style) = block_style {
479            value.push_str(block_style);
480        }
481        if let Some(id) = id {
482            value.push('#');
483            value.push_str(id);
484        }
485        for role in &roles {
486            value.push('.');
487            value.push_str(role);
488        }
489        for option in &options {
490            value.push('%');
491            value.push_str(option);
492        }
493
494        // The shorthand string is synthesized entirely from components that
495        // were already parsed and validated when their source lines were read
496        // (a block style, a non-empty ID, and non-empty roles/options, each
497        // separated by a single delimiter). Re-parsing it therefore can never
498        // produce a warning, so rather than plumb an always-empty warning list
499        // back to the caller we assert that invariant — turning a silent
500        // discard into an explicit, regression-guarded one.
501        let mut warnings: Vec<WarningType> = vec![];
502        let shorthand_item_indices = if value.is_empty() {
503            vec![]
504        } else {
505            parse_shorthand_items(&value, &mut warnings)
506        };
507
508        debug_assert!(
509            warnings.is_empty(),
510            "merging block-style shorthand should not produce warnings, got: {warnings:?}"
511        );
512
513        Self {
514            name: None,
515            value: CowStr::from(value),
516            shorthand_item_indices,
517            // The merged shorthand is the first positional of the earlier line,
518            // so it keeps that line's position (1).
519            positional_index: earlier.positional_index,
520            value_is_quoted: false,
521            value_is_substituted: false,
522        }
523    }
524
525    /// Return the 1-based positional index of this attribute, if it is a
526    /// positional (unnamed) attribute whose position is known. See the
527    /// [`positional_index`](Self::positional_index) field for the numbering
528    /// rules.
529    pub(crate) fn positional_index(&self) -> Option<usize> {
530        self.positional_index
531    }
532
533    /// Record the 1-based positional index of this attribute. Called by the
534    /// attribute-list parser once it knows the entry's position within the
535    /// list.
536    pub(crate) fn set_positional_index(&mut self, index: usize) {
537        self.positional_index = Some(index);
538    }
539
540    /// Returns `true` when this attribute's value came from a genuinely
541    /// quote-delimited string (both delimiters present). See the
542    /// [`value_is_quoted`](Self::value_is_quoted) field.
543    pub(crate) fn value_is_quoted(&self) -> bool {
544        self.value_is_quoted
545    }
546
547    /// Return the attribute's value.
548    ///
549    /// Note that this value will have had special characters and attribute
550    /// value replacements applied to it.
551    pub fn value(&'src self) -> &'src str {
552        self.value.as_ref()
553    }
554
555    /// Return the attribute's value.
556    ///
557    /// Unlike [`value`](Self::value), this borrows for the duration of the call
558    /// only, so it can be used on temporary `ElementAttribute` values (for
559    /// example while an owned attribute list is still being assembled).
560    pub(crate) fn value_str(&self) -> &str {
561        self.value.as_ref()
562    }
563
564    /// Returns `true` when the normal substitution group has already been
565    /// applied to this attribute's [`value`](Self::value) (a single-quoted
566    /// value in a block attribute list). A consumer that would otherwise
567    /// substitute the value itself uses this to avoid substituting it a
568    /// second time.
569    pub(crate) fn value_is_substituted(&self) -> bool {
570        self.value_is_substituted
571    }
572}
573
574fn parse_shorthand_items(source: &str, warnings: &mut Vec<WarningType>) -> Vec<usize> {
575    let mut shorthand_item_indices: Vec<usize> = vec![];
576    let mut span = Span::new(source);
577
578    // Look for block style selector.
579    if let Some(block_style_pr) = span.split_at_match_non_empty(is_shorthand_delimiter) {
580        shorthand_item_indices.push(block_style_pr.item.discard_whitespace().byte_offset());
581
582        span = block_style_pr.after;
583    }
584
585    while !span.is_empty() {
586        // Assumption: First character is a delimiter.
587        let after_delimiter = span.discard(1);
588
589        match after_delimiter.position(is_shorthand_delimiter) {
590            None => {
591                if after_delimiter.is_empty() {
592                    warnings.push(WarningType::EmptyShorthandItem);
593                    shorthand_item_indices.push(span.byte_offset());
594                    span = after_delimiter;
595                } else {
596                    shorthand_item_indices.push(span.byte_offset());
597                    span = span.discard_all();
598                }
599            }
600
601            Some(0) => {
602                shorthand_item_indices.push(span.byte_offset());
603                warnings.push(WarningType::EmptyShorthandItem);
604                span = after_delimiter;
605            }
606
607            Some(index) => {
608                let mi: MatchedItem<Span> = span.into_parse_result(index + 1);
609                shorthand_item_indices.push(span.byte_offset());
610                span = mi.after;
611            }
612        }
613    }
614
615    shorthand_item_indices
616}
617
618fn is_shorthand_delimiter(c: char) -> bool {
619    c == '#' || c == '%' || c == '.'
620}
621
622#[derive(Clone, Debug)]
623pub(crate) struct ParseShorthand(pub bool);
624
625fn cowstr_from_source_and_span<'src>(source: &CowStr<'src>, span: &Span<'_>) -> CowStr<'src> {
626    if let CowStr::Borrowed(source) = source {
627        let borrowed: Span<'src> = Span::new(source)
628            .discard(span.byte_offset())
629            .slice_to(..span.len());
630
631        CowStr::Borrowed(borrowed.data())
632    } else {
633        CowStr::from(span.data().to_string())
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    #![allow(clippy::unwrap_used)]
640
641    use crate::{
642        attributes::{AttrlistContext, element_attribute::ParseShorthand},
643        strings::CowStr,
644        tests::prelude::*,
645    };
646
647    #[test]
648    fn impl_clone() {
649        // Silly test to mark the #[derive(...)] line as covered.
650        let p = Parser::default();
651
652        let b1 = crate::attributes::ElementAttribute::parse(
653            &CowStr::from("abc"),
654            0,
655            &p,
656            ParseShorthand(false),
657            AttrlistContext::Inline,
658        )
659        .0;
660
661        let b2 = b1.clone();
662
663        assert_eq!(b1, b2);
664    }
665
666    #[test]
667    fn empty_source() {
668        let p = Parser::default();
669
670        let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
671            &CowStr::from(""),
672            0,
673            &p,
674            ParseShorthand(false),
675            AttrlistContext::Inline,
676        );
677
678        assert!(warning_types.is_empty());
679
680        assert_eq!(
681            element_attr,
682            ElementAttribute {
683                name: None,
684                shorthand_items: &[],
685                value: "",
686            }
687        );
688
689        assert!(element_attr.name().is_none());
690        assert!(element_attr.block_style().is_none());
691        assert!(element_attr.id().is_none());
692        assert!(element_attr.roles().is_empty());
693        assert!(element_attr.options().is_empty());
694
695        assert_eq!(offset, 0);
696    }
697
698    #[test]
699    fn only_spaces() {
700        let p = Parser::default();
701
702        let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
703            &CowStr::from("   "),
704            0,
705            &p,
706            ParseShorthand(false),
707            AttrlistContext::Inline,
708        );
709
710        assert!(warning_types.is_empty());
711
712        assert_eq!(
713            element_attr,
714            ElementAttribute {
715                name: None,
716                shorthand_items: &[],
717                value: "",
718            }
719        );
720
721        assert!(element_attr.name().is_none());
722        assert!(element_attr.block_style().is_none());
723        assert!(element_attr.id().is_none());
724        assert!(element_attr.roles().is_empty());
725        assert!(element_attr.options().is_empty());
726
727        assert_eq!(offset, 3);
728    }
729
730    #[test]
731    fn unquoted_and_unnamed_value() {
732        let p = Parser::default();
733
734        let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
735            &CowStr::from("abc"),
736            0,
737            &p,
738            ParseShorthand(false),
739            AttrlistContext::Inline,
740        );
741
742        assert!(warning_types.is_empty());
743
744        assert_eq!(
745            element_attr,
746            ElementAttribute {
747                name: None,
748                shorthand_items: &[],
749                value: "abc",
750            }
751        );
752
753        assert!(element_attr.name().is_none());
754        assert!(element_attr.block_style().is_none());
755        assert!(element_attr.id().is_none());
756        assert!(element_attr.roles().is_empty());
757        assert!(element_attr.options().is_empty());
758
759        assert_eq!(offset, 3);
760    }
761
762    #[test]
763    fn unquoted_stops_at_comma() {
764        let p = Parser::default();
765
766        let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
767            &CowStr::from("abc,def"),
768            0,
769            &p,
770            ParseShorthand(false),
771            AttrlistContext::Inline,
772        );
773
774        assert!(warning_types.is_empty());
775
776        assert_eq!(
777            element_attr,
778            ElementAttribute {
779                name: None,
780                shorthand_items: &[],
781                value: "abc",
782            }
783        );
784
785        assert!(element_attr.name().is_none());
786        assert!(element_attr.block_style().is_none());
787        assert!(element_attr.id().is_none());
788        assert!(element_attr.roles().is_empty());
789        assert!(element_attr.options().is_empty());
790
791        assert_eq!(offset, 3);
792    }
793
794    mod quoted_string {
795        use crate::{
796            attributes::{AttrlistContext, element_attribute::ParseShorthand},
797            strings::CowStr,
798            tests::prelude::*,
799        };
800
801        #[test]
802        fn err_unterminated_double_quote() {
803            let p = Parser::default();
804
805            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
806                &CowStr::from("\"xyz"),
807                0,
808                &p,
809                ParseShorthand(false),
810                AttrlistContext::Inline,
811            );
812
813            assert_eq!(
814                element_attr,
815                ElementAttribute {
816                    name: None,
817                    shorthand_items: &[],
818                    value: "\"xyz"
819                }
820            );
821
822            assert!(element_attr.name().is_none());
823            assert!(element_attr.block_style().is_none());
824            assert!(element_attr.id().is_none());
825            assert!(element_attr.roles().is_empty());
826            assert!(element_attr.options().is_empty());
827
828            assert_eq!(offset, 4);
829
830            assert_eq!(
831                warning_types,
832                vec![WarningType::AttributeValueMissingTerminatingQuote]
833            );
834        }
835
836        #[test]
837        fn err_unterminated_double_quote_ends_at_comma() {
838            let p = Parser::default();
839
840            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
841                &CowStr::from("\"xyz,abc"),
842                0,
843                &p,
844                ParseShorthand(false),
845                AttrlistContext::Inline,
846            );
847
848            assert_eq!(
849                element_attr,
850                ElementAttribute {
851                    name: None,
852                    shorthand_items: &[],
853                    value: "\"xyz"
854                }
855            );
856
857            assert!(element_attr.name().is_none());
858            assert!(element_attr.block_style().is_none());
859            assert!(element_attr.id().is_none());
860            assert!(element_attr.roles().is_empty());
861            assert!(element_attr.options().is_empty());
862
863            assert_eq!(offset, 4);
864            assert_eq!(
865                warning_types,
866                vec![WarningType::AttributeValueMissingTerminatingQuote]
867            );
868        }
869
870        #[test]
871        fn double_quoted_string() {
872            let p = Parser::default();
873
874            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
875                &CowStr::from("\"abc\"def"),
876                0,
877                &p,
878                ParseShorthand(false),
879                AttrlistContext::Inline,
880            );
881
882            assert!(warning_types.is_empty());
883
884            assert_eq!(
885                element_attr,
886                ElementAttribute {
887                    name: None,
888                    shorthand_items: &[],
889                    value: "abc"
890                }
891            );
892
893            assert!(element_attr.name().is_none());
894            assert!(element_attr.block_style().is_none());
895            assert!(element_attr.id().is_none());
896            assert!(element_attr.roles().is_empty());
897            assert!(element_attr.options().is_empty());
898
899            assert_eq!(offset, 5);
900        }
901
902        #[test]
903        fn double_quoted_with_escape() {
904            let p = Parser::default();
905
906            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
907                &CowStr::from("\"a\\\"bc\"def"),
908                0,
909                &p,
910                ParseShorthand(false),
911                AttrlistContext::Inline,
912            );
913
914            assert!(warning_types.is_empty());
915
916            assert_eq!(
917                element_attr,
918                ElementAttribute {
919                    name: None,
920                    shorthand_items: &[],
921                    value: "a\"bc"
922                }
923            );
924
925            assert!(element_attr.name().is_none());
926            assert!(element_attr.block_style().is_none());
927            assert!(element_attr.id().is_none());
928            assert!(element_attr.roles().is_empty());
929            assert!(element_attr.options().is_empty());
930
931            assert_eq!(offset, 7);
932        }
933
934        #[test]
935        fn double_quoted_with_single_quote() {
936            let p = Parser::default();
937
938            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
939                &CowStr::from("\"a'bc\"def"),
940                0,
941                &p,
942                ParseShorthand(false),
943                AttrlistContext::Inline,
944            );
945
946            assert!(warning_types.is_empty());
947
948            assert_eq!(
949                element_attr,
950                ElementAttribute {
951                    name: None,
952                    shorthand_items: &[],
953                    value: "a'bc"
954                }
955            );
956
957            assert!(element_attr.name().is_none());
958            assert!(element_attr.block_style().is_none());
959            assert!(element_attr.id().is_none());
960            assert!(element_attr.roles().is_empty());
961            assert!(element_attr.options().is_empty());
962
963            assert_eq!(offset, 6);
964        }
965
966        #[test]
967        fn err_unterminated_single_quote() {
968            let p = Parser::default();
969
970            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
971                &CowStr::from("\'xyz"),
972                0,
973                &p,
974                ParseShorthand(false),
975                AttrlistContext::Inline,
976            );
977
978            assert_eq!(
979                element_attr,
980                ElementAttribute {
981                    name: None,
982                    shorthand_items: &[],
983                    value: "\'xyz"
984                }
985            );
986
987            assert!(element_attr.name().is_none());
988            assert!(element_attr.block_style().is_none());
989            assert!(element_attr.id().is_none());
990            assert!(element_attr.roles().is_empty());
991            assert!(element_attr.options().is_empty());
992
993            assert_eq!(offset, 4);
994
995            assert_eq!(
996                warning_types,
997                vec![WarningType::AttributeValueMissingTerminatingQuote]
998            );
999        }
1000
1001        #[test]
1002        fn err_unterminated_single_quote_ends_at_comma() {
1003            let p = Parser::default();
1004
1005            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1006                &CowStr::from("\'xyz,abc"),
1007                0,
1008                &p,
1009                ParseShorthand(false),
1010                AttrlistContext::Inline,
1011            );
1012
1013            assert_eq!(
1014                element_attr,
1015                ElementAttribute {
1016                    name: None,
1017                    shorthand_items: &[],
1018                    value: "\'xyz"
1019                }
1020            );
1021
1022            assert!(element_attr.name().is_none());
1023            assert!(element_attr.block_style().is_none());
1024            assert!(element_attr.id().is_none());
1025            assert!(element_attr.roles().is_empty());
1026            assert!(element_attr.options().is_empty());
1027
1028            assert_eq!(offset, 4);
1029            assert_eq!(
1030                warning_types,
1031                vec![WarningType::AttributeValueMissingTerminatingQuote]
1032            );
1033        }
1034
1035        #[test]
1036        fn single_quoted_string() {
1037            let p = Parser::default();
1038
1039            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1040                &CowStr::from("'abc'def"),
1041                0,
1042                &p,
1043                ParseShorthand(false),
1044                AttrlistContext::Inline,
1045            );
1046
1047            assert!(warning_types.is_empty());
1048
1049            assert_eq!(
1050                element_attr,
1051                ElementAttribute {
1052                    name: None,
1053                    shorthand_items: &[],
1054                    value: "abc"
1055                }
1056            );
1057
1058            assert!(element_attr.name().is_none());
1059            assert!(element_attr.block_style().is_none());
1060            assert!(element_attr.id().is_none());
1061            assert!(element_attr.roles().is_empty());
1062            assert!(element_attr.options().is_empty());
1063
1064            assert_eq!(offset, 5);
1065        }
1066
1067        #[test]
1068        fn single_quoted_with_escape() {
1069            let p = Parser::default();
1070
1071            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1072                &CowStr::from("'a\\'bc'def"),
1073                0,
1074                &p,
1075                ParseShorthand(false),
1076                AttrlistContext::Inline,
1077            );
1078
1079            assert!(warning_types.is_empty());
1080
1081            assert_eq!(
1082                element_attr,
1083                ElementAttribute {
1084                    name: None,
1085                    shorthand_items: &[],
1086                    value: "a'bc"
1087                }
1088            );
1089
1090            assert!(element_attr.name().is_none());
1091            assert!(element_attr.block_style().is_none());
1092            assert!(element_attr.id().is_none());
1093            assert!(element_attr.roles().is_empty());
1094            assert!(element_attr.options().is_empty());
1095
1096            assert_eq!(offset, 7);
1097        }
1098
1099        #[test]
1100        fn single_quoted_with_double_quote() {
1101            let p = Parser::default();
1102
1103            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1104                &CowStr::from("'a\"bc'def"),
1105                0,
1106                &p,
1107                ParseShorthand(false),
1108                AttrlistContext::Inline,
1109            );
1110
1111            assert!(warning_types.is_empty());
1112
1113            assert_eq!(
1114                element_attr,
1115                ElementAttribute {
1116                    name: None,
1117                    shorthand_items: &[],
1118                    value: "a\"bc"
1119                }
1120            );
1121
1122            assert!(element_attr.name().is_none());
1123            assert!(element_attr.block_style().is_none());
1124            assert!(element_attr.id().is_none());
1125            assert!(element_attr.roles().is_empty());
1126            assert!(element_attr.options().is_empty());
1127
1128            assert_eq!(offset, 6);
1129        }
1130
1131        #[test]
1132        fn single_quoted_gets_substitions() {
1133            let p = Parser::default().with_intrinsic_attribute(
1134                "foo",
1135                "bar",
1136                ModificationContext::Anywhere,
1137            );
1138
1139            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1140                &CowStr::from("'*abc* def {foo}'"),
1141                0,
1142                &p,
1143                ParseShorthand(false),
1144                AttrlistContext::Block,
1145            );
1146
1147            assert!(warning_types.is_empty());
1148
1149            assert_eq!(
1150                element_attr,
1151                ElementAttribute {
1152                    name: None,
1153                    shorthand_items: &[],
1154                    value: "<strong>abc</strong> def bar"
1155                }
1156            );
1157
1158            assert!(element_attr.name().is_none());
1159            assert!(element_attr.block_style().is_none());
1160            assert!(element_attr.id().is_none());
1161            assert!(element_attr.roles().is_empty());
1162            assert!(element_attr.options().is_empty());
1163
1164            assert_eq!(offset, 17);
1165        }
1166    }
1167
1168    mod named {
1169        use crate::{
1170            attributes::{AttrlistContext, element_attribute::ParseShorthand},
1171            strings::CowStr,
1172            tests::prelude::*,
1173        };
1174
1175        #[test]
1176        fn simple_named_value() {
1177            let p = Parser::default();
1178
1179            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1180                &CowStr::from("abc=def"),
1181                0,
1182                &p,
1183                ParseShorthand(false),
1184                AttrlistContext::Inline,
1185            );
1186
1187            assert!(warning_types.is_empty());
1188
1189            assert_eq!(
1190                element_attr,
1191                ElementAttribute {
1192                    name: Some("abc"),
1193                    shorthand_items: &[],
1194                    value: "def"
1195                }
1196            );
1197
1198            assert_eq!(element_attr.name().unwrap(), "abc");
1199            assert!(element_attr.block_style().is_none());
1200            assert!(element_attr.id().is_none());
1201            assert!(element_attr.roles().is_empty());
1202            assert!(element_attr.options().is_empty());
1203
1204            assert_eq!(offset, 7);
1205        }
1206
1207        #[test]
1208        fn ignores_spaces_around_equals() {
1209            let p = Parser::default();
1210
1211            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1212                &CowStr::from("abc =  def"),
1213                0,
1214                &p,
1215                ParseShorthand(false),
1216                AttrlistContext::Inline,
1217            );
1218
1219            assert!(warning_types.is_empty());
1220
1221            assert_eq!(
1222                element_attr,
1223                ElementAttribute {
1224                    name: Some("abc"),
1225                    shorthand_items: &[],
1226                    value: "def"
1227                }
1228            );
1229
1230            assert_eq!(element_attr.name().unwrap(), "abc");
1231
1232            assert_eq!(offset, 10);
1233        }
1234
1235        #[test]
1236        fn numeric_name() {
1237            let p = Parser::default();
1238
1239            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1240                &CowStr::from("94-x =def"),
1241                0,
1242                &p,
1243                ParseShorthand(false),
1244                AttrlistContext::Inline,
1245            );
1246
1247            assert!(warning_types.is_empty());
1248
1249            assert_eq!(
1250                element_attr,
1251                ElementAttribute {
1252                    name: Some("94-x"),
1253                    shorthand_items: &[],
1254                    value: "def"
1255                }
1256            );
1257
1258            assert_eq!(element_attr.name().unwrap(), "94-x");
1259            assert!(element_attr.block_style().is_none());
1260            assert!(element_attr.id().is_none());
1261            assert!(element_attr.roles().is_empty());
1262            assert!(element_attr.options().is_empty());
1263
1264            assert_eq!(offset, 9);
1265        }
1266
1267        #[test]
1268        fn quoted_value() {
1269            let p = Parser::default();
1270
1271            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1272                &CowStr::from("abc='def'g"),
1273                0,
1274                &p,
1275                ParseShorthand(false),
1276                AttrlistContext::Inline,
1277            );
1278
1279            assert!(warning_types.is_empty());
1280
1281            assert_eq!(
1282                element_attr,
1283                ElementAttribute {
1284                    name: Some("abc"),
1285                    shorthand_items: &[],
1286                    value: "def"
1287                }
1288            );
1289
1290            assert_eq!(element_attr.name().unwrap(), "abc");
1291            assert!(element_attr.block_style().is_none());
1292            assert!(element_attr.id().is_none());
1293            assert!(element_attr.roles().is_empty());
1294            assert!(element_attr.options().is_empty());
1295
1296            assert_eq!(offset, 9);
1297        }
1298
1299        #[test]
1300        fn named_with_empty_value() {
1301            let p = Parser::default();
1302
1303            // `name=` with nothing after the `=` is a named attribute whose value
1304            // is the empty string (e.g. `[caption=]` clears a label), not a
1305            // positional attribute with the literal value "abc=".
1306            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1307                &CowStr::from("abc="),
1308                0,
1309                &p,
1310                ParseShorthand(false),
1311                AttrlistContext::Inline,
1312            );
1313
1314            assert!(warning_types.is_empty());
1315
1316            assert_eq!(
1317                element_attr,
1318                ElementAttribute {
1319                    name: Some("abc"),
1320                    shorthand_items: &[],
1321                    value: ""
1322                }
1323            );
1324
1325            assert_eq!(element_attr.name(), Some("abc"));
1326            assert!(element_attr.block_style().is_none());
1327            assert!(element_attr.id().is_none());
1328            assert!(element_attr.roles().is_empty());
1329            assert!(element_attr.options().is_empty());
1330
1331            assert_eq!(offset, 4);
1332        }
1333
1334        #[test]
1335        fn named_with_empty_value_before_comma() {
1336            let p = Parser::default();
1337
1338            // `name=` immediately followed by a comma is likewise a named
1339            // attribute with an empty value; parsing stops at the comma so the
1340            // next attribute can be read separately.
1341            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1342                &CowStr::from("abc=,def"),
1343                0,
1344                &p,
1345                ParseShorthand(false),
1346                AttrlistContext::Inline,
1347            );
1348
1349            assert!(warning_types.is_empty());
1350
1351            assert_eq!(
1352                element_attr,
1353                ElementAttribute {
1354                    name: Some("abc"),
1355                    shorthand_items: &[],
1356                    value: ""
1357                }
1358            );
1359
1360            assert_eq!(element_attr.name(), Some("abc"));
1361            assert!(element_attr.block_style().is_none());
1362            assert!(element_attr.id().is_none());
1363            assert!(element_attr.roles().is_empty());
1364            assert!(element_attr.options().is_empty());
1365
1366            assert_eq!(offset, 4);
1367        }
1368    }
1369
1370    mod parse_with_shorthand {
1371        use crate::{
1372            attributes::{AttrlistContext, element_attribute::ParseShorthand},
1373            strings::CowStr,
1374            tests::prelude::*,
1375        };
1376
1377        #[test]
1378        fn block_style_only() {
1379            let p = Parser::default();
1380
1381            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1382                &CowStr::from("abc"),
1383                0,
1384                &p,
1385                ParseShorthand(true),
1386                AttrlistContext::Inline,
1387            );
1388
1389            assert!(warning_types.is_empty());
1390
1391            assert_eq!(
1392                element_attr,
1393                ElementAttribute {
1394                    name: None,
1395                    shorthand_items: &["abc"],
1396                    value: "abc"
1397                }
1398            );
1399
1400            assert!(element_attr.name().is_none());
1401            assert_eq!(element_attr.shorthand_items(), vec!["abc"]);
1402            assert_eq!(element_attr.block_style().unwrap(), "abc");
1403            assert!(element_attr.id().is_none());
1404            assert!(element_attr.roles().is_empty());
1405            assert!(element_attr.options().is_empty());
1406
1407            assert_eq!(offset, 3);
1408        }
1409
1410        #[test]
1411        fn ignore_if_named_attribute() {
1412            let p = Parser::default();
1413
1414            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1415                &CowStr::from("name=block_style#id"),
1416                0,
1417                &p,
1418                ParseShorthand(true),
1419                AttrlistContext::Inline,
1420            );
1421
1422            assert!(warning_types.is_empty());
1423
1424            assert_eq!(
1425                element_attr,
1426                ElementAttribute {
1427                    name: Some("name"),
1428                    shorthand_items: &[],
1429                    value: "block_style#id"
1430                }
1431            );
1432
1433            assert_eq!(element_attr.name().unwrap(), "name");
1434            assert!(element_attr.shorthand_items().is_empty());
1435            assert!(element_attr.block_style().is_none());
1436            assert!(element_attr.id().is_none());
1437            assert!(element_attr.roles().is_empty());
1438            assert!(element_attr.options().is_empty());
1439
1440            assert_eq!(offset, 19);
1441        }
1442
1443        #[test]
1444        fn error_empty_id() {
1445            let p = Parser::default();
1446
1447            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1448                &CowStr::from("abc#"),
1449                0,
1450                &p,
1451                ParseShorthand(true),
1452                AttrlistContext::Inline,
1453            );
1454
1455            assert_eq!(
1456                element_attr,
1457                ElementAttribute {
1458                    name: None,
1459                    shorthand_items: &["abc"],
1460                    value: "abc#"
1461                }
1462            );
1463
1464            assert_eq!(offset, 4);
1465            assert_eq!(warning_types, vec![WarningType::EmptyShorthandItem]);
1466        }
1467
1468        #[test]
1469        fn error_duplicate_delimiter() {
1470            let p = Parser::default();
1471
1472            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1473                &CowStr::from("abc##id"),
1474                0,
1475                &p,
1476                ParseShorthand(true),
1477                AttrlistContext::Inline,
1478            );
1479
1480            assert_eq!(
1481                element_attr,
1482                ElementAttribute {
1483                    name: None,
1484                    shorthand_items: &["abc", "#id"],
1485                    value: "abc##id"
1486                }
1487            );
1488
1489            assert_eq!(offset, 7);
1490            assert_eq!(warning_types, vec![WarningType::EmptyShorthandItem]);
1491        }
1492
1493        #[test]
1494        fn id_only() {
1495            let p = Parser::default();
1496
1497            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1498                &CowStr::from("#xyz"),
1499                0,
1500                &p,
1501                ParseShorthand(true),
1502                AttrlistContext::Inline,
1503            );
1504
1505            assert!(warning_types.is_empty());
1506
1507            assert_eq!(
1508                element_attr,
1509                ElementAttribute {
1510                    name: None,
1511                    shorthand_items: &["#xyz"],
1512                    value: "#xyz"
1513                }
1514            );
1515
1516            assert!(element_attr.name().is_none());
1517            assert_eq!(element_attr.shorthand_items(), vec!["#xyz"]);
1518            assert!(element_attr.block_style().is_none());
1519            assert_eq!(element_attr.id().unwrap(), "xyz");
1520            assert!(element_attr.roles().is_empty());
1521            assert!(element_attr.options().is_empty());
1522
1523            assert_eq!(offset, 4);
1524        }
1525
1526        #[test]
1527        fn one_role_only() {
1528            let p = Parser::default();
1529
1530            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1531                &CowStr::from(".role1"),
1532                0,
1533                &p,
1534                ParseShorthand(true),
1535                AttrlistContext::Inline,
1536            );
1537
1538            assert!(warning_types.is_empty());
1539
1540            assert_eq!(
1541                element_attr,
1542                ElementAttribute {
1543                    name: None,
1544                    shorthand_items: &[".role1",],
1545                    value: ".role1"
1546                }
1547            );
1548
1549            assert!(element_attr.name().is_none());
1550            assert_eq!(element_attr.shorthand_items(), vec![".role1"]);
1551            assert!(element_attr.block_style().is_none());
1552            assert!(element_attr.id().is_none());
1553            assert_eq!(element_attr.roles(), vec!("role1"));
1554            assert!(element_attr.options().is_empty());
1555
1556            assert_eq!(offset, 6);
1557        }
1558
1559        #[test]
1560        fn multiple_roles() {
1561            let p = Parser::default();
1562
1563            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1564                &CowStr::from(".role1.role2.role3"),
1565                0,
1566                &p,
1567                ParseShorthand(true),
1568                AttrlistContext::Inline,
1569            );
1570
1571            assert!(warning_types.is_empty());
1572
1573            assert_eq!(
1574                element_attr,
1575                ElementAttribute {
1576                    name: None,
1577                    shorthand_items: &[".role1", ".role2", ".role3"],
1578                    value: ".role1.role2.role3"
1579                }
1580            );
1581
1582            assert!(element_attr.name().is_none());
1583
1584            assert_eq!(
1585                element_attr.shorthand_items(),
1586                vec![".role1", ".role2", ".role3"]
1587            );
1588
1589            assert!(element_attr.block_style().is_none());
1590            assert!(element_attr.id().is_none());
1591            assert_eq!(element_attr.roles(), vec!("role1", "role2", "role3",));
1592            assert!(element_attr.options().is_empty());
1593
1594            assert_eq!(offset, 18);
1595        }
1596
1597        #[test]
1598        fn one_option_only() {
1599            let p = Parser::default();
1600
1601            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1602                &CowStr::from("%option1"),
1603                0,
1604                &p,
1605                ParseShorthand(true),
1606                AttrlistContext::Inline,
1607            );
1608
1609            assert!(warning_types.is_empty());
1610
1611            assert_eq!(
1612                element_attr,
1613                ElementAttribute {
1614                    name: None,
1615                    shorthand_items: &["%option1"],
1616                    value: "%option1"
1617                }
1618            );
1619
1620            assert!(element_attr.name().is_none());
1621            assert_eq!(element_attr.shorthand_items(), vec!["%option1"]);
1622            assert!(element_attr.block_style().is_none());
1623            assert!(element_attr.id().is_none());
1624            assert!(element_attr.roles().is_empty());
1625            assert_eq!(element_attr.options(), vec!("option1"));
1626
1627            assert_eq!(offset, 8);
1628        }
1629
1630        #[test]
1631        fn multiple_options() {
1632            let p = Parser::default();
1633
1634            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1635                &CowStr::from("%option1%option2%option3"),
1636                0,
1637                &p,
1638                ParseShorthand(true),
1639                AttrlistContext::Inline,
1640            );
1641
1642            assert!(warning_types.is_empty());
1643
1644            assert_eq!(
1645                element_attr,
1646                ElementAttribute {
1647                    name: None,
1648                    shorthand_items: &["%option1", "%option2", "%option3"],
1649                    value: "%option1%option2%option3"
1650                }
1651            );
1652
1653            assert!(element_attr.name().is_none());
1654
1655            assert_eq!(
1656                element_attr.shorthand_items(),
1657                vec!["%option1", "%option2", "%option3"]
1658            );
1659
1660            assert!(element_attr.block_style().is_none());
1661            assert!(element_attr.id().is_none());
1662            assert!(element_attr.roles().is_empty());
1663            assert_eq!(
1664                element_attr.options(),
1665                vec!("option1", "option2", "option3")
1666            );
1667
1668            assert_eq!(offset, 24);
1669        }
1670
1671        #[test]
1672        fn block_style_and_id() {
1673            let p = Parser::default();
1674
1675            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1676                &CowStr::from("appendix#custom-id"),
1677                0,
1678                &p,
1679                ParseShorthand(true),
1680                AttrlistContext::Inline,
1681            );
1682
1683            assert!(warning_types.is_empty());
1684
1685            assert_eq!(
1686                element_attr,
1687                ElementAttribute {
1688                    name: None,
1689                    shorthand_items: &["appendix", "#custom-id"],
1690                    value: "appendix#custom-id"
1691                }
1692            );
1693
1694            assert!(element_attr.name().is_none());
1695            assert_eq!(
1696                element_attr.shorthand_items(),
1697                vec!["appendix", "#custom-id"]
1698            );
1699            assert_eq!(element_attr.block_style().unwrap(), "appendix",);
1700            assert_eq!(element_attr.id().unwrap(), "custom-id",);
1701            assert!(element_attr.roles().is_empty());
1702            assert!(element_attr.options().is_empty());
1703
1704            assert_eq!(offset, 18);
1705        }
1706
1707        #[test]
1708        fn id_role_and_option() {
1709            let p = Parser::default();
1710
1711            let (element_attr, offset, warning_types) = crate::attributes::ElementAttribute::parse(
1712                &CowStr::from("#rules.prominent%incremental"),
1713                0,
1714                &p,
1715                ParseShorthand(true),
1716                AttrlistContext::Inline,
1717            );
1718
1719            assert!(warning_types.is_empty());
1720
1721            assert_eq!(
1722                element_attr,
1723                ElementAttribute {
1724                    name: None,
1725                    shorthand_items: &["#rules", ".prominent", "%incremental"],
1726                    value: "#rules.prominent%incremental"
1727                }
1728            );
1729
1730            assert!(element_attr.name().is_none());
1731
1732            assert_eq!(
1733                element_attr.shorthand_items(),
1734                vec!["#rules", ".prominent", "%incremental"]
1735            );
1736
1737            assert!(element_attr.block_style().is_none());
1738            assert_eq!(element_attr.id().unwrap(), "rules");
1739            assert_eq!(element_attr.roles(), vec!("prominent"));
1740            assert_eq!(element_attr.options(), vec!("incremental"));
1741
1742            assert_eq!(offset, 28);
1743        }
1744    }
1745
1746    mod merge_block_style_shorthand {
1747        use crate::{
1748            attributes::{AttrlistContext, element_attribute::ParseShorthand},
1749            strings::CowStr,
1750            tests::prelude::*,
1751        };
1752
1753        fn parse(value: &str) -> crate::attributes::ElementAttribute<'_> {
1754            let p = Parser::default();
1755            crate::attributes::ElementAttribute::parse(
1756                &CowStr::from(value),
1757                0,
1758                &p,
1759                ParseShorthand(true),
1760                AttrlistContext::Block,
1761            )
1762            .0
1763        }
1764
1765        #[test]
1766        fn merges_disjoint_shorthand() {
1767            let earlier = parse("#myid");
1768            let later = parse(".myrole");
1769
1770            let merged =
1771                crate::attributes::ElementAttribute::merge_block_style_shorthand(&earlier, &later);
1772
1773            assert!(merged.name().is_none());
1774            assert_eq!(merged.id().unwrap(), "myid");
1775            assert_eq!(merged.roles(), vec!["myrole"]);
1776        }
1777
1778        #[test]
1779        fn two_empty_positionals_stay_empty() {
1780            // Merging two empty first positionals exercises the empty-value
1781            // branch, which produces an attribute with no shorthand items.
1782            let earlier = parse("");
1783            let later = parse("");
1784
1785            let merged =
1786                crate::attributes::ElementAttribute::merge_block_style_shorthand(&earlier, &later);
1787
1788            assert_eq!(
1789                merged,
1790                ElementAttribute {
1791                    name: None,
1792                    shorthand_items: &[],
1793                    value: "",
1794                }
1795            );
1796
1797            assert!(merged.name().is_none());
1798            assert!(merged.block_style().is_none());
1799            assert!(merged.id().is_none());
1800            assert!(merged.roles().is_empty());
1801            assert!(merged.options().is_empty());
1802        }
1803    }
1804}