Skip to main content

asciidoc_parser/blocks/
media.rs

1use crate::{
2    HasSpan, Parser, Span,
3    attributes::{Attrlist, AttrlistContext},
4    blocks::{ChildBlocks, ContentModel, IsBlock, caption, metadata::BlockMetadata},
5    content::{Content, substitute_attributes_in_macro_target},
6    span::MatchedItem,
7    strings::CowStr,
8    warnings::{MatchAndWarnings, Warning, WarningType},
9};
10
11/// A media block is used to represent an image, video, or audio block macro.
12#[derive(Clone, Debug, Eq, Hash, PartialEq)]
13pub struct MediaBlock<'src> {
14    type_: MediaType,
15    target: Span<'src>,
16    resolved_target: CowStr<'src>,
17    macro_attrlist: Attrlist<'src>,
18    source: Span<'src>,
19    title_source: Option<Span<'src>>,
20    title: Option<Content<'src>>,
21    caption: Option<String>,
22    number: Option<usize>,
23    anchor: Option<Span<'src>>,
24    anchor_reftext: Option<Span<'src>>,
25    attrlist: Option<Attrlist<'src>>,
26}
27
28/// Outcome of resolving attribute references in a media block's target via
29/// [`MediaBlock::resolve_target`].
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub(crate) enum TargetResolution {
32    /// The target was resolved and stored; the block should be kept.
33    Keep,
34
35    /// The target referenced a missing attribute under
36    /// `attribute-missing=drop-line`, so the entire block should be dropped.
37    Drop,
38}
39
40/// A media type may be one of three different types.
41#[derive(Clone, Copy, Eq, Hash, PartialEq)]
42pub enum MediaType {
43    /// Still image
44    Image,
45
46    /// Video
47    Video,
48
49    /// Audio
50    Audio,
51}
52
53impl std::fmt::Debug for MediaType {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            MediaType::Image => write!(f, "MediaType::Image"),
57            MediaType::Video => write!(f, "MediaType::Video"),
58            MediaType::Audio => write!(f, "MediaType::Audio"),
59        }
60    }
61}
62
63impl<'src> MediaBlock<'src> {
64    /// Returns a document-order iterator over this block's direct child blocks.
65    ///
66    /// A media block (image, audio, or video) never has child blocks, so this
67    /// iterator is always empty. See [`FindBlocks`](crate::blocks::FindBlocks)
68    /// to search from a [`Block`](crate::blocks::Block) or
69    /// [`Document`](crate::Document).
70    pub fn child_blocks(&'src self) -> ChildBlocks<'src> {
71        ChildBlocks::empty()
72    }
73
74    /// Returns the block's title as a mutable [`Content`], if the block has
75    /// one.
76    ///
77    /// This narrow seam exists for the document-order title resolution pass
78    /// (see `document::title_refs`), which installs the re-rendered title
79    /// after resolving any cross-references embedded in it. All other access
80    /// goes through the read-only [`IsBlock::title`] accessor.
81    pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
82        self.title.as_mut()
83    }
84
85    pub(crate) fn parse(
86        metadata: &BlockMetadata<'src>,
87        parser: &mut Parser,
88    ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
89        let line = metadata.block_start.take_normalized_line();
90
91        // Line must end with `]`; otherwise, it's not a block macro.
92        if !line.item.ends_with(']') {
93            return MatchAndWarnings {
94                item: None,
95                warnings: vec![],
96            };
97        }
98
99        let Some(name) = line.item.take_block_macro_name() else {
100            return MatchAndWarnings {
101                item: None,
102                warnings: vec![],
103            };
104        };
105
106        let type_ = match name.item.data() {
107            "image" => MediaType::Image,
108            "video" => MediaType::Video,
109            "audio" => MediaType::Audio,
110            _ => {
111                return MatchAndWarnings {
112                    item: None,
113                    warnings: vec![],
114                };
115            }
116        };
117
118        let Some(colons) = name.after.take_prefix("::") else {
119            return MatchAndWarnings {
120                item: None,
121                warnings: vec![Warning::new(name.after, WarningType::MacroMissingSeparator)],
122            };
123        };
124
125        // The target field must exist and be non-empty.
126        let target = colons.after.take_while(|c| c != '[');
127
128        if target.item.is_empty() {
129            return MatchAndWarnings {
130                item: None,
131                warnings: vec![Warning::new(
132                    target.after,
133                    WarningType::MediaMacroMissingTarget,
134                )],
135            };
136        }
137
138        let Some(open_brace) = target.after.take_prefix("[") else {
139            return MatchAndWarnings {
140                item: None,
141                warnings: vec![Warning::new(
142                    target.after,
143                    WarningType::MacroMissingAttributeList,
144                )],
145            };
146        };
147
148        let attrlist = open_brace.after.slice(0..open_brace.after.len() - 1);
149
150        // Note that we already checked that this line ends with a close brace.
151
152        let macro_attrlist = Attrlist::parse(attrlist, parser, AttrlistContext::Inline);
153
154        let source: Span = metadata.source.trim_remainder(line.after);
155        let source = source.slice(0..source.trim().len());
156
157        MatchAndWarnings {
158            item: Some(MatchedItem {
159                item: Self {
160                    type_,
161                    target: target.item,
162
163                    // Attribute references in the target are resolved later, in
164                    // `resolve_target` (which also decides whether a missing
165                    // reference should drop the whole block); until then, the
166                    // resolved target mirrors the raw target verbatim.
167                    resolved_target: target.item.data().into(),
168                    macro_attrlist: macro_attrlist.item.item,
169                    source,
170                    title_source: metadata.title_source,
171                    title: metadata.title.clone(),
172
173                    // The caption (and its number) is assigned later, in
174                    // `assign_caption`, which the caller invokes only once the
175                    // block survives `resolve_target`. Assigning it here would
176                    // consume the `figure-number` counter even for an image that
177                    // is then dropped under `attribute-missing=drop-line`,
178                    // leaving a gap in the figure numbering.
179                    caption: None,
180                    number: None,
181                    anchor: metadata.anchor,
182                    anchor_reftext: metadata.anchor_reftext,
183                    attrlist: metadata.attrlist.clone(),
184                },
185
186                after: line.after.discard_empty_lines(),
187            }),
188            warnings: macro_attrlist.warnings,
189        }
190    }
191
192    /// Return a [`Span`] describing the macro name.
193    pub fn type_(&self) -> MediaType {
194        self.type_
195    }
196
197    /// Return a [`Span`] describing the macro target.
198    ///
199    /// This is the target exactly as written in the source, _before_ any
200    /// attribute references within it are resolved. See
201    /// [`resolved_target()`](Self::resolved_target) for the resolved form.
202    pub fn target(&'src self) -> Option<&'src Span<'src>> {
203        Some(&self.target)
204    }
205
206    /// Return the macro target after any attribute references within it have
207    /// been resolved (honoring the [`attribute-missing`] document attribute).
208    ///
209    /// For the common case of a target with no attribute references, this is
210    /// identical to the text of [`target()`](Self::target).
211    ///
212    /// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
213    pub fn resolved_target(&self) -> &str {
214        self.resolved_target.as_ref()
215    }
216
217    /// Resolve attribute references in this block's target, honoring the
218    /// [`attribute-missing`] document attribute.
219    ///
220    /// On success the resolved target is stored (see
221    /// [`resolved_target()`](Self::resolved_target)) and
222    /// [`TargetResolution::Keep`] is returned. When the target references a
223    /// missing attribute and `attribute-missing=drop-line` is in effect,
224    /// [`TargetResolution::Drop`] is returned and the caller drops the
225    /// entire block.
226    ///
227    /// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
228    pub(crate) fn resolve_target(&mut self, parser: &Parser) -> TargetResolution {
229        match substitute_attributes_in_macro_target(self.target, parser) {
230            Some(resolved) => {
231                self.resolved_target = resolved;
232                TargetResolution::Keep
233            }
234            None => TargetResolution::Drop,
235        }
236    }
237
238    /// Assign this block's caption (and number) from its context.
239    ///
240    /// Only an image is captionable, and it is captioned under the `figure`
241    /// context (so its label comes from `figure-caption` and its number from
242    /// the `figure-number` counter), mirroring Asciidoctor, which assigns
243    /// the caption with the explicit key `figure`. A `caption` attribute on
244    /// the macro itself wins over one on the block's attribute list; either
245    /// supplies a verbatim, unnumbered override.
246    ///
247    /// This is called by the block parser **after** [`resolve_target`] keeps
248    /// the block, so the auto-numbering counter is never consumed by an
249    /// image that is subsequently dropped under
250    /// `attribute-missing=drop-line`.
251    ///
252    /// [`resolve_target`]: Self::resolve_target
253    pub(crate) fn assign_caption(&mut self, parser: &mut Parser) {
254        if self.type_ != MediaType::Image {
255            return;
256        }
257
258        let explicit_caption = self
259            .macro_attrlist
260            .named_attribute("caption")
261            .or_else(|| {
262                self.attrlist
263                    .as_ref()
264                    .and_then(|attrlist| attrlist.named_attribute("caption"))
265            })
266            .map(|attr| attr.value().to_string());
267
268        let caption = caption::assign_caption(
269            parser,
270            "figure",
271            self.title.is_some(),
272            explicit_caption.as_deref(),
273        );
274        self.number = caption.as_ref().and_then(|c| c.number);
275        self.caption = caption.map(|c| c.prefix);
276    }
277
278    /// Return the macro's attribute list.
279    ///
280    /// **IMPORTANT:** This is the list of attributes _within_ the macro block
281    /// definition itself.
282    ///
283    /// See also [`attrlist()`] for attributes that can be defined before the
284    /// macro invocation.
285    ///
286    /// [`attrlist()`]: Self::attrlist()
287    pub fn macro_attrlist(&'src self) -> &'src Attrlist<'src> {
288        &self.macro_attrlist
289    }
290}
291
292impl<'src> IsBlock<'src> for MediaBlock<'src> {
293    fn content_model(&self) -> ContentModel {
294        ContentModel::Empty
295    }
296
297    fn raw_context(&self) -> CowStr<'src> {
298        match self.type_ {
299            MediaType::Audio => "audio",
300            MediaType::Image => "image",
301            MediaType::Video => "video",
302        }
303        .into()
304    }
305
306    fn title_source(&'src self) -> Option<Span<'src>> {
307        self.title_source
308    }
309
310    fn title(&self) -> Option<&str> {
311        self.title.as_ref().map(Content::rendered_str)
312    }
313
314    fn caption(&self) -> Option<&str> {
315        self.caption.as_deref()
316    }
317
318    fn number(&self) -> Option<usize> {
319        self.number
320    }
321
322    fn id(&'src self) -> Option<&'src str> {
323        // In addition to a block anchor (`[[id]]`/`[#id]`) or the block
324        // attribute list above the macro, a media block may carry its ID as a
325        // named `id=` attribute _inside_ the macro attribute list (e.g.
326        // `image::sunset.jpg[id=sunset-img]`), which the trait default does not
327        // consider. Fall back to that last, so the block-level forms win.
328        self.anchor()
329            .map(|a| a.data())
330            .or_else(|| self.attrlist().and_then(|attrlist| attrlist.id()))
331            .or_else(|| self.macro_attrlist.id())
332    }
333
334    fn anchor(&'src self) -> Option<Span<'src>> {
335        self.anchor
336    }
337
338    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
339        self.anchor_reftext
340    }
341
342    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
343        self.attrlist.as_ref()
344    }
345}
346
347impl<'src> HasSpan<'src> for MediaBlock<'src> {
348    fn span(&self) -> Span<'src> {
349        self.source
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    #![allow(clippy::unwrap_used)]
356
357    use std::ops::Deref;
358
359    use crate::{
360        blocks::{ContentModel, MediaType, metadata::BlockMetadata},
361        tests::prelude::*,
362    };
363
364    #[test]
365    fn impl_clone() {
366        // Silly test to mark the #[derive(...)] line as covered.
367        let mut parser = Parser::default();
368
369        let b1 =
370            crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::foo.jpg[]"), &mut parser)
371                .unwrap_if_no_warnings()
372                .unwrap()
373                .item;
374
375        let b2 = b1.clone();
376        assert_eq!(b1, b2);
377    }
378
379    #[test]
380    fn err_empty_source() {
381        let mut parser = Parser::default();
382        assert!(
383            crate::blocks::MediaBlock::parse(&BlockMetadata::new(""), &mut parser)
384                .unwrap_if_no_warnings()
385                .is_none()
386        );
387    }
388
389    #[test]
390    fn err_only_spaces() {
391        let mut parser = Parser::default();
392        assert!(
393            crate::blocks::MediaBlock::parse(&BlockMetadata::new("    "), &mut parser)
394                .unwrap_if_no_warnings()
395                .is_none()
396        );
397    }
398
399    #[test]
400    fn err_macro_name_not_word_char() {
401        // A macro name must begin with a word character; a leading `#` is
402        // rejected before any name is captured (see `take_block_macro_name`).
403        let mut parser = Parser::default();
404        let maw = crate::blocks::MediaBlock::parse(
405            &BlockMetadata::new("#xyz::bar[blah,blap]"),
406            &mut parser,
407        );
408
409        assert!(maw.item.is_none());
410        assert!(maw.warnings.is_empty());
411    }
412
413    #[test]
414    fn err_missing_double_colon() {
415        let mut parser = Parser::default();
416        let maw = crate::blocks::MediaBlock::parse(
417            &BlockMetadata::new("image:bar[blah,blap]"),
418            &mut parser,
419        );
420
421        assert!(maw.item.is_none());
422
423        assert_eq!(
424            maw.warnings,
425            vec![Warning {
426                source: Span {
427                    data: ":bar[blah,blap]",
428                    line: 1,
429                    col: 6,
430                    offset: 5,
431                },
432                warning: WarningType::MacroMissingSeparator,
433            }]
434        );
435    }
436
437    #[test]
438    fn err_missing_macro_attrlist() {
439        let mut parser = Parser::default();
440        let maw = crate::blocks::MediaBlock::parse(
441            &BlockMetadata::new("image::barblah,blap]"),
442            &mut parser,
443        );
444
445        assert!(maw.item.is_none());
446
447        assert_eq!(
448            maw.warnings,
449            vec![Warning {
450                source: Span {
451                    data: "",
452                    line: 1,
453                    col: 21,
454                    offset: 20,
455                },
456                warning: WarningType::MacroMissingAttributeList,
457            }]
458        );
459    }
460
461    #[test]
462    fn err_unknown_type() {
463        let mut parser = Parser::default();
464        assert!(
465            crate::blocks::MediaBlock::parse(&BlockMetadata::new("imagex::bar[]"), &mut parser)
466                .unwrap_if_no_warnings()
467                .is_none()
468        );
469    }
470
471    #[test]
472    fn err_no_attr_list() {
473        let mut parser = Parser::default();
474        assert!(
475            crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar"), &mut parser)
476                .unwrap_if_no_warnings()
477                .is_none()
478        );
479    }
480
481    #[test]
482    fn err_attr_list_not_closed() {
483        let mut parser = Parser::default();
484        assert!(
485            crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar[blah"), &mut parser)
486                .unwrap_if_no_warnings()
487                .is_none()
488        );
489    }
490
491    #[test]
492    fn err_unexpected_after_attr_list() {
493        let mut parser = Parser::default();
494        assert!(
495            crate::blocks::MediaBlock::parse(
496                &BlockMetadata::new("image::bar[blah]bonus"),
497                &mut parser
498            )
499            .unwrap_if_no_warnings()
500            .is_none()
501        );
502    }
503
504    #[test]
505    fn simplest_block_macro() {
506        let mut parser = Parser::default();
507
508        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::[]"), &mut parser);
509        assert!(mi.item.is_none());
510
511        assert_eq!(
512            mi.warnings,
513            vec![Warning {
514                source: Span {
515                    data: "[]",
516                    line: 1,
517                    col: 8,
518                    offset: 7,
519                },
520                warning: WarningType::MediaMacroMissingTarget,
521            }]
522        );
523    }
524
525    #[test]
526    fn has_target() {
527        let mut parser = Parser::default();
528
529        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar[]"), &mut parser)
530            .unwrap_if_no_warnings()
531            .unwrap();
532
533        assert_eq!(
534            mi.item,
535            MediaBlock {
536                type_: MediaType::Image,
537                target: Span {
538                    data: "bar",
539                    line: 1,
540                    col: 8,
541                    offset: 7,
542                },
543                macro_attrlist: Attrlist {
544                    attributes: &[],
545                    anchor: None,
546                    source: Span {
547                        data: "",
548                        line: 1,
549                        col: 12,
550                        offset: 11,
551                    }
552                },
553                source: Span {
554                    data: "image::bar[]",
555                    line: 1,
556                    col: 1,
557                    offset: 0,
558                },
559                title_source: None,
560                title: None,
561                caption: None,
562                number: None,
563                anchor: None,
564                anchor_reftext: None,
565                attrlist: None,
566            }
567        );
568
569        assert_eq!(
570            mi.after,
571            Span {
572                data: "",
573                line: 1,
574                col: 13,
575                offset: 12
576            }
577        );
578
579        assert_eq!(mi.item.content_model(), ContentModel::Empty);
580        assert_eq!(mi.item.raw_context().deref(), "image");
581        assert!(mi.item.child_blocks().next().is_none());
582        assert!(mi.item.title_source().is_none());
583        assert!(mi.item.title().is_none());
584        assert!(mi.item.anchor().is_none());
585        assert!(mi.item.anchor_reftext().is_none());
586        assert!(mi.item.attrlist().is_none());
587        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
588    }
589
590    #[test]
591    fn has_target_and_attrlist() {
592        let mut parser = Parser::default();
593
594        let mi =
595            crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar[blah]"), &mut parser)
596                .unwrap_if_no_warnings()
597                .unwrap();
598
599        assert_eq!(
600            mi.item,
601            MediaBlock {
602                type_: MediaType::Image,
603                target: Span {
604                    data: "bar",
605                    line: 1,
606                    col: 8,
607                    offset: 7,
608                },
609                macro_attrlist: Attrlist {
610                    attributes: &[ElementAttribute {
611                        name: None,
612                        shorthand_items: &["blah"],
613                        value: "blah"
614                    }],
615                    anchor: None,
616                    source: Span {
617                        data: "blah",
618                        line: 1,
619                        col: 12,
620                        offset: 11,
621                    }
622                },
623                source: Span {
624                    data: "image::bar[blah]",
625                    line: 1,
626                    col: 1,
627                    offset: 0,
628                },
629                title_source: None,
630                title: None,
631                caption: None,
632                number: None,
633                anchor: None,
634                anchor_reftext: None,
635                attrlist: None,
636            }
637        );
638
639        assert_eq!(
640            mi.after,
641            Span {
642                data: "",
643                line: 1,
644                col: 17,
645                offset: 16
646            }
647        );
648    }
649
650    #[test]
651    fn audio() {
652        let mut parser = Parser::default();
653
654        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("audio::bar[]"), &mut parser)
655            .unwrap_if_no_warnings()
656            .unwrap();
657
658        assert_eq!(
659            mi.item,
660            MediaBlock {
661                type_: MediaType::Audio,
662                target: Span {
663                    data: "bar",
664                    line: 1,
665                    col: 8,
666                    offset: 7,
667                },
668                macro_attrlist: Attrlist {
669                    attributes: &[],
670                    anchor: None,
671                    source: Span {
672                        data: "",
673                        line: 1,
674                        col: 12,
675                        offset: 11,
676                    }
677                },
678                source: Span {
679                    data: "audio::bar[]",
680                    line: 1,
681                    col: 1,
682                    offset: 0,
683                },
684                title_source: None,
685                title: None,
686                caption: None,
687                number: None,
688                anchor: None,
689                anchor_reftext: None,
690                attrlist: None,
691            }
692        );
693
694        assert_eq!(
695            mi.after,
696            Span {
697                data: "",
698                line: 1,
699                col: 13,
700                offset: 12
701            }
702        );
703
704        assert_eq!(mi.item.content_model(), ContentModel::Empty);
705        assert_eq!(mi.item.raw_context().deref(), "audio");
706        assert!(mi.item.child_blocks().next().is_none());
707        assert!(mi.item.title_source().is_none());
708        assert!(mi.item.title().is_none());
709        assert!(mi.item.anchor().is_none());
710        assert!(mi.item.anchor_reftext().is_none());
711        assert!(mi.item.attrlist().is_none());
712        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
713    }
714
715    #[test]
716    fn video() {
717        let mut parser = Parser::default();
718
719        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("video::bar[]"), &mut parser)
720            .unwrap_if_no_warnings()
721            .unwrap();
722
723        assert_eq!(
724            mi.item,
725            MediaBlock {
726                type_: MediaType::Video,
727                target: Span {
728                    data: "bar",
729                    line: 1,
730                    col: 8,
731                    offset: 7,
732                },
733                macro_attrlist: Attrlist {
734                    attributes: &[],
735                    anchor: None,
736                    source: Span {
737                        data: "",
738                        line: 1,
739                        col: 12,
740                        offset: 11,
741                    }
742                },
743                source: Span {
744                    data: "video::bar[]",
745                    line: 1,
746                    col: 1,
747                    offset: 0,
748                },
749                title_source: None,
750                title: None,
751                caption: None,
752                number: None,
753                anchor: None,
754                anchor_reftext: None,
755                attrlist: None,
756            }
757        );
758
759        assert_eq!(
760            mi.after,
761            Span {
762                data: "",
763                line: 1,
764                col: 13,
765                offset: 12
766            }
767        );
768
769        assert_eq!(mi.item.content_model(), ContentModel::Empty);
770        assert_eq!(mi.item.raw_context().deref(), "video");
771        assert!(mi.item.child_blocks().next().is_none());
772        assert!(mi.item.title_source().is_none());
773        assert!(mi.item.title().is_none());
774        assert!(mi.item.anchor().is_none());
775        assert!(mi.item.anchor_reftext().is_none());
776        assert!(mi.item.attrlist().is_none());
777        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
778    }
779
780    #[test]
781    fn err_duplicate_comma() {
782        let mut parser = Parser::default();
783        let maw = crate::blocks::MediaBlock::parse(
784            &BlockMetadata::new("image::bar[blah,,blap]"),
785            &mut parser,
786        );
787
788        let mi = maw.item.unwrap().clone();
789
790        assert_eq!(
791            mi.item,
792            MediaBlock {
793                type_: MediaType::Image,
794                target: Span {
795                    data: "bar",
796                    line: 1,
797                    col: 8,
798                    offset: 7,
799                },
800                macro_attrlist: Attrlist {
801                    attributes: &[
802                        ElementAttribute {
803                            name: None,
804                            shorthand_items: &["blah"],
805                            value: "blah"
806                        },
807                        ElementAttribute {
808                            name: None,
809                            shorthand_items: &[],
810                            value: "blap"
811                        }
812                    ],
813                    anchor: None,
814                    source: Span {
815                        data: "blah,,blap",
816                        line: 1,
817                        col: 12,
818                        offset: 11,
819                    }
820                },
821                source: Span {
822                    data: "image::bar[blah,,blap]",
823                    line: 1,
824                    col: 1,
825                    offset: 0,
826                },
827                title_source: None,
828                title: None,
829                caption: None,
830                number: None,
831                anchor: None,
832                anchor_reftext: None,
833                attrlist: None,
834            }
835        );
836
837        assert_eq!(
838            mi.after,
839            Span {
840                data: "",
841                line: 1,
842                col: 23,
843                offset: 22
844            }
845        );
846
847        assert_eq!(
848            maw.warnings,
849            vec![Warning {
850                source: Span {
851                    data: "blah,,blap",
852                    line: 1,
853                    col: 12,
854                    offset: 11,
855                },
856                warning: WarningType::EmptyAttributeValue,
857            }]
858        );
859    }
860
861    mod target_resolution {
862        #![allow(clippy::indexing_slicing)]
863
864        use crate::{
865            blocks::{MediaBlock, media::TargetResolution, metadata::BlockMetadata},
866            parser::ModificationContext,
867            tests::prelude::*,
868            warnings::WarningType,
869        };
870
871        /// Parses `input` as a media block and resolves its target against
872        /// `parser`, returning the resolved [`MediaBlock`] (or `None` if the
873        /// block was dropped).
874        fn resolve<'i>(input: &'i str, parser: &mut Parser) -> Option<MediaBlock<'i>> {
875            let mut block = MediaBlock::parse(&BlockMetadata::new(input), parser)
876                .unwrap_if_no_warnings()
877                .unwrap()
878                .item;
879
880            match block.resolve_target(parser) {
881                TargetResolution::Keep => Some(block),
882                TargetResolution::Drop => None,
883            }
884        }
885
886        fn parser_with_mode(mode: &str) -> Parser {
887            Parser::default().with_intrinsic_attribute(
888                "attribute-missing",
889                mode,
890                ModificationContext::Anywhere,
891            )
892        }
893
894        #[test]
895        fn target_without_reference_is_unchanged() {
896            // The fast path (no `{`) returns the borrowed target verbatim.
897            let mut p = Parser::default();
898            let block = resolve("image::foo.png[]", &mut p).unwrap();
899            assert_eq!(block.resolved_target(), "foo.png");
900        }
901
902        #[test]
903        fn resolves_a_defined_reference() {
904            let mut p = Parser::default().with_intrinsic_attribute(
905                "name",
906                "bar",
907                ModificationContext::Anywhere,
908            );
909            let block = resolve("image::pre-{name}-post.png[]", &mut p).unwrap();
910            assert_eq!(block.resolved_target(), "pre-bar-post.png");
911        }
912
913        #[test]
914        fn skip_leaves_a_missing_reference_in_place() {
915            // `skip` is the default `attribute-missing` mode.
916            let mut p = Parser::default();
917            let block = resolve("image::a{missing}b.png[]", &mut p).unwrap();
918            assert_eq!(block.resolved_target(), "a{missing}b.png");
919            assert!(p.take_substitution_warnings().is_empty());
920        }
921
922        #[test]
923        fn drop_removes_only_the_missing_reference() {
924            let mut p = parser_with_mode("drop");
925            let block = resolve("image::a{missing}b.png[]", &mut p).unwrap();
926            assert_eq!(block.resolved_target(), "ab.png");
927        }
928
929        #[test]
930        fn warn_leaves_the_reference_and_records_a_warning() {
931            let mut p = parser_with_mode("warn");
932            let block = resolve("image::a{missing}b.png[]", &mut p).unwrap();
933            assert_eq!(block.resolved_target(), "a{missing}b.png");
934
935            let warnings = p.take_substitution_warnings();
936            assert_eq!(warnings.len(), 1);
937            assert_eq!(
938                warnings[0].warning,
939                WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
940            );
941        }
942
943        #[test]
944        fn drop_line_drops_the_whole_block() {
945            let mut p = parser_with_mode("drop-line");
946            assert!(resolve("image::a{missing}b.png[]", &mut p).is_none());
947
948            // Dropping the block is surfaced as a diagnostic naming the missing
949            // attribute (the crate's analogue of Asciidoctor's INFO log).
950            let warnings = p.take_substitution_warnings();
951            assert_eq!(warnings.len(), 1);
952            assert_eq!(
953                warnings[0].warning,
954                WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
955            );
956        }
957
958        #[test]
959        fn drop_line_keeps_a_block_whose_reference_resolves() {
960            let mut p = parser_with_mode("drop-line").with_intrinsic_attribute(
961                "name",
962                "bar",
963                ModificationContext::Anywhere,
964            );
965            let block = resolve("image::{name}.png[]", &mut p).unwrap();
966            assert_eq!(block.resolved_target(), "bar.png");
967        }
968
969        #[test]
970        fn drop_line_drops_a_top_level_block_but_keeps_following_blocks() {
971            // Exercises the drop at the document (non-list) level, which flows
972            // through `parse_blocks_until`.
973            let doc = Parser::default().parse(
974                ":attribute-missing: drop-line\n\nimage::{unresolved}[]\n\nparagraph after\n",
975            );
976
977            assert_css(&doc, ".imageblock", 0);
978            assert_css(&doc, ".paragraph", 1);
979        }
980
981        #[test]
982        fn drop_line_drops_a_top_level_audio_block() {
983            // Audio is a block macro too, so it honors `drop-line` just like an
984            // image block.
985            let doc = Parser::default().parse(
986                ":attribute-missing: drop-line\n\naudio::{unresolved}[]\n\nparagraph after\n",
987            );
988
989            assert_css(&doc, ".audioblock", 0);
990            assert_css(&doc, ".paragraph", 1);
991        }
992
993        #[test]
994        fn escaped_missing_reference_never_drops_the_block() {
995            // An escaped reference is not a missing reference, so even under
996            // `drop-line` the block survives. As elsewhere in the crate, the
997            // escaping backslash is removed and the reference is passed through
998            // literally.
999            let mut p = parser_with_mode("drop-line");
1000            let block = resolve("image::a\\{missing}b.png[]", &mut p).unwrap();
1001            assert_eq!(block.resolved_target(), "a{missing}b.png");
1002            assert!(p.take_substitution_warnings().is_empty());
1003        }
1004    }
1005
1006    mod media_type {
1007        mod impl_debug {
1008            use crate::blocks::MediaType;
1009
1010            #[test]
1011            fn image() {
1012                let media_type = MediaType::Image;
1013                let debug_output = format!("{:?}", media_type);
1014                assert_eq!(debug_output, "MediaType::Image");
1015            }
1016
1017            #[test]
1018            fn video() {
1019                let media_type = MediaType::Video;
1020                let debug_output = format!("{:?}", media_type);
1021                assert_eq!(debug_output, "MediaType::Video");
1022            }
1023
1024            #[test]
1025            fn audio() {
1026                let media_type = MediaType::Audio;
1027                let debug_output = format!("{:?}", media_type);
1028                assert_eq!(debug_output, "MediaType::Audio");
1029            }
1030        }
1031    }
1032}