use std::path::{Path, PathBuf};
use crate::custom::{ShapeError, ShapeSet};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Form {
Shape,
ShapeAfter,
Kind,
Let,
Rule,
}
impl Form {
#[must_use]
pub fn keyword(self) -> &'static str {
match self {
Form::Shape => "shape",
Form::ShapeAfter => "shape-after",
Form::Kind => "kind",
Form::Let => "let",
Form::Rule => "rule",
}
}
#[must_use]
pub fn of_keyword(keyword: &str) -> Option<Form> {
match keyword {
"shape" => Some(Form::Shape),
"shape-after" => Some(Form::ShapeAfter),
"kind" => Some(Form::Kind),
"let" => Some(Form::Let),
"rule" => Some(Form::Rule),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Atom {
pub name: String,
pub form: Form,
pub definition: String,
pub file: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug)]
pub struct AtomDecl<'a> {
pub name: &'a str,
pub form: Form,
pub definition: &'a str,
pub accepts: &'a [String],
pub rejects: &'a [String],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DeclareError {
LineBreak {
what: &'static str,
},
Backtick,
Exists {
name: String,
},
NoKeyword {
word: String,
},
Read {
file: PathBuf,
reason: String,
},
NoPatternFile {
dir: PathBuf,
},
ReadDir {
dir: PathBuf,
reason: String,
},
Refused(ShapeError),
}
impl std::fmt::Display for DeclareError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeclareError::LineBreak { what } => write!(f, "{what} is one line; this one holds a line break"),
DeclareError::Backtick => {
write!(f, "a shape's byte-pattern is written between backticks, so it cannot hold one")
}
DeclareError::Exists { name } => write!(f, "an atom named {name} is already declared here"),
DeclareError::NoKeyword { word } => write!(
f,
"{word:?} opens no declaration; a line opens with let, kind, shape, shape-after, test, fields or rule, or one of a rule's fix, meta, files, unless, record, record-start or record-span"
),
DeclareError::Read { file, reason } => write!(f, "{}: {reason}", file.display()),
DeclareError::NoPatternFile { dir } => write!(f, "{}: no .trex file under it", dir.display()),
DeclareError::ReadDir { dir, reason } => write!(f, "cannot read {}: {reason}", dir.display()),
DeclareError::Refused(e) => write!(f, "{}", e.msg),
}
}
}
impl std::error::Error for DeclareError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Kept {
InFile {
name: String,
file: PathBuf,
},
NoName {
name: String,
},
NoFile {
file: PathBuf,
},
}
#[derive(Clone, Debug)]
enum Source {
Line { name: String, form: Option<Form>, definition: String, text: String, base: Option<PathBuf> },
File(PathBuf),
}
#[derive(Clone, Debug, Default)]
pub struct Declarations {
sources: Vec<Source>,
set: ShapeSet,
}
impl Declarations {
#[must_use]
pub fn new() -> Self {
Declarations::default()
}
#[must_use]
pub fn set(&self) -> &ShapeSet {
&self.set
}
fn build(sources: &[Source]) -> Result<ShapeSet, DeclareError> {
let mut set = ShapeSet::new();
for source in sources {
match source {
Source::Line { text, base, .. } => {
set.set_base_dir(base.clone());
let declared = set.declare_text(text);
set.set_base_dir(None);
declared.map_err(DeclareError::Refused)?;
}
Source::File(path) => set.declare_file(path).map_err(DeclareError::Refused)?,
}
}
Ok(set)
}
fn commit(&mut self, sources: Vec<Source>) -> Result<(), DeclareError> {
let set = Self::build(&sources)?;
self.sources = sources;
self.set = set;
Ok(())
}
#[must_use]
pub fn declares(&self, name: &str) -> bool {
self.sources.iter().any(|s| matches!(s, Source::Line { name: n, form: Some(_), .. } if n == name))
}
pub fn declare(&mut self, line: &str, base: Option<PathBuf>) -> Result<(), DeclareError> {
let trimmed = line.trim();
one_line("a declaration", trimmed)?;
let (keyword, rest) = trimmed.split_once(char::is_whitespace).unwrap_or((trimmed, ""));
let (name, definition) = name_and_definition(rest);
let form = match (keyword, Form::of_keyword(keyword)) {
(_, Some(form)) => Some(form),
(
"test" | "fields" | "fix" | "meta" | "files" | "unless" | "record" | "record-start" | "record-span",
None,
) => None,
(word, None) => return Err(DeclareError::NoKeyword { word: word.to_string() }),
};
let mut next = self.sources.clone();
next.push(Source::Line { name, form, definition, text: trimmed.to_string(), base });
self.commit(next)
}
pub fn add(&mut self, atom: &AtomDecl<'_>, base: Option<PathBuf>, replace: bool) -> Result<(), DeclareError> {
one_line("a definition", atom.definition)?;
let mut text = match atom.form {
Form::Shape | Form::ShapeAfter => {
if atom.definition.contains('`') {
return Err(DeclareError::Backtick);
}
format!("{} {} = `{}`", atom.form.keyword(), atom.name, atom.definition)
}
Form::Kind | Form::Let | Form::Rule => {
format!("{} {} = {}", atom.form.keyword(), atom.name, atom.definition)
}
};
for given in atom.accepts.iter().chain(atom.rejects) {
one_line("an accepted or rejected text", given)?;
}
if !atom.accepts.is_empty() || !atom.rejects.is_empty() {
let mut test = format!("test {}", atom.name);
if !atom.accepts.is_empty() {
test.push_str(" accepts");
for a in atom.accepts {
test.push(' ');
test.push_str("ed(a));
}
}
if !atom.rejects.is_empty() {
test.push_str(" rejects");
for r in atom.rejects {
test.push(' ');
test.push_str("ed(r));
}
}
text.push('\n');
text.push_str(&test);
}
if self.declares(atom.name) && !replace {
return Err(DeclareError::Exists { name: atom.name.to_string() });
}
let mut next: Vec<Source> = self
.sources
.iter()
.filter(|s| !matches!(s, Source::Line { name, form: Some(_), .. } if name == atom.name))
.cloned()
.collect();
next.push(Source::Line {
name: atom.name.to_string(),
form: Some(atom.form),
definition: atom.definition.to_string(),
text,
base,
});
self.commit(next)
}
pub fn include<P: AsRef<Path>>(&mut self, paths: &[P]) -> Result<(), DeclareError> {
let mut files: Vec<PathBuf> = Vec::new();
for file in pattern_files(paths)? {
if !files.contains(&file) {
files.push(file);
}
}
let mut next: Vec<Source> =
self.sources.iter().filter(|s| !matches!(s, Source::File(p) if files.contains(p))).cloned().collect();
next.extend(files.into_iter().map(Source::File));
self.commit(next)
}
pub fn remove(&mut self, names: &[String], files: &[PathBuf]) -> Result<Vec<Kept>, DeclareError> {
let mut kept = Vec::new();
let mut removed: Vec<&String> = Vec::new();
for name in names {
if self.declares(name) {
removed.push(name);
} else {
kept.push(self.why_kept(name)?);
}
}
for file in files {
if !self.sources.iter().any(|s| matches!(s, Source::File(p) if p.starts_with(file))) {
kept.push(Kept::NoFile { file: file.clone() });
}
}
let next: Vec<Source> = self
.sources
.iter()
.filter(|s| match s {
Source::Line { name, .. } => !removed.contains(&name),
Source::File(p) => !files.iter().any(|f| p.starts_with(f)),
})
.cloned()
.collect();
if next.len() != self.sources.len() {
self.commit(next)?;
}
Ok(kept)
}
fn why_kept(&self, name: &str) -> Result<Kept, DeclareError> {
for source in &self.sources {
if let Source::File(path) = source
&& atoms_in_file(path)?.iter().any(|a| a.name == name)
{
return Ok(Kept::InFile { name: name.to_string(), file: path.clone() });
}
}
Ok(Kept::NoName { name: name.to_string() })
}
pub fn clear(&mut self) {
self.sources.clear();
self.set = ShapeSet::new();
}
pub fn atoms(&self) -> Result<Vec<Atom>, DeclareError> {
let mut out = Vec::new();
for source in &self.sources {
match source {
Source::Line { name, form: Some(form), definition, .. } => out.push(Atom {
name: name.clone(),
form: *form,
definition: definition.clone(),
file: None,
}),
Source::Line { form: None, .. } => {}
Source::File(path) => out.extend(atoms_in_file(path)?),
}
}
Ok(out)
}
#[must_use]
pub fn files(&self) -> Vec<PathBuf> {
self.sources
.iter()
.filter_map(|s| match s {
Source::File(path) => Some(path.clone()),
Source::Line { .. } => None,
})
.collect()
}
pub fn with_files(&self, files: &[PathBuf]) -> Result<Declarations, DeclareError> {
let mut out = self.clone();
if !files.is_empty() {
out.include(files)?;
}
Ok(out)
}
}
pub fn atoms_in_file(path: &Path) -> Result<Vec<Atom>, DeclareError> {
let bytes =
std::fs::read(path).map_err(|e| DeclareError::Read { file: path.to_path_buf(), reason: e.to_string() })?;
let text = String::from_utf8_lossy(&crate::encoding::decode(bytes)).into_owned();
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
let (keyword, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
let Some(form) = Form::of_keyword(keyword) else {
continue;
};
let (name, definition) = name_and_definition(rest);
out.push(Atom { name, form, definition, file: Some(path.to_path_buf()) });
}
Ok(out)
}
pub fn pattern_files<P: AsRef<Path>>(paths: &[P]) -> Result<Vec<PathBuf>, DeclareError> {
let mut out = Vec::new();
for given in paths {
let path = given.as_ref();
if path.is_dir() {
let mut found = Vec::new();
files_under(path, &mut found)?;
if found.is_empty() {
return Err(DeclareError::NoPatternFile { dir: path.to_path_buf() });
}
found.sort();
out.extend(found);
} else {
out.push(path.to_path_buf());
}
}
Ok(out)
}
fn files_under(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), DeclareError> {
let unread = |e: std::io::Error| DeclareError::ReadDir { dir: dir.to_path_buf(), reason: e.to_string() };
for entry in std::fs::read_dir(dir).map_err(unread)? {
let path = entry.map_err(unread)?.path();
if path.is_dir() {
files_under(&path, out)?;
} else if path.extension().is_some_and(|ext| ext == "trex") {
out.push(path);
}
}
Ok(())
}
fn name_and_definition(rest: &str) -> (String, String) {
let rest = rest.trim();
let (head, definition) = match rest.split_once('=') {
Some((head, definition)) => (head.trim(), definition.trim()),
None => (rest, ""),
};
let name = head.split_whitespace().next().unwrap_or(head);
(name.to_string(), definition.to_string())
}
fn quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('"');
out
}
fn one_line(what: &'static str, text: &str) -> Result<(), DeclareError> {
if text.contains(['\n', '\r']) {
return Err(DeclareError::LineBreak { what });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn atom<'a>(name: &'a str, form: Form, definition: &'a str) -> AtomDecl<'a> {
AtomDecl { name, form, definition, accepts: &[], rejects: &[] }
}
#[test]
fn a_declared_line_builds_the_set_and_lists_as_an_atom() {
let mut d = Declarations::new();
d.declare("shape ticket = `[A-Z]{2,4}-\\d{1,4}`", None).expect("a valid shape");
assert!(d.declares("ticket"));
let atoms = d.atoms().expect("no file to read");
assert_eq!(atoms.len(), 1);
assert_eq!((atoms[0].name.as_str(), atoms[0].form), ("ticket", Form::Shape));
let p = crate::parser::parse_with_shapes(r"\{ticket}", d.set()).expect("the shape is declared");
let spans = crate::scan_with_shapes(&p, b"see AB-12 now", d.set());
assert_eq!(spans.len(), 1);
}
#[test]
fn a_refused_change_leaves_the_declarations_as_they_were() {
let mut d = Declarations::new();
d.declare("let n = \\N", None).expect("a valid let");
let refused = d.declare("let bad = \\N{>", None);
assert!(matches!(refused, Err(DeclareError::Refused(_))), "{refused:?}");
assert_eq!(d.atoms().expect("no file").len(), 1);
assert!(matches!(d.declare("nonsense x = 1", None), Err(DeclareError::NoKeyword { .. })));
assert!(matches!(d.declare("let a = \\N\nlet b = \\W", None), Err(DeclareError::LineBreak { .. })));
}
#[test]
fn a_name_declared_twice_is_refused_unless_replaced() {
let mut d = Declarations::new();
d.add(&atom("n", Form::Let, "\\N"), None, false).expect("first declaration");
assert_eq!(
d.add(&atom("n", Form::Let, "\\W"), None, false),
Err(DeclareError::Exists { name: "n".to_string() })
);
d.add(&atom("n", Form::Let, "\\W"), None, true).expect("replaced");
let atoms = d.atoms().expect("no file");
assert_eq!(atoms.len(), 1);
assert_eq!(atoms[0].definition, "\\W");
assert_eq!(d.add(&atom("s", Form::Shape, "a`b"), None, false), Err(DeclareError::Backtick));
}
#[test]
fn expectations_become_a_test_line_the_set_runs() {
let mut d = Declarations::new();
let accepts = ["AB-12".to_string()];
let rejects = ["A-1".to_string()];
let ticket = AtomDecl {
name: "ticket",
form: Form::Shape,
definition: "[A-Z]{2,4}-\\d{1,4}",
accepts: &accepts,
rejects: &rejects,
};
d.add(&ticket, None, false).expect("a valid shape and test");
assert_eq!(d.set().tests().len(), 1);
assert!(d.set().run_tests().is_empty());
d.declare("test ticket accepts \"XYZ-9\"", None).expect("a valid test line");
assert_eq!(d.set().tests().len(), 2);
assert_eq!(d.atoms().expect("no file").len(), 1);
}
#[test]
fn removal_takes_names_and_files_out_together_and_says_what_stayed() {
let dir = std::env::temp_dir().join(format!("trex-declarations-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("make a folder");
let file = dir.join("defs.trex");
std::fs::write(&file, "let tag = \\W\nlet count = \\N\n").expect("write the file");
let mut d = Declarations::new();
d.include(std::slice::from_ref(&file)).expect("the file declares");
d.add(&atom("pair", Form::Let, "\\{tag} \\{count}"), None, false).expect("reads the file's atoms");
assert_eq!(d.files(), std::slice::from_ref(&file));
let names: Vec<String> = d.atoms().expect("the file reads").into_iter().map(|a| a.name).collect();
assert_eq!(names, ["tag", "count", "pair"]);
assert!(matches!(d.remove(&[], std::slice::from_ref(&file)), Err(DeclareError::Refused(_))));
assert_eq!(d.files(), std::slice::from_ref(&file));
let kept = d
.remove(
&["pair".to_string(), "tag".to_string(), "nope".to_string()],
&[file.clone(), dir.join("other.trex")],
)
.expect("pair and the file go together");
assert_eq!(
kept,
[
Kept::InFile { name: "tag".to_string(), file: file.clone() },
Kept::NoName { name: "nope".to_string() },
Kept::NoFile { file: dir.join("other.trex") },
]
);
assert!(d.atoms().expect("nothing to read").is_empty());
std::fs::remove_dir_all(&dir).expect("remove the folder");
}
#[test]
fn a_rule_declared_a_line_at_a_time_goes_with_the_lines_that_add_to_it() {
let mut d = Declarations::new();
d.declare(r#"rule num note "a number" = \N:n"#, None).expect("a rule line");
d.declare("fix num = <${n}>", None).expect("a fix line");
d.declare("meta num team = core", None).expect("a meta line");
d.declare("test num accepts \"7\"", None).expect("a test line");
let rule = &d.set().rules()[0];
assert_eq!(rule.fix.as_deref(), Some("<${n}>"));
assert_eq!(rule.meta, [("team".to_string(), "core".to_string())]);
assert_eq!(d.atoms().expect("no file").len(), 1);
assert!(matches!(d.declare("bogus num = 1", None), Err(DeclareError::NoKeyword { .. })));
assert!(d.remove(&["num".to_string()], &[]).expect("the rule goes with its lines").is_empty());
assert!(d.set().rules().is_empty());
assert!(d.set().tests().is_empty());
}
#[test]
fn a_directory_includes_and_removes_as_its_pattern_files() {
let dir = std::env::temp_dir().join(format!("trex-declarations-dir-{}", std::process::id()));
let nested = dir.join("more");
std::fs::create_dir_all(&nested).expect("make the folders");
std::fs::write(dir.join("b.trex"), "let second = \\W\n").expect("write b.trex");
std::fs::write(dir.join("a.trex"), "let first = \\N\n").expect("write a.trex");
std::fs::write(nested.join("a.trex"), "let third = \\N\n").expect("write more/a.trex");
std::fs::write(dir.join("notes.txt"), "let ignored = \\N\n").expect("write notes.txt");
let mut d = Declarations::new();
d.include(&[dir.clone(), dir.join("a.trex")]).expect("the directory declares");
assert_eq!(d.files(), [dir.join("a.trex"), dir.join("b.trex"), nested.join("a.trex")]);
let names: Vec<String> = d.atoms().expect("the files read").into_iter().map(|a| a.name).collect();
assert_eq!(names, ["first", "second", "third"]);
assert!(d.remove(&[], std::slice::from_ref(&nested)).expect("nothing reads third").is_empty());
assert_eq!(d.files(), [dir.join("a.trex"), dir.join("b.trex")]);
assert!(d.remove(&[], std::slice::from_ref(&dir)).expect("nothing reads them").is_empty());
assert!(d.files().is_empty());
assert_eq!(
d.remove(&[], std::slice::from_ref(&dir)).expect("nothing to build"),
[Kept::NoFile { file: dir.clone() }]
);
let empty = dir.join("empty");
std::fs::create_dir_all(&empty).expect("make the empty folder");
assert_eq!(d.include(std::slice::from_ref(&empty)), Err(DeclareError::NoPatternFile { dir: empty.clone() }));
std::fs::remove_dir_all(&dir).expect("remove the folders");
}
}