Skip to main content

supercov_engine/
source_tools.rs

1//! Tools that judge the project's source as text.
2//!
3//! A linter, a formatter or a spell checker that a test script runs reads the
4//! files of the instrumented workspace, and fails on code nobody wrote:
5//! Biome on probe calls it would format otherwise, cspell on Supercov's own
6//! names, knip on the runtime the files now import. ESLint and Prettier are
7//! Node programs, and the preload hands them the authored text of each file
8//! they read. A native program cannot be handed anything.
9//!
10//! Such a tool is started through a launcher in the workspace's own
11//! `node_modules/.bin`, which calls back into this binary. It then runs in a
12//! view of the workspace that holds every file as its author wrote it: a
13//! directory beside the workspace with a hard link to each file the run did
14//! not rewrite, and the authored text of each one it did. What the tool
15//! writes there is the workspace's too, as if it had run in it.
16//!
17//! A tool that changes a source file, a formatter with `--write` or a linter
18//! with `--fix`, changes the authored text. The workspace holds what was
19//! instrumented from the text before, and written over that file the change
20//! would leave it unmeasured: Prettier did exactly that through the preload,
21//! and a file whose tests passed read 0% covered. The new text is kept
22//! beside the instrumented copy instead. Tools that run later in the command
23//! read it, the tests run the copy that is measured, and when the command
24//! ends the change is the project's, as the command meant it to be.
25
26use std::collections::BTreeSet;
27use std::ffi::{OsStr, OsString};
28use std::fs;
29use std::io;
30use std::path::{Path, PathBuf};
31use std::process::Command;
32
33use crate::javascript_frontend::{authored_path, changed_directory, changed_path, rewritten_files};
34
35/// The hidden command a launcher runs: `supercov __source-tool <tool> <args>`.
36pub const SOURCE_TOOL_COMMAND: &str = "__source-tool";
37
38/// Names added to [`SOURCE_TOOLS`], separated by commas.
39pub const SOURCE_TOOLS_VARIABLE: &str = "SUPERCOV_SOURCE_TOOLS";
40
41/// The tools measured to fail on an instrumented workspace and to pass in its
42/// source view. Anything else that reads source as text can be named in
43/// `SUPERCOV_SOURCE_TOOLS`.
44pub const SOURCE_TOOLS: &[&str] = &["biome", "cspell", "dprint", "knip", "oxlint"];
45
46/// The names a launcher is written for.
47pub fn source_tools() -> BTreeSet<String> {
48    let mut tools = SOURCE_TOOLS
49        .iter()
50        .map(|tool| (*tool).to_owned())
51        .collect::<BTreeSet<_>>();
52    if let Some(named) = std::env::var_os(SOURCE_TOOLS_VARIABLE) {
53        tools.extend(
54            named
55                .to_string_lossy()
56                .split(',')
57                .map(str::trim)
58                .filter(|name| !name.is_empty() && !name.contains(['/', '\\']))
59                .map(str::to_owned),
60        );
61    }
62    tools
63}
64
65/// The launcher that stands in for `tool` where a shell starts it.
66pub fn launcher(binary: &Path, tool: &Path) -> String {
67    let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"));
68    format!(
69        "#!/bin/sh\n# Runs {name} on the project's source as it was written; see `supercov --help`.\nexec {binary} {SOURCE_TOOL_COMMAND} {tool} \"$@\"\n",
70        name = tool
71            .file_name()
72            .unwrap_or_default()
73            .to_string_lossy()
74            .replace('\n', " "),
75        binary = quoted(binary),
76        tool = quoted(tool),
77    )
78}
79
80/// The same for `cmd.exe`, which is what npm runs a script with on Windows.
81fn command_launcher(binary: &Path, tool: &Path) -> String {
82    format!(
83        "@ECHO off\r\n\"{}\" {SOURCE_TOOL_COMMAND} \"{}\" %*\r\n",
84        binary.display(),
85        tool.display()
86    )
87}
88
89/// The same for PowerShell.
90fn powershell_launcher(binary: &Path, tool: &Path) -> String {
91    let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "''"));
92    format!(
93        "& {} {SOURCE_TOOL_COMMAND} {} @args\r\nexit $LASTEXITCODE\r\n",
94        quoted(binary),
95        quoted(tool)
96    )
97}
98
99/// What stands in a workspace's `.bin` for the file `name` of the project's
100/// `.bin` at `tools`, when that file starts a tool that judges source.
101///
102/// On Windows a tool is three files: the shim `cmd.exe` runs, one for
103/// PowerShell and one for a POSIX shell. Each gets a launcher in its own
104/// language, and all three run the `.cmd`.
105pub fn launcher_for(
106    judges: &BTreeSet<String>,
107    binary: &Path,
108    tools: &Path,
109    name: &OsStr,
110) -> Option<String> {
111    launcher_on(cfg!(windows), judges, binary, tools, name)
112}
113
114fn launcher_on(
115    windows: bool,
116    judges: &BTreeSet<String>,
117    binary: &Path,
118    tools: &Path,
119    name: &OsStr,
120) -> Option<String> {
121    let name = name.to_str()?;
122    if windows {
123        let (tool, kind) = match name.rsplit_once('.') {
124            Some((tool, extension)) if extension.eq_ignore_ascii_case("cmd") => (tool, 1),
125            Some((tool, extension)) if extension.eq_ignore_ascii_case("ps1") => (tool, 2),
126            _ => (name, 0),
127        };
128        if !judges.contains(tool) {
129            return None;
130        }
131        let shim = tools.join(format!("{tool}.cmd"));
132        return shim.is_file().then(|| match kind {
133            1 => command_launcher(binary, &shim),
134            2 => powershell_launcher(binary, &shim),
135            _ => launcher(binary, &shim),
136        });
137    }
138    judges
139        .contains(name)
140        .then(|| launcher(binary, &tools.join(name)))
141}
142
143/// This binary, by a path a shell and `cmd.exe` can both be given.
144pub fn launching_binary() -> Option<PathBuf> {
145    std::env::current_exe()
146        .ok()
147        .map(crate::workspace::simplified)
148}
149
150/// Write a launcher where `path` is, replacing what is there. Never through
151/// it: in a workspace whose dependencies are hard links, the file there is
152/// the project's own.
153pub fn write_launcher(path: &Path, text: &str) -> io::Result<()> {
154    remove(path)?;
155    fs::write(path, text)?;
156    #[cfg(unix)]
157    {
158        use std::os::unix::fs::PermissionsExt;
159        fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
160    }
161    Ok(())
162}
163
164/// Put launchers in a `.bin` the workspace already has as a directory of its
165/// own, which is how a workspace package's dependencies are laid out: for
166/// each source tool the project's `.bin` at `tools` has, in place of the
167/// entry the copy at `to` has for it.
168pub fn place_launchers(tools: &Path, to: &Path) -> io::Result<()> {
169    let judges = source_tools();
170    let (Some(binary), Ok(entries)) = (launching_binary(), fs::read_dir(tools)) else {
171        return Ok(());
172    };
173    if !fs::symlink_metadata(to).is_ok_and(|metadata| metadata.file_type().is_dir()) {
174        return Ok(());
175    }
176    for entry in entries {
177        let name = entry?.file_name();
178        if let Some(text) = launcher_for(&judges, &binary, tools, &name) {
179            write_launcher(&to.join(&name), &text)?;
180        }
181    }
182    Ok(())
183}
184
185/// PATH for the test command with launchers first for the source tools it
186/// has: a package manager's script finds a project's own tool in the
187/// workspace's `.bin`, but `supercov -- biome check`, and a tool installed
188/// for the whole machine, are found here. A name that is not on PATH gets no
189/// launcher, so a script that asks whether a tool is installed hears what it
190/// heard.
191pub fn path_with_launchers(workspace: &Path) -> Option<OsString> {
192    let path = std::env::var_os("PATH")?;
193    let binary = launching_binary()?;
194    let directory = workspace.join(".supercov/node_modules/.tools");
195    remove(&directory).ok()?;
196    // The forms a tool is installed in, in the order the system tries them,
197    // and the launcher that answers to the name.
198    let forms: &[&str] = if cfg!(windows) {
199        &[".exe", ".cmd", ".bat"]
200    } else {
201        &[""]
202    };
203    let mut found = false;
204    for name in source_tools() {
205        let Some(tool) = std::env::split_paths(&path)
206            .flat_map(|directory| {
207                forms
208                    .iter()
209                    .map(|form| directory.join(format!("{name}{form}")))
210                    .collect::<Vec<_>>()
211            })
212            .find(|candidate| executable(candidate))
213        else {
214            continue;
215        };
216        fs::create_dir_all(&directory).ok()?;
217        if cfg!(windows) {
218            let launcher = command_launcher(&binary, &tool);
219            write_launcher(&directory.join(format!("{name}.cmd")), &launcher).ok()?;
220        } else {
221            write_launcher(&directory.join(&name), &launcher(&binary, &tool)).ok()?;
222        }
223        found = true;
224    }
225    if !found {
226        return None;
227    }
228    std::env::join_paths(std::iter::once(directory).chain(std::env::split_paths(&path))).ok()
229}
230
231#[cfg(unix)]
232fn executable(path: &Path) -> bool {
233    use std::os::unix::fs::PermissionsExt;
234    fs::metadata(path)
235        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
236}
237
238#[cfg(not(unix))]
239fn executable(path: &Path) -> bool {
240    path.is_file()
241}
242
243/// Where the source view of a workspace is kept: beside the directory that
244/// holds the workspaces, under the same name.
245pub fn source_view_path(workspace: &Path) -> Option<PathBuf> {
246    let name = workspace.file_name()?;
247    let kind = workspace.parent()?;
248    let container = kind.parent()?;
249    // `workspace` has its view in `source`, and `workspace-2` in `source-2`.
250    let kind = kind.file_name()?.to_str()?;
251    let number = kind.strip_prefix("workspace")?;
252    (number.is_empty()
253        || number
254            .strip_prefix('-')
255            .is_some_and(|number| number.parse::<usize>().is_ok()))
256    .then(|| container.join(format!("source{number}")).join(name))
257}
258
259/// A view is kept under the project's `.supercov`, which the project's
260/// `.gitignore` names, and a tool that honours ignore files, oxlint or
261/// dprint, then finds no file in it. Such a tool stops reading the ignore
262/// files of parent directories at the first directory that has a `.git`, so
263/// a view whose workspace has none gets an empty one. To git itself an empty
264/// `.git` is no repository, and it looks further up, as it did.
265const BOUNDARY: &str = ".git";
266
267/// A rewritten file's text as the command has it now: what one of its tools
268/// made of it, or what its author wrote.
269fn current_source(workspace: &Path, file: &Path) -> PathBuf {
270    let changed = changed_path(workspace, file);
271    if changed.is_file() {
272        changed
273    } else {
274        authored_path(workspace, file)
275    }
276}
277
278/// What a file of the view is, to tell afterwards what the tool did to it.
279#[derive(Default)]
280struct View {
281    /// Files linked from the workspace, by their path in it.
282    linked: BTreeSet<PathBuf>,
283    /// Files that hold authored text.
284    authored: BTreeSet<PathBuf>,
285}
286
287#[cfg(unix)]
288fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
289    use std::os::unix::fs::MetadataExt;
290    left.dev() == right.dev() && left.ino() == right.ino()
291}
292
293/// Where a file's identity is not to be had, two hard links to one file are
294/// told by what they share: its length and the moment it was last written.
295#[cfg(not(unix))]
296fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
297    left.len() == right.len()
298        && left.modified().is_ok()
299        && left.modified().ok() == right.modified().ok()
300}
301
302/// A link at `link` to `target`: a symbolic link, or on Windows a junction
303/// for a directory, which needs no privilege.
304fn link_to(target: &Path, link: &Path) -> io::Result<()> {
305    crate::workspace::create_link(target, link, target.is_dir())
306}
307
308fn remove(path: &Path) -> io::Result<()> {
309    match fs::symlink_metadata(path) {
310        Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(path),
311        // A link to a directory is removed as a directory on Windows.
312        Ok(_) => fs::remove_file(path).or_else(|_| fs::remove_dir(path)),
313        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
314        Err(error) => Err(error),
315    }
316}
317
318/// Make `link` a symbolic link to `target`, whatever it was.
319fn point(link: &Path, target: &Path) -> io::Result<()> {
320    if fs::read_link(link).is_ok_and(|current| current == target) {
321        return Ok(());
322    }
323    remove(link)?;
324    link_to(target, link)
325}
326
327/// Make the file at `to` the file at `from`.
328fn link_file(from: &Path, to: &Path) -> io::Result<()> {
329    let source = fs::metadata(from)?;
330    if fs::symlink_metadata(to).is_ok_and(|current| same_file(&current, &source)) {
331        return Ok(());
332    }
333    remove(to)?;
334    fs::hard_link(from, to).or_else(|_| fs::copy(from, to).map(|_| ()))
335}
336
337/// Give the file at `to` the contents of `from`, as a file of its own.
338fn copy_file(from: &Path, to: &Path) -> io::Result<()> {
339    let contents = fs::read(from)?;
340    if fs::symlink_metadata(to).is_ok_and(|current| current.is_file())
341        && fs::read(to).is_ok_and(|current| current == contents)
342    {
343        return Ok(());
344    }
345    remove(to)?;
346    fs::write(to, contents)
347}
348
349/// Bring the view of `workspace` at `view` up to date: every file and
350/// directory of the workspace, a rewritten file as its author wrote it, and
351/// nothing else. Dependencies are the project's own, so a tool the judged one
352/// starts in turn is the real one.
353fn refresh(workspace: &Path, view: &Path, dependencies: &Path) -> io::Result<View> {
354    let rewritten = rewritten_files(workspace)
355        .into_iter()
356        .map(PathBuf::from)
357        .collect::<BTreeSet<_>>();
358    let mut state = View::default();
359    fs::create_dir_all(view)?;
360    refresh_directory(
361        workspace,
362        view,
363        dependencies,
364        Path::new(""),
365        &rewritten,
366        &mut state,
367    )?;
368    Ok(state)
369}
370
371fn refresh_directory(
372    workspace: &Path,
373    view: &Path,
374    dependencies: &Path,
375    local: &Path,
376    rewritten: &BTreeSet<PathBuf>,
377    state: &mut View,
378) -> io::Result<()> {
379    let mut present = BTreeSet::new();
380    for entry in fs::read_dir(workspace.join(local))? {
381        let entry = entry?;
382        let name = entry.file_name();
383        if name == ".supercov" {
384            continue;
385        }
386        let path = local.join(&name);
387        let (from, to) = (workspace.join(&path), view.join(&path));
388        let file_type = entry.file_type()?;
389        present.insert(name.clone());
390        if name == "node_modules" {
391            let target = if local.as_os_str().is_empty() && dependencies.is_dir() {
392                dependencies.to_path_buf()
393            } else {
394                from
395            };
396            point(&to, &target)?;
397        } else if file_type.is_symlink() {
398            point(&to, &fs::read_link(&from)?)?;
399        } else if file_type.is_dir() {
400            if !fs::symlink_metadata(&to).is_ok_and(|current| current.file_type().is_dir()) {
401                remove(&to)?;
402                fs::create_dir(&to)?;
403            }
404            refresh_directory(workspace, view, dependencies, &path, rewritten, state)?;
405        } else if file_type.is_file() {
406            let authored = current_source(workspace, &path);
407            if rewritten.contains(&path) && authored.is_file() {
408                copy_file(&authored, &to)?;
409                state.authored.insert(path);
410            } else {
411                link_file(&from, &to)?;
412                state.linked.insert(path);
413            }
414        }
415    }
416    let boundary = local.as_os_str().is_empty() && !present.contains(OsStr::new(BOUNDARY));
417    if boundary && !view.join(BOUNDARY).is_dir() {
418        remove(&view.join(BOUNDARY))?;
419        fs::create_dir(view.join(BOUNDARY))?;
420    }
421    for entry in fs::read_dir(view.join(local))? {
422        let entry = entry?;
423        let kept =
424            present.contains(&entry.file_name()) || (boundary && entry.file_name() == BOUNDARY);
425        if !kept {
426            remove(&entry.path())?;
427        }
428    }
429    Ok(())
430}
431
432/// Give the workspace what the tool wrote in its view: new files, files it
433/// replaced, and the removal of files it deleted. A file it changed in place
434/// is the workspace's own file already.
435///
436/// The text of a rewritten file is the exception: the workspace holds what
437/// was instrumented from it. A change to it is kept beside that copy, for
438/// the tools that run next and for the project when the command ends.
439/// Returns how many files the tool changed that way.
440fn reconcile(workspace: &Path, view: &Path, state: &View) -> io::Result<usize> {
441    let mut changed = 0;
442    for path in &state.linked {
443        if fs::symlink_metadata(view.join(path)).is_err() {
444            remove(&workspace.join(path))?;
445        }
446    }
447    for path in &state.authored {
448        let Ok(text) = fs::read(view.join(path)) else {
449            continue;
450        };
451        if fs::read(current_source(workspace, path)).is_ok_and(|current| current == text) {
452            continue;
453        }
454        let kept = changed_path(workspace, path);
455        if let Some(parent) = kept.parent() {
456            fs::create_dir_all(parent)?;
457        }
458        fs::write(&kept, text)?;
459        changed += 1;
460    }
461    reconcile_directory(workspace, view, Path::new(""), state)?;
462    Ok(changed)
463}
464
465fn reconcile_directory(
466    workspace: &Path,
467    view: &Path,
468    local: &Path,
469    state: &View,
470) -> io::Result<()> {
471    for entry in fs::read_dir(view.join(local))? {
472        let entry = entry?;
473        let path = local.join(entry.file_name());
474        let (from, to) = (view.join(&path), workspace.join(&path));
475        let file_type = entry.file_type()?;
476        if path == Path::new(BOUNDARY) && fs::symlink_metadata(&to).is_err() {
477            continue;
478        }
479        if file_type.is_dir() {
480            if fs::symlink_metadata(&to).is_err() {
481                fs::create_dir(&to)?;
482            }
483            reconcile_directory(workspace, view, &path, state)?;
484        } else if file_type.is_file() && !state.authored.contains(&path) {
485            link_file(&from, &to)?;
486        } else if file_type.is_symlink()
487            && fs::symlink_metadata(&to).is_err()
488            && let Ok(target) = fs::read_link(&from)
489        {
490            link_to(&target, &to)?;
491        }
492    }
493    Ok(())
494}
495
496/// `value` with every mention of the workspace's path replaced by the view's.
497fn in_view(value: &OsStr, workspace: &[&Path], view: &Path) -> OsString {
498    let Some(text) = value.to_str() else {
499        return value.to_owned();
500    };
501    let mut translated = text.to_owned();
502    for workspace in workspace {
503        if let (Some(from), Some(to)) = (workspace.to_str(), view.to_str())
504            && !from.is_empty()
505        {
506            translated = translated.replace(from, to);
507        }
508    }
509    translated.into()
510}
511
512fn status(command: &mut Command) -> i32 {
513    match command.status() {
514        Ok(status) => status.code().unwrap_or(1),
515        Err(error) => {
516            eprintln!(
517                "[supercov] could not start {}: {error}",
518                command.get_program().to_string_lossy()
519            );
520            127
521        }
522    }
523}
524
525/// Run `tool` with `arguments` in the source view of the workspace this
526/// process was started in, and return its exit status. Outside a workspace
527/// of a run, the tool runs where it was started.
528pub fn run_source_tool(tool: &Path, arguments: &[OsString]) -> i32 {
529    let run_as_is = || status(Command::new(tool).args(arguments));
530    let Some(named) = std::env::var_os("SUPERCOV_PROJECT_ROOT").map(PathBuf::from) else {
531        return run_as_is();
532    };
533    // By paths a child process can be started in: Windows' own canonical
534    // form is one `cmd.exe` refuses as a working directory.
535    let (Ok(workspace), Ok(directory)) = (
536        crate::workspace::canonicalize_simplified(&named),
537        std::env::current_dir().and_then(crate::workspace::canonicalize_simplified),
538    ) else {
539        return run_as_is();
540    };
541    let (Ok(local), Some(view)) = (
542        directory.strip_prefix(&workspace),
543        source_view_path(&workspace),
544    ) else {
545        return run_as_is();
546    };
547    let dependencies = std::env::var_os("SUPERCOV_SOURCE_PROJECT_ROOT")
548        .map(|root| PathBuf::from(root).join("node_modules"))
549        .unwrap_or_else(|| workspace.join("node_modules"));
550    let state = match refresh(&workspace, &view, &dependencies) {
551        Ok(state) => state,
552        Err(error) => {
553            eprintln!(
554                "[supercov] {} reads the instrumented files: the project's source could not be laid out for it at {} ({error})",
555                tool.display(),
556                view.display()
557            );
558            return run_as_is();
559        }
560    };
561    let names = [workspace.as_path(), named.as_path()];
562    let mut command = Command::new(tool);
563    command.current_dir(view.join(local)).args(
564        arguments
565            .iter()
566            .map(|argument| in_view(argument, &names, &view)),
567    );
568    for (name, value) in std::env::vars_os() {
569        // Where the tool is, and where a package manager says it is. PATH
570        // too: the view's dependencies are the project's, so what the tool
571        // starts in turn is the real one. Everything else that names the
572        // workspace is Supercov's, the preload in NODE_OPTIONS included, and
573        // belongs to the workspace still.
574        let name_text = name.to_string_lossy();
575        if ["PWD", "OLDPWD", "INIT_CWD", "PATH"]
576            .iter()
577            .any(|location| name_text.eq_ignore_ascii_case(location))
578            || name_text.starts_with("npm_")
579        {
580            let translated = in_view(&value, &names, &view);
581            if translated != value {
582                command.env(name, translated);
583            }
584        }
585    }
586    let code = status(&mut command);
587    if let Err(error) = reconcile(&workspace, &view, &state) {
588        eprintln!(
589            "[supercov] what {} wrote could not be brought into the workspace: {error}",
590            tool.display()
591        );
592    }
593    code
594}
595
596/// The source files the command's tools changed, by what became of each.
597#[derive(Debug, Default, PartialEq, Eq)]
598pub struct SourceChanges {
599    /// Changed in the project, as the command meant.
600    pub applied: Vec<PathBuf>,
601    /// Left alone: the project's file is no longer the one the run started
602    /// from, and the change was made to that one.
603    pub kept_back: Vec<PathBuf>,
604}
605
606/// Forget the changes a command before this one left behind.
607pub fn clear_source_changes(workspace: &Path) -> io::Result<()> {
608    remove(&changed_directory(workspace))
609}
610
611/// Give the project the changes the command's tools made to its source
612/// files. Each was made to the text the run started from, so it is applied
613/// only where the project still has that text.
614pub fn apply_source_changes(root: &Path, workspace: &Path) -> io::Result<SourceChanges> {
615    let mut changes = SourceChanges::default();
616    let directory = changed_directory(workspace);
617    if directory.is_dir() {
618        apply_directory(root, workspace, &directory, Path::new(""), &mut changes)?;
619        remove(&directory)?;
620    }
621    changes.applied.sort();
622    changes.kept_back.sort();
623    Ok(changes)
624}
625
626fn apply_directory(
627    root: &Path,
628    workspace: &Path,
629    directory: &Path,
630    local: &Path,
631    changes: &mut SourceChanges,
632) -> io::Result<()> {
633    for entry in fs::read_dir(directory.join(local))? {
634        let entry = entry?;
635        let path = local.join(entry.file_name());
636        if entry.file_type()?.is_dir() {
637            apply_directory(root, workspace, directory, &path, changes)?;
638            continue;
639        }
640        let text = fs::read(entry.path())?;
641        let started_from = fs::read(authored_path(workspace, &path)).ok();
642        // A tool that writes every file it formats, changed or not.
643        if started_from.as_ref() == Some(&text) {
644            continue;
645        }
646        let project = root.join(&path);
647        if started_from.is_some() && fs::read(&project).ok() == started_from {
648            // Written in place, as a formatter writes it: the file keeps its
649            // mode and whatever links to it.
650            fs::write(&project, text)?;
651            changes.applied.push(path);
652        } else {
653            changes.kept_back.push(path);
654        }
655    }
656    Ok(())
657}
658
659#[cfg(all(test, unix))]
660mod tests {
661    use super::*;
662    use std::sync::atomic::{AtomicU64, Ordering};
663
664    static UNIQUE: AtomicU64 = AtomicU64::new(0);
665
666    /// A workspace with one rewritten file, one plain file, a dependency and
667    /// Supercov's own directory.
668    fn workspace() -> (PathBuf, PathBuf, PathBuf) {
669        let root = std::env::temp_dir().join(format!(
670            "supercov-source-view-{}-{}-{}",
671            std::process::id(),
672            std::time::SystemTime::now()
673                .duration_since(std::time::UNIX_EPOCH)
674                .unwrap()
675                .as_nanos(),
676            UNIQUE.fetch_add(1, Ordering::Relaxed)
677        ));
678        let workspace = root.join("workspaces/workspace/project");
679        fs::create_dir_all(workspace.join("src")).unwrap();
680        fs::create_dir_all(workspace.join("node_modules/.bin")).unwrap();
681        fs::create_dir_all(workspace.join(".supercov/node_modules/.authored/src")).unwrap();
682        fs::write(workspace.join("src/a.js"), "probe(); const a = 1;\n").unwrap();
683        fs::write(
684            workspace.join(".supercov/node_modules/.authored/src/a.js"),
685            "const a = 1;\n",
686        )
687        .unwrap();
688        fs::write(
689            workspace.join(".supercov/node_modules/authored-sources.json"),
690            "[\"src/a.js\"]",
691        )
692        .unwrap();
693        fs::write(workspace.join("README.md"), "read me\n").unwrap();
694        let dependencies = root.join("project/node_modules");
695        fs::create_dir_all(&dependencies).unwrap();
696        let view = source_view_path(&workspace).unwrap();
697        (workspace, view, dependencies)
698    }
699
700    #[test]
701    fn a_view_holds_each_file_as_its_author_wrote_it() {
702        let (workspace, view, dependencies) = workspace();
703        let state = refresh(&workspace, &view, &dependencies).unwrap();
704        assert_eq!(
705            fs::read_to_string(view.join("src/a.js")).unwrap(),
706            "const a = 1;\n"
707        );
708        assert!(same_file(
709            &fs::metadata(view.join("README.md")).unwrap(),
710            &fs::metadata(workspace.join("README.md")).unwrap()
711        ));
712        assert_eq!(
713            fs::read_link(view.join("node_modules")).unwrap(),
714            dependencies
715        );
716        assert!(!view.join(".supercov").exists());
717        assert_eq!(state.authored.len(), 1);
718        assert!(view.join(".git").is_dir());
719
720        // What a tool writes there is the workspace's: a new file, a file
721        // replaced by another, a file removed, a file changed in place.
722        fs::create_dir(view.join("reports")).unwrap();
723        fs::write(view.join("reports/lint.json"), "[]").unwrap();
724        fs::remove_file(view.join("README.md")).unwrap();
725        fs::write(view.join("README.md"), "replaced\n").unwrap();
726        let changed = reconcile(&workspace, &view, &state).unwrap();
727        assert_eq!(
728            fs::read_to_string(workspace.join("reports/lint.json")).unwrap(),
729            "[]"
730        );
731        assert_eq!(
732            fs::read_to_string(workspace.join("README.md")).unwrap(),
733            "replaced\n"
734        );
735        assert_eq!(changed, 0);
736        assert!(!workspace.join(".git").exists());
737        // The instrumented file stays what was instrumented.
738        assert_eq!(
739            fs::read_to_string(workspace.join("src/a.js")).unwrap(),
740            "probe(); const a = 1;\n"
741        );
742
743        // A change to a source file is kept beside the instrumented copy,
744        // which stays what the tests run, and the authored text stays what
745        // the run started from.
746        fs::write(view.join("src/a.js"), "const a = 1\n").unwrap();
747        assert_eq!(reconcile(&workspace, &view, &state).unwrap(), 1);
748        assert_eq!(
749            fs::read_to_string(workspace.join(".supercov/node_modules/.changed/src/a.js")).unwrap(),
750            "const a = 1\n"
751        );
752        assert_eq!(
753            fs::read_to_string(workspace.join(".supercov/node_modules/.authored/src/a.js"))
754                .unwrap(),
755            "const a = 1;\n"
756        );
757        assert_eq!(
758            fs::read_to_string(workspace.join("src/a.js")).unwrap(),
759            "probe(); const a = 1;\n"
760        );
761        // Nothing more to keep when the next tool leaves it as it found it.
762        assert_eq!(reconcile(&workspace, &view, &state).unwrap(), 0);
763
764        // The next tool starts from the workspace again.
765        fs::remove_file(workspace.join("reports/lint.json")).unwrap();
766        let state = refresh(&workspace, &view, &dependencies).unwrap();
767        assert!(!view.join("reports/lint.json").exists());
768        // And from the source as the tool before it left it.
769        assert_eq!(
770            fs::read_to_string(view.join("src/a.js")).unwrap(),
771            "const a = 1\n"
772        );
773        fs::remove_file(view.join("README.md")).unwrap();
774        reconcile(&workspace, &view, &state).unwrap();
775        assert!(!workspace.join("README.md").exists());
776        fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
777    }
778
779    #[test]
780    fn a_tools_change_to_source_is_the_projects_when_the_command_ends() {
781        let (workspace, _, dependencies) = workspace();
782        let root = dependencies.parent().unwrap().to_path_buf();
783        fs::create_dir_all(root.join("src")).unwrap();
784        fs::create_dir_all(workspace.join("lib")).unwrap();
785        // Three rewritten files a formatter wrote. One of them was edited in
786        // the project while the command ran, and one was written as it was.
787        for (file, authored, project, written) in [
788            (
789                "src/a.js",
790                "const a = 1;\n",
791                "const a = 1;\n",
792                "const a = 1\n",
793            ),
794            (
795                "src/b.js",
796                "const b = 2;\n",
797                "const b = 3;\n",
798                "const b = 2\n",
799            ),
800            (
801                "src/c.js",
802                "const c = 4\n",
803                "const c = 4\n",
804                "const c = 4\n",
805            ),
806        ] {
807            fs::write(authored_path(&workspace, Path::new(file)), authored).unwrap();
808            fs::write(root.join(file), project).unwrap();
809            let changed = changed_path(&workspace, Path::new(file));
810            fs::create_dir_all(changed.parent().unwrap()).unwrap();
811            fs::write(changed, written).unwrap();
812        }
813
814        let changes = apply_source_changes(&root, &workspace).unwrap();
815
816        assert_eq!(changes.applied, [PathBuf::from("src/a.js")]);
817        assert_eq!(changes.kept_back, [PathBuf::from("src/b.js")]);
818        assert_eq!(
819            fs::read_to_string(root.join("src/a.js")).unwrap(),
820            "const a = 1\n"
821        );
822        assert_eq!(
823            fs::read_to_string(root.join("src/b.js")).unwrap(),
824            "const b = 3;\n"
825        );
826        // Applied once: the next command starts with nothing to apply.
827        assert_eq!(
828            apply_source_changes(&root, &workspace).unwrap(),
829            SourceChanges::default()
830        );
831        fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
832    }
833
834    #[test]
835    fn a_launcher_survives_a_quote_in_a_path() {
836        let text = launcher(
837            Path::new("/opt/it's here/supercov"),
838            Path::new("/work/app/node_modules/.bin/biome"),
839        );
840        assert!(text.starts_with("#!/bin/sh\n"), "{text}");
841        assert!(
842            text.contains("exec '/opt/it'\\''s here/supercov' __source-tool '/work/app/node_modules/.bin/biome' \"$@\"\n"),
843            "{text}"
844        );
845    }
846
847    #[test]
848    fn a_windows_tool_gets_a_launcher_for_each_of_its_shims() {
849        // npm writes three files for a tool on Windows: one `cmd.exe` runs,
850        // one for PowerShell, one for a POSIX shell. Each is replaced in its
851        // own language, and each runs the `.cmd`.
852        let (workspace, ..) = workspace();
853        let tools = workspace.join("node_modules/.bin");
854        for file in ["biome", "biome.cmd", "biome.ps1", "jest.cmd"] {
855            fs::write(tools.join(file), "").unwrap();
856        }
857        let judges = BTreeSet::from(["biome".to_owned()]);
858        let binary = Path::new("C:/Program Files/supercov.exe");
859        let shim = tools.join("biome.cmd");
860        let launcher = |name: &str| launcher_on(true, &judges, binary, &tools, OsStr::new(name));
861        assert_eq!(
862            launcher("biome.cmd").unwrap(),
863            format!(
864                "@ECHO off\r\n\"C:/Program Files/supercov.exe\" __source-tool \"{}\" %*\r\n",
865                shim.display()
866            )
867        );
868        assert_eq!(
869            launcher("biome.ps1").unwrap(),
870            format!(
871                "& 'C:/Program Files/supercov.exe' __source-tool '{}' @args\r\nexit $LASTEXITCODE\r\n",
872                shim.display()
873            )
874        );
875        assert!(launcher("biome").unwrap().starts_with("#!/bin/sh\n"));
876        assert!(
877            launcher("biome")
878                .unwrap()
879                .contains(&format!("'{}'", shim.display()))
880        );
881        // A tool that runs code is started as it is.
882        assert_eq!(launcher("jest.cmd"), None);
883        // Elsewhere a tool is one file, started by its name.
884        assert!(launcher_on(false, &judges, binary, &tools, OsStr::new("biome")).is_some());
885        assert_eq!(
886            launcher_on(false, &judges, binary, &tools, OsStr::new("biome.cmd")),
887            None
888        );
889        fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
890    }
891
892    #[test]
893    fn a_package_bin_of_its_own_gets_launchers_in_place() {
894        // A workspace package's dependencies are a tree of their own in the
895        // workspace, hard links to the project's files where the filesystem
896        // has no clones. A launcher replaces the entry: written through it,
897        // it would have been written into the project.
898        let (workspace, _, dependencies) = workspace();
899        let tools = dependencies.join(".bin");
900        fs::create_dir_all(&tools).unwrap();
901        fs::write(tools.join("oxlint"), "the project's own\n").unwrap();
902        fs::write(tools.join("jest"), "runs code\n").unwrap();
903        let copy = workspace.join("packages/app/node_modules/.bin");
904        fs::create_dir_all(&copy).unwrap();
905        fs::hard_link(tools.join("oxlint"), copy.join("oxlint")).unwrap();
906        fs::hard_link(tools.join("jest"), copy.join("jest")).unwrap();
907
908        place_launchers(&tools, &copy).unwrap();
909
910        assert!(
911            fs::read_to_string(copy.join("oxlint"))
912                .unwrap()
913                .contains("__source-tool"),
914        );
915        assert_eq!(
916            fs::read_to_string(tools.join("oxlint")).unwrap(),
917            "the project's own\n"
918        );
919        assert_eq!(
920            fs::read_to_string(copy.join("jest")).unwrap(),
921            "runs code\n"
922        );
923        fs::remove_dir_all(workspace.ancestors().nth(3).unwrap()).unwrap();
924    }
925
926    #[test]
927    fn a_path_in_an_argument_is_the_views() {
928        let translated = in_view(
929            OsStr::new("--config=/w/workspaces/workspace/p/biome.json"),
930            &[Path::new("/w/workspaces/workspace/p")],
931            Path::new("/w/workspaces/source/p"),
932        );
933        assert_eq!(translated, "--config=/w/workspaces/source/p/biome.json");
934    }
935}