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