plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-19-cli-formatter-design.md
| d2781a5 | 1 | # Plum CLI & Formatter Design |
| d2781a5 | 2 | |
| d2781a5 | 3 | **Date:** 2026-07-19 |
| d2781a5 | 4 | **Status:** Approved |
| d2781a5 | 5 | |
| d2781a5 | 6 | ## Overview |
| d2781a5 | 7 | |
| d2781a5 | 8 | Add a `plum` CLI binary and a `format` command that formats `.plum` source files in place using Topiary (topiary-core v0.7.3) as the formatting engine. The project is restructured as a Cargo workspace. |
| d2781a5 | 9 | |
| d2781a5 | 10 | --- |
| d2781a5 | 11 | |
| d2781a5 | 12 | ## Workspace Structure |
| d2781a5 | 13 | |
| d2781a5 | 14 | ``` |
| d2781a5 | 15 | plum/ |
| d2781a5 | 16 | ├── Cargo.toml ← workspace root |
| d2781a5 | 17 | ├── plum-core/ |
| d2781a5 | 18 | │ ├── Cargo.toml |
| d2781a5 | 19 | │ └── src/ |
| d2781a5 | 20 | │ ├── lib.rs ← public API surface |
| d2781a5 | 21 | │ ├── ast.rs ← moved from current src/ast.rs |
| d2781a5 | 22 | │ ├── parser.rs ← moved from current src/parser.rs |
| d2781a5 | 23 | │ └── formatter.rs ← new: Topiary wrapper |
| d2781a5 | 24 | ├── plum-cli/ |
| d2781a5 | 25 | │ ├── Cargo.toml |
| d2781a5 | 26 | │ └── src/ |
| d2781a5 | 27 | │ └── main.rs ← clap CLI, file I/O only |
| d2781a5 | 28 | └── tooling/ |
| d2781a5 | 29 | └── tree-sitter-plum/ |
| d2781a5 | 30 | └── queries/plum/ |
| d2781a5 | 31 | └── format.scm ← new: Topiary formatting rules |
| d2781a5 | 32 | ``` |
| d2781a5 | 33 | |
| d2781a5 | 34 | The current `plum` crate (`src/ast.rs`, `src/parser.rs`, `src/main.rs`) is split: library code moves to `plum-core`, the entry point becomes `plum-cli`. |
| d2781a5 | 35 | |
| d2781a5 | 36 | --- |
| d2781a5 | 37 | |
| d2781a5 | 38 | ## plum-core |
| d2781a5 | 39 | |
| d2781a5 | 40 | ### Public API |
| d2781a5 | 41 | |
| d2781a5 | 42 | ```rust |
| d2781a5 | 43 | // src/lib.rs |
| d2781a5 | 44 | pub mod ast; |
| d2781a5 | 45 | pub mod parser; |
| d2781a5 | 46 | pub mod formatter; |
| d2781a5 | 47 | |
| d2781a5 | 48 | pub use formatter::{format_source, FormatterError}; |
| d2781a5 | 49 | pub use parser::AstParser; |
| d2781a5 | 50 | ``` |
| d2781a5 | 51 | |
| d2781a5 | 52 | ### formatter.rs |
| d2781a5 | 53 | |
| d2781a5 | 54 | ```rust |
| d2781a5 | 55 | pub fn format_source(source: &str) -> Result<String, FormatterError> |
| d2781a5 | 56 | pub fn format_source_with_opts(source: &str, skip_idempotence: bool) -> Result<String, FormatterError> |
| d2781a5 | 57 | ``` |
| d2781a5 | 58 | |
| d2781a5 | 59 | - Constructs a Topiary `Language` from: |
| d2781a5 | 60 | - `tree_sitter_plum::LANGUAGE` (converted via `.into()` to `topiary_tree_sitter_facade::Language`) |
| d2781a5 | 61 | - `format.scm` embedded at compile time via `include_str!` |
| d2781a5 | 62 | - Calls `topiary_core::formatter_str` |
| d2781a5 | 63 | - Returns the formatted source as a `String` |
| d2781a5 | 64 | |
| d2781a5 | 65 | ### Dependencies (plum-core/Cargo.toml) |
| d2781a5 | 66 | |
| d2781a5 | 67 | ```toml |
| d2781a5 | 68 | [dependencies] |
| d2781a5 | 69 | tree-sitter = "0.24.5" |
| d2781a5 | 70 | tree-sitter-plum = "0.1.0" |
| d2781a5 | 71 | topiary-core = "0.7.3" |
| d2781a5 | 72 | topiary-tree-sitter-facade = "0.7.3" |
| d2781a5 | 73 | ``` |
| d2781a5 | 74 | |
| d2781a5 | 75 | --- |
| d2781a5 | 76 | |
| d2781a5 | 77 | ## plum-cli |
| d2781a5 | 78 | |
| d2781a5 | 79 | ### Commands |
| d2781a5 | 80 | |
| d2781a5 | 81 | ``` |
| d2781a5 | 82 | plum format <file> Format file in place (silent on success) |
| d2781a5 | 83 | plum format --check <file> Exit 1 if file would change; print message to stderr |
| d2781a5 | 84 | plum format --stdin Read from stdin, write to stdout |
| d2781a5 | 85 | ``` |
| d2781a5 | 86 | |
| d2781a5 | 87 | ### Behaviour |
| d2781a5 | 88 | |
| d2781a5 | 89 | - `format <file>`: reads file, calls `plum_core::format_source`, writes result back only if content changed (avoids touching mtime unnecessarily) |
| d2781a5 | 90 | - `format --check <file>`: same read + format, but instead of writing, compares and exits 1 with a message if different |
| d2781a5 | 91 | - `format --stdin`: reads all of stdin, formats, writes to stdout — useful for editor integrations |
| d2781a5 | 92 | - All errors print to stderr; stdout is reserved for formatted source (`--stdin` mode only) |
| d2781a5 | 93 | |
| d2781a5 | 94 | ### Dependencies (plum-cli/Cargo.toml) |
| d2781a5 | 95 | |
| d2781a5 | 96 | ```toml |
| d2781a5 | 97 | [dependencies] |
| d2781a5 | 98 | plum-core = { path = "../plum-core" } |
| d2781a5 | 99 | clap = { version = "4", features = ["derive"] } |
| d2781a5 | 100 | anyhow = "1" |
| d2781a5 | 101 | ``` |
| d2781a5 | 102 | |
| d2781a5 | 103 | --- |
| d2781a5 | 104 | |
| d2781a5 | 105 | ## format.scm — Topiary Formatting Rules |
| d2781a5 | 106 | |
| d2781a5 | 107 | Located at `tooling/tree-sitter-plum/queries/plum/format.scm`, embedded in `formatter.rs` via `include_str!`. |
| d2781a5 | 108 | |
| d2781a5 | 109 | ### Rules |
| d2781a5 | 110 | |
| d2781a5 | 111 | | Construct | Rule | |
| d2781a5 | 112 | |---|---| |
| d2781a5 | 113 | | Binary operators (`+`, `-`, `*`, `/`, `%`, `\|`, `&`, `^`, `<<`, `>>`, `..`) | space before and after | |
| d2781a5 | 114 | | Comparison operators (`<`, `<=`, `==`, `!=`, `>=`, `>`, `<>`) | space before and after | |
| d2781a5 | 115 | | Boolean operators (`&&`, `\|\|`) | space before and after | |
| d2781a5 | 116 | | `->` (return type arrow) | space before and after | |
| d2781a5 | 117 | | `=` (in fn/const/assign) | space before and after | |
| d2781a5 | 118 | | `=>` (in match case / pair argument) | space before and after | |
| d2781a5 | 119 | | `,` separator | no space before, one space after | |
| d2781a5 | 120 | | `:` in params and fields | no space before, one space after | |
| d2781a5 | 121 | | `\|` in enum variants | hardline before | |
| d2781a5 | 122 | | Statements in a `body` block | hardline between each | |
| d2781a5 | 123 | | Top-level items (`fn`, `type`, `enum`, `trait`, `const`) | blank line between each | |
| d2781a5 | 124 | | Comments | preserved as-is; blank line allowed before | |
| d2781a5 | 125 | | `(` `)` in argument lists | scoped softline (single-line if fits, multi-line if not) | |
| d2781a5 | 126 | |
| d2781a5 | 127 | ### Idempotence |
| d2781a5 | 128 | |
| d2781a5 | 129 | `skip_idempotence` defaults to `false` — Topiary runs formatting twice and errors if the result differs. This catches poorly-written query rules during development. The CLI exposes no flag for this; it is a library-level option. |
| d2781a5 | 130 | |
| d2781a5 | 131 | --- |
| d2781a5 | 132 | |
| d2781a5 | 133 | ## Error Handling |
| d2781a5 | 134 | |
| d2781a5 | 135 | - Parse errors in the source: `tolerate_parsing_errors: false` — the formatter refuses to format files with syntax errors, printing the tree-sitter error to stderr. |
| d2781a5 | 136 | - File not found / permission errors: `anyhow` propagates these with context. |
| d2781a5 | 137 | - Formatter errors (bad query, non-idempotent output): printed to stderr, exit code 1. |
| d2781a5 | 138 | |
| d2781a5 | 139 | --- |
| d2781a5 | 140 | |
| d2781a5 | 141 | ## Out of Scope |
| d2781a5 | 142 | |
| d2781a5 | 143 | - `plum check` / `plum build` / other compiler subcommands |
| d2781a5 | 144 | - LSP integration |
| d2781a5 | 145 | - Watch mode |
| d2781a5 | 146 | - Formatting multiple files via glob patterns |