plum

#treesitter#compiler#wasm

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
}