Skip to main content

asciidoc_parser/blocks/
media.rs

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