tytanic_core/test/
annotation.rs

1//! Test annotations are used to override settings of a test.
2//!
3//! Annotations may be placed on a leading doc comment block (indicated by
4//! `///`), such a doc comment block can be placed after initial empty or
5//! regular comment lines, but must come before any content. All annotations in
6//! such a block must be at the start, once non-annotation content is
7//! encountered parsing stops.
8//!
9//! ```typst
10//! // SPDX-License-Identifier: MIT
11//!
12//! /// [skip]
13//! /// [max-delta: 10]
14//! ///
15//! /// Synopsis:
16//! /// ...
17//!
18//! #set page("a4")
19//! ...
20//! ```
21
22use std::str::FromStr;
23
24use ecow::{EcoString, EcoVec};
25use thiserror::Error;
26
27use crate::config::Direction;
28
29/// An error which may occur while parsing an annotation.
30#[derive(Debug, Error)]
31pub enum ParseAnnotationError {
32    /// The delimiter were missing or unclosed.
33    #[error("the annotation had only one or no delimiter")]
34    MissingDelimiter,
35
36    /// The annotation identifier is unknown, invalid or empty.
37    #[error("unknown or invalid annotation identifier: {0:?}")]
38    Unknown(EcoString),
39
40    /// The annotation expected no argument, but received one.
41    #[error("the annotation expected no argument, but received one")]
42    UnexpectedArg(&'static str),
43
44    /// The annotation expected an argument, but received none.
45    #[error("the annotation expected an argument, but received none")]
46    MissingArg(&'static str),
47
48    /// An error occured while parsing the annotation.
49    #[error("an error occured while parsing the annotation")]
50    Other(#[source] Box<dyn std::error::Error + Sync + Send + 'static>),
51}
52
53/// A test annotation used to configure test specific behavior.
54///
55/// Test annotations are placed on doc comments at the top of a test's source
56/// file:
57///
58/// Each annotation is on its own line.
59#[derive(Debug, Clone, PartialEq)]
60pub enum Annotation {
61    /// The ignored annotation, this can be used to exclude a test by virtue of
62    /// the `ignored` test set.
63    Skip,
64
65    /// The direction to use for diffing the documents.
66    Dir(Direction),
67
68    /// The pixel per inch to use for exporting the documents.
69    Ppi(f32),
70
71    /// The maximum allowed per pixel delta to use for comparsion.
72    MaxDelta(u8),
73
74    /// The maximum allowed amount of deviations to use fro comparison.
75    MaxDeviations(usize),
76}
77
78impl Annotation {
79    /// Collects all annotations found within a test's source code.
80    pub fn collect(source: &str) -> Result<EcoVec<Self>, ParseAnnotationError> {
81        // skip regular comments and leading empty lines
82        let lines = source.lines().skip_while(|line| {
83            line.strip_prefix("//")
84                .is_some_and(|rest| !rest.starts_with('/'))
85                || line.trim().is_empty()
86        });
87
88        // then collect all consecutive doc comment lines
89        let lines = lines.map_while(|line| line.strip_prefix("///").map(str::trim));
90
91        // ignore empty ones
92        let lines = lines.filter(|line| !line.is_empty());
93
94        // take only those which start with an annotation deimiter
95        let lines = lines.take_while(|line| line.starts_with('['));
96
97        lines.map(str::parse).collect()
98    }
99}
100
101impl FromStr for Annotation {
102    type Err = ParseAnnotationError;
103
104    fn from_str(s: &str) -> Result<Self, Self::Err> {
105        let Some(rest) = s.strip_prefix('[') else {
106            return Err(ParseAnnotationError::MissingDelimiter);
107        };
108
109        let Some(rest) = rest.strip_suffix(']') else {
110            return Err(ParseAnnotationError::MissingDelimiter);
111        };
112
113        let (id, arg) = match rest.split_once(':') {
114            Some((id, arg)) => (id, Some(arg.trim())),
115            None => (rest, None),
116        };
117
118        match id.trim() {
119            "skip" => {
120                if arg.is_some() {
121                    Err(ParseAnnotationError::UnexpectedArg("test"))
122                } else {
123                    Ok(Annotation::Skip)
124                }
125            }
126            "dir" => match arg {
127                Some(arg) => match arg.trim() {
128                    "ltr" => Ok(Annotation::Dir(Direction::Ltr)),
129                    "rtl" => Ok(Annotation::Dir(Direction::Rtl)),
130                    _ => Err(ParseAnnotationError::Other(
131                        format!("invalid direction {arg:?}, expected one of ltr or rtl").into(),
132                    )),
133                },
134                None => Err(ParseAnnotationError::UnexpectedArg("test")),
135            },
136            "ppi" => match arg {
137                Some(arg) => match arg.trim().parse() {
138                    Ok(arg) => Ok(Annotation::Ppi(arg)),
139                    Err(err) => Err(ParseAnnotationError::Other(err.into())),
140                },
141                None => Err(ParseAnnotationError::UnexpectedArg("test")),
142            },
143            "max-delta" => match arg {
144                Some(arg) => match arg.trim().parse() {
145                    Ok(arg) => Ok(Annotation::MaxDelta(arg)),
146                    Err(err) => Err(ParseAnnotationError::Other(err.into())),
147                },
148                None => Err(ParseAnnotationError::UnexpectedArg("test")),
149            },
150            "max-deviations" => match arg {
151                Some(arg) => match arg.trim().parse() {
152                    Ok(arg) => Ok(Annotation::MaxDeviations(arg)),
153                    Err(err) => Err(ParseAnnotationError::Other(err.into())),
154                },
155                None => Err(ParseAnnotationError::UnexpectedArg("test")),
156            },
157            _ => Err(ParseAnnotationError::Unknown(id.into())),
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_annotation_from_str() {
168        assert_eq!(Annotation::from_str("[skip]").unwrap(), Annotation::Skip);
169        assert_eq!(Annotation::from_str("[ skip  ]").unwrap(), Annotation::Skip);
170
171        assert!(Annotation::from_str("[ skip  ").is_err());
172        assert!(Annotation::from_str("[unknown]").is_err());
173    }
174
175    #[test]
176    fn test_annotation_unexpected_arg() {
177        assert!(Annotation::from_str("[skip:]").is_err());
178        assert!(Annotation::from_str("[skip: 10]").is_err());
179    }
180
181    #[test]
182    fn test_annotation_expected_arg() {
183        assert!(Annotation::from_str("[ppi]").is_err());
184        assert!(Annotation::from_str("[max-delta:]").is_err());
185    }
186
187    #[test]
188    fn test_annotation_arg() {
189        assert_eq!(
190            Annotation::from_str("[max-deviations: 20]").unwrap(),
191            Annotation::MaxDeviations(20)
192        );
193        assert_eq!(
194            Annotation::from_str("[ppi: 42.5]").unwrap(),
195            Annotation::Ppi(42.5)
196        );
197    }
198
199    #[test]
200    fn test_collect_book_example() {
201        let source = "\
202        /// [skip]    \n\
203        ///           \n\
204        /// Synopsis: \n\
205        /// ...       \n\
206                      \n\
207        #import \"/src/internal.typ\": foo \n\
208        ...";
209
210        assert_eq!(Annotation::collect(source).unwrap(), [Annotation::Skip]);
211    }
212
213    #[test]
214    fn test_collect_issue_109() {
215        assert_eq!(
216            Annotation::collect("///[skip]").unwrap(),
217            [Annotation::Skip]
218        );
219        assert_eq!(Annotation::collect("///").unwrap(), []);
220        assert_eq!(
221            Annotation::collect("/// [skip]").unwrap(),
222            [Annotation::Skip]
223        );
224        assert_eq!(
225            Annotation::collect("///[skip]\n///").unwrap(),
226            [Annotation::Skip]
227        );
228    }
229}