use std::collections::HashSet;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ecosystem {
Npm,
Pypi,
Crates,
Rubygems,
Go,
}
const MIN_LEN: usize = 5;
impl Ecosystem {
#[must_use]
pub fn id(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Pypi => "pypi",
Self::Crates => "crates",
Self::Rubygems => "rubygems",
Self::Go => "go",
}
}
fn index(self) -> usize {
match self {
Self::Npm => 0,
Self::Pypi => 1,
Self::Crates => 2,
Self::Rubygems => 3,
Self::Go => 4,
}
}
fn list(self) -> &'static str {
match self {
Self::Npm => include_str!("../data/top-packages/npm.txt"),
Self::Pypi => include_str!("../data/top-packages/pypi.txt"),
Self::Crates => include_str!("../data/top-packages/crates.txt"),
Self::Rubygems => include_str!("../data/top-packages/rubygems.txt"),
Self::Go => include_str!("../data/top-packages/go.txt"),
}
}
fn affixes(self) -> (&'static [&'static str], &'static [&'static str]) {
match self {
Self::Npm => (
&["node-", "nodejs-", "js-"],
&["-js", ".js", "js", "-node", "-nodejs"],
),
Self::Pypi => (&["python-", "python3-", "py-", "py"], &["-python", "-py"]),
Self::Crates => (&["rust-", "rs-"], &["-rs", "-rust"]),
Self::Rubygems => (&["ruby-"], &["-ruby", "-rb", ".rb"]),
Self::Go => (&[], &[]),
}
}
#[must_use]
pub fn normalise(self, name: &str) -> String {
let lower = name.to_ascii_lowercase();
match self {
Self::Pypi => {
let mut out = String::with_capacity(lower.len());
for c in lower.chars() {
if matches!(c, '-' | '_' | '.') {
if !out.ends_with('-') {
out.push('-');
}
} else {
out.push(c);
}
}
out
}
Self::Crates => lower.replace('_', "-"),
Self::Go => match lower.rsplit_once("/v") {
Some((module, major))
if !major.is_empty() && major.bytes().all(|b| b.is_ascii_digit()) =>
{
module.to_owned()
}
_ => lower,
},
Self::Npm | Self::Rubygems => lower,
}
}
}
struct Top {
names: Vec<(String, &'static str)>,
known: HashSet<String>,
}
impl Top {
fn load(ecosystem: Ecosystem) -> Self {
let names: Vec<(String, &'static str)> = ecosystem
.list()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| (ecosystem.normalise(l), l))
.collect();
let known = names.iter().map(|(n, _)| n.clone()).collect();
Self { names, known }
}
}
static TOP: LazyLock<[Top; 5]> = LazyLock::new(|| {
[
Ecosystem::Npm,
Ecosystem::Pypi,
Ecosystem::Crates,
Ecosystem::Rubygems,
Ecosystem::Go,
]
.map(Top::load)
});
#[cfg(test)]
fn list_len(ecosystem: Ecosystem) -> usize {
TOP[ecosystem.index()].names.len()
}
#[must_use]
pub fn near_miss(ecosystem: Ecosystem, name: &str) -> Option<&'static str> {
let top = &TOP[ecosystem.index()];
let key = ecosystem.normalise(name);
if !key.is_ascii() || top.known.contains(&key) {
return None;
}
let (prefixes, suffixes) = ecosystem.affixes();
let stripped = without_separators(&key);
let collapsed = collapse_runs(&key);
top.names
.iter()
.filter(|(popular, _)| popular.len() >= MIN_LEN)
.find(|(popular, _)| {
(stripped.len() >= MIN_LEN && stripped == without_separators(popular))
|| (collapsed.len() >= MIN_LEN && collapsed == collapse_runs(popular))
|| one_edit(&key, popular)
|| affixed(&key, popular, prefixes, suffixes)
|| (key.len() >= MIN_LEN && affixed(popular, &key, prefixes, suffixes))
})
.map(|(_, written)| *written)
}
fn without_separators(name: &str) -> String {
name.chars()
.filter(|c| !matches!(c, '-' | '_' | '.'))
.collect()
}
fn collapse_runs(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if !out.ends_with(c) {
out.push(c);
}
}
out
}
fn affixed(long: &str, base: &str, prefixes: &[&str], suffixes: &[&str]) -> bool {
prefixes
.iter()
.any(|p| long.strip_prefix(p).is_some_and(|rest| rest == base))
|| suffixes
.iter()
.any(|s| long.strip_suffix(s).is_some_and(|rest| rest == base))
}
fn one_edit(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a == b || a.len().abs_diff(b.len()) > 1 {
return false;
}
let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (a, b) = (&a[prefix..], &b[prefix..]);
match (a.len(), b.len()) {
(n, m) if n == m => {
a[1..] == b[1..] || (n >= 2 && a[0] == b[1] && a[1] == b[0] && a[2..] == b[2..])
}
(n, m) if n > m => a[1..] == *b,
_ => *a == b[1..],
}
}
#[cfg(test)]
mod tests;