Skip to main content

geop/
vscode.rs

1//! `geop vscode`: install the VS Code extension of this very release —
2//! the one whose kernel and page speak this build's protocol — into VS
3//! Code, or an editor built on it (Cursor, VSCodium).
4//!
5//! The extension is downloaded from the release (see [`release`]), its
6//! SHA-256 checked, and given to the editor's command line:
7//! `code --install-extension <file>`.
8
9use crate::release;
10use geop_core_math::geop_error::{GeopError, GeopResult};
11use std::{
12    ffi::OsStr,
13    path::{Path, PathBuf},
14    process::Command,
15};
16
17#[derive(clap::Args)]
18pub struct VscodeArgs {
19    /// The editor's command line to install into: `code`, `cursor`,
20    /// `codium`, or a path to one. The first of those on PATH, if not
21    /// given.
22    #[arg(long, value_name = "CMD")]
23    pub editor: Option<String>,
24}
25
26/// The command lines of the editors an extension of VS Code installs into,
27/// in the order they are looked for.
28const EDITORS: [&str; 3] = ["code", "cursor", "codium"];
29
30/// The file `name` names among the directories `path` (as `PATH` lists
31/// them), with one of the extensions `extensions` (`""` for none): what a
32/// shell would run for `name`. A name with a directory in it is taken as
33/// it is.
34pub fn find_command(name: &str, path: &OsStr, extensions: &[&str]) -> Option<PathBuf> {
35    let candidates = |dir: &Path| -> Vec<PathBuf> {
36        (extensions.iter())
37            .map(|e| dir.join(format!("{name}{e}")))
38            .collect()
39    };
40    if Path::new(name).components().count() > 1 {
41        return candidates(Path::new("")).into_iter().find(|p| p.is_file());
42    }
43    std::env::split_paths(path)
44        .flat_map(|dir| candidates(&dir))
45        .find(|p| p.is_file())
46}
47
48/// The extensions a command is run with here: Windows runs `code.cmd` for
49/// `code`.
50fn command_extensions() -> Vec<String> {
51    if cfg!(windows) {
52        let listed = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
53        (listed.split(';'))
54            .filter(|e| !e.is_empty())
55            .map(|e| e.to_ascii_lowercase())
56            .collect()
57    } else {
58        vec![String::new()]
59    }
60}
61
62/// The editor command line to install into: `chosen`, or the first of
63/// [`EDITORS`] on PATH.
64fn editor(chosen: Option<&str>) -> GeopResult<PathBuf> {
65    let path = std::env::var_os("PATH").unwrap_or_default();
66    let extensions = command_extensions();
67    let extensions: Vec<&str> = extensions.iter().map(String::as_str).collect();
68    if let Some(chosen) = chosen {
69        return find_command(chosen, &path, &extensions)
70            .ok_or_else(|| GeopError::new(format!("no command {chosen:?} found on PATH")));
71    }
72    (EDITORS.iter())
73        .find_map(|name| find_command(name, &path, &extensions))
74        .ok_or_else(|| {
75            GeopError::new(format!(
76                "none of the editor command lines {} is on PATH. In VS Code, run \
77                 \"Shell Command: Install 'code' command in PATH\" from the command palette, \
78                 or pass the editor's command with --editor",
79                EDITORS.join(", ")
80            ))
81        })
82}
83
84pub fn install(args: &VscodeArgs) -> GeopResult<()> {
85    let editor = editor(args.editor.as_deref())?;
86    let version = env!("CARGO_PKG_VERSION");
87    let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
88    let target = release::vscode_target(os, arch).ok_or_else(|| {
89        GeopError::new(format!(
90            "no geop VS Code extension is released for {os} {arch}"
91        ))
92    })?;
93    let name = release::vsix_name(version, target);
94    eprintln!("downloading {name}");
95    let vsix = release::download(version, &name)?;
96
97    let dir = std::env::temp_dir().join(format!("geop-vscode-{}", std::process::id()));
98    let file = dir.join(&name);
99    let written = std::fs::create_dir_all(&dir).and_then(|()| std::fs::write(&file, &vsix));
100    let installed = written
101        .map_err(|e| GeopError::new(format!("writing {}: {e}", file.display())))
102        .and_then(|()| {
103            eprintln!("installing it with {}", editor.display());
104            let status = Command::new(&editor)
105                .arg("--install-extension")
106                .arg(&file)
107                .arg("--force")
108                .status()
109                .map_err(|e| GeopError::new(format!("running {}: {e}", editor.display())))?;
110            if !status.success() {
111                return Err(GeopError::new(format!(
112                    "{} --install-extension failed ({status})",
113                    editor.display()
114                )));
115            }
116            Ok(())
117        });
118    let _ = std::fs::remove_dir_all(&dir);
119    installed?;
120    eprintln!("installed the geop extension {version}: open a .geop file in the editor");
121    Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn a_command_is_found_on_path_with_its_extension() {
130        let dir = std::env::temp_dir().join(format!("geop-cli-find-{}", std::process::id()));
131        let (a, b) = (dir.join("a"), dir.join("b"));
132        std::fs::create_dir_all(&a).unwrap();
133        std::fs::create_dir_all(&b).unwrap();
134        std::fs::write(b.join("code.cmd"), "").unwrap();
135        std::fs::write(a.join("cursor"), "").unwrap();
136        let path = std::env::join_paths([&a, &b]).unwrap();
137        assert_eq!(
138            find_command("code", &path, &[".exe", ".cmd"]),
139            Some(b.join("code.cmd"))
140        );
141        assert_eq!(find_command("code", &path, &[""]), None);
142        assert_eq!(find_command("cursor", &path, &[""]), Some(a.join("cursor")));
143        // A path is taken as it is, not looked for.
144        let given = b.join("code").to_string_lossy().into_owned();
145        assert_eq!(
146            find_command(&given, OsStr::new(""), &[".cmd"]),
147            Some(b.join("code.cmd"))
148        );
149    }
150}