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
//! Installs Plum syntax-highlighting support into the local editor configuration.
//!
//! Everything the installers write is embedded into the `plum` binary at compile
//! time (via `include_str!`, relative to this crate), so `plum editor ...` works
//! from any working directory — it doesn't depend on being run from inside a
//! checkout the way a plain relative-path copy would.

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use clap::ValueEnum;

#[derive(Clone, Copy, ValueEnum)]
pub enum EditorType {
    Helix,
    Vscode,
}

// ---- embedded VSCode extension assets -------------------------------------

const VSCODE_PACKAGE_JSON: &str =
    include_str!("../../tooling/vscode-plum/package.json");
const VSCODE_LANGUAGE_CONFIG: &str =
    include_str!("../../tooling/vscode-plum/language-configuration.json");
const VSCODE_TMLANGUAGE: &str =
    include_str!("../../tooling/vscode-plum/syntaxes/plum.tmLanguage.json");

const VSCODE_PUBLISHER: &str = "plum-lang";
const VSCODE_EXTENSION_NAME: &str = "plum";
const VSCODE_EXTENSION_VERSION: &str = "0.0.1";

// ---- embedded Helix / tree-sitter query assets ----------------------------

const HELIX_HIGHLIGHTS: &str =
    include_str!("../../tooling/tree-sitter-plum/queries/plum/highlights.scm");
const HELIX_INDENTS: &str =
    include_str!("../../tooling/tree-sitter-plum/queries/plum/indents.scm");
const HELIX_INJECTIONS: &str =
    include_str!("../../tooling/tree-sitter-plum/queries/plum/injections.scm");
const HELIX_TAGS: &str =
    include_str!("../../tooling/tree-sitter-plum/queries/plum/tags.scm");
const HELIX_TEXTOBJECTS: &str =
    include_str!("../../tooling/tree-sitter-plum/queries/plum/textobjects.scm");

/// The absolute path to the `tree-sitter-plum` grammar crate on the machine that
/// *built* this `plum` binary. Baked in at compile time so a contributor running
/// `cargo run -p plum-cli -- editor helix` from their own checkout gets a
/// languages.toml entry that actually resolves, instead of a hand-copied path
/// that goes stale the moment the repo moves.
const TREE_SITTER_PLUM_DIR: &str =
    concat!(env!("CARGO_MANIFEST_DIR"), "/../tooling/tree-sitter-plum");

/// [`TREE_SITTER_PLUM_DIR`] with the `..` component resolved away, for a tidier
/// `languages.toml`. Falls back to the raw (still-correct, just uglier) path if
/// the directory has moved since this binary was built.
fn treeSitterPlumDir() -> String {
    fs::canonicalize(TREE_SITTER_PLUM_DIR)
        .map(|p| p.display().to_string())
        .unwrap_or_else(|_| TREE_SITTER_PLUM_DIR.to_string())
}

pub fn install(editor: EditorType, insiders: bool) -> Result<()> {
    match editor {
        EditorType::Helix => installHelix(),
        EditorType::Vscode => installVscode(insiders),
    }
}

// ---- Helix -----------------------------------------------------------------

fn helixConfigDir() -> Result<PathBuf> {
    if let Ok(dir) = env::var("HELIX_RUNTIME_CONFIG") {
        return Ok(PathBuf::from(dir));
    }
    if let Ok(dir) = env::var("XDG_CONFIG_HOME") {
        return Ok(PathBuf::from(dir).join("helix"));
    }
    if let Ok(appdata) = env::var("APPDATA") {
        return Ok(PathBuf::from(appdata).join("helix"));
    }
    let home = env::var("HOME")
        .or_else(|_| env::var("USERPROFILE"))
        .context("could not determine home directory (checked $HOME / $USERPROFILE)")?;
    Ok(PathBuf::from(home).join(".config").join("helix"))
}

fn installHelix() -> Result<()> {
    let config_dir = helixConfigDir()?;
    fs::create_dir_all(&config_dir)
        .with_context(|| format!("failed to create {}", config_dir.display()))?;

    mergeLanguagesToml(&config_dir.join("languages.toml"))?;

    let queries_dir = config_dir.join("runtime").join("queries").join("plum");
    fs::create_dir_all(&queries_dir)
        .with_context(|| format!("failed to create {}", queries_dir.display()))?;
    fs::write(queries_dir.join("highlights.scm"), HELIX_HIGHLIGHTS)?;
    fs::write(queries_dir.join("indents.scm"), HELIX_INDENTS)?;
    fs::write(queries_dir.join("injections.scm"), HELIX_INJECTIONS)?;
    fs::write(queries_dir.join("tags.scm"), HELIX_TAGS)?;
    fs::write(queries_dir.join("textobjects.scm"), HELIX_TEXTOBJECTS)?;

    println!("installed Plum support for Helix in {}", config_dir.display());

    // Registering the language and dropping in query files isn't enough on its
    // own — Helix only highlights a file once the grammar has been compiled
    // into a shared library under runtime/grammars/. `hx --health <lang>`
    // reports the tree-sitter parser as present just because the config is
    // valid, even when that .so has never been built, which makes a missing
    // build step easy to miss. Build it now instead of just telling the user to.
    buildGrammar();
    Ok(())
}

fn buildGrammar() {
    let mut cmd = std::process::Command::new("hx");
    cmd.arg("--grammar").arg("build");

    // When `plum` itself is run via `cargo run`/`cargo test`, Cargo sets
    // CARGO_MANIFEST_DIR (and friends) in *this* process's environment, and a
    // plain `Command` inherits it into the `hx` child. Helix's own runtime-dir
    // resolution treats a present CARGO_MANIFEST_DIR as "I'm Helix's own dev
    // build running under cargo", and resolves grammar output relative to
    // *our* workspace root instead of the user's real Helix config — so `hx`
    // silently tries to write the compiled grammar to `<plum repo>/runtime/`
    // rather than `~/.config/helix/runtime/`. Strip every CARGO_* var so `hx`
    // falls back to its normal (non-dev) config/runtime-dir resolution.
    for (key, _) in env::vars() {
        if key.starts_with("CARGO") {
            cmd.env_remove(key);
        }
    }

    match cmd.status() {
        Ok(status) if status.success() => {
            println!("compiled the Plum tree-sitter grammar (hx --grammar build)");
        }
        Ok(status) => {
            println!(
                "`hx --grammar build` exited with {status}; run it manually to compile the Plum grammar"
            );
        }
        Err(_) => {
            println!("could not find `hx` on PATH; run `hx --grammar build` manually to compile the Plum grammar");
        }
    }
}

/// Structurally merges (rather than blindly appends, unlike a plain text-append)
/// a `[[language]]` + `[[grammar]]` entry for `plum` into the user's
/// `languages.toml`, using `toml_edit` so existing formatting/comments survive
/// and re-running the install is a no-op if the entry is already present.
fn mergeLanguagesToml(path: &Path) -> Result<()> {
    let existing = if path.exists() {
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?
    } else {
        String::new()
    };
    let mut doc = existing
        .parse::<toml_edit::DocumentMut>()
        .with_context(|| format!("failed to parse {} as TOML", path.display()))?;

    let already_present = doc
        .get("language")
        .and_then(|v| v.as_array_of_tables())
        .map(|arr| arr.iter().any(|t| t.get("name").and_then(|n| n.as_str()) == Some("plum")))
        .unwrap_or(false);

    if already_present {
        println!("{} already has a `plum` language entry, leaving it as-is", path.display());
        return Ok(());
    }

    let mut language = toml_edit::Table::new();
    language["name"] = toml_edit::value("plum");
    language["scope"] = toml_edit::value("source.plum");
    language["injection-regex"] = toml_edit::value("plum");
    let mut file_types = toml_edit::Array::new();
    file_types.push("plum");
    language["file-types"] = toml_edit::value(file_types);
    language["comment-tokens"] = toml_edit::value("#");
    let mut indent = toml_edit::InlineTable::new();
    indent.insert("tab-width", 2.into());
    indent.insert("unit", "  ".into());
    language["indent"] = toml_edit::Item::Value(toml_edit::Value::InlineTable(indent));

    doc.entry("language")
        .or_insert(toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
        .as_array_of_tables_mut()
        .context("`language` key in languages.toml is not an array of tables")?
        .push(language);

    let mut grammar = toml_edit::Table::new();
    grammar["name"] = toml_edit::value("plum");
    let mut source = toml_edit::InlineTable::new();
    source.insert("path", treeSitterPlumDir().into());
    grammar["source"] = toml_edit::Item::Value(toml_edit::Value::InlineTable(source));

    doc.entry("grammar")
        .or_insert(toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
        .as_array_of_tables_mut()
        .context("`grammar` key in languages.toml is not an array of tables")?
        .push(grammar);

    fs::write(path, doc.to_string()).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}

// ---- VSCode -----------------------------------------------------------------

fn homeDir() -> Result<PathBuf> {
    let home = env::var("HOME")
        .or_else(|_| env::var("USERPROFILE"))
        .context("could not determine home directory (checked $HOME / $USERPROFILE)")?;
    Ok(PathBuf::from(home))
}

fn installVscode(insiders: bool) -> Result<()> {
    let vscode_dir_name = if insiders { ".vscode-insiders" } else { ".vscode" };
    let extension_id = format!("{VSCODE_PUBLISHER}.{VSCODE_EXTENSION_NAME}-{VSCODE_EXTENSION_VERSION}");
    let target = homeDir()?.join(vscode_dir_name).join("extensions").join(&extension_id);

    if target.exists() {
        fs::remove_dir_all(&target)
            .with_context(|| format!("failed to remove existing {}", target.display()))?;
    }
    let syntaxes_dir = target.join("syntaxes");
    fs::create_dir_all(&syntaxes_dir)
        .with_context(|| format!("failed to create {}", syntaxes_dir.display()))?;

    fs::write(target.join("package.json"), VSCODE_PACKAGE_JSON)?;
    fs::write(target.join("language-configuration.json"), VSCODE_LANGUAGE_CONFIG)?;
    fs::write(syntaxes_dir.join("plum.tmLanguage.json"), VSCODE_TMLANGUAGE)?;

    println!("installed Plum extension for VSCode{} at {}", if insiders { " Insiders" } else { "" }, target.display());
    println!("reload the editor window (or restart VSCode) to pick it up");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn embeddedVscodePackageJsonMatchesPinnedIdentity() {
        assert!(VSCODE_PACKAGE_JSON.contains(&format!("\"version\": \"{VSCODE_EXTENSION_VERSION}\"")));
        assert!(VSCODE_PACKAGE_JSON.contains(&format!("\"publisher\": \"{VSCODE_PUBLISHER}\"")));
    }

    #[test]
    fn mergeLanguagesTomlIsIdempotent() {
        let dir = std::env::temp_dir().join(format!("plum-editor-test-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("languages.toml");

        mergeLanguagesToml(&path).unwrap();
        let first = std::fs::read_to_string(&path).unwrap();
        assert!(first.contains("name = \"plum\""));

        mergeLanguagesToml(&path).unwrap();
        let second = std::fs::read_to_string(&path).unwrap();
        assert_eq!(first, second, "second install should be a no-op");

        std::fs::remove_dir_all(&dir).ok();
    }
}