use super::jev_patterns::Patterns;
use crate::eval::redact::is_identity_trailer;
const PHABRICATOR_USERNAME_TRAILERS: &[&str] =
&["Reviewers", "Reviewed By", "Subscribers", "Auditors"];
const ROLE_WORDS: &[&str] = &["reviewer", "author", "owner", "assignee", "approver"];
const LINK_WORDS: &[&str] = &["by", "with", "to"];
fn token_role(token: &str) -> (bool, bool, bool) {
let words: Vec<String> = token
.split(|c: char| c.is_whitespace() || c == '-')
.filter(|w| !w.is_empty())
.map(str::to_ascii_lowercase)
.collect();
if words.is_empty()
|| words.len() > 4
|| !words
.iter()
.all(|w| w.bytes().all(|b| b.is_ascii_alphabetic()))
{
return (false, false, false);
}
let last = words[words.len() - 1].as_str();
if words.len() >= 2 && LINK_WORDS.contains(&last) {
return (true, false, last != "by");
}
let singular = last.strip_suffix('s').unwrap_or(last);
if words.len() <= 2 && ROLE_WORDS.contains(&singular) {
return (true, singular != last, true);
}
(false, false, false)
}
fn learnable(name: &str) -> bool {
let words: Vec<&str> = name.split_whitespace().collect();
let name_word = |w: &&str| {
w.starts_with(|c: char| c.is_uppercase())
&& w.chars()
.all(|c| c.is_alphabetic() || matches!(c, '\'' | '\u{2019}' | '-'))
};
let named = (1..=4).contains(&words.len()) && words.iter().all(name_word);
let login = words.len() == 1
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
&& name
.chars()
.any(|c| c.is_ascii_digit() || matches!(c, '.' | '_' | '-'));
named || login
}
pub(super) struct Trailer<'a> {
pub(super) prefix: &'a str,
pub(super) token: &'a str,
pub(super) value: &'a str,
pub(super) usernames: bool,
pub(super) weak: bool,
}
pub(super) enum Role<'a> {
Trailer(Trailer<'a>),
Continuation {
indent: &'a str,
value: &'a str,
usernames: bool,
weak: bool,
},
Text,
}
fn prefix_len(line: &str) -> usize {
let mut rest = line;
loop {
let trimmed = rest.trim_start();
let mut chars = trimmed.chars();
let consumed = match chars.next() {
Some('>') => Some(1),
Some(c @ ('*' | '-' | '+' | '•')) if chars.next().is_some_and(char::is_whitespace) => {
Some(c.len_utf8())
}
_ => None,
};
match consumed {
Some(n) => rest = &trimmed[n..],
None => return line.len() - trimmed.len(),
}
}
}
fn indent_len(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn trailer_shaped(s: &str) -> bool {
s.split_once(':').is_some_and(|(t, v)| {
!t.is_empty()
&& t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
&& v.starts_with(char::is_whitespace)
})
}
pub(super) fn parse<'a>(p: &Patterns, line: &'a str) -> Option<Trailer<'a>> {
let cut = prefix_len(line);
let rest = &line[cut..];
let (token, value) = rest.split_once(':')?;
let words = token.split_whitespace().collect::<Vec<_>>().join(" ");
let is = |list: &[&str]| list.iter().any(|t| t.eq_ignore_ascii_case(&words));
let (role, plural, weak_role) = token_role(token);
let phabricator = is(PHABRICATOR_USERNAME_TRAILERS);
let usernames = phabricator || plural;
let t = token.trim_end();
let shaped = t.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
let spaced = value.starts_with(char::is_whitespace);
let listed = is_identity_trailer(rest) || phabricator;
let identity = listed || usernames || role || (shaped && spaced && p.email.is_match(value));
identity.then_some(Trailer {
prefix: &line[..cut],
token,
value,
usernames,
weak: weak_role && !listed,
})
}
pub(super) fn scan<'a>(p: &Patterns, text: &'a str) -> Vec<Role<'a>> {
let mut open: Option<(usize, bool, bool, bool)> = None;
let continues = |v: &str| v.trim_end().ends_with([',', ';']);
text.split('\n')
.map(|line| {
if let Some(t) = parse(p, line) {
open = Some((indent_len(line), t.usernames, continues(t.value), t.weak));
return Role::Trailer(t);
}
let lead = indent_len(line);
let body = &line[lead..];
match open {
Some((ind, usernames, more, weak))
if lead > ind
&& !body.trim().is_empty()
&& !trailer_shaped(body)
&& (usernames || more || p.email.is_match(body)) =>
{
open = Some((ind, usernames, continues(body), weak));
Role::Continuation {
indent: &line[..lead],
value: body,
usernames,
weak,
}
}
_ => {
open = None;
Role::Text
}
}
})
.collect()
}
pub(super) fn items(value: &str, usernames: bool) -> Vec<&str> {
value
.split(|c: char| c == ',' || c == ';' || (usernames && c.is_whitespace()))
.filter(|i| !i.trim().is_empty())
.collect()
}
pub(super) fn strip_brackets(name: &str) -> String {
bracket_split(name).0
}
fn bracket_split(s: &str) -> (String, Vec<String>) {
let mut depth = 0_usize;
let (mut outside, mut inside, mut cur) = (String::new(), Vec::new(), String::new());
for c in s.chars() {
match c {
'(' | '[' => depth += 1,
')' | ']' => {
depth = depth.saturating_sub(1);
if depth == 0 && !cur.is_empty() {
inside.push(std::mem::take(&mut cur));
}
}
_ if depth == 0 => outside.push(c),
_ => cur.push(c),
}
}
if !cur.is_empty() {
inside.push(cur);
}
(outside, inside)
}
pub(super) fn item_names(p: &Patterns, item: &str) -> Vec<String> {
let bare = p.email.replace_all(item, "").replace(['<', '>'], "");
let (outside, inside) = bracket_split(&bare);
std::iter::once(outside)
.chain(inside)
.map(|n| {
n.trim()
.trim_matches(|c: char| !c.is_alphanumeric())
.to_string()
})
.filter(|n| !n.is_empty() && n.split_whitespace().count() <= 4)
.collect()
}
pub(crate) fn trailer_names<'a>(
messages: impl IntoIterator<Item = &'a str>,
) -> Result<std::collections::BTreeSet<String>, super::jev_error::JevError> {
let p = super::jev_patterns::patterns()?;
Ok(messages.into_iter().flat_map(|m| names_in(p, m)).collect())
}
pub(super) fn names_in(p: &Patterns, text: &str) -> Vec<String> {
scan(p, text)
.into_iter()
.filter_map(|role| match role {
Role::Trailer(t) => Some((t.value, t.usernames, t.weak)),
Role::Continuation {
value,
usernames,
weak,
..
} => Some((value, usernames, weak)),
Role::Text => None,
})
.flat_map(|(value, usernames, weak)| {
let locals = p
.email
.find_iter(value)
.filter_map(|m| m.as_str().split('@').next().map(str::to_string));
items(value, usernames)
.into_iter()
.flat_map(|i| item_names(p, i))
.filter(|n| !weak || learnable(n))
.chain(locals)
.collect::<Vec<_>>()
})
.collect()
}