plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


plum-cli/src/main.rs
// Functions/methods are named camelCase across this project (matching plum's own
// naming convention), not Rust's idiomatic snake_case — silence the resulting lint.
#![allow(non_snake_case)]

use std::fs;
use std::io::{self, Read};
use std::process;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};

use plum_core::formatSource;

mod editor;
use editor::EditorType;

#[derive(Parser)]
#[command(name = "plum", about = "The Plum language toolchain")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Format a Plum source file
    Format {
        /// File to format (omit to use --stdin)
        file: Option<std::path::PathBuf>,
        /// Check if file is formatted; exit 1 if it would change
        #[arg(long)]
        check: bool,
        /// Read from stdin and write formatted source to stdout
        #[arg(long)]
        stdin: bool,
    },
    /// Compile a Plum source file to WASM
    Compile {
        /// Source file to compile
        file: std::path::PathBuf,
        /// Output path (default: input with .wasm extension)
        #[arg(short, long)]
        output: Option<std::path::PathBuf>,
        /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
        #[arg(long, default_value = "libs")]
        lib_path: std::path::PathBuf,
    },
    /// Compile a Plum source file to WASM and immediately run it under wasmtime
    Run {
        /// Source file to compile and run
        file: std::path::PathBuf,
        /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
        #[arg(long, default_value = "libs")]
        lib_path: std::path::PathBuf,
    },
    /// Compile a Plum source file to WASM and embed it into a wasmtime-backed
    /// native executable
    Build {
        /// Source file to compile
        file: std::path::PathBuf,
        /// Output executable path (default: input file's name, no extension)
        #[arg(short, long)]
        output: Option<std::path::PathBuf>,
        /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
        #[arg(long, default_value = "libs")]
        lib_path: std::path::PathBuf,
    },
    /// Install Plum syntax highlighting into an editor's configuration
    Editor {
        /// Editor to install support for
        editor: EditorType,
        /// Install for VSCode Insiders instead of stable VSCode (ignored for helix)
        #[arg(long)]
        insiders: bool,
    },
}

fn main() {
    if let Err(e) = run() {
        eprintln!("error: {e:#}");
        process::exit(1);
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Command::Format { file, check, stdin } => cmdFormat(file, check, stdin),
        Command::Compile { file, output, lib_path } => cmdCompile(file, output, lib_path),
        Command::Run { file, lib_path } => cmdRun(file, lib_path),
        Command::Build { file, output, lib_path } => cmdBuild(file, output, lib_path),
        Command::Editor { editor, insiders } => editor::install(editor, insiders),
    }
}

fn cmdFormat(
    file: Option<std::path::PathBuf>,
    check: bool,
    use_stdin: bool,
) -> Result<()> {
    if use_stdin && check {
        anyhow::bail!("--check cannot be used with --stdin");
    }
    if use_stdin {
        let mut source = String::new();
        io::stdin()
            .read_to_string(&mut source)
            .context("failed to read stdin")?;
        let formatted = formatSource(&source).map_err(|e| anyhow::anyhow!("{e}"))?;
        print!("{formatted}");
        return Ok(());
    }

    let path = file.ok_or_else(|| anyhow::anyhow!("provide a file path or --stdin"))?;
    let source =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let formatted = formatSource(&source).map_err(|e| anyhow::anyhow!("{e}"))?;

    if check {
        if source != formatted {
            eprintln!("{}: would reformat", path.display());
            process::exit(1);
        }
        return Ok(());
    }

    if source != formatted {
        fs::write(&path, formatted.as_bytes())
            .with_context(|| format!("failed to write {}", path.display()))?;
    }
    Ok(())
}

fn compileToWasm(file: &std::path::Path, lib_path: &std::path::Path) -> Result<Vec<u8>> {
    let ast = plum_core::loadAndMerge(file, lib_path)
        .map_err(|e| anyhow::anyhow!("{e}"))?;

    // Type check
    if let Err(errors) = plum_checker::checkSource(&ast) {
        for e in &errors {
            eprintln!("type error: {}", e.message);
        }
        process::exit(1);
    }

    // Codegen
    plum_wasm_codegen::compileSource(&ast).map_err(|e| anyhow::anyhow!("codegen error: {e}"))
}

fn cmdCompile(
    file: std::path::PathBuf,
    output: Option<std::path::PathBuf>,
    lib_path: std::path::PathBuf,
) -> Result<()> {
    let wasm_bytes = compileToWasm(&file, &lib_path)?;

    let out_path = output.unwrap_or_else(|| file.with_extension("wasm"));
    fs::write(&out_path, &wasm_bytes)
        .with_context(|| format!("failed to write {}", out_path.display()))?;

    eprintln!("compiled {} → {}", file.display(), out_path.display());
    Ok(())
}

/// Builds the `Extern`s that satisfy a compiled Plum module's host imports (today,
/// only `plum::printLn`) in declaration order, matching each one's exact `FuncType`
/// (its `Str` param is a concrete wasm-gc array type, so the host function must be
/// built from the module's own reported type rather than a generic `Rooted<ArrayRef>`
/// wrapper, which would carry the wrong, unrelated top-array type and fail to
/// instantiate).
fn hostImports(store: &mut wasmtime::Store<()>, module: &wasmtime::Module) -> Result<Vec<wasmtime::Extern>> {
    module.imports().map(|imp| {
        match (imp.module(), imp.name()) {
            ("plum", "printLn") => {
                let func_ty = imp.ty().func().cloned()
                    .ok_or_else(|| anyhow::anyhow!("plum::printLn import must be a function"))?;
                Ok(wasmtime::Extern::Func(wasmtime::Func::new(&mut *store, func_ty, |mut caller, params, _results| {
                    let wasmtime::Val::AnyRef(Some(s)) = &params[0] else {
                        return Err(wasmtime::Error::msg("printLn expects a Str argument"));
                    };
                    let arr = s.unwrap_array(&caller)?;
                    let len = arr.len(&caller)?;
                    let mut buf = vec![0u8; len as usize];
                    arr.copy_to_i8_slice(&mut caller, &mut buf)?;
                    println!("{}", String::from_utf8_lossy(&buf));
                    Ok(())
                })))
            }
            (m, n) => anyhow::bail!("unsupported host import {m}::{n}"),
        }
    }).collect()
}

fn cmdRun(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()> {
    let wasm_bytes = compileToWasm(&file, &lib_path)?;

    // GC + typed function references are required to load modules containing
    // Plum's structs/enums/strings/closures (see docs/superpowers/plans/2026-07-25-wasm-gc-migration.md).
    let mut config = wasmtime::Config::new();
    config.wasm_gc(true);
    config.wasm_function_references(true);
    let engine = wasmtime::Engine::new(&config)
        .map_err(|e| anyhow::anyhow!("failed to construct wasmtime engine: {e}"))?;

    let module = wasmtime::Module::new(&engine, &wasm_bytes)
        .map_err(|e| anyhow::anyhow!("compiled wasm failed to load: {e}"))?;
    let mut store = wasmtime::Store::new(&engine, ());
    let imports = hostImports(&mut store, &module)?;
    let instance = wasmtime::Instance::new(&mut store, &module, &imports)
        .map_err(|e| anyhow::anyhow!("module failed to instantiate: {e}"))?;

    // `main` returns `i64` unless it's declared `Unit` (no return value), in
    // which case codegen gives it an empty wasm result type instead.
    if let Ok(main) = instance.get_typed_func::<(), i64>(&mut store, "main") {
        match main.call(&mut store, ()) {
            Ok(result) => println!("{result}"),
            Err(trap) => {
                eprintln!("error: {trap}");
                process::exit(1);
            }
        }
        return Ok(());
    }

    let main = instance
        .get_typed_func::<(), ()>(&mut store, "main")
        .map_err(|_| anyhow::anyhow!("module has no `main` export with signature () -> i64 or () -> ()"))?;
    if let Err(trap) = main.call(&mut store, ()) {
        eprintln!("error: {trap}");
        process::exit(1);
    }
    Ok(())
}

/// Path to the `plum-runtime` crate, resolved relative to `plum-cli`'s own
/// manifest directory (baked in at compile time) rather than the current
/// working directory, so `plum build` works no matter where it's invoked from.
fn runtimeCrateDir() -> std::path::PathBuf {
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("plum-cli should live inside the workspace root")
        .join("plum-runtime")
}

fn cmdBuild(
    file: std::path::PathBuf,
    output: Option<std::path::PathBuf>,
    lib_path: std::path::PathBuf,
) -> Result<()> {
    let wasm_bytes = compileToWasm(&file, &lib_path)?;

    let stem = file
        .file_stem()
        .ok_or_else(|| anyhow::anyhow!("invalid source file path {}", file.display()))?;

    let wasm_path = std::env::temp_dir().join(format!(
        "plum-build-{}-{}.wasm",
        stem.to_string_lossy(),
        process::id()
    ));
    fs::write(&wasm_path, &wasm_bytes)
        .with_context(|| format!("failed to write {}", wasm_path.display()))?;
    let wasm_abs_path = fs::canonicalize(&wasm_path)
        .with_context(|| format!("failed to resolve {}", wasm_path.display()))?;

    let runtime_dir = runtimeCrateDir();
    eprintln!("building executable (embedding wasm via wasmtime)...");
    let status = process::Command::new("cargo")
        .args(["build", "--release", "--manifest-path"])
        .arg(runtime_dir.join("Cargo.toml"))
        .env("PLUM_WASM_PATH", &wasm_abs_path)
        .status()
        .context("failed to run cargo — is it installed and on PATH?")?;
    let _ = fs::remove_file(&wasm_path);
    if !status.success() {
        anyhow::bail!("cargo build failed");
    }

    let built_bin = runtime_dir.join("target/release/plum-runtime");
    let out_path = output.unwrap_or_else(|| std::path::PathBuf::from(stem));
    fs::copy(&built_bin, &out_path).with_context(|| {
        format!("failed to copy {} to {}", built_bin.display(), out_path.display())
    })?;

    eprintln!("built {} → {}", file.display(), out_path.display());
    Ok(())
}