use std::{
collections::hash_map::{Entry, HashMap},
default::Default,
error::Error,
fmt,
str::Lines,
};
use regex::Regex;
const WILDCARD: &str = "...";
const ERROR_MARKER: &str = ">>";
#[derive(Debug)]
struct FMOptions {
name_matcher: Option<(Regex, Regex)>,
distinct_name_matching: bool,
ignore_leading_whitespace: bool,
ignore_trailing_whitespace: bool,
ignore_surrounding_blank_lines: bool,
}
impl Default for FMOptions {
fn default() -> Self {
FMOptions {
name_matcher: None,
distinct_name_matching: false,
ignore_leading_whitespace: true,
ignore_trailing_whitespace: true,
ignore_surrounding_blank_lines: true,
}
}
}
#[derive(Debug)]
pub struct FMBuilder<'a> {
ptn: &'a str,
options: FMOptions,
}
impl<'a> FMBuilder<'a> {
pub fn new(ptn: &'a str) -> Result<Self, Box<dyn Error>> {
Ok(FMBuilder {
ptn,
options: FMOptions::default(),
})
}
pub fn name_matcher(mut self, matcher: Option<(Regex, Regex)>) -> Self {
self.options.name_matcher = matcher;
self
}
pub fn distinct_name_matching(mut self, yes: bool) -> Self {
self.options.distinct_name_matching = yes;
self
}
pub fn ignore_leading_whitespace(mut self, yes: bool) -> Self {
self.options.ignore_leading_whitespace = yes;
self
}
pub fn ignore_trailing_whitespace(mut self, yes: bool) -> Self {
self.options.ignore_trailing_whitespace = yes;
self
}
pub fn ignore_surrounding_blank_lines(mut self, yes: bool) -> Self {
self.options.ignore_surrounding_blank_lines = yes;
self
}
pub fn build(self) -> Result<FMatcher<'a>, Box<dyn Error>> {
self.validate()?;
Ok(FMatcher {
ptn: self.ptn,
options: self.options,
})
}
fn validate(&self) -> Result<(), Box<dyn Error>> {
if let Some((ref ptn_re, _)) = self.options.name_matcher {
for (i, l) in self.ptn.lines().enumerate() {
let l = l.trim();
if (l.starts_with("...") || l.ends_with("...")) && ptn_re.is_match(l) {
return Err(Box::<dyn Error>::from(format!(
"Can't mix name matching with wildcards on line {}.",
i + 1
)));
}
}
}
Ok(())
}
}
#[derive(Debug)]
pub struct FMatcher<'a> {
ptn: &'a str,
options: FMOptions,
}
impl<'a> FMatcher<'a> {
pub fn new(ptn: &'a str) -> Result<FMatcher, Box<dyn Error>> {
FMBuilder::new(ptn)?.build()
}
pub fn matches(&self, text: &str) -> Result<(), FMatchError> {
let mut names = HashMap::new();
let mut ptn_lines = self.ptn.lines();
let (mut ptnl, mut ptn_lines_off) = self.skip_blank_lines(&mut ptn_lines, None);
ptn_lines_off += 1;
let mut text_lines = text.lines();
let (mut textl, mut text_lines_off) = self.skip_blank_lines(&mut text_lines, None);
text_lines_off += 1;
loop {
match (ptnl, textl) {
(Some(x), Some(y)) => {
if x.trim() == WILDCARD {
ptnl = ptn_lines.next();
ptn_lines_off += 1;
match ptnl {
Some(x) => {
while let Some(y) = textl {
text_lines_off += 1;
if self.match_line(&mut names, x, y) {
break;
}
textl = text_lines.next();
}
text_lines_off -= 1;
}
None => return Ok(()),
}
} else if self.match_line(&mut names, x, y) {
ptnl = ptn_lines.next();
ptn_lines_off += 1;
textl = text_lines.next();
text_lines_off += 1;
} else {
return Err(FMatchError {
ptn: self.ptn.to_owned(),
text: text.to_owned(),
ptn_line_off: ptn_lines_off,
text_line_off: text_lines_off,
});
}
}
(None, None) => return Ok(()),
(Some(x), None) => {
if x.trim() == WILDCARD {
while let Some(ptnl) = ptn_lines.next() {
ptn_lines_off += 1;
if !self.match_line(&mut names, ptnl, "") {
return Err(FMatchError {
ptn: self.ptn.to_owned(),
text: text.to_owned(),
ptn_line_off: ptn_lines_off,
text_line_off: text_lines_off,
});
}
}
return Ok(());
} else {
match self.skip_blank_lines(&mut ptn_lines, Some(x)) {
(Some(_), skipped) => {
return Err(FMatchError {
ptn: self.ptn.to_owned(),
text: text.to_owned(),
ptn_line_off: ptn_lines_off + skipped,
text_line_off: text_lines_off,
});
}
(None, _) => return Ok(()),
}
}
}
(None, Some(x)) => {
let (x, skipped) = self.skip_blank_lines(&mut text_lines, Some(x));
if x.is_none() {
return Ok(());
}
return Err(FMatchError {
ptn: self.ptn.to_owned(),
text: text.to_owned(),
ptn_line_off: ptn_lines_off,
text_line_off: text_lines_off + skipped,
});
}
}
}
}
#[allow(clippy::while_let_on_iterator)]
fn skip_blank_lines(
&self,
lines: &mut Lines<'a>,
line: Option<&'a str>,
) -> (Option<&'a str>, usize) {
if !self.options.ignore_surrounding_blank_lines {
if line.is_some() {
return (line, 0);
}
return (lines.next(), 0);
}
let mut trimmed = 0;
if let Some(l) = line {
if !l.trim().is_empty() {
return (Some(l), 0);
}
trimmed += 1;
}
while let Some(l) = lines.next() {
if !l.trim().is_empty() {
return (Some(l), trimmed);
}
trimmed += 1;
}
(None, trimmed)
}
fn match_line<'b>(
&self,
names: &mut HashMap<&'a str, &'b str>,
mut ptn: &'a str,
mut text: &'b str,
) -> bool {
if self.options.ignore_leading_whitespace {
ptn = ptn.trim_start();
text = text.trim_start();
}
if self.options.ignore_trailing_whitespace {
ptn = ptn.trim_end();
text = text.trim_end();
}
let sww = ptn.starts_with(WILDCARD);
let eww = ptn.ends_with(WILDCARD);
if sww && eww {
text.find(&ptn[WILDCARD.len()..ptn.len() - WILDCARD.len()])
.is_some()
} else if sww {
text.ends_with(&ptn[WILDCARD.len()..])
} else if eww {
text.starts_with(&ptn[..ptn.len() - WILDCARD.len()])
} else {
match self.options.name_matcher {
Some((ref ptn_re, ref text_re)) => {
while let Some(ptnm) = ptn_re.find(ptn) {
if ptnm.start() == ptnm.end() {
panic!("Name pattern matched the empty string.");
}
if ptn[..ptnm.start()] != text[..ptnm.start()] {
return false;
}
ptn = &ptn[ptnm.end()..];
text = &text[ptnm.start()..];
if let Some(textm) = text_re.find(text) {
if self.options.distinct_name_matching {
for (x, y) in names.iter() {
if x != &ptnm.as_str() && y == &textm.as_str() {
return false;
}
}
}
if textm.start() == textm.end() {
panic!("Text pattern matched the empty string.");
}
match names.entry(ptnm.as_str()) {
Entry::Occupied(e) => {
if e.get() != &textm.as_str() {
return false;
}
}
Entry::Vacant(e) => {
e.insert(textm.as_str());
}
}
text = &text[textm.end()..];
} else {
return false;
}
}
ptn == text
}
None => ptn == text,
}
}
}
}
pub struct FMatchError {
ptn: String,
text: String,
ptn_line_off: usize,
text_line_off: usize,
}
impl FMatchError {
pub fn ptn_line_off(&self) -> usize {
self.ptn_line_off
}
pub fn text_line_off(&self) -> usize {
self.text_line_off
}
}
impl fmt::Display for FMatchError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let max_line = usize::max(self.ptn_line_off, self.text_line_off);
let err_mk_chars = ERROR_MARKER.chars().count() + ' '.len_utf8();
let lno_chars = usize::max(err_mk_chars, format!("{}", max_line).len());
let display_lines =
|f: &mut fmt::Formatter, s: &str, lno_chars: usize, mark_line: usize| -> fmt::Result {
let mut i = 1;
for line in s.lines() {
if mark_line == i {
write!(
f,
"{} {}",
ERROR_MARKER,
" ".repeat(err_mk_chars - err_mk_chars)
)?;
} else {
write!(f, "{}", " ".repeat(lno_chars))?;
}
if line.is_empty() {
writeln!(f, "|")?;
} else {
writeln!(f, "|{}", line)?;
}
i += 1;
if mark_line == i - 1 {
break;
}
}
if mark_line == i {
writeln!(f, "{}", ERROR_MARKER)?;
}
Ok(())
};
writeln!(f, "Pattern (error at line {}):", self.ptn_line_off)?;
display_lines(f, &self.ptn, lno_chars, self.ptn_line_off)?;
writeln!(f, "\nText (error at line {}):", self.text_line_off)?;
display_lines(f, &self.text, lno_chars, self.text_line_off)
}
}
impl fmt::Debug for FMatchError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Failed to match at line {}", self.text_line_off)
}
}
impl Error for FMatchError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() {
fn helper(ptn: &str, text: &str) -> bool {
FMatcher::new(ptn).unwrap().matches(text).is_ok()
}
assert!(helper("", ""));
assert!(helper("\n", ""));
assert!(helper("", "\n"));
assert!(helper("a", "a"));
assert!(!helper("a", "ab"));
assert!(helper("...", ""));
assert!(helper("...", "a"));
assert!(helper("...", "a\nb"));
assert!(helper("...\na", "a"));
assert!(helper("...\na\n...", "a"));
assert!(helper("a\n...", "a"));
assert!(helper("a\n...\nd", "a\nd"));
assert!(helper("a\n...\nd", "a\nb\nc\nd"));
assert!(!helper("a\n...\nd", "a\nb\nc"));
assert!(helper("a\n...\nc\n...\ne", "a\nb\nc\nd\ne"));
assert!(helper("a\n...\n...b", "a\nb"));
assert!(helper("a\n...\nb...", "a\nb"));
assert!(helper("a\n...\nb...", "a\nbc"));
assert!(helper("a\nb...", "a\nbc"));
assert!(!helper("a\nb...", "a\nb\nc"));
assert!(helper("a\n...b...", "a\nb"));
assert!(helper("a\n...b...", "a\nxbz"));
assert!(helper("a\n...b...", "a\nbz"));
assert!(helper("a\n...b...", "a\nxb"));
assert!(!helper("a\n...b...", "a\nxb\nc"));
assert!(!helper("a", "a\nb"));
assert!(!helper("a\nb", "a"));
assert!(!helper("a\n...\nb", "a"));
assert!(helper("a\n", "a\n"));
assert!(helper("a\n", "a"));
assert!(helper("a", "a\n"));
assert!(helper("a\n\n", "a\n\n"));
assert!(helper("a\n\n", "a"));
assert!(helper("a", "a\n\n"));
assert!(!helper("a\n\nb", "a\n"));
assert!(!helper("a\n", "a\n\nb"));
}
#[test]
fn dont_ignore_surrounding_blank_lines() {
fn helper(ptn: &str, text: &str) -> bool {
FMBuilder::new(ptn)
.unwrap()
.ignore_surrounding_blank_lines(false)
.build()
.unwrap()
.matches(text)
.is_ok()
}
assert!(helper("", ""));
assert!(!helper("\n", ""));
assert!(!helper("", "\n"));
assert!(helper("a\n", "a\n"));
assert!(helper("a\n", "a"));
assert!(helper("a", "a\n"));
assert!(helper("a\n\n", "a\n\n"));
assert!(!helper("a\n\n", "a"));
assert!(!helper("a", "a\n\n"));
assert!(!helper("a\n\nb", "a\n"));
assert!(!helper("a\n", "a\n\nb"));
}
#[test]
fn name_matcher() {
let nameptn_re = Regex::new(r"\$.+?\b").unwrap();
let name_re = Regex::new(r".+?\b").unwrap();
let helper = |ptn: &str, text: &str| -> bool {
FMBuilder::new(ptn)
.unwrap()
.name_matcher(Some((nameptn_re.clone(), name_re.clone())))
.build()
.unwrap()
.matches(text)
.is_ok()
};
assert!(helper("", ""));
assert!(helper("a", "a"));
assert!(!helper("a", "ab"));
assert!(helper("...", ""));
assert!(helper("...", "a"));
assert!(helper("......", "a"));
assert!(!helper("......", ""));
assert!(helper("...", "a\nb"));
assert!(!helper("......", "a\nb"));
assert!(helper("...\na", "a"));
assert!(helper("...\na\n...", "a"));
assert!(helper("a\n...", "a"));
assert!(helper("a\n...\nd", "a\nd"));
assert!(helper("a\n...\nd", "a\nb\nc\nd"));
assert!(!helper("a\n...\nd", "a\nb\nc"));
assert!(helper("a\n...\nc\n...\ne", "a\nb\nc\nd\ne"));
assert!(helper("a\n...\n...b", "a\nb"));
assert!(helper("a\n...\nb...", "a\nb"));
assert!(helper("a\n...\nb...", "a\nbc"));
assert!(helper("a\nb...", "a\nbc"));
assert!(!helper("a\nb...", "a\nb\nc"));
assert!(helper("a\n...b...", "a\nb"));
assert!(helper("a\n...b...", "a\nxbz"));
assert!(helper("a\n...b...", "a\nbz"));
assert!(helper("a\n...b...", "a\nxb"));
assert!(!helper("a\n...b...", "a\nxb\nc"));
assert!(!helper("$1", ""));
assert!(helper("$1", "a"));
assert!(helper("$1, $1", "a, a"));
assert!(!helper("$1, $1", "a, b"));
assert!(helper("$1, a, $1", "a, a, a"));
assert!(!helper("$1, a, $1", "a, b, a"));
assert!(!helper("$1, a, $1", "a, a, b"));
assert!(helper("$1, $1, a", "a, a, a"));
assert!(!helper("$1, $1, a", "a, a, b"));
assert!(!helper("$1, $1, a", "a, b, a"));
}
#[test]
fn error_lines() {
let ptn_re = Regex::new("\\$.+?\\b").unwrap();
let text_re = Regex::new(".+?\\b").unwrap();
let helper = |ptn: &str, text: &str| -> (usize, usize) {
let err = FMBuilder::new(ptn)
.unwrap()
.name_matcher(Some((ptn_re.clone(), text_re.clone())))
.build()
.unwrap()
.matches(text)
.unwrap_err();
(err.ptn_line_off(), err.text_line_off())
};
assert_eq!(helper("a\n...\nd", "a\nb\nc"), (3, 3));
assert_eq!(helper("a\nb...", "a\nb\nc"), (3, 3));
assert_eq!(helper("a\n...b...", "a\nxb\nc"), (3, 3));
assert_eq!(helper("a\n\nb", "a\n"), (3, 2));
assert_eq!(helper("a\n", "a\n\nb"), (2, 3));
assert_eq!(helper("$1", ""), (1, 1));
assert_eq!(helper("$1, $1", "a, b"), (1, 1));
assert_eq!(helper("$1, a, $1", "a, b, a"), (1, 1));
assert_eq!(helper("$1, a, $1", "a, a, b"), (1, 1));
assert_eq!(helper("$1, $1, a", "a, a, b"), (1, 1));
assert_eq!(helper("$1, $1, a", "a, b, a"), (1, 1));
assert_eq!(helper("$1", ""), (1, 1));
assert_eq!(helper("$1\n$1", "a\nb"), (2, 2));
assert_eq!(helper("$1\na\n$1", "a\nb\na"), (2, 2));
assert_eq!(helper("$1\na\n$1", "a\na\nb"), (3, 3));
assert_eq!(helper("$1\n$1\na", "a\na\nb"), (3, 3));
assert_eq!(helper("$1\n$1\na", "a\nb\na"), (2, 2));
assert_eq!(helper("...\nb\nc\nd\n", "a\nb\nc\n0\ne"), (4, 4));
assert_eq!(helper("...\nc\nd\n", "a\nb\nc\n0\ne"), (3, 4));
assert_eq!(helper("...\nd\n", "a\nb\nc\n0\ne"), (2, 5));
}
#[test]
#[should_panic]
fn empty_name_pattern() {
let ptn_re = Regex::new("").unwrap();
let text_re = Regex::new(".+?\\b").unwrap();
FMBuilder::new("$1")
.unwrap()
.name_matcher(Some((ptn_re, text_re)))
.build()
.unwrap()
.matches("x")
.unwrap();
}
#[test]
#[should_panic]
fn empty_text_pattern() {
let ptn_re = Regex::new("\\$.+?\\b").unwrap();
let text_re = Regex::new("").unwrap();
FMBuilder::new("$1")
.unwrap()
.name_matcher(Some((ptn_re, text_re)))
.build()
.unwrap()
.matches("x")
.unwrap();
}
#[test]
fn wildcards_and_names() {
let ptn_re = Regex::new("\\$.+?\\b").unwrap();
let text_re = Regex::new("").unwrap();
let builder = FMBuilder::new("$1\n...$1abc")
.unwrap()
.name_matcher(Some((ptn_re, text_re)));
assert_eq!(
&(*(builder.build().unwrap_err())).to_string(),
"Can't mix name matching with wildcards on line 2."
);
}
#[test]
fn distinct_names() {
let nameptn_re = Regex::new(r"\$.+?\b").unwrap();
let name_re = Regex::new(r".+?\b").unwrap();
let helper = |ptn: &str, text: &str| -> bool {
FMBuilder::new(ptn)
.unwrap()
.name_matcher(Some((nameptn_re.clone(), name_re.clone())))
.distinct_name_matching(true)
.build()
.unwrap()
.matches(text)
.is_ok()
};
assert!(helper("$1 $1", "a a"));
assert!(!helper("$1 $1", "a b"));
assert!(!helper("$1 $2", "a a"));
}
#[test]
fn error_display() {
let ptn_re = Regex::new("\\$.+?\\b").unwrap();
let text_re = Regex::new(".+?\\b").unwrap();
let helper = |ptn: &str, text: &str| -> String {
let err = FMBuilder::new(ptn)
.unwrap()
.name_matcher(Some((ptn_re.clone(), text_re.clone())))
.build()
.unwrap()
.matches(text)
.unwrap_err();
format!("{}", err)
};
assert_eq!(
helper("a\nb\nc\nd\n", "a\nb\nc\nz\nd\n"),
"Pattern (error at line 4):
|a
|b
|c
>> |d
Text (error at line 4):
|a
|b
|c
>> |z
"
);
assert_eq!(
helper("a\n", "a\n\nb"),
"Pattern (error at line 2):
|a
>>
Text (error at line 3):
|a
|
>> |b
"
);
}
#[test]
fn test_allow_whitespace() {
let helper = |ptn: &str, text: &str| -> bool {
FMBuilder::new(ptn)
.unwrap()
.ignore_leading_whitespace(false)
.ignore_trailing_whitespace(false)
.build()
.unwrap()
.matches(text)
.is_ok()
};
assert!(helper("a\na", "a\na"));
assert!(helper("a\n a", "a\n a"));
assert!(!helper("a\n a", "a\na"));
assert!(!helper("a\na", "a\n a"));
assert!(helper("a\na ", "a\na "));
assert!(!helper("a\na", "a\na "));
assert!(!helper("a\na ", "a\na"));
}
}