use std::collections::BTreeSet;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::javascript_frontend::{authored_path, rewritten_files};
pub const SOURCE_TOOL_COMMAND: &str = "__source-tool";
pub const SOURCE_TOOLS_VARIABLE: &str = "SUPERCOV_SOURCE_TOOLS";
pub const SOURCE_TOOLS: &[&str] = &["biome", "cspell", "dprint", "knip", "oxlint"];
pub fn source_tools() -> BTreeSet<String> {
let mut tools = SOURCE_TOOLS
.iter()
.map(|tool| (*tool).to_owned())
.collect::<BTreeSet<_>>();
if let Some(named) = std::env::var_os(SOURCE_TOOLS_VARIABLE) {
tools.extend(
named
.to_string_lossy()
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty() && !name.contains(['/', '\\']))
.map(str::to_owned),
);
}
tools
}
pub fn launcher(binary: &Path, tool: &Path) -> String {
let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"));
format!(
"#!/bin/sh\n# Runs {name} on the project's source as it was written; see `supercov --help`.\nexec {binary} {SOURCE_TOOL_COMMAND} {tool} \"$@\"\n",
name = tool
.file_name()
.unwrap_or_default()
.to_string_lossy()
.replace('\n', " "),
binary = quoted(binary),
tool = quoted(tool),
)
}
fn command_launcher(binary: &Path, tool: &Path) -> String {
format!(
"@ECHO off\r\n\"{}\" {SOURCE_TOOL_COMMAND} \"{}\" %*\r\n",
binary.display(),
tool.display()
)
}
fn powershell_launcher(binary: &Path, tool: &Path) -> String {
let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "''"));
format!(
"& {} {SOURCE_TOOL_COMMAND} {} @args\r\nexit $LASTEXITCODE\r\n",
quoted(binary),
quoted(tool)
)
}
pub fn launcher_for(
judges: &BTreeSet<String>,
binary: &Path,
tools: &Path,
name: &OsStr,
) -> Option<String> {
launcher_on(cfg!(windows), judges, binary, tools, name)
}
fn launcher_on(
windows: bool,
judges: &BTreeSet<String>,
binary: &Path,
tools: &Path,
name: &OsStr,
) -> Option<String> {
let name = name.to_str()?;
if windows {
let (tool, kind) = match name.rsplit_once('.') {
Some((tool, extension)) if extension.eq_ignore_ascii_case("cmd") => (tool, 1),
Some((tool, extension)) if extension.eq_ignore_ascii_case("ps1") => (tool, 2),
_ => (name, 0),
};
if !judges.contains(tool) {
return None;
}
let shim = tools.join(format!("{tool}.cmd"));
return shim.is_file().then(|| match kind {
1 => command_launcher(binary, &shim),
2 => powershell_launcher(binary, &shim),
_ => launcher(binary, &shim),
});
}
judges
.contains(name)
.then(|| launcher(binary, &tools.join(name)))
}
pub fn launching_binary() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.map(crate::workspace::simplified)
}
pub fn write_launcher(path: &Path, text: &str) -> io::Result<()> {
remove(path)?;
fs::write(path, text)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
pub fn place_launchers(tools: &Path, to: &Path) -> io::Result<()> {
let judges = source_tools();
let (Some(binary), Ok(entries)) = (launching_binary(), fs::read_dir(tools)) else {
return Ok(());
};
if !fs::symlink_metadata(to).is_ok_and(|metadata| metadata.file_type().is_dir()) {
return Ok(());
}
for entry in entries {
let name = entry?.file_name();
if let Some(text) = launcher_for(&judges, &binary, tools, &name) {
write_launcher(&to.join(&name), &text)?;
}
}
Ok(())
}
pub fn path_with_launchers(workspace: &Path) -> Option<OsString> {
let path = std::env::var_os("PATH")?;
let binary = launching_binary()?;
let directory = workspace.join(".supercov/node_modules/.tools");
remove(&directory).ok()?;
let forms: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat"]
} else {
&[""]
};
let mut found = false;
for name in source_tools() {
let Some(tool) = std::env::split_paths(&path)
.flat_map(|directory| {
forms
.iter()
.map(|form| directory.join(format!("{name}{form}")))
.collect::<Vec<_>>()
})
.find(|candidate| executable(candidate))
else {
continue;
};
fs::create_dir_all(&directory).ok()?;
if cfg!(windows) {
let launcher = command_launcher(&binary, &tool);
write_launcher(&directory.join(format!("{name}.cmd")), &launcher).ok()?;
} else {
write_launcher(&directory.join(&name), &launcher(&binary, &tool)).ok()?;
}
found = true;
}
if !found {
return None;
}
std::env::join_paths(std::iter::once(directory).chain(std::env::split_paths(&path))).ok()
}
#[cfg(unix)]
fn executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path)
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn executable(path: &Path) -> bool {
path.is_file()
}
pub fn source_view_path(workspace: &Path) -> Option<PathBuf> {
let name = workspace.file_name()?;
let kind = workspace.parent()?;
let container = kind.parent()?;
(kind.file_name()? == "workspace").then(|| container.join("source").join(name))
}
const BOUNDARY: &str = ".git";
#[derive(Default)]
struct View {
linked: BTreeSet<PathBuf>,
authored: BTreeSet<PathBuf>,
}
#[cfg(unix)]
fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
left.dev() == right.dev() && left.ino() == right.ino()
}
#[cfg(not(unix))]
fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
left.len() == right.len()
&& left.modified().is_ok()
&& left.modified().ok() == right.modified().ok()
}
fn link_to(target: &Path, link: &Path) -> io::Result<()> {
crate::workspace::create_link(target, link, target.is_dir())
}
fn remove(path: &Path) -> io::Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(path),
Ok(_) => fs::remove_file(path).or_else(|_| fs::remove_dir(path)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn point(link: &Path, target: &Path) -> io::Result<()> {
if fs::read_link(link).is_ok_and(|current| current == target) {
return Ok(());
}
remove(link)?;
link_to(target, link)
}
fn link_file(from: &Path, to: &Path) -> io::Result<()> {
let source = fs::metadata(from)?;
if fs::symlink_metadata(to).is_ok_and(|current| same_file(¤t, &source)) {
return Ok(());
}
remove(to)?;
fs::hard_link(from, to).or_else(|_| fs::copy(from, to).map(|_| ()))
}
fn copy_file(from: &Path, to: &Path) -> io::Result<()> {
let contents = fs::read(from)?;
if fs::symlink_metadata(to).is_ok_and(|current| current.is_file())
&& fs::read(to).is_ok_and(|current| current == contents)
{
return Ok(());
}
remove(to)?;
fs::write(to, contents)
}
fn refresh(workspace: &Path, view: &Path, dependencies: &Path) -> io::Result<View> {
let rewritten = rewritten_files(workspace)
.into_iter()
.map(PathBuf::from)
.collect::<BTreeSet<_>>();
let mut state = View::default();
fs::create_dir_all(view)?;
refresh_directory(
workspace,
view,
dependencies,
Path::new(""),
&rewritten,
&mut state,
)?;
Ok(state)
}
fn refresh_directory(
workspace: &Path,
view: &Path,
dependencies: &Path,
local: &Path,
rewritten: &BTreeSet<PathBuf>,
state: &mut View,
) -> io::Result<()> {
let mut present = BTreeSet::new();
for entry in fs::read_dir(workspace.join(local))? {
let entry = entry?;
let name = entry.file_name();
if name == ".supercov" {
continue;
}
let path = local.join(&name);
let (from, to) = (workspace.join(&path), view.join(&path));
let file_type = entry.file_type()?;
present.insert(name.clone());
if name == "node_modules" {
let target = if local.as_os_str().is_empty() && dependencies.is_dir() {
dependencies.to_path_buf()
} else {
from
};
point(&to, &target)?;
} else if file_type.is_symlink() {
point(&to, &fs::read_link(&from)?)?;
} else if file_type.is_dir() {
if !fs::symlink_metadata(&to).is_ok_and(|current| current.file_type().is_dir()) {
remove(&to)?;
fs::create_dir(&to)?;
}
refresh_directory(workspace, view, dependencies, &path, rewritten, state)?;
} else if file_type.is_file() {
let authored = authored_path(workspace, &path);
if rewritten.contains(&path) && authored.is_file() {
copy_file(&authored, &to)?;
state.authored.insert(path);
} else {
link_file(&from, &to)?;
state.linked.insert(path);
}
}
}
let boundary = local.as_os_str().is_empty() && !present.contains(OsStr::new(BOUNDARY));
if boundary && !view.join(BOUNDARY).is_dir() {
remove(&view.join(BOUNDARY))?;
fs::create_dir(view.join(BOUNDARY))?;
}
for entry in fs::read_dir(view.join(local))? {
let entry = entry?;
let kept =
present.contains(&entry.file_name()) || (boundary && entry.file_name() == BOUNDARY);
if !kept {
remove(&entry.path())?;
}
}
Ok(())
}
#[derive(Default)]
struct Outcome {
changed_sources: Vec<PathBuf>,
}
fn reconcile(workspace: &Path, view: &Path, state: &View) -> io::Result<Outcome> {
let mut outcome = Outcome::default();
for path in &state.linked {
if fs::symlink_metadata(view.join(path)).is_err() {
remove(&workspace.join(path))?;
}
}
for path in &state.authored {
let unchanged = fs::read(view.join(path))
.and_then(|current| Ok(current == fs::read(authored_path(workspace, path))?))
.unwrap_or(false);
if !unchanged {
outcome.changed_sources.push(path.clone());
}
}
reconcile_directory(workspace, view, Path::new(""), state)?;
Ok(outcome)
}
fn reconcile_directory(
workspace: &Path,
view: &Path,
local: &Path,
state: &View,
) -> io::Result<()> {
for entry in fs::read_dir(view.join(local))? {
let entry = entry?;
let path = local.join(entry.file_name());
let (from, to) = (view.join(&path), workspace.join(&path));
let file_type = entry.file_type()?;
if path == Path::new(BOUNDARY) && fs::symlink_metadata(&to).is_err() {
continue;
}
if file_type.is_dir() {
if fs::symlink_metadata(&to).is_err() {
fs::create_dir(&to)?;
}
reconcile_directory(workspace, view, &path, state)?;
} else if file_type.is_file() && !state.authored.contains(&path) {
link_file(&from, &to)?;
} else if file_type.is_symlink()
&& fs::symlink_metadata(&to).is_err()
&& let Ok(target) = fs::read_link(&from)
{
link_to(&target, &to)?;
}
}
Ok(())
}
fn in_view(value: &OsStr, workspace: &[&Path], view: &Path) -> OsString {
let Some(text) = value.to_str() else {
return value.to_owned();
};
let mut translated = text.to_owned();
for workspace in workspace {
if let (Some(from), Some(to)) = (workspace.to_str(), view.to_str())
&& !from.is_empty()
{
translated = translated.replace(from, to);
}
}
translated.into()
}
fn status(command: &mut Command) -> i32 {
match command.status() {
Ok(status) => status.code().unwrap_or(1),
Err(error) => {
eprintln!(
"[supercov] could not start {}: {error}",
command.get_program().to_string_lossy()
);
127
}
}
}
pub fn run_source_tool(tool: &Path, arguments: &[OsString]) -> i32 {
let run_as_is = || status(Command::new(tool).args(arguments));
let Some(named) = std::env::var_os("SUPERCOV_PROJECT_ROOT").map(PathBuf::from) else {
return run_as_is();
};
let (Ok(workspace), Ok(directory)) = (
crate::workspace::canonicalize_simplified(&named),
std::env::current_dir().and_then(crate::workspace::canonicalize_simplified),
) else {
return run_as_is();
};
let (Ok(local), Some(view)) = (
directory.strip_prefix(&workspace),
source_view_path(&workspace),
) else {
return run_as_is();
};
let dependencies = std::env::var_os("SUPERCOV_SOURCE_PROJECT_ROOT")
.map(|root| PathBuf::from(root).join("node_modules"))
.unwrap_or_else(|| workspace.join("node_modules"));
let state = match refresh(&workspace, &view, &dependencies) {
Ok(state) => state,
Err(error) => {
eprintln!(
"[supercov] {} reads the instrumented files: the project's source could not be laid out for it at {} ({error})",
tool.display(),
view.display()
);
return run_as_is();
}
};
let names = [workspace.as_path(), named.as_path()];
let mut command = Command::new(tool);
command.current_dir(view.join(local)).args(
arguments
.iter()
.map(|argument| in_view(argument, &names, &view)),
);
for (name, value) in std::env::vars_os() {
let name_text = name.to_string_lossy();
if ["PWD", "OLDPWD", "INIT_CWD", "PATH"]
.iter()
.any(|location| name_text.eq_ignore_ascii_case(location))
|| name_text.starts_with("npm_")
{
let translated = in_view(&value, &names, &view);
if translated != value {
command.env(name, translated);
}
}
}
let code = status(&mut command);
match reconcile(&workspace, &view, &state) {
Ok(outcome) if !outcome.changed_sources.is_empty() => {
let listed = outcome
.changed_sources
.iter()
.take(3)
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"[supercov] {} changed {} source file(s) ({listed}{}); a measured run does not apply a tool's changes to source, so run it without Supercov to keep them",
tool.file_name().unwrap_or_default().to_string_lossy(),
outcome.changed_sources.len(),
if outcome.changed_sources.len() > 3 {
", …"
} else {
""
},
);
}
Ok(_) => {}
Err(error) => eprintln!(
"[supercov] what {} wrote could not be brought into the workspace: {error}",
tool.display()
),
}
code
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static UNIQUE: AtomicU64 = AtomicU64::new(0);
fn workspace() -> (PathBuf, PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!(
"supercov-source-view-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
UNIQUE.fetch_add(1, Ordering::Relaxed)
));
let workspace = root.join("workspaces/workspace/project");
fs::create_dir_all(workspace.join("src")).unwrap();
fs::create_dir_all(workspace.join("node_modules/.bin")).unwrap();
fs::create_dir_all(workspace.join(".supercov/node_modules/.authored/src")).unwrap();
fs::write(workspace.join("src/a.js"), "probe(); const a = 1;\n").unwrap();
fs::write(
workspace.join(".supercov/node_modules/.authored/src/a.js"),
"const a = 1;\n",
)
.unwrap();
fs::write(
workspace.join(".supercov/node_modules/authored-sources.json"),
"[\"src/a.js\"]",
)
.unwrap();
fs::write(workspace.join("README.md"), "read me\n").unwrap();
let dependencies = root.join("project/node_modules");
fs::create_dir_all(&dependencies).unwrap();
let view = source_view_path(&workspace).unwrap();
(workspace, view, dependencies)
}
#[test]
fn a_view_holds_each_file_as_its_author_wrote_it() {
let (workspace, view, dependencies) = workspace();
let state = refresh(&workspace, &view, &dependencies).unwrap();
assert_eq!(
fs::read_to_string(view.join("src/a.js")).unwrap(),
"const a = 1;\n"
);
assert!(same_file(
&fs::metadata(view.join("README.md")).unwrap(),
&fs::metadata(workspace.join("README.md")).unwrap()
));
assert_eq!(
fs::read_link(view.join("node_modules")).unwrap(),
dependencies
);
assert!(!view.join(".supercov").exists());
assert_eq!(state.authored.len(), 1);
assert!(view.join(".git").is_dir());
fs::create_dir(view.join("reports")).unwrap();
fs::write(view.join("reports/lint.json"), "[]").unwrap();
fs::remove_file(view.join("README.md")).unwrap();
fs::write(view.join("README.md"), "replaced\n").unwrap();
let outcome = reconcile(&workspace, &view, &state).unwrap();
assert_eq!(
fs::read_to_string(workspace.join("reports/lint.json")).unwrap(),
"[]"
);
assert_eq!(
fs::read_to_string(workspace.join("README.md")).unwrap(),
"replaced\n"
);
assert!(outcome.changed_sources.is_empty());
assert!(!workspace.join(".git").exists());
assert_eq!(
fs::read_to_string(workspace.join("src/a.js")).unwrap(),
"probe(); const a = 1;\n"
);
fs::write(view.join("src/a.js"), "const a = 1\n").unwrap();
let outcome = reconcile(&workspace, &view, &state).unwrap();
assert_eq!(outcome.changed_sources, [PathBuf::from("src/a.js")]);
assert_eq!(
fs::read_to_string(workspace.join(".supercov/node_modules/.authored/src/a.js"))
.unwrap(),
"const a = 1;\n"
);
fs::remove_file(workspace.join("reports/lint.json")).unwrap();
let state = refresh(&workspace, &view, &dependencies).unwrap();
assert!(!view.join("reports/lint.json").exists());
assert_eq!(
fs::read_to_string(view.join("src/a.js")).unwrap(),
"const a = 1;\n"
);
fs::remove_file(view.join("README.md")).unwrap();
reconcile(&workspace, &view, &state).unwrap();
assert!(!workspace.join("README.md").exists());
fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
}
#[test]
fn a_launcher_survives_a_quote_in_a_path() {
let text = launcher(
Path::new("/opt/it's here/supercov"),
Path::new("/work/app/node_modules/.bin/biome"),
);
assert!(text.starts_with("#!/bin/sh\n"), "{text}");
assert!(
text.contains("exec '/opt/it'\\''s here/supercov' __source-tool '/work/app/node_modules/.bin/biome' \"$@\"\n"),
"{text}"
);
}
#[test]
fn a_windows_tool_gets_a_launcher_for_each_of_its_shims() {
let (workspace, ..) = workspace();
let tools = workspace.join("node_modules/.bin");
for file in ["biome", "biome.cmd", "biome.ps1", "jest.cmd"] {
fs::write(tools.join(file), "").unwrap();
}
let judges = BTreeSet::from(["biome".to_owned()]);
let binary = Path::new("C:/Program Files/supercov.exe");
let shim = tools.join("biome.cmd");
let launcher = |name: &str| launcher_on(true, &judges, binary, &tools, OsStr::new(name));
assert_eq!(
launcher("biome.cmd").unwrap(),
format!(
"@ECHO off\r\n\"C:/Program Files/supercov.exe\" __source-tool \"{}\" %*\r\n",
shim.display()
)
);
assert_eq!(
launcher("biome.ps1").unwrap(),
format!(
"& 'C:/Program Files/supercov.exe' __source-tool '{}' @args\r\nexit $LASTEXITCODE\r\n",
shim.display()
)
);
assert!(launcher("biome").unwrap().starts_with("#!/bin/sh\n"));
assert!(
launcher("biome")
.unwrap()
.contains(&format!("'{}'", shim.display()))
);
assert_eq!(launcher("jest.cmd"), None);
assert!(launcher_on(false, &judges, binary, &tools, OsStr::new("biome")).is_some());
assert_eq!(
launcher_on(false, &judges, binary, &tools, OsStr::new("biome.cmd")),
None
);
fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
}
#[test]
fn a_package_bin_of_its_own_gets_launchers_in_place() {
let (workspace, _, dependencies) = workspace();
let tools = dependencies.join(".bin");
fs::create_dir_all(&tools).unwrap();
fs::write(tools.join("oxlint"), "the project's own\n").unwrap();
fs::write(tools.join("jest"), "runs code\n").unwrap();
let copy = workspace.join("packages/app/node_modules/.bin");
fs::create_dir_all(©).unwrap();
fs::hard_link(tools.join("oxlint"), copy.join("oxlint")).unwrap();
fs::hard_link(tools.join("jest"), copy.join("jest")).unwrap();
place_launchers(&tools, ©).unwrap();
assert!(
fs::read_to_string(copy.join("oxlint"))
.unwrap()
.contains("__source-tool"),
);
assert_eq!(
fs::read_to_string(tools.join("oxlint")).unwrap(),
"the project's own\n"
);
assert_eq!(
fs::read_to_string(copy.join("jest")).unwrap(),
"runs code\n"
);
fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
}
#[test]
fn a_path_in_an_argument_is_the_views() {
let translated = in_view(
OsStr::new("--config=/w/workspaces/workspace/p/biome.json"),
&[Path::new("/w/workspaces/workspace/p")],
Path::new("/w/workspaces/source/p"),
);
assert_eq!(translated, "--config=/w/workspaces/source/p/biome.json");
}
}