use std::path::{Path, PathBuf};
use std::process::ExitCode;
pub const PREFIX: &str = "tw-";
pub const PROTOCOL: &str = "1";
pub struct Extension {
pub name: String,
pub path: PathBuf,
}
pub fn discover(extra: &[PathBuf]) -> Vec<Extension> {
let mut found: Vec<Extension> = Vec::new();
let path = std::env::var_os("PATH").unwrap_or_default();
let dirs = std::env::split_paths(&path).chain(extra.iter().cloned());
for dir in dirs {
if dir.as_os_str().is_empty() {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let file = entry.file_name().to_string_lossy().to_string();
let Some(name) = command_name(&file) else {
continue;
};
if found.iter().any(|e| e.name == name) {
continue;
}
let path = entry.path();
if is_executable(&path) {
found.push(Extension { name, path });
}
}
}
found.sort_by(|a, b| a.name.cmp(&b.name));
found
}
fn is_executable(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
path.extension()
.map(|e| is_path_ext(&e.to_string_lossy()))
.unwrap_or(false)
}
}
fn is_path_ext(ext: &str) -> bool {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
.split(';')
.any(|known| known.trim_start_matches('.').eq_ignore_ascii_case(ext))
}
fn command_name(file_name: &str) -> Option<String> {
let rest = file_name.strip_prefix(PREFIX)?;
let rest = match rest.rsplit_once('.') {
Some((stem, ext)) if cfg!(windows) && is_path_ext(ext) => stem,
_ => rest,
};
if rest.is_empty() {
return None;
}
Some(rest.to_string())
}
pub fn find(name: &str, extra: &[PathBuf]) -> Option<PathBuf> {
if let Some(path) = crate::tools::which(&format!("{PREFIX}{name}")) {
return Some(path);
}
let file = crate::tools::exe(&format!("{PREFIX}{name}"));
extra
.iter()
.map(|dir| dir.join(&file))
.find(|p| is_executable(p))
}
pub fn pinned_dirs(root: &Path, config: &crate::external::Config) -> Vec<PathBuf> {
if config.extensions.is_empty() {
return Vec::new();
}
let Ok(tooling) = crate::tools::Tooling::find(root) else {
return Vec::new();
};
config
.extensions
.iter()
.filter_map(|name| tooling.cached(name))
.map(|dir| dir.join("bin"))
.filter(|dir| dir.is_dir())
.collect()
}
pub fn run(
args: &[String],
root: &Path,
config: &crate::external::Config,
) -> Result<ExitCode, String> {
let Some((name, rest)) = args.split_first() else {
return Err("no command".to_string());
};
let extra = pinned_dirs(root, config);
let Some(path) = find(name, &extra) else {
let mut message = format!(
"`{name}` is not a tw command, and no `{PREFIX}{name}` is on PATH \
or in a pinned archive."
);
if config.extensions.is_empty() {
message.push_str(
"\n\nThis corpus names no extension archives. `tw.toml`'s \
`extensions` lists the .tooling/tooling-versions.json pins \
that carry commands, and `tw setup` fetches them.",
);
}
let others = discover(&extra);
if !others.is_empty() {
message.push_str("\n\nExtensions that are:\n");
for e in &others {
message.push_str(&format!(" {:<14} {}\n", e.name, e.path.display()));
}
}
return Err(message);
};
let mut command = std::process::Command::new(&path);
command.args(rest);
crate::dotenv::apply(&mut command);
for (key, value) in context(root) {
command.env(key, value);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let error = command.exec();
Err(format!("{}: {error}", path.display()))
}
#[cfg(not(unix))]
{
let status = command
.status()
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
}
}
fn context(root: &Path) -> Vec<(String, String)> {
let mut out = vec![
("TW_PROTOCOL".to_string(), PROTOCOL.to_string()),
("TW_ROOT".to_string(), root.display().to_string()),
("TW_FORMAT".to_string(), "text".to_string()),
];
let config = root.join(crate::external::CONFIG_FILENAME);
if config.is_file() {
out.push(("TW_CONFIG".to_string(), config.display().to_string()));
}
out
}
pub fn help_section(found: &[Extension], builtins: &[String]) -> String {
let width = found.iter().map(|e| e.name.len()).max().unwrap_or(0);
let mut out = String::from("Commands from PATH and pinned archives:\n");
for e in found {
let shadowed = builtins.contains(&e.name);
out.push_str(&format!(
" {:<width$} {}{}\n",
e.name,
e.path.display(),
if shadowed {
" (never runs: tw has a command of that name)"
} else {
""
},
width = width
));
}
out.push_str(&format!(
"\nAnything not listed above is looked for as `{PREFIX}<command>`: on \
PATH first, then in the archives tw.toml names under `extensions`."
));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_is_what_a_writer_types() {
assert_eq!(command_name("tw-xlate").as_deref(), Some("xlate"));
assert_eq!(command_name("tw-assets").as_deref(), Some("assets"));
assert_eq!(command_name("tw"), None);
assert_eq!(command_name("twiddle"), None);
assert_eq!(command_name("markdoc-ls"), None);
assert_eq!(command_name("tw-"), None);
}
#[cfg(windows)]
#[test]
fn windows_drops_an_executable_suffix_and_only_that() {
assert_eq!(command_name("tw-xlate.exe").as_deref(), Some("xlate"));
assert_eq!(command_name("tw-xlate.cmd").as_deref(), Some("xlate"));
assert_eq!(command_name("tw-xlate.bak").as_deref(), Some("xlate.bak"));
}
#[cfg(unix)]
#[test]
fn a_file_that_cannot_be_run_is_not_a_command() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("tw-ext-exe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let runnable = dir.join("tw-real");
std::fs::write(&runnable, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&runnable, std::fs::Permissions::from_mode(0o755)).unwrap();
let plain = dir.join("tw-real.d");
std::fs::write(&plain, "deps").unwrap();
assert!(is_executable(&runnable));
assert!(!is_executable(&plain));
assert!(!is_executable(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(windows))]
#[test]
fn elsewhere_a_dot_is_part_of_the_name() {
assert_eq!(command_name("tw-foo.sh").as_deref(), Some("foo.sh"));
}
#[test]
fn the_root_is_the_nearest_corpus_above_us() {
use crate::tools::corpus;
let dir = made(std::env::temp_dir().join(format!("tw-ext-root-{}", std::process::id())));
let deep = dir.join("products").join("robot").join("manual");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(dir.join("markdoc-lint.toml"), "").unwrap();
assert_eq!(corpus(&deep), dir);
assert_eq!(corpus(&dir), dir);
let orphan = std::env::temp_dir().join(format!("tw-ext-none-{}", std::process::id()));
std::fs::create_dir_all(&orphan).unwrap();
assert_eq!(
corpus(&orphan).canonicalize().unwrap(),
orphan.canonicalize().unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&orphan);
}
#[test]
fn a_tooling_dir_above_a_nested_config_is_the_root_tw_root_and_the_env_share() {
use crate::tools::corpus;
let dir = made(std::env::temp_dir().join(format!("tw-ext-nested-{}", std::process::id())));
let manual = dir.join("products").join("x");
std::fs::create_dir_all(&manual).unwrap();
std::fs::create_dir_all(dir.join(".tooling")).unwrap();
std::fs::write(manual.join("markdoc-lint.toml"), "").unwrap();
std::fs::write(dir.join("tw.toml"), "").unwrap();
assert_eq!(corpus(&manual), dir);
assert_eq!(corpus(&manual.join("..")), dir);
let context = context(&corpus(&manual));
let get = |k: &str| context.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
assert_eq!(get("TW_ROOT"), Some(dir.display().to_string()));
assert_eq!(
get("TW_CONFIG"),
Some(dir.join("tw.toml").display().to_string())
);
assert_eq!(crate::tools::tooling_root(&manual), Some(dir.clone()));
std::fs::remove_dir(dir.join(".tooling")).unwrap();
assert_eq!(corpus(&manual), manual);
let _ = std::fs::remove_dir_all(&dir);
}
fn made(dir: PathBuf) -> PathBuf {
std::fs::create_dir_all(&dir).unwrap();
dir.canonicalize().unwrap_or(dir)
}
#[cfg(unix)]
#[test]
fn a_pinned_archive_is_looked_in_after_path_and_not_before() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("tw-ext-pin-{}", std::process::id()));
let pinned = dir.join("pinned");
let on_path = dir.join("path");
std::fs::create_dir_all(&pinned).unwrap();
std::fs::create_dir_all(&on_path).unwrap();
let make = |at: &Path, body: &str| {
let p = at.join("tw-demo");
std::fs::write(&p, body).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
};
let in_pin = make(&pinned, "#!/bin/sh\necho pinned\n");
let found = find("demo", std::slice::from_ref(&pinned));
assert_eq!(found.as_deref(), Some(in_pin.as_path()));
let in_path = make(&on_path, "#!/bin/sh\necho path\n");
let saved = std::env::var_os("PATH");
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let in_front = std::env::join_paths(
std::iter::once(on_path.clone()).chain(saved.iter().flat_map(std::env::split_paths)),
)
.unwrap();
unsafe { std::env::set_var("PATH", &in_front) };
let found = find("demo", std::slice::from_ref(&pinned));
unsafe {
match saved {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
assert_eq!(found.as_deref(), Some(in_path.as_path()));
assert!(find("nosuch", std::slice::from_ref(&pinned)).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_corpus_that_names_no_archives_looks_in_none() {
let config = crate::external::Config::default();
assert!(pinned_dirs(Path::new("/nonexistent"), &config).is_empty());
}
#[test]
fn the_help_section_lists_what_was_found() {
let found = vec![
Extension {
name: "assets".to_string(),
path: PathBuf::from("/usr/local/bin/tw-assets"),
},
Extension {
name: "xlate".to_string(),
path: PathBuf::from("/usr/local/bin/tw-xlate"),
},
];
let text = help_section(&found, &[]);
assert!(text.contains("assets /usr/local/bin/tw-assets"), "{text}");
assert!(text.contains("pinned archives"), "{text}");
assert!(text.contains("xlate /usr/local/bin/tw-xlate"), "{text}");
}
#[test]
fn an_extension_a_builtin_shadows_is_named_and_marked() {
let found = vec![Extension {
name: "check".to_string(),
path: PathBuf::from("/usr/local/bin/tw-check"),
}];
let text = help_section(&found, &["check".to_string()]);
assert!(text.contains("tw-check"), "{text}");
assert!(text.contains("never runs"), "{text}");
}
}