mod tools;
use super::{ClassifyError, Sink};
use crate::action::AtomicAction;
use crate::packages::{self, Ecosystem};
use tools::Tool;
pub(crate) const NEAR_MISS: &str = "@package-near-miss";
const MAX_NAMES: usize = 64;
pub(super) fn note(argv: &[String], program: &str, sink: &mut Sink) -> Result<(), ClassifyError> {
for argv in near_misses(argv, program) {
sink.push(AtomicAction::Shell { argv })?;
}
Ok(())
}
#[must_use]
pub fn near_miss_atoms(command: &str) -> Vec<String> {
let cwd = [Some("/".to_owned())];
let ctx = super::ShellContext {
home: "/",
cwd: &cwd,
};
let (atoms, _) = super::classify_globs(command, &ctx).0.into_parts();
let mut lines: Vec<String> = Vec::new();
for atom in atoms {
if let AtomicAction::Shell { argv } = atom
&& argv.first().is_some_and(|tag| tag == NEAR_MISS)
{
let line = argv.join(" ");
if !lines.contains(&line) {
lines.push(line);
}
}
}
lines
}
fn near_misses(argv: &[String], program: &str) -> Vec<Vec<String>> {
let Some((ecosystem, names)) = requested(argv, program) else {
return Vec::new();
};
names
.into_iter()
.take(MAX_NAMES)
.filter_map(|name| {
let popular = packages::near_miss(ecosystem, &name)?;
Some(vec![
NEAR_MISS.to_owned(),
ecosystem.id().to_owned(),
name,
popular.to_owned(),
])
})
.collect()
}
pub(crate) fn requested(argv: &[String], program: &str) -> Option<(Ecosystem, Vec<String>)> {
let (tool, operands) = tools::find(argv, program)?;
let rest = tool
.subcommands
.iter()
.find_map(|words| after_subcommand(operands, words, tool))?;
let mut names = Vec::new();
let mut options_ended = false;
let mut skip_value = false;
for arg in rest {
if skip_value {
skip_value = false;
continue;
}
if !options_ended && arg == "--" {
options_ended = true;
continue;
}
if !options_ended && arg.starts_with('-') && arg.len() > 1 {
let flag = arg.split_once('=').map_or(arg.as_str(), |(f, _)| f);
if tool.foreign.contains(&flag) {
return Some((tool.ecosystem, Vec::new()));
}
skip_value = !arg.contains('=') && tool.values.contains(&flag);
continue;
}
if let Some(name) = clean(tool.ecosystem, arg)
&& !names.contains(&name)
{
names.push(name);
}
}
Some((tool.ecosystem, names))
}
fn after_subcommand<'a>(args: &'a [String], words: &[&str], tool: &Tool) -> Option<&'a [String]> {
let mut matched = 0;
let mut skip_value = false;
for (i, arg) in args.iter().enumerate() {
if skip_value {
skip_value = false;
continue;
}
if (arg.starts_with('-') && arg.len() > 1) || arg.starts_with('+') {
skip_value = !arg.contains('=') && tool.values.contains(&arg.as_str());
continue;
}
if words.get(matched) != Some(&arg.as_str()) {
return None;
}
matched += 1;
if matched == words.len() {
return Some(&args[i + 1..]);
}
}
None
}
fn clean(ecosystem: Ecosystem, word: &str) -> Option<String> {
if word.contains("://") || word.starts_with(['.', '/', '~', '\\']) || is_archive(word) {
return None;
}
let name = match ecosystem {
Ecosystem::Npm => npm_name(word)?,
Ecosystem::Pypi => {
if word.contains(['/', '\\']) || word.starts_with("git+") {
return None;
}
let end = word
.find(['[', '=', '<', '>', '!', '~', ';', '@', ' ', '(', ','])
.unwrap_or(word.len());
word[..end].trim().to_owned()
}
Ecosystem::Crates => word.split('@').next()?.to_owned(),
Ecosystem::Rubygems => {
if word.contains('/') {
return None;
}
word.split(':').next()?.to_owned()
}
Ecosystem::Go => {
let module = word.split('@').next()?;
let host = module.split('/').next()?;
if !host.contains('.') || module.contains("...") {
return None;
}
module.to_owned()
}
};
let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '@');
let well_formed = !name.is_empty()
&& name.chars().all(allowed)
&& name.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '@')
&& name.split('/').all(|part| !part.is_empty());
well_formed.then_some(name)
}
fn npm_name(word: &str) -> Option<String> {
let word = word.split_once("@npm:").map_or(word, |(_, real)| real);
if word.contains(':') {
return None;
}
if let Some(scoped) = word.strip_prefix('@') {
let name = scoped.split('@').next()?;
return (name.matches('/').count() == 1).then(|| format!("@{name}"));
}
let name = word.split('@').next()?;
(!name.contains('/')).then(|| name.to_owned())
}
fn is_archive(word: &str) -> bool {
let lower = word.to_ascii_lowercase();
[
".whl", ".zip", ".tar.gz", ".tgz", ".tar", ".tar.bz2", ".gem", ".crate",
]
.iter()
.any(|ext| lower.ends_with(ext))
}