plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-cli/src/editor.rs
| 141de54 | 1 | //! Installs Plum syntax-highlighting support into the local editor configuration. |
| 141de54 | 2 | //! |
| 141de54 | 3 | //! Everything the installers write is embedded into the `plum` binary at compile |
| 141de54 | 4 | //! time (via `include_str!`, relative to this crate), so `plum editor ...` works |
| 141de54 | 5 | //! from any working directory — it doesn't depend on being run from inside a |
| 141de54 | 6 | //! checkout the way a plain relative-path copy would. |
| 141de54 | 7 | |
| 141de54 | 8 | use std::env; |
| 141de54 | 9 | use std::fs; |
| 141de54 | 10 | use std::path::{Path, PathBuf}; |
| 141de54 | 11 | |
| 141de54 | 12 | use anyhow::{Context, Result}; |
| 141de54 | 13 | use clap::ValueEnum; |
| 141de54 | 14 | |
| 141de54 | 15 | #[derive(Clone, Copy, ValueEnum)] |
| 141de54 | 16 | pub enum EditorType { |
| 141de54 | 17 | Helix, |
| 141de54 | 18 | Vscode, |
| 141de54 | 19 | } |
| 141de54 | 20 | |
| 141de54 | 21 | // ---- embedded VSCode extension assets ------------------------------------- |
| 141de54 | 22 | |
| 141de54 | 23 | const VSCODE_PACKAGE_JSON: &str = |
| 141de54 | 24 | include_str!("../../tooling/vscode-plum/package.json"); |
| 141de54 | 25 | const VSCODE_LANGUAGE_CONFIG: &str = |
| 141de54 | 26 | include_str!("../../tooling/vscode-plum/language-configuration.json"); |
| 141de54 | 27 | const VSCODE_TMLANGUAGE: &str = |
| 141de54 | 28 | include_str!("../../tooling/vscode-plum/syntaxes/plum.tmLanguage.json"); |
| 141de54 | 29 | |
| 141de54 | 30 | const VSCODE_PUBLISHER: &str = "plum-lang"; |
| 141de54 | 31 | const VSCODE_EXTENSION_NAME: &str = "plum"; |
| 141de54 | 32 | const VSCODE_EXTENSION_VERSION: &str = "0.0.1"; |
| 141de54 | 33 | |
| 141de54 | 34 | // ---- embedded Helix / tree-sitter query assets ---------------------------- |
| 141de54 | 35 | |
| 141de54 | 36 | const HELIX_HIGHLIGHTS: &str = |
| 141de54 | 37 | include_str!("../../tooling/tree-sitter-plum/queries/plum/highlights.scm"); |
| 141de54 | 38 | const HELIX_INDENTS: &str = |
| 141de54 | 39 | include_str!("../../tooling/tree-sitter-plum/queries/plum/indents.scm"); |
| 141de54 | 40 | const HELIX_INJECTIONS: &str = |
| 141de54 | 41 | include_str!("../../tooling/tree-sitter-plum/queries/plum/injections.scm"); |
| 141de54 | 42 | const HELIX_TAGS: &str = |
| 141de54 | 43 | include_str!("../../tooling/tree-sitter-plum/queries/plum/tags.scm"); |
| ab4f826 | 44 | const HELIX_TEXTOBJECTS: &str = |
| ab4f826 | 45 | include_str!("../../tooling/tree-sitter-plum/queries/plum/textobjects.scm"); |
| 141de54 | 46 | |
| 141de54 | 47 | /// The absolute path to the `tree-sitter-plum` grammar crate on the machine that |
| 141de54 | 48 | /// *built* this `plum` binary. Baked in at compile time so a contributor running |
| 141de54 | 49 | /// `cargo run -p plum-cli -- editor helix` from their own checkout gets a |
| 141de54 | 50 | /// languages.toml entry that actually resolves, instead of a hand-copied path |
| 141de54 | 51 | /// that goes stale the moment the repo moves. |
| 141de54 | 52 | const TREE_SITTER_PLUM_DIR: &str = |
| 141de54 | 53 | concat!(env!("CARGO_MANIFEST_DIR"), "/../tooling/tree-sitter-plum"); |
| 141de54 | 54 | |
| 8eeb785 | 55 | /// [`TREE_SITTER_PLUM_DIR`] with the `..` component resolved away, for a tidier |
| 8eeb785 | 56 | /// `languages.toml`. Falls back to the raw (still-correct, just uglier) path if |
| 8eeb785 | 57 | /// the directory has moved since this binary was built. |
| 3d6f280 | 58 | fn treeSitterPlumDir() -> String { |
| 8eeb785 | 59 | fs::canonicalize(TREE_SITTER_PLUM_DIR) |
| 8eeb785 | 60 | .map(|p| p.display().to_string()) |
| 8eeb785 | 61 | .unwrap_or_else(|_| TREE_SITTER_PLUM_DIR.to_string()) |
| 8eeb785 | 62 | } |
| 8eeb785 | 63 | |
| 141de54 | 64 | pub fn install(editor: EditorType, insiders: bool) -> Result<()> { |
| 141de54 | 65 | match editor { |
| 3d6f280 | 66 | EditorType::Helix => installHelix(), |
| 3d6f280 | 67 | EditorType::Vscode => installVscode(insiders), |
| 141de54 | 68 | } |
| 141de54 | 69 | } |
| 141de54 | 70 | |
| 141de54 | 71 | // ---- Helix ----------------------------------------------------------------- |
| 141de54 | 72 | |
| 3d6f280 | 73 | fn helixConfigDir() -> Result<PathBuf> { |
| 141de54 | 74 | if let Ok(dir) = env::var("HELIX_RUNTIME_CONFIG") { |
| 141de54 | 75 | return Ok(PathBuf::from(dir)); |
| 141de54 | 76 | } |
| 141de54 | 77 | if let Ok(dir) = env::var("XDG_CONFIG_HOME") { |
| 141de54 | 78 | return Ok(PathBuf::from(dir).join("helix")); |
| 141de54 | 79 | } |
| 141de54 | 80 | if let Ok(appdata) = env::var("APPDATA") { |
| 141de54 | 81 | return Ok(PathBuf::from(appdata).join("helix")); |
| 141de54 | 82 | } |
| 141de54 | 83 | let home = env::var("HOME") |
| 141de54 | 84 | .or_else(|_| env::var("USERPROFILE")) |
| 141de54 | 85 | .context("could not determine home directory (checked $HOME / $USERPROFILE)")?; |
| 141de54 | 86 | Ok(PathBuf::from(home).join(".config").join("helix")) |
| 141de54 | 87 | } |
| 141de54 | 88 | |
| 3d6f280 | 89 | fn installHelix() -> Result<()> { |
| 3d6f280 | 90 | let config_dir = helixConfigDir()?; |
| 141de54 | 91 | fs::create_dir_all(&config_dir) |
| 141de54 | 92 | .with_context(|| format!("failed to create {}", config_dir.display()))?; |
| 141de54 | 93 | |
| 3d6f280 | 94 | mergeLanguagesToml(&config_dir.join("languages.toml"))?; |
| 141de54 | 95 | |
| 141de54 | 96 | let queries_dir = config_dir.join("runtime").join("queries").join("plum"); |
| 141de54 | 97 | fs::create_dir_all(&queries_dir) |
| 141de54 | 98 | .with_context(|| format!("failed to create {}", queries_dir.display()))?; |
| 141de54 | 99 | fs::write(queries_dir.join("highlights.scm"), HELIX_HIGHLIGHTS)?; |
| 141de54 | 100 | fs::write(queries_dir.join("indents.scm"), HELIX_INDENTS)?; |
| 141de54 | 101 | fs::write(queries_dir.join("injections.scm"), HELIX_INJECTIONS)?; |
| 141de54 | 102 | fs::write(queries_dir.join("tags.scm"), HELIX_TAGS)?; |
| ab4f826 | 103 | fs::write(queries_dir.join("textobjects.scm"), HELIX_TEXTOBJECTS)?; |
| 141de54 | 104 | |
| 141de54 | 105 | println!("installed Plum support for Helix in {}", config_dir.display()); |
| 8eeb785 | 106 | |
| 8eeb785 | 107 | // Registering the language and dropping in query files isn't enough on its |
| 8eeb785 | 108 | // own — Helix only highlights a file once the grammar has been compiled |
| 8eeb785 | 109 | // into a shared library under runtime/grammars/. `hx --health <lang>` |
| 8eeb785 | 110 | // reports the tree-sitter parser as present just because the config is |
| 8eeb785 | 111 | // valid, even when that .so has never been built, which makes a missing |
| 8eeb785 | 112 | // build step easy to miss. Build it now instead of just telling the user to. |
| 3d6f280 | 113 | buildGrammar(); |
| 141de54 | 114 | Ok(()) |
| 141de54 | 115 | } |
| 141de54 | 116 | |
| 3d6f280 | 117 | fn buildGrammar() { |
| ab4f826 | 118 | let mut cmd = std::process::Command::new("hx"); |
| ab4f826 | 119 | cmd.arg("--grammar").arg("build"); |
| ab4f826 | 120 | |
| ab4f826 | 121 | // When `plum` itself is run via `cargo run`/`cargo test`, Cargo sets |
| ab4f826 | 122 | // CARGO_MANIFEST_DIR (and friends) in *this* process's environment, and a |
| ab4f826 | 123 | // plain `Command` inherits it into the `hx` child. Helix's own runtime-dir |
| ab4f826 | 124 | // resolution treats a present CARGO_MANIFEST_DIR as "I'm Helix's own dev |
| ab4f826 | 125 | // build running under cargo", and resolves grammar output relative to |
| ab4f826 | 126 | // *our* workspace root instead of the user's real Helix config — so `hx` |
| ab4f826 | 127 | // silently tries to write the compiled grammar to `<plum repo>/runtime/` |
| ab4f826 | 128 | // rather than `~/.config/helix/runtime/`. Strip every CARGO_* var so `hx` |
| ab4f826 | 129 | // falls back to its normal (non-dev) config/runtime-dir resolution. |
| ab4f826 | 130 | for (key, _) in env::vars() { |
| ab4f826 | 131 | if key.starts_with("CARGO") { |
| ab4f826 | 132 | cmd.env_remove(key); |
| ab4f826 | 133 | } |
| ab4f826 | 134 | } |
| ab4f826 | 135 | |
| ab4f826 | 136 | match cmd.status() { |
| 8eeb785 | 137 | Ok(status) if status.success() => { |
| 8eeb785 | 138 | println!("compiled the Plum tree-sitter grammar (hx --grammar build)"); |
| 8eeb785 | 139 | } |
| 8eeb785 | 140 | Ok(status) => { |
| 8eeb785 | 141 | println!( |
| 8eeb785 | 142 | "`hx --grammar build` exited with {status}; run it manually to compile the Plum grammar" |
| 8eeb785 | 143 | ); |
| 8eeb785 | 144 | } |
| 8eeb785 | 145 | Err(_) => { |
| 8eeb785 | 146 | println!("could not find `hx` on PATH; run `hx --grammar build` manually to compile the Plum grammar"); |
| 8eeb785 | 147 | } |
| 8eeb785 | 148 | } |
| 8eeb785 | 149 | } |
| 8eeb785 | 150 | |
| 141de54 | 151 | /// Structurally merges (rather than blindly appends, unlike a plain text-append) |
| 141de54 | 152 | /// a `[[language]]` + `[[grammar]]` entry for `plum` into the user's |
| 141de54 | 153 | /// `languages.toml`, using `toml_edit` so existing formatting/comments survive |
| 141de54 | 154 | /// and re-running the install is a no-op if the entry is already present. |
| 3d6f280 | 155 | fn mergeLanguagesToml(path: &Path) -> Result<()> { |
| 141de54 | 156 | let existing = if path.exists() { |
| 141de54 | 157 | fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))? |
| 141de54 | 158 | } else { |
| 141de54 | 159 | String::new() |
| 141de54 | 160 | }; |
| 141de54 | 161 | let mut doc = existing |
| 141de54 | 162 | .parse::<toml_edit::DocumentMut>() |
| 141de54 | 163 | .with_context(|| format!("failed to parse {} as TOML", path.display()))?; |
| 141de54 | 164 | |
| 141de54 | 165 | let already_present = doc |
| 141de54 | 166 | .get("language") |
| 141de54 | 167 | .and_then(|v| v.as_array_of_tables()) |
| 141de54 | 168 | .map(|arr| arr.iter().any(|t| t.get("name").and_then(|n| n.as_str()) == Some("plum"))) |
| 141de54 | 169 | .unwrap_or(false); |
| 141de54 | 170 | |
| 141de54 | 171 | if already_present { |
| 141de54 | 172 | println!("{} already has a `plum` language entry, leaving it as-is", path.display()); |
| 141de54 | 173 | return Ok(()); |
| 141de54 | 174 | } |
| 141de54 | 175 | |
| 141de54 | 176 | let mut language = toml_edit::Table::new(); |
| 141de54 | 177 | language["name"] = toml_edit::value("plum"); |
| 141de54 | 178 | language["scope"] = toml_edit::value("source.plum"); |
| 141de54 | 179 | language["injection-regex"] = toml_edit::value("plum"); |
| 141de54 | 180 | let mut file_types = toml_edit::Array::new(); |
| 141de54 | 181 | file_types.push("plum"); |
| 141de54 | 182 | language["file-types"] = toml_edit::value(file_types); |
| 141de54 | 183 | language["comment-tokens"] = toml_edit::value("#"); |
| 141de54 | 184 | let mut indent = toml_edit::InlineTable::new(); |
| 141de54 | 185 | indent.insert("tab-width", 2.into()); |
| 141de54 | 186 | indent.insert("unit", " ".into()); |
| 141de54 | 187 | language["indent"] = toml_edit::Item::Value(toml_edit::Value::InlineTable(indent)); |
| 141de54 | 188 | |
| 141de54 | 189 | doc.entry("language") |
| 141de54 | 190 | .or_insert(toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new())) |
| 141de54 | 191 | .as_array_of_tables_mut() |
| 141de54 | 192 | .context("`language` key in languages.toml is not an array of tables")? |
| 141de54 | 193 | .push(language); |
| 141de54 | 194 | |
| 141de54 | 195 | let mut grammar = toml_edit::Table::new(); |
| 141de54 | 196 | grammar["name"] = toml_edit::value("plum"); |
| 141de54 | 197 | let mut source = toml_edit::InlineTable::new(); |
| 3d6f280 | 198 | source.insert("path", treeSitterPlumDir().into()); |
| 141de54 | 199 | grammar["source"] = toml_edit::Item::Value(toml_edit::Value::InlineTable(source)); |
| 141de54 | 200 | |
| 141de54 | 201 | doc.entry("grammar") |
| 141de54 | 202 | .or_insert(toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new())) |
| 141de54 | 203 | .as_array_of_tables_mut() |
| 141de54 | 204 | .context("`grammar` key in languages.toml is not an array of tables")? |
| 141de54 | 205 | .push(grammar); |
| 141de54 | 206 | |
| 141de54 | 207 | fs::write(path, doc.to_string()).with_context(|| format!("failed to write {}", path.display()))?; |
| 141de54 | 208 | Ok(()) |
| 141de54 | 209 | } |
| 141de54 | 210 | |
| 141de54 | 211 | // ---- VSCode ----------------------------------------------------------------- |
| 141de54 | 212 | |
| 3d6f280 | 213 | fn homeDir() -> Result<PathBuf> { |
| 141de54 | 214 | let home = env::var("HOME") |
| 141de54 | 215 | .or_else(|_| env::var("USERPROFILE")) |
| 141de54 | 216 | .context("could not determine home directory (checked $HOME / $USERPROFILE)")?; |
| 141de54 | 217 | Ok(PathBuf::from(home)) |
| 141de54 | 218 | } |
| 141de54 | 219 | |
| 3d6f280 | 220 | fn installVscode(insiders: bool) -> Result<()> { |
| 141de54 | 221 | let vscode_dir_name = if insiders { ".vscode-insiders" } else { ".vscode" }; |
| 141de54 | 222 | let extension_id = format!("{VSCODE_PUBLISHER}.{VSCODE_EXTENSION_NAME}-{VSCODE_EXTENSION_VERSION}"); |
| 3d6f280 | 223 | let target = homeDir()?.join(vscode_dir_name).join("extensions").join(&extension_id); |
| 141de54 | 224 | |
| 141de54 | 225 | if target.exists() { |
| 141de54 | 226 | fs::remove_dir_all(&target) |
| 141de54 | 227 | .with_context(|| format!("failed to remove existing {}", target.display()))?; |
| 141de54 | 228 | } |
| 141de54 | 229 | let syntaxes_dir = target.join("syntaxes"); |
| 141de54 | 230 | fs::create_dir_all(&syntaxes_dir) |
| 141de54 | 231 | .with_context(|| format!("failed to create {}", syntaxes_dir.display()))?; |
| 141de54 | 232 | |
| 141de54 | 233 | fs::write(target.join("package.json"), VSCODE_PACKAGE_JSON)?; |
| 141de54 | 234 | fs::write(target.join("language-configuration.json"), VSCODE_LANGUAGE_CONFIG)?; |
| 141de54 | 235 | fs::write(syntaxes_dir.join("plum.tmLanguage.json"), VSCODE_TMLANGUAGE)?; |
| 141de54 | 236 | |
| 141de54 | 237 | println!("installed Plum extension for VSCode{} at {}", if insiders { " Insiders" } else { "" }, target.display()); |
| 141de54 | 238 | println!("reload the editor window (or restart VSCode) to pick it up"); |
| 141de54 | 239 | Ok(()) |
| 141de54 | 240 | } |
| 141de54 | 241 | |
| 141de54 | 242 | #[cfg(test)] |
| 141de54 | 243 | mod tests { |
| 141de54 | 244 | use super::*; |
| 141de54 | 245 | |
| 141de54 | 246 | #[test] |
| 3d6f280 | 247 | fn embeddedVscodePackageJsonMatchesPinnedIdentity() { |
| 141de54 | 248 | assert!(VSCODE_PACKAGE_JSON.contains(&format!("\"version\": \"{VSCODE_EXTENSION_VERSION}\""))); |
| 141de54 | 249 | assert!(VSCODE_PACKAGE_JSON.contains(&format!("\"publisher\": \"{VSCODE_PUBLISHER}\""))); |
| 141de54 | 250 | } |
| 141de54 | 251 | |
| 141de54 | 252 | #[test] |
| 3d6f280 | 253 | fn mergeLanguagesTomlIsIdempotent() { |
| 141de54 | 254 | let dir = std::env::temp_dir().join(format!("plum-editor-test-{}", std::process::id())); |
| 141de54 | 255 | std::fs::create_dir_all(&dir).unwrap(); |
| 141de54 | 256 | let path = dir.join("languages.toml"); |
| 141de54 | 257 | |
| 3d6f280 | 258 | mergeLanguagesToml(&path).unwrap(); |
| 141de54 | 259 | let first = std::fs::read_to_string(&path).unwrap(); |
| 141de54 | 260 | assert!(first.contains("name = \"plum\"")); |
| 141de54 | 261 | |
| 3d6f280 | 262 | mergeLanguagesToml(&path).unwrap(); |
| 141de54 | 263 | let second = std::fs::read_to_string(&path).unwrap(); |
| 141de54 | 264 | assert_eq!(first, second, "second install should be a no-op"); |
| 141de54 | 265 | |
| 141de54 | 266 | std::fs::remove_dir_all(&dir).ok(); |
| 141de54 | 267 | } |
| 141de54 | 268 | } |