plum
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)) = ¶ms[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 | } |