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, 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 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_ident() 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_ident() {
407        let mut parser = Parser::default();
408        let maw = crate::blocks::MediaBlock::parse(
409            &BlockMetadata::new("98xyz::bar[blah,blap]"),
410            &mut parser,
411        );
412
413        assert!(maw.item.is_none());
414        assert!(maw.warnings.is_empty());
415    }
416
417    #[test]
418    fn err_missing_double_colon() {
419        let mut parser = Parser::default();
420        let maw = crate::blocks::MediaBlock::parse(
421            &BlockMetadata::new("image:bar[blah,blap]"),
422            &mut parser,
423        );
424
425        assert!(maw.item.is_none());
426
427        assert_eq!(
428            maw.warnings,
429            vec![Warning {
430                source: Span {
431                    data: ":bar[blah,blap]",
432                    line: 1,
433                    col: 6,
434                    offset: 5,
435                },
436                warning: WarningType::MacroMissingSeparator,
437            }]
438        );
439    }
440
441    #[test]
442    fn err_missing_macro_attrlist() {
443        let mut parser = Parser::default();
444        let maw = crate::blocks::MediaBlock::parse(
445            &BlockMetadata::new("image::barblah,blap]"),
446            &mut parser,
447        );
448
449        assert!(maw.item.is_none());
450
451        assert_eq!(
452            maw.warnings,
453            vec![Warning {
454                source: Span {
455                    data: "",
456                    line: 1,
457                    col: 21,
458                    offset: 20,
459                },
460                warning: WarningType::MacroMissingAttributeList,
461            }]
462        );
463    }
464
465    #[test]
466    fn err_unknown_type() {
467        let mut parser = Parser::default();
468        assert!(
469            crate::blocks::MediaBlock::parse(&BlockMetadata::new("imagex::bar[]"), &mut parser)
470                .unwrap_if_no_warnings()
471                .is_none()
472        );
473    }
474
475    #[test]
476    fn err_no_attr_list() {
477        let mut parser = Parser::default();
478        assert!(
479            crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar"), &mut parser)
480                .unwrap_if_no_warnings()
481                .is_none()
482        );
483    }
484
485    #[test]
486    fn err_attr_list_not_closed() {
487        let mut parser = Parser::default();
488        assert!(
489            crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar[blah"), &mut parser)
490                .unwrap_if_no_warnings()
491                .is_none()
492        );
493    }
494
495    #[test]
496    fn err_unexpected_after_attr_list() {
497        let mut parser = Parser::default();
498        assert!(
499            crate::blocks::MediaBlock::parse(
500                &BlockMetadata::new("image::bar[blah]bonus"),
501                &mut parser
502            )
503            .unwrap_if_no_warnings()
504            .is_none()
505        );
506    }
507
508    #[test]
509    fn simplest_block_macro() {
510        let mut parser = Parser::default();
511
512        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::[]"), &mut parser);
513        assert!(mi.item.is_none());
514
515        assert_eq!(
516            mi.warnings,
517            vec![Warning {
518                source: Span {
519                    data: "[]",
520                    line: 1,
521                    col: 8,
522                    offset: 7,
523                },
524                warning: WarningType::MediaMacroMissingTarget,
525            }]
526        );
527    }
528
529    #[test]
530    fn has_target() {
531        let mut parser = Parser::default();
532
533        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar[]"), &mut parser)
534            .unwrap_if_no_warnings()
535            .unwrap();
536
537        assert_eq!(
538            mi.item,
539            MediaBlock {
540                type_: MediaType::Image,
541                target: Span {
542                    data: "bar",
543                    line: 1,
544                    col: 8,
545                    offset: 7,
546                },
547                macro_attrlist: Attrlist {
548                    attributes: &[],
549                    anchor: None,
550                    source: Span {
551                        data: "",
552                        line: 1,
553                        col: 12,
554                        offset: 11,
555                    }
556                },
557                source: Span {
558                    data: "image::bar[]",
559                    line: 1,
560                    col: 1,
561                    offset: 0,
562                },
563                title_source: None,
564                title: None,
565                caption: None,
566                number: None,
567                anchor: None,
568                anchor_reftext: None,
569                attrlist: None,
570            }
571        );
572
573        assert_eq!(
574            mi.after,
575            Span {
576                data: "",
577                line: 1,
578                col: 13,
579                offset: 12
580            }
581        );
582
583        assert_eq!(mi.item.content_model(), ContentModel::Empty);
584        assert_eq!(mi.item.raw_context().deref(), "image");
585        assert!(mi.item.child_blocks().next().is_none());
586        assert!(mi.item.title_source().is_none());
587        assert!(mi.item.title().is_none());
588        assert!(mi.item.anchor().is_none());
589        assert!(mi.item.anchor_reftext().is_none());
590        assert!(mi.item.attrlist().is_none());
591        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
592    }
593
594    #[test]
595    fn has_target_and_attrlist() {
596        let mut parser = Parser::default();
597
598        let mi =
599            crate::blocks::MediaBlock::parse(&BlockMetadata::new("image::bar[blah]"), &mut parser)
600                .unwrap_if_no_warnings()
601                .unwrap();
602
603        assert_eq!(
604            mi.item,
605            MediaBlock {
606                type_: MediaType::Image,
607                target: Span {
608                    data: "bar",
609                    line: 1,
610                    col: 8,
611                    offset: 7,
612                },
613                macro_attrlist: Attrlist {
614                    attributes: &[ElementAttribute {
615                        name: None,
616                        shorthand_items: &["blah"],
617                        value: "blah"
618                    }],
619                    anchor: None,
620                    source: Span {
621                        data: "blah",
622                        line: 1,
623                        col: 12,
624                        offset: 11,
625                    }
626                },
627                source: Span {
628                    data: "image::bar[blah]",
629                    line: 1,
630                    col: 1,
631                    offset: 0,
632                },
633                title_source: None,
634                title: None,
635                caption: None,
636                number: None,
637                anchor: None,
638                anchor_reftext: None,
639                attrlist: None,
640            }
641        );
642
643        assert_eq!(
644            mi.after,
645            Span {
646                data: "",
647                line: 1,
648                col: 17,
649                offset: 16
650            }
651        );
652    }
653
654    #[test]
655    fn audio() {
656        let mut parser = Parser::default();
657
658        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("audio::bar[]"), &mut parser)
659            .unwrap_if_no_warnings()
660            .unwrap();
661
662        assert_eq!(
663            mi.item,
664            MediaBlock {
665                type_: MediaType::Audio,
666                target: Span {
667                    data: "bar",
668                    line: 1,
669                    col: 8,
670                    offset: 7,
671                },
672                macro_attrlist: Attrlist {
673                    attributes: &[],
674                    anchor: None,
675                    source: Span {
676                        data: "",
677                        line: 1,
678                        col: 12,
679                        offset: 11,
680                    }
681                },
682                source: Span {
683                    data: "audio::bar[]",
684                    line: 1,
685                    col: 1,
686                    offset: 0,
687                },
688                title_source: None,
689                title: None,
690                caption: None,
691                number: None,
692                anchor: None,
693                anchor_reftext: None,
694                attrlist: None,
695            }
696        );
697
698        assert_eq!(
699            mi.after,
700            Span {
701                data: "",
702                line: 1,
703                col: 13,
704                offset: 12
705            }
706        );
707
708        assert_eq!(mi.item.content_model(), ContentModel::Empty);
709        assert_eq!(mi.item.raw_context().deref(), "audio");
710        assert!(mi.item.child_blocks().next().is_none());
711        assert!(mi.item.title_source().is_none());
712        assert!(mi.item.title().is_none());
713        assert!(mi.item.anchor().is_none());
714        assert!(mi.item.anchor_reftext().is_none());
715        assert!(mi.item.attrlist().is_none());
716        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
717    }
718
719    #[test]
720    fn video() {
721        let mut parser = Parser::default();
722
723        let mi = crate::blocks::MediaBlock::parse(&BlockMetadata::new("video::bar[]"), &mut parser)
724            .unwrap_if_no_warnings()
725            .unwrap();
726
727        assert_eq!(
728            mi.item,
729            MediaBlock {
730                type_: MediaType::Video,
731                target: Span {
732                    data: "bar",
733                    line: 1,
734                    col: 8,
735                    offset: 7,
736                },
737                macro_attrlist: Attrlist {
738                    attributes: &[],
739                    anchor: None,
740                    source: Span {
741                        data: "",
742                        line: 1,
743                        col: 12,
744                        offset: 11,
745                    }
746                },
747                source: Span {
748                    data: "video::bar[]",
749                    line: 1,
750                    col: 1,
751                    offset: 0,
752                },
753                title_source: None,
754                title: None,
755                caption: None,
756                number: None,
757                anchor: None,
758                anchor_reftext: None,
759                attrlist: None,
760            }
761        );
762
763        assert_eq!(
764            mi.after,
765            Span {
766                data: "",
767                line: 1,
768                col: 13,
769                offset: 12
770            }
771        );
772
773        assert_eq!(mi.item.content_model(), ContentModel::Empty);
774        assert_eq!(mi.item.raw_context().deref(), "video");
775        assert!(mi.item.child_blocks().next().is_none());
776        assert!(mi.item.title_source().is_none());
777        assert!(mi.item.title().is_none());
778        assert!(mi.item.anchor().is_none());
779        assert!(mi.item.anchor_reftext().is_none());
780        assert!(mi.item.attrlist().is_none());
781        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
782    }
783
784    #[test]
785    fn err_duplicate_comma() {
786        let mut parser = Parser::default();
787        let maw = crate::blocks::MediaBlock::parse(
788            &BlockMetadata::new("image::bar[blah,,blap]"),
789            &mut parser,
790        );
791
792        let mi = maw.item.unwrap().clone();
793
794        assert_eq!(
795            mi.item,
796            MediaBlock {
797                type_: MediaType::Image,
798                target: Span {
799                    data: "bar",
800                    line: 1,
801                    col: 8,
802                    offset: 7,
803                },
804                macro_attrlist: Attrlist {
805                    attributes: &[
806                        ElementAttribute {
807                            name: None,
808                            shorthand_items: &["blah"],
809                            value: "blah"
810                        },
811                        ElementAttribute {
812                            name: None,
813                            shorthand_items: &[],
814                            value: "blap"
815                        }
816                    ],
817                    anchor: None,
818                    source: Span {
819                        data: "blah,,blap",
820                        line: 1,
821                        col: 12,
822                        offset: 11,
823                    }
824                },
825                source: Span {
826                    data: "image::bar[blah,,blap]",
827                    line: 1,
828                    col: 1,
829                    offset: 0,
830                },
831                title_source: None,
832                title: None,
833                caption: None,
834                number: None,
835                anchor: None,
836                anchor_reftext: None,
837                attrlist: None,
838            }
839        );
840
841        assert_eq!(
842            mi.after,
843            Span {
844                data: "",
845                line: 1,
846                col: 23,
847                offset: 22
848            }
849        );
850
851        assert_eq!(
852            maw.warnings,
853            vec![Warning {
854                source: Span {
855                    data: "blah,,blap",
856                    line: 1,
857                    col: 12,
858                    offset: 11,
859                },
860                warning: WarningType::EmptyAttributeValue,
861            }]
862        );
863    }
864
865    mod target_resolution {
866        #![allow(clippy::indexing_slicing)]
867
868        use crate::{
869            blocks::{MediaBlock, media::TargetResolution, metadata::BlockMetadata},
870            parser::ModificationContext,
871            tests::prelude::*,
872            warnings::WarningType,
873        };
874
875        /// Parses `input` as a media block and resolves its target against
876        /// `parser`, returning the resolved [`MediaBlock`] (or `None` if the
877        /// block was dropped).
878        fn resolve<'i>(input: &'i str, parser: &mut Parser) -> Option<MediaBlock<'i>> {
879            let mut block = MediaBlock::parse(&BlockMetadata::new(input), parser)
880                .unwrap_if_no_warnings()
881                .unwrap()
882                .item;
883
884            match block.resolve_target(parser) {
885                TargetResolution::Keep => Some(block),
886                TargetResolution::Drop => None,
887            }
888        }
889
890        fn parser_with_mode(mode: &str) -> Parser {
891            Parser::default().with_intrinsic_attribute(
892                "attribute-missing",
893                mode,
894                ModificationContext::Anywhere,
895            )
896        }
897
898        #[test]
899        fn target_without_reference_is_unchanged() {
900            // The fast path (no `{`) returns the borrowed target verbatim.
901            let mut p = Parser::default();
902            let block = resolve("image::foo.png[]", &mut p).unwrap();
903            assert_eq!(block.resolved_target(), "foo.png");
904        }
905
906        #[test]
907        fn resolves_a_defined_reference() {
908            let mut p = Parser::default().with_intrinsic_attribute(
909                "name",
910                "bar",
911                ModificationContext::Anywhere,
912            );
913            let block = resolve("image::pre-{name}-post.png[]", &mut p).unwrap();
914            assert_eq!(block.resolved_target(), "pre-bar-post.png");
915        }
916
917        #[test]
918        fn skip_leaves_a_missing_reference_in_place() {
919            // `skip` is the default `attribute-missing` mode.
920            let mut p = Parser::default();
921            let block = resolve("image::a{missing}b.png[]", &mut p).unwrap();
922            assert_eq!(block.resolved_target(), "a{missing}b.png");
923            assert!(p.take_substitution_warnings().is_empty());
924        }
925
926        #[test]
927        fn drop_removes_only_the_missing_reference() {
928            let mut p = parser_with_mode("drop");
929            let block = resolve("image::a{missing}b.png[]", &mut p).unwrap();
930            assert_eq!(block.resolved_target(), "ab.png");
931        }
932
933        #[test]
934        fn warn_leaves_the_reference_and_records_a_warning() {
935            let mut p = parser_with_mode("warn");
936            let block = resolve("image::a{missing}b.png[]", &mut p).unwrap();
937            assert_eq!(block.resolved_target(), "a{missing}b.png");
938
939            let warnings = p.take_substitution_warnings();
940            assert_eq!(warnings.len(), 1);
941            assert_eq!(
942                warnings[0].warning,
943                WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
944            );
945        }
946
947        #[test]
948        fn drop_line_drops_the_whole_block() {
949            let mut p = parser_with_mode("drop-line");
950            assert!(resolve("image::a{missing}b.png[]", &mut p).is_none());
951        }
952
953        #[test]
954        fn drop_line_keeps_a_block_whose_reference_resolves() {
955            let mut p = parser_with_mode("drop-line").with_intrinsic_attribute(
956                "name",
957                "bar",
958                ModificationContext::Anywhere,
959            );
960            let block = resolve("image::{name}.png[]", &mut p).unwrap();
961            assert_eq!(block.resolved_target(), "bar.png");
962        }
963
964        #[test]
965        fn drop_line_drops_a_top_level_block_but_keeps_following_blocks() {
966            // Exercises the drop at the document (non-list) level, which flows
967            // through `parse_blocks_until`.
968            let doc = Parser::default().parse(
969                ":attribute-missing: drop-line\n\nimage::{unresolved}[]\n\nparagraph after\n",
970            );
971
972            assert_css(&doc, ".imageblock", 0);
973            assert_css(&doc, ".paragraph", 1);
974        }
975
976        #[test]
977        fn drop_line_drops_a_top_level_audio_block() {
978            // Audio is a block macro too, so it honors `drop-line` just like an
979            // image block.
980            let doc = Parser::default().parse(
981                ":attribute-missing: drop-line\n\naudio::{unresolved}[]\n\nparagraph after\n",
982            );
983
984            assert_css(&doc, ".audioblock", 0);
985            assert_css(&doc, ".paragraph", 1);
986        }
987
988        #[test]
989        fn escaped_missing_reference_never_drops_the_block() {
990            // An escaped reference is not a missing reference, so even under
991            // `drop-line` the block survives. As elsewhere in the crate, the
992            // escaping backslash is removed and the reference is passed through
993            // literally.
994            let mut p = parser_with_mode("drop-line");
995            let block = resolve("image::a\\{missing}b.png[]", &mut p).unwrap();
996            assert_eq!(block.resolved_target(), "a{missing}b.png");
997            assert!(p.take_substitution_warnings().is_empty());
998        }
999    }
1000
1001    mod media_type {
1002        mod impl_debug {
1003            use crate::blocks::MediaType;
1004
1005            #[test]
1006            fn image() {
1007                let media_type = MediaType::Image;
1008                let debug_output = format!("{:?}", media_type);
1009                assert_eq!(debug_output, "MediaType::Image");
1010            }
1011
1012            #[test]
1013            fn video() {
1014                let media_type = MediaType::Video;
1015                let debug_output = format!("{:?}", media_type);
1016                assert_eq!(debug_output, "MediaType::Video");
1017            }
1018
1019            #[test]
1020            fn audio() {
1021                let media_type = MediaType::Audio;
1022                let debug_output = format!("{:?}", media_type);
1023                assert_eq!(debug_output, "MediaType::Audio");
1024            }
1025        }
1026    }
1027}