Skip to main content

asciidoc_parser/blocks/
media.rs

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