334 lines
11 KiB
Rust
334 lines
11 KiB
Rust
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<Annotation>,
|
|
}
|
|
|
|
/// 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<bool>,
|
|
/// Hint annotations will be compared to compiler hints.
|
|
///
|
|
/// Defaults to `true`, can be disabled with `Hints: false`.
|
|
pub validate_hints: Option<bool>,
|
|
/// 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<bool>,
|
|
}
|
|
|
|
/// 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<InvalidMetadata>,
|
|
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<Range<usize>>,
|
|
/// 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<Self, Self::Err> {
|
|
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<TestMetadata, Vec<InvalidMetadata>> {
|
|
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<isize> {
|
|
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<usize> {
|
|
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<Range<usize>> {
|
|
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<bool>,
|
|
invalid_data: &mut Vec<InvalidMetadata>,
|
|
) {
|
|
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")
|
|
}
|
|
}
|