use anyhow::{Result, anyhow};
use regex::{Regex, RegexBuilder};
use std::collections::HashSet;
use std::io::BufRead;
const GIT_SCISSORS: &str = "# ------------------------ >8 ------------------------";
lazy_static! {
static ref TOKEN_RE: Regex = RegexBuilder::new(
r"
(
\w | # letters .. or:
[ \\ + . / : = ? @ _ ~ ' - ] # all possible chars found in an URL, an email,
# or a word with an apostrophe (like doesn't)
)+
"
).ignore_whitespace(true).build().expect("syntax error in static regex");
static ref ABBREV_RE: Regex = RegexBuilder::new(
r"
^
(\p{Lu}+) # Some upper case letters
\p{Lu} # An uppercase letter
\p{Ll} # A lower case letter
"
)
.ignore_whitespace(true).build().expect("syntax error in static regex");
static ref CONSTANT_RE: Regex = RegexBuilder::new(
r"
# Only uppercase letters, except maybe a 's' at the end
^(\p{Lu}+) s ?$
"
)
.ignore_whitespace(true).build().expect("syntax error in static regex");
static ref HEXA_RE: Regex = RegexBuilder::new(
r"
# Only letter a to f and numbers, at list 5 in size
[a-f0-9]{5,}
"
).ignore_whitespace(true).build().expect("syntax error in static regex");
static ref IDENT_RE_DEFAULT: Regex = RegexBuilder::new(
r"
# A word is just a bunch of unicode characters matching
# the Alphabetic group, possibly inside space escapes like
# \n or \t, and possibly containing exactly one apostrophe
(\\[nrt])*
(
\p{Alphabetic}+ ' \p{Alphabetic}+ | (\p{Alphabetic}+)
)
(\\[nrt])*
"
).ignore_whitespace(true).build().expect("syntax error in static regex");
static ref IDENT_RE_LATEX: Regex = RegexBuilder::new(
r"
# Same as IDENT_RE, without handling \n, \r or \t
\p{Alphabetic}+ ' \p{Alphabetic}+ | (\p{Alphabetic}+)
"
).ignore_whitespace(true).build().expect("syntax error in static regex");
}
#[rustfmt::skip]
const PYTHON_STRING_PREFIXES: [&str; 24] = [
"r'", "u'", "R'", "U'", "f'", "F'",
"fr'", "Fr'", "fR'", "FR'", "rf'", "rF'", "Rf'", "RF'",
"b'", "B'", "br'", "Br'", "bR'", "BR'", "rb'", "rB'", "Rb'", "RB'",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractMode {
Default,
Latex,
Python,
}
impl ExtractMode {
fn from_extension(extension: &str) -> Self {
match extension {
"tex" => ExtractMode::Latex,
"py" => ExtractMode::Python,
_ => ExtractMode::Default,
}
}
}
struct Tokenizer<'input, 'skipped> {
input: &'input str,
pos: usize,
extract_mode: ExtractMode,
skipped: &'skipped HashSet<String>,
}
impl<'input, 'skipped> Tokenizer<'input, 'skipped> {
fn new(
input: &'input str,
extract_mode: ExtractMode,
skipped: &'skipped HashSet<String>,
) -> Self {
Self {
input,
pos: 0,
extract_mode,
skipped,
}
}
fn extract_word(&self, token: &'input str) -> Option<(&'input str, usize)> {
if token == "s" {
return None;
}
if token.contains("://") {
return None;
}
if token.contains('@') {
return None;
}
if HEXA_RE.find(token).is_some() {
return None;
}
let (captures, index) = match self.extract_mode {
ExtractMode::Latex => (IDENT_RE_LATEX.captures(token), 0),
ExtractMode::Default | ExtractMode::Python => (IDENT_RE_DEFAULT.captures(token), 2),
};
let captures = captures?;
let ident_match = captures.get(index).expect("index should match captures");
let ident = ident_match.as_str();
let pos = ident_match.start();
if self.extract_mode == ExtractMode::Python {
let prefix = self.get_python_string_prefix(token);
if let Some(p) = prefix {
let ident = ident.get(p.len()..);
if let Some(i) = ident {
return self.word_from_ident(i, p.len());
}
}
}
self.word_from_ident(ident, pos)
}
fn word_from_ident(&self, ident: &'input str, pos: usize) -> Option<(&'input str, usize)> {
let mut iter = ident.char_indices();
let (_, first_char) = iter.next().expect("empty ident");
if first_char.is_lowercase() {
if let Some(p) = ident.find(char::is_uppercase) {
return Some((&ident[..p], pos));
}
}
if first_char.is_uppercase() {
if let Some(captures) = CONSTANT_RE.captures(ident) {
let res = captures
.get(1)
.expect("CONSTANTS_RE always captures one group")
.as_str();
return Some((res, pos));
}
if let Some(captures) = ABBREV_RE.captures(ident) {
let res = captures
.get(1)
.expect("CONSTANTS_RE always captures one group")
.as_str();
return Some((res, pos));
}
let (second_pos, _) = match iter.next() {
None => return Some((ident, pos)),
Some(x) => x,
};
if let Some(next_upper) = ident[second_pos..].find(char::is_uppercase) {
let res = &ident[..next_upper + second_pos];
return Some((res, pos));
}
}
Some((ident, pos))
}
fn get_python_string_prefix(&self, token: &str) -> Option<&str> {
PYTHON_STRING_PREFIXES
.into_iter()
.find(|&prefix| token.starts_with(prefix))
}
}
impl<'input> Iterator for Tokenizer<'input, '_> {
type Item = (&'input str, usize);
fn next(&mut self) -> Option<<Self as Iterator>::Item> {
loop {
let captures = TOKEN_RE.captures(&self.input[self.pos..])?;
let token_match = captures.get(0).expect("TOKEN_RE contains one capture");
let token = token_match.as_str();
let start = token_match.range().start;
if self.skipped.contains(token) {
self.pos += start + token.len();
continue;
}
let next_word = self.extract_word(token);
if let Some((w, pos)) = next_word {
let res = (w, self.pos + start + pos);
self.pos += start + pos + w.len();
return Some(res);
} else {
self.pos += start + token.len();
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Position {
pub line: usize,
pub column: usize,
}
pub struct Token {
pub text: String,
pub position: Position,
}
impl Token {
pub(crate) fn new(text: &str, position: Position) -> Self {
Self {
text: text.to_string(),
position,
}
}
fn cloned(&self) -> Self {
Self {
text: self.text.to_string(),
position: self.position,
}
}
}
pub struct TokenProcessor<R: BufRead> {
reader: R,
file_name: String,
current_line: String,
current_tokens: Vec<Token>,
extract_mode: ExtractMode,
word_index: usize,
line_index: usize,
skipped_tokens: HashSet<String>,
is_git_message: bool,
}
impl<R: BufRead> TokenProcessor<R> {
pub fn new(reader: R, file_name: &str) -> Self {
let is_git_message = file_name == "COMMIT_EDITMSG";
let extension = file_name.rsplit(".").next().unwrap_or_default();
let extract_mode = ExtractMode::from_extension(extension);
Self {
reader,
file_name: file_name.to_owned(),
extract_mode,
current_line: String::new(),
current_tokens: Vec::new(),
word_index: 0,
line_index: 0,
skipped_tokens: HashSet::new(),
is_git_message,
}
}
pub fn skip_tokens(&mut self, tokens: &[String]) {
for token in tokens {
self.skipped_tokens.insert(token.to_string());
}
}
fn read_next_line(&mut self) -> Result<bool> {
self.current_line.clear();
self.line_index += 1;
let bytes_read = self.reader.read_line(&mut self.current_line);
match bytes_read {
Err(read_error) => Err(anyhow!(
"Error when reading: '{}': {read_error}",
self.file_name,
)),
Ok(n) => Ok(n == 0),
}
}
fn on_last_token(&mut self) -> Result<bool> {
let is_end_of_file = self.read_next_line()?;
if is_end_of_file {
return Ok(true);
}
if self.is_git_message && self.current_line.trim() == GIT_SCISSORS {
return Ok(true);
}
self.extract_tokens();
Ok(false)
}
fn extract_tokens(&mut self) {
self.word_index = 0;
let tokenizer = Tokenizer::new(&self.current_line, self.extract_mode, &self.skipped_tokens);
self.current_tokens = tokenizer
.map(|(token, column)| {
let position = Position {
line: self.line_index,
column,
};
Token::new(token, position)
})
.collect();
}
}
impl<R: BufRead> Iterator for TokenProcessor<R> {
type Item = Result<Token>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let next_token = self.current_tokens.get(self.word_index);
match next_token {
None => {
let on_last_token = self.on_last_token();
match on_last_token {
Err(e) => return Some(Err(e)),
Ok(is_end_of_file) => {
if is_end_of_file {
return None;
}
}
}
}
Some(token) => {
let token = token.cloned();
self.word_index += 1;
return Some(Ok(token));
}
}
}
}
}
#[cfg(test)]
mod tests;