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