use std::{ffi::OsString, num::NonZeroUsize, sync::Arc};
use camino::{Utf8Path, Utf8PathBuf};
use minijinja::{
Environment, Error, ErrorKind,
value::{Kwargs, Value},
};
mod cache;
mod env;
mod lookup;
mod options;
mod resolve_error;
pub(crate) use lookup::{WORKSPACE_SKIP_DIRS, WorkspaceSkipList};
pub(crate) use cache::WhichResolver;
pub(crate) use options::WhichOptions;
use crate::localization::{self, LocalizedMessage, keys};
use resolve_error::ResolveError;
const NOT_FOUND_CODE: &str = "netsuke::jinja::which::not_found";
#[derive(Clone, Debug)]
pub(crate) struct WhichConfig {
pub(crate) cwd_override: Option<Arc<Utf8PathBuf>>,
pub(crate) path_override: Option<OsString>,
pub(crate) workspace_skips: WorkspaceSkipList,
pub(crate) cache_capacity: NonZeroUsize,
}
impl WhichConfig {
pub(crate) const fn new(
cwd_override: Option<Arc<Utf8PathBuf>>,
path_override: Option<OsString>,
workspace_skips: WorkspaceSkipList,
cache_capacity: NonZeroUsize,
) -> Self {
Self {
cwd_override,
path_override,
workspace_skips,
cache_capacity,
}
}
}
pub(crate) fn register(env: &mut Environment<'_>, config: WhichConfig) {
let resolver = Arc::new(WhichResolver::new(
config.cwd_override,
config.path_override,
config.workspace_skips,
config.cache_capacity,
));
{
let filter_resolver = Arc::clone(&resolver);
env.add_filter("which", move |value: Value, kwargs: Kwargs| {
resolve_with(&filter_resolver, &value, &kwargs).and_then(|output| {
kwargs.assert_all_used()?;
Ok(output)
})
});
}
{
let function_resolver = Arc::clone(&resolver);
env.add_function("which", move |value: Value, kwargs: Kwargs| {
resolve_with(&function_resolver, &value, &kwargs).and_then(|output| {
kwargs.assert_all_used()?;
Ok(output)
})
});
}
{
let predicate_resolver = Arc::clone(&resolver);
env.add_function("command_available", move |value: Value, kwargs: Kwargs| {
command_available_with(&predicate_resolver, &value, &kwargs).and_then(|output| {
kwargs.assert_all_used()?;
Ok(output)
})
});
}
}
fn resolve_command_name_and_options<'v>(
command: &'v Value,
kwargs: &Kwargs,
) -> Result<(&'v str, WhichOptions), Error> {
let name = command
.as_str()
.map(str::trim)
.filter(|candidate| !candidate.is_empty())
.ok_or_else(|| {
Error::from(ResolveError::args(localization::message(
keys::STDLIB_WHICH_COMMAND_EMPTY,
)))
})?;
let options = WhichOptions::from_kwargs(kwargs).map_err(Error::from)?;
Ok((name, options))
}
fn resolve_with(
resolver: &WhichResolver,
command: &Value,
kwargs: &Kwargs,
) -> Result<Value, Error> {
let (name, options) = resolve_command_name_and_options(command, kwargs)?;
let matches = resolver.resolve(name, &options).map_err(Error::from)?;
Ok(render_value(&matches, &options))
}
fn command_available_with(
resolver: &WhichResolver,
command: &Value,
kwargs: &Kwargs,
) -> Result<Value, Error> {
let (name, options) = resolve_command_name_and_options(command, kwargs)?;
kwargs.assert_all_used()?;
is_command_available(resolver.resolve(name, &options))
.map(Value::from)
.map_err(Error::from)
}
pub(super) fn is_command_available(
result: Result<Vec<Utf8PathBuf>, ResolveError>,
) -> Result<bool, ResolveError> {
match result {
Ok(matches) => Ok(!matches.is_empty()),
Err(ResolveError::NotFound { .. } | ResolveError::DirectNotFound { .. }) => Ok(false),
Err(err) => Err(err),
}
}
fn render_value(matches: &[Utf8PathBuf], options: &WhichOptions) -> Value {
if options.all {
let rendered: Vec<String> = matches
.iter()
.map(|path| format_path_for_output(path))
.collect();
Value::from_serialize(rendered)
} else {
let first = matches
.first()
.map_or_else(String::new, |path| format_path_for_output(path));
Value::from(first)
}
}
impl From<ResolveError> for Error {
fn from(value: ResolveError) -> Self {
let (kind, message) = resolve_error_parts(&value);
Self::new(kind, message)
}
}
fn resolve_error_parts(error: &ResolveError) -> (ErrorKind, String) {
match error {
ResolveError::NotFound {
command,
dirs,
cwd_mode,
} => (
ErrorKind::InvalidOperation,
with_not_found_code(¬_found_message(command, dirs, *cwd_mode)),
),
ResolveError::DirectNotFound { command, path } => (
ErrorKind::InvalidOperation,
with_not_found_code(&direct_not_found_message(command, path)),
),
ResolveError::Args { detail } => (ErrorKind::InvalidOperation, args_message(detail)),
ResolveError::Canonicalize { path, source } => (
ErrorKind::InvalidOperation,
canonicalize_message(path, source),
),
ResolveError::IsExecutable { path, source } => (
ErrorKind::InvalidOperation,
is_executable_message(path, source),
),
ResolveError::CanonicalizeNonUtf8 => (
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_WHICH_CANONICALIZE_NON_UTF8).to_string(),
),
ResolveError::WorkspaceNonUtf8 { command, path } => (
ErrorKind::InvalidOperation,
workspace_non_utf8_message(command, path),
),
ResolveError::CwdResolve { source } => (
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_WHICH_CWD_RESOLVE_FAILED)
.with_arg("details", source.to_string())
.to_string(),
),
ResolveError::CwdNonUtf8 => (
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_WHICH_CWD_NON_UTF8).to_string(),
),
ResolveError::WalkDir { source } => {
(ErrorKind::InvalidOperation, walkdir_error_message(source))
}
}
}
fn with_not_found_code(message: &str) -> String {
format!("{NOT_FOUND_CODE}: {message}")
}
fn not_found_message(command: &str, dirs: &[Utf8PathBuf], mode: options::CwdMode) -> String {
let mut message = localization::message(keys::STDLIB_WHICH_NOT_FOUND)
.with_arg("command", command)
.with_arg("count", dirs.len())
.with_arg("preview", path_preview(dirs))
.to_string();
if let Some(hint) = hint_for_mode(mode) {
message.push_str(". ");
message.push_str(&hint.to_string());
}
message
}
fn direct_not_found_message(command: &str, path: &Utf8Path) -> String {
localization::message(keys::STDLIB_WHICH_DIRECT_NOT_FOUND)
.with_arg("command", command)
.with_arg("path", path.as_str())
.to_string()
}
fn args_message(detail: impl std::fmt::Display) -> String {
localization::message(keys::STDLIB_WHICH_ARGS_ERROR)
.with_arg("details", detail.to_string())
.to_string()
}
fn canonicalize_message(path: &Utf8Path, source: &std::io::Error) -> String {
localization::message(keys::STDLIB_WHICH_CANONICALIZE_FAILED)
.with_arg("path", path.as_str())
.with_arg("details", source.to_string())
.to_string()
}
fn is_executable_message(path: &Utf8Path, source: &std::io::Error) -> String {
localization::message(keys::STDLIB_WHICH_IS_EXECUTABLE)
.with_arg("path", path.as_str())
.with_arg("details", source.to_string())
.to_string()
}
fn workspace_non_utf8_message(command: &str, path: &str) -> String {
localization::message(keys::STDLIB_WHICH_WORKSPACE_NON_UTF8)
.with_arg("command", command)
.with_arg("path", path)
.to_string()
}
fn walkdir_error_message(source: &walkdir::Error) -> String {
localization::message(keys::STDLIB_WHICH_WALKDIR_ERROR)
.with_arg("details", source.to_string())
.to_string()
}
fn path_preview(dirs: &[Utf8PathBuf]) -> String {
const LIMIT: usize = 4;
if dirs.is_empty() {
return localization::message(keys::STDLIB_WHICH_PATH_PREVIEW_EMPTY).to_string();
}
let mut parts: Vec<_> = dirs
.iter()
.take(LIMIT)
.map(|dir| format_path_for_output(dir))
.collect();
if dirs.len() > LIMIT {
parts.push("…".into());
}
parts.join(", ")
}
const fn hint_for_mode(mode: options::CwdMode) -> Option<LocalizedMessage> {
#[cfg(windows)]
{
match mode {
options::CwdMode::Always => None,
_ => Some(localization::message(
keys::STDLIB_WHICH_NOT_FOUND_HINT_CWD_ALWAYS,
)),
}
}
#[cfg(not(windows))]
{
match mode {
options::CwdMode::Never => Some(localization::message(
keys::STDLIB_WHICH_NOT_FOUND_HINT_CWD_AUTO,
)),
_ => None,
}
}
}
pub(super) fn format_path_for_output(path: &Utf8Path) -> String {
#[cfg(windows)]
{
path.as_str().replace('\\', "/")
}
#[cfg(not(windows))]
{
path.as_str().to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn command_available_helper_returns_true_for_matches() {
let result = is_command_available(Ok(vec![Utf8PathBuf::from("/bin/tool")]));
assert!(result.expect("availability result"));
}
#[rstest]
fn command_available_helper_returns_false_for_search_misses() {
let result = is_command_available(Err(ResolveError::NotFound {
command: "tool".to_owned(),
dirs: Vec::new(),
cwd_mode: options::CwdMode::Never,
}));
assert!(!result.expect("availability result"));
}
#[rstest]
fn command_available_helper_returns_false_for_direct_misses() {
let result = is_command_available(Err(ResolveError::DirectNotFound {
command: "./tool".to_owned(),
path: Utf8PathBuf::from("/workspace/tool"),
}));
assert!(!result.expect("availability result"));
}
#[rstest]
fn command_available_helper_propagates_argument_errors() {
let result = is_command_available(Err(ResolveError::args("bad option")));
assert!(matches!(result, Err(ResolveError::Args { .. })));
}
}