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
3d6f280 1
// Functions/methods are named camelCase across this project (matching plum's own
3d6f280 2
// naming convention), not Rust's idiomatic snake_case — silence the resulting lint.
3d6f280 3
#![allow(non_snake_case)]
3d6f280 4
cca230e 5
use std::fs;
cca230e 6
use std::io::{self, Read};
cca230e 7
use std::process;
cca230e 8
cca230e 9
use anyhow::{Context, Result};
cca230e 10
use clap::{Parser, Subcommand};
cca230e 11
3d6f280 12
use plum_core::formatSource;
cca230e 13
141de54 14
mod editor;
141de54 15
use editor::EditorType;
141de54 16
cca230e 17
#[derive(Parser)]
cca230e 18
#[command(name = "plum", about = "The Plum language toolchain")]
cca230e 19
struct Cli {
cca230e 20
    #[command(subcommand)]
cca230e 21
    command: Command,
cca230e 22
}
cca230e 23
cca230e 24
#[derive(Subcommand)]
cca230e 25
enum Command {
cca230e 26
    /// Format a Plum source file
cca230e 27
    Format {
cca230e 28
        /// File to format (omit to use --stdin)
cca230e 29
        file: Option<std::path::PathBuf>,
cca230e 30
        /// Check if file is formatted; exit 1 if it would change
cca230e 31
        #[arg(long)]
cca230e 32
        check: bool,
cca230e 33
        /// Read from stdin and write formatted source to stdout
cca230e 34
        #[arg(long)]
cca230e 35
        stdin: bool,
cca230e 36
    },
50effc5 37
    /// Compile a Plum source file to WASM
50effc5 38
    Compile {
50effc5 39
        /// Source file to compile
50effc5 40
        file: std::path::PathBuf,
50effc5 41
        /// Output path (default: input with .wasm extension)
50effc5 42
        #[arg(short, long)]
50effc5 43
        output: Option<std::path::PathBuf>,
1a9a65c 44
        /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
1a9a65c 45
        #[arg(long, default_value = "libs")]
1a9a65c 46
        lib_path: std::path::PathBuf,
50effc5 47
    },
0000000 48
    /// Compile a Plum source file to WASM and immediately run it under wasmtime
0000000 49
    Run {
0000000 50
        /// Source file to compile and run
0000000 51
        file: std::path::PathBuf,
0000000 52
        /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
0000000 53
        #[arg(long, default_value = "libs")]
0000000 54
        lib_path: std::path::PathBuf,
0000000 55
    },
0000000 56
    /// Compile a Plum source file to WASM and embed it into a wasmtime-backed
0000000 57
    /// native executable
0000000 58
    Build {
0000000 59
        /// Source file to compile
0000000 60
        file: std::path::PathBuf,
0000000 61
        /// Output executable path (default: input file's name, no extension)
0000000 62
        #[arg(short, long)]
0000000 63
        output: Option<std::path::PathBuf>,
0000000 64
        /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
0000000 65
        #[arg(long, default_value = "libs")]
0000000 66
        lib_path: std::path::PathBuf,
0000000 67
    },
141de54 68
    /// Install Plum syntax highlighting into an editor's configuration
141de54 69
    Editor {
141de54 70
        /// Editor to install support for
141de54 71
        editor: EditorType,
141de54 72
        /// Install for VSCode Insiders instead of stable VSCode (ignored for helix)
141de54 73
        #[arg(long)]
141de54 74
        insiders: bool,
141de54 75
    },
cca230e 76
}
cca230e 77
cca230e 78
fn main() {
cca230e 79
    if let Err(e) = run() {
cca230e 80
        eprintln!("error: {e:#}");
cca230e 81
        process::exit(1);
cca230e 82
    }
cca230e 83
}
cca230e 84
cca230e 85
fn run() -> Result<()> {
cca230e 86
    let cli = Cli::parse();
cca230e 87
    match cli.command {
3d6f280 88
        Command::Format { file, check, stdin } => cmdFormat(file, check, stdin),
3d6f280 89
        Command::Compile { file, output, lib_path } => cmdCompile(file, output, lib_path),
0000000 90
        Command::Run { file, lib_path } => cmdRun(file, lib_path),
0000000 91
        Command::Build { file, output, lib_path } => cmdBuild(file, output, lib_path),
141de54 92
        Command::Editor { editor, insiders } => editor::install(editor, insiders),
cca230e 93
    }
cca230e 94
}
cca230e 95
3d6f280 96
fn cmdFormat(
cca230e 97
    file: Option<std::path::PathBuf>,
cca230e 98
    check: bool,
cca230e 99
    use_stdin: bool,
cca230e 100
) -> Result<()> {
62ecfff 101
    if use_stdin && check {
62ecfff 102
        anyhow::bail!("--check cannot be used with --stdin");
62ecfff 103
    }
cca230e 104
    if use_stdin {
cca230e 105
        let mut source = String::new();
cca230e 106
        io::stdin()
cca230e 107
            .read_to_string(&mut source)
cca230e 108
            .context("failed to read stdin")?;
3d6f280 109
        let formatted = formatSource(&source).map_err(|e| anyhow::anyhow!("{e}"))?;
cca230e 110
        print!("{formatted}");
cca230e 111
        return Ok(());
cca230e 112
    }
cca230e 113
cca230e 114
    let path = file.ok_or_else(|| anyhow::anyhow!("provide a file path or --stdin"))?;
cca230e 115
    let source =
cca230e 116
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
3d6f280 117
    let formatted = formatSource(&source).map_err(|e| anyhow::anyhow!("{e}"))?;
cca230e 118
cca230e 119
    if check {
cca230e 120
        if source != formatted {
cca230e 121
            eprintln!("{}: would reformat", path.display());
cca230e 122
            process::exit(1);
cca230e 123
        }
cca230e 124
        return Ok(());
cca230e 125
    }
cca230e 126
cca230e 127
    if source != formatted {
cca230e 128
        fs::write(&path, formatted.as_bytes())
cca230e 129
            .with_context(|| format!("failed to write {}", path.display()))?;
cca230e 130
    }
cca230e 131
    Ok(())
cca230e 132
}
50effc5 133
0000000 134
fn compileToWasm(file: &std::path::Path, lib_path: &std::path::Path) -> Result<Vec<u8>> {
0000000 135
    let ast = plum_core::loadAndMerge(file, lib_path)
1a9a65c 136
        .map_err(|e| anyhow::anyhow!("{e}"))?;
50effc5 137
50effc5 138
    // Type check
3d6f280 139
    if let Err(errors) = plum_checker::checkSource(&ast) {
50effc5 140
        for e in &errors {
50effc5 141
            eprintln!("type error: {}", e.message);
50effc5 142
        }
50effc5 143
        process::exit(1);
50effc5 144
    }
50effc5 145
50effc5 146
    // Codegen
0000000 147
    plum_wasm_codegen::compileSource(&ast).map_err(|e| anyhow::anyhow!("codegen error: {e}"))
0000000 148
}
0000000 149
0000000 150
fn cmdCompile(
0000000 151
    file: std::path::PathBuf,
0000000 152
    output: Option<std::path::PathBuf>,
0000000 153
    lib_path: std::path::PathBuf,
0000000 154
) -> Result<()> {
0000000 155
    let wasm_bytes = compileToWasm(&file, &lib_path)?;
50effc5 156
50effc5 157
    let out_path = output.unwrap_or_else(|| file.with_extension("wasm"));
50effc5 158
    fs::write(&out_path, &wasm_bytes)
50effc5 159
        .with_context(|| format!("failed to write {}", out_path.display()))?;
50effc5 160
50effc5 161
    eprintln!("compiled {} → {}", file.display(), out_path.display());
50effc5 162
    Ok(())
50effc5 163
}
0000000 164
0000000 165
/// Builds the `Extern`s that satisfy a compiled Plum module's host imports (today,
0000000 166
/// only `plum::printLn`) in declaration order, matching each one's exact `FuncType`
0000000 167
/// (its `Str` param is a concrete wasm-gc array type, so the host function must be
0000000 168
/// built from the module's own reported type rather than a generic `Rooted<ArrayRef>`
0000000 169
/// wrapper, which would carry the wrong, unrelated top-array type and fail to
0000000 170
/// instantiate).
0000000 171
fn hostImports(store: &mut wasmtime::Store<()>, module: &wasmtime::Module) -> Result<Vec<wasmtime::Extern>> {
0000000 172
    module.imports().map(|imp| {
0000000 173
        match (imp.module(), imp.name()) {
0000000 174
            ("plum", "printLn") => {
0000000 175
                let func_ty = imp.ty().func().cloned()
0000000 176
                    .ok_or_else(|| anyhow::anyhow!("plum::printLn import must be a function"))?;
0000000 177
                Ok(wasmtime::Extern::Func(wasmtime::Func::new(&mut *store, func_ty, |mut caller, params, _results| {
0000000 178
                    let wasmtime::Val::AnyRef(Some(s)) = &params[0] else {
0000000 179
                        return Err(wasmtime::Error::msg("printLn expects a Str argument"));
0000000 180
                    };
0000000 181
                    let arr = s.unwrap_array(&caller)?;
0000000 182
                    let len = arr.len(&caller)?;
0000000 183
                    let mut buf = vec![0u8; len as usize];
0000000 184
                    arr.copy_to_i8_slice(&mut caller, &mut buf)?;
0000000 185
                    println!("{}", String::from_utf8_lossy(&buf));
0000000 186
                    Ok(())
0000000 187
                })))
0000000 188
            }
0000000 189
            (m, n) => anyhow::bail!("unsupported host import {m}::{n}"),
0000000 190
        }
0000000 191
    }).collect()
0000000 192
}
0000000 193
0000000 194
fn cmdRun(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()> {
0000000 195
    let wasm_bytes = compileToWasm(&file, &lib_path)?;
0000000 196
0000000 197
    // GC + typed function references are required to load modules containing
0000000 198
    // Plum's structs/enums/strings/closures (see docs/superpowers/plans/2026-07-25-wasm-gc-migration.md).
0000000 199
    let mut config = wasmtime::Config::new();
0000000 200
    config.wasm_gc(true);
0000000 201
    config.wasm_function_references(true);
0000000 202
    let engine = wasmtime::Engine::new(&config)
0000000 203
        .map_err(|e| anyhow::anyhow!("failed to construct wasmtime engine: {e}"))?;
0000000 204
0000000 205
    let module = wasmtime::Module::new(&engine, &wasm_bytes)
0000000 206
        .map_err(|e| anyhow::anyhow!("compiled wasm failed to load: {e}"))?;
0000000 207
    let mut store = wasmtime::Store::new(&engine, ());
0000000 208
    let imports = hostImports(&mut store, &module)?;
0000000 209
    let instance = wasmtime::Instance::new(&mut store, &module, &imports)
0000000 210
        .map_err(|e| anyhow::anyhow!("module failed to instantiate: {e}"))?;
0000000 211
0000000 212
    // `main` returns `i64` unless it's declared `Unit` (no return value), in
0000000 213
    // which case codegen gives it an empty wasm result type instead.
0000000 214
    if let Ok(main) = instance.get_typed_func::<(), i64>(&mut store, "main") {
0000000 215
        match main.call(&mut store, ()) {
0000000 216
            Ok(result) => println!("{result}"),
0000000 217
            Err(trap) => {
0000000 218
                eprintln!("error: {trap}");
0000000 219
                process::exit(1);
0000000 220
            }
0000000 221
        }
0000000 222
        return Ok(());
0000000 223
    }
0000000 224
0000000 225
    let main = instance
0000000 226
        .get_typed_func::<(), ()>(&mut store, "main")
0000000 227
        .map_err(|_| anyhow::anyhow!("module has no `main` export with signature () -> i64 or () -> ()"))?;
0000000 228
    if let Err(trap) = main.call(&mut store, ()) {
0000000 229
        eprintln!("error: {trap}");
0000000 230
        process::exit(1);
0000000 231
    }
0000000 232
    Ok(())
0000000 233
}
0000000 234
0000000 235
/// Path to the `plum-runtime` crate, resolved relative to `plum-cli`'s own
0000000 236
/// manifest directory (baked in at compile time) rather than the current
0000000 237
/// working directory, so `plum build` works no matter where it's invoked from.
0000000 238
fn runtimeCrateDir() -> std::path::PathBuf {
0000000 239
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
0000000 240
        .parent()
0000000 241
        .expect("plum-cli should live inside the workspace root")
0000000 242
        .join("plum-runtime")
0000000 243
}
0000000 244
0000000 245
fn cmdBuild(
0000000 246
    file: std::path::PathBuf,
0000000 247
    output: Option<std::path::PathBuf>,
0000000 248
    lib_path: std::path::PathBuf,
0000000 249
) -> Result<()> {
0000000 250
    let wasm_bytes = compileToWasm(&file, &lib_path)?;
0000000 251
0000000 252
    let stem = file
0000000 253
        .file_stem()
0000000 254
        .ok_or_else(|| anyhow::anyhow!("invalid source file path {}", file.display()))?;
0000000 255
0000000 256
    let wasm_path = std::env::temp_dir().join(format!(
0000000 257
        "plum-build-{}-{}.wasm",
0000000 258
        stem.to_string_lossy(),
0000000 259
        process::id()
0000000 260
    ));
0000000 261
    fs::write(&wasm_path, &wasm_bytes)
0000000 262
        .with_context(|| format!("failed to write {}", wasm_path.display()))?;
0000000 263
    let wasm_abs_path = fs::canonicalize(&wasm_path)
0000000 264
        .with_context(|| format!("failed to resolve {}", wasm_path.display()))?;
0000000 265
0000000 266
    let runtime_dir = runtimeCrateDir();
0000000 267
    eprintln!("building executable (embedding wasm via wasmtime)...");
0000000 268
    let status = process::Command::new("cargo")
0000000 269
        .args(["build", "--release", "--manifest-path"])
0000000 270
        .arg(runtime_dir.join("Cargo.toml"))
0000000 271
        .env("PLUM_WASM_PATH", &wasm_abs_path)
0000000 272
        .status()
0000000 273
        .context("failed to run cargo — is it installed and on PATH?")?;
0000000 274
    let _ = fs::remove_file(&wasm_path);
0000000 275
    if !status.success() {
0000000 276
        anyhow::bail!("cargo build failed");
0000000 277
    }
0000000 278
0000000 279
    let built_bin = runtime_dir.join("target/release/plum-runtime");
0000000 280
    let out_path = output.unwrap_or_else(|| std::path::PathBuf::from(stem));
0000000 281
    fs::copy(&built_bin, &out_path).with_context(|| {
0000000 282
        format!("failed to copy {} to {}", built_bin.display(), out_path.display())
0000000 283
    })?;
0000000 284
0000000 285
    eprintln!("built {} → {}", file.display(), out_path.display());
0000000 286
    Ok(())
0000000 287
}