plum
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();
}
}