use std::collections::HashSet; use std::fmt::{self, Display, Formatter}; use std::ops::Range; use std::str::FromStr; use ecow::EcoString; use typst::syntax::{PackageVersion, Source}; use unscanny::Scanner; /// Each test and subset may contain metadata. #[derive(Debug)] pub struct TestMetadata { /// Configures how the test is run. pub config: TestConfig, /// Declares properties that must hold for a test. /// /// For instance, `// Warning: 1-3 no text within underscores` /// will fail the test if the warning isn't generated by your test. pub annotations: HashSet, } /// Configuration of a test or subtest. #[derive(Debug, Default)] pub struct TestConfig { /// Reference images will be generated and compared. /// /// Defaults to `true`, can be disabled with `Ref: false`. pub compare_ref: Option, /// Hint annotations will be compared to compiler hints. /// /// Defaults to `true`, can be disabled with `Hints: false`. pub validate_hints: Option, /// Autocompletion annotations will be validated against autocompletions. /// Mutually exclusive with error and hint annotations. /// /// Defaults to `false`, can be enabled with `Autocomplete: true`. pub validate_autocomplete: Option, } /// Parsing error when the metadata is invalid. pub(crate) enum InvalidMetadata { /// An invalid annotation and it's error message. InvalidAnnotation(Annotation, String), /// Setting metadata can only be done with `true` or `false` as a value. InvalidSet(String), } impl InvalidMetadata { pub(crate) fn write( invalid_data: Vec, output: &mut String, print_annotation: &mut impl FnMut(&Annotation, &mut String), ) { use std::fmt::Write; for data in invalid_data.into_iter() { let (annotation, error) = match data { InvalidMetadata::InvalidAnnotation(a, e) => (Some(a), e), InvalidMetadata::InvalidSet(e) => (None, e), }; write!(output, "{error}",).unwrap(); if let Some(annotation) = annotation { print_annotation(&annotation, output) } else { writeln!(output).unwrap(); } } } } /// Annotation of the form `// KIND: RANGE TEXT`. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Annotation { /// Which kind of annotation this is. pub kind: AnnotationKind, /// May be written as: /// - `{line}:{col}-{line}:{col}`, e.g. `0:4-0:6`. /// - `{col}-{col}`, e.g. `4-6`: /// The line is assumed to be the line after the annotation. /// - `-1`: Produces a range of length zero at the end of the next line. /// Mostly useful for autocompletion tests which require an index. pub range: Option>, /// The raw text after the annotation. pub text: EcoString, } /// The different kinds of in-test annotations. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum AnnotationKind { Error, Warning, Hint, AutocompleteContains, AutocompleteExcludes, } impl AnnotationKind { /// Returns the user-facing string for this annotation. pub fn as_str(self) -> &'static str { match self { AnnotationKind::Error => "Error", AnnotationKind::Warning => "Warning", AnnotationKind::Hint => "Hint", AnnotationKind::AutocompleteContains => "Autocomplete contains", AnnotationKind::AutocompleteExcludes => "Autocomplete excludes", } } } impl FromStr for AnnotationKind { type Err = &'static str; fn from_str(s: &str) -> Result { Ok(match s { "Error" => AnnotationKind::Error, "Warning" => AnnotationKind::Warning, "Hint" => AnnotationKind::Hint, "Autocomplete contains" => AnnotationKind::AutocompleteContains, "Autocomplete excludes" => AnnotationKind::AutocompleteExcludes, _ => return Err("invalid annotatino"), }) } } impl Display for AnnotationKind { fn fmt(&self, f: &mut Formatter) -> fmt::Result { f.pad(self.as_str()) } } /// Parse metadata for a test. pub fn parse_part_metadata( source: &Source, is_header: bool, ) -> Result> { let mut config = TestConfig::default(); let mut annotations = HashSet::default(); let mut invalid_data = vec![]; let lines = source_to_lines(source); for (i, line) in lines.iter().enumerate() { if let Some((key, value)) = parse_metadata_line(line) { let key = key.trim(); match key { "Ref" => validate_set_annotation( value, &mut config.compare_ref, &mut invalid_data, ), "Hints" => validate_set_annotation( value, &mut config.validate_hints, &mut invalid_data, ), "Autocomplete" => validate_set_annotation( value, &mut config.validate_autocomplete, &mut invalid_data, ), annotation_key => { let Ok(kind) = AnnotationKind::from_str(annotation_key) else { continue; }; let mut s = Scanner::new(value); let range = parse_range(&mut s, i, source); let rest = if range.is_some() { s.after() } else { s.string() }; let message = rest .trim() .replace("VERSION", &PackageVersion::compiler().to_string()) .into(); let annotation = Annotation { kind, range: range.clone(), text: message }; if is_header { invalid_data.push(InvalidMetadata::InvalidAnnotation( annotation, format!( "Error: header may not contain annotations of type {kind}" ), )); continue; } if matches!( kind, AnnotationKind::AutocompleteContains | AnnotationKind::AutocompleteExcludes ) { if let Some(range) = range { if range.start != range.end { invalid_data.push(InvalidMetadata::InvalidAnnotation( annotation, "Error: found range in Autocomplete annotation where range.start != range.end, range.end would be ignored." .to_string() )); continue; } } else { invalid_data.push(InvalidMetadata::InvalidAnnotation( annotation, "Error: autocomplete annotation but no range specified" .to_string(), )); continue; } } annotations.insert(annotation); } } } } if invalid_data.is_empty() { Ok(TestMetadata { config, annotations }) } else { Err(invalid_data) } } /// Extract key and value for a metadata line of the form: `// KEY: VALUE`. fn parse_metadata_line(line: &str) -> Option<(&str, &str)> { let mut s = Scanner::new(line); if !s.eat_if("// ") { return None; } let key = s.eat_until(':').trim(); if !s.eat_if(':') { return None; } let value = s.eat_until('\n').trim(); Some((key, value)) } /// Parse a quoted string. fn parse_string<'a>(s: &mut Scanner<'a>) -> Option<&'a str> { if !s.eat_if('"') { return None; } let sub = s.eat_until('"'); if !s.eat_if('"') { return None; } Some(sub) } /// Parse a number. fn parse_num(s: &mut Scanner) -> Option { let mut first = true; let n = &s.eat_while(|c: char| { let valid = first && c == '-' || c.is_numeric(); first = false; valid }); n.parse().ok() } /// Parse a comma-separated list of strings. pub fn parse_string_list(text: &str) -> HashSet<&str> { let mut s = Scanner::new(text); let mut result = HashSet::new(); while let Some(sub) = parse_string(&mut s) { result.insert(sub); s.eat_whitespace(); if !s.eat_if(',') { break; } s.eat_whitespace(); } result } /// Parse a position. fn parse_pos(s: &mut Scanner, i: usize, source: &Source) -> Option { let first = parse_num(s)? - 1; let (delta, column) = if s.eat_if(':') { (first, parse_num(s)? - 1) } else { (0, first) }; let line = (i + comments_until_code(source, i)).checked_add_signed(delta)?; source.line_column_to_byte(line, usize::try_from(column).ok()?) } /// Parse a range. fn parse_range(s: &mut Scanner, i: usize, source: &Source) -> Option> { let lines = source_to_lines(source); s.eat_whitespace(); if s.eat_if("-1") { let mut add = 1; while let Some(line) = lines.get(i + add) { if !line.starts_with("//") { break; } add += 1; } let next_line = lines.get(i + add)?; let col = next_line.chars().count(); let index = source.line_column_to_byte(i + add, col)?; s.eat_whitespace(); return Some(index..index); } let start = parse_pos(s, i, source)?; let end = if s.eat_if('-') { parse_pos(s, i, source)? } else { start }; s.eat_whitespace(); Some(start..end) } /// Returns the number of lines of comment from line i to next line of code. fn comments_until_code(source: &Source, i: usize) -> usize { source_to_lines(source)[i..] .iter() .take_while(|line| line.starts_with("//")) .count() } fn source_to_lines(source: &Source) -> Vec<&str> { source.text().lines().map(str::trim).collect() } fn validate_set_annotation( value: &str, flag: &mut Option, invalid_data: &mut Vec, ) { let value = value.trim(); if value != "false" && value != "true" { invalid_data.push( InvalidMetadata::InvalidSet(format!("Error: trying to set Ref, Hints, or Autocomplete with value {value:?} != true, != false."))) } else { *flag = Some(value == "true") } }