use std::collections::HashMap;
use std::fmt;
use std::ops::Range;
use std::path::{Component, Path};
use super::Test;
use crate::canonical::{Digest, digest_bytes};
pub trait TestFiles {
fn read(&self, path: &str) -> TestFile;
fn tests(&self, path: &str, text: &str) -> Option<ParsedFile> {
let _ = (path, text);
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestFile {
Text(String),
Missing,
NotText,
Unreadable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ParsedFile {
pub tests: Vec<LocatedTest>,
pub errors: Vec<ParseError>,
}
impl ParsedFile {
#[must_use]
pub const fn new(tests: Vec<LocatedTest>, errors: Vec<ParseError>) -> Self {
Self { tests, errors }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LocatedTest {
pub name: String,
pub lines: Lines,
pub bytes: Range<usize>,
pub canonical: String,
pub source: String,
}
impl LocatedTest {
#[must_use]
pub const fn new(
name: String,
lines: Lines,
bytes: Range<usize>,
canonical: String,
source: String,
) -> Self {
Self {
name,
lines,
bytes,
canonical,
source,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ParseError {
pub at: usize,
pub line: usize,
pub column: usize,
pub text: String,
pub missing: bool,
}
impl ParseError {
#[must_use]
pub const fn new(at: usize, line: usize, column: usize, text: String, missing: bool) -> Self {
Self {
at,
line,
column,
text,
missing,
}
}
}
impl fmt::Display for ParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let (line, column, text) = (self.line, self.column, &self.text);
if self.missing {
write!(formatter, "{line}:{column}, where `{text}` is missing")
} else {
write!(formatter, "{line}:{column}, at `{text}`")
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Lines {
pub first: usize,
pub last: usize,
}
impl Lines {
#[must_use]
pub const fn new(first: usize, last: usize) -> Self {
Self { first, last }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Found {
Located { lines: Vec<Lines>, digest: Digest },
Word(Untracked),
Unparsed(ParseError),
Missing(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Untracked {
NoGrammar,
NoTest(Option<ParseError>),
}
pub(super) struct Seeker<'a, F> {
files: &'a F,
read: HashMap<String, Read>,
}
struct Read {
file: TestFile,
parsed: Option<ParsedFile>,
}
impl<'a, F: TestFiles> Seeker<'a, F> {
pub(super) fn new(files: &'a F) -> Self {
Self {
files,
read: HashMap::new(),
}
}
pub(super) fn seek(&mut self, test: &Test) -> Found {
let file = test.file.as_str();
if !inside(file) {
return Found::Missing(format!(
"the file `{file}` lies outside the directory that holds `spec/`"
));
}
let read = self.read(file);
let held = match &read.file {
TestFile::Text(held) => held,
TestFile::Missing => {
return Found::Missing(format!("the file `{file}` does not exist"));
}
TestFile::NotText => {
return Found::Missing(format!("the file `{file}` is not UTF-8 text"));
}
TestFile::Unreadable(why) => {
return Found::Missing(format!("the file `{file}` cannot be read: {why}"));
}
};
let parsed = read.parsed.as_ref();
let located: Vec<&LocatedTest> = named(parsed, &test.name).collect();
if let Some(error) = parsed.and_then(|parsed| {
located
.iter()
.find_map(|located| cut_short(parsed, located))
}) {
return Found::Unparsed(error.clone());
}
if !located.is_empty() {
let canonical: Vec<&str> = located.iter().map(|test| test.canonical.as_str()).collect();
return Found::Located {
lines: located.iter().map(|test| test.lines).collect(),
digest: digest_bytes(canonical.join("\n").as_bytes()),
};
}
if !occurs_whole(held, &test.name) {
return Found::Missing(format!(
"`{}` does not occur in `{file}` as a whole word",
test.name
));
}
Found::Word(parsed.map_or(Untracked::NoGrammar, |parsed| {
Untracked::NoTest(parsed.errors.first().cloned())
}))
}
pub(super) fn holds(&self, test: &Test, found: &Found, literal: &str) -> bool {
let Some(read) = self.read.get(&test.file) else {
return false;
};
match (found, &read.file) {
(Found::Located { .. }, _) => named(read.parsed.as_ref(), &test.name)
.any(|located| located.source.contains(literal)),
(Found::Word(_) | Found::Unparsed(_), TestFile::Text(held)) => held.contains(literal),
_ => false,
}
}
fn read(&mut self, file: &str) -> &Read {
let files = self.files;
self.read.entry(file.to_owned()).or_insert_with(|| {
let read = files.read(file);
let parsed = match &read {
TestFile::Text(text) => files.tests(file, text),
_ => None,
};
Read { file: read, parsed }
})
}
}
fn named<'a>(
parsed: Option<&'a ParsedFile>,
name: &'a str,
) -> impl Iterator<Item = &'a LocatedTest> {
parsed
.map_or(&[][..], |parsed| &parsed.tests)
.iter()
.filter(move |test| test.name == name)
}
fn cut_short<'a>(parsed: &'a ParsedFile, test: &LocatedTest) -> Option<&'a ParseError> {
let Range { start, end } = test.bytes;
parsed.errors.iter().find(|error| {
start <= error.at
&& (error.at < end
|| !parsed
.tests
.iter()
.any(|other| (end..=error.at).contains(&other.bytes.start)))
})
}
fn inside(path: &str) -> bool {
let mut depth = 0_usize;
Path::new(path).components().all(|part| match part {
Component::Normal(_) => {
depth += 1;
true
}
Component::CurDir => true,
Component::ParentDir => depth.checked_sub(1).map(|up| depth = up).is_some(),
Component::RootDir | Component::Prefix(_) => false,
})
}
fn occurs_whole(text: &str, name: &str) -> bool {
if name.is_empty() {
return false;
}
let word = |character: char| character.is_alphanumeric() || character == '_';
let mut from = 0;
while let Some(found) = text[from..].find(name) {
let at = from + found;
let before = text[..at].chars().next_back();
let after = text[at + name.len()..].chars().next();
if !before.is_some_and(word) && !after.is_some_and(word) {
return true;
}
from = at + text[at..].chars().next().map_or(1, char::len_utf8);
}
false
}
pub(super) fn literals(name: &str) -> Vec<&str> {
let parts: Vec<&str> = name.split('`').collect();
let mut literals: Vec<&str> = Vec::new();
for part in parts.iter().take(parts.len() - 1).skip(1).step_by(2) {
if !part.is_empty() && !literals.contains(part) {
literals.push(part);
}
}
literals
}