plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/plans/2026-07-19-cli-formatter.md
| 889886f | 1 | # CLI & Formatter Implementation Plan |
| 889886f | 2 | |
| 889886f | 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 889886f | 4 | |
| 889886f | 5 | **Goal:** Restructure the project as a Cargo workspace, extract a `plum-core` library, and add a `plum-cli` binary with a `format` subcommand backed by Topiary. |
| 889886f | 6 | |
| 889886f | 7 | **Architecture:** Current `src/` code splits into `plum-core` (library: ast, parser, formatter) and `plum-cli` (thin binary). `formatter.rs` wraps `topiary-core`, embedding `format.scm` at compile time via `include_str!`. The CLI uses `clap` for argument parsing and delegates all logic to `plum-core`. |
| 889886f | 8 | |
| 889886f | 9 | **Tech Stack:** Rust 2021 edition, Cargo workspaces, topiary-core 0.7.3, topiary-tree-sitter-facade 0.7.3, tree-sitter 0.26 (required by topiary), tree-sitter-plum 0.1.0, clap 4, anyhow 1. |
| 889886f | 10 | |
| 889886f | 11 | ## Global Constraints |
| 889886f | 12 | |
| 889886f | 13 | - topiary-core version: `"0.7.3"` — exact, do not upgrade |
| 889886f | 14 | - topiary-tree-sitter-facade version: `"0.7.3"` — exact, matches topiary-core |
| 889886f | 15 | - tree-sitter version: `"0.26"` — topiary-core 0.7.3 resolves tree-sitter 0.26.11; use `"0.26"` (not `"0.24.5"`) in plum-core |
| 889886f | 16 | - tree-sitter-plum version: `"0.1.0"` — local crate at `tooling/tree-sitter-plum`; reference via `path` |
| 889886f | 17 | - clap version: `"4"` with `features = ["derive"]` |
| 889886f | 18 | - anyhow version: `"1"` |
| 889886f | 19 | - format.scm lives at `tooling/tree-sitter-plum/queries/plum/format.scm` |
| 889886f | 20 | - `plum format <file>` edits in place silently on success; `--check` exits 1 if file would change; `--stdin` reads stdin, writes stdout |
| 889886f | 21 | - `tolerate_parsing_errors = false` — refuse to format files with syntax errors |
| 889886f | 22 | |
| 889886f | 23 | --- |
| 889886f | 24 | |
| 889886f | 25 | ## File Map |
| 889886f | 26 | |
| 889886f | 27 | | Path | Action | Responsibility | |
| 889886f | 28 | |---|---|---| |
| 889886f | 29 | | `Cargo.toml` | Replace | Workspace root — lists members, no `[package]` | |
| 889886f | 30 | | `plum-core/Cargo.toml` | Create | Library dependencies (tree-sitter 0.26, topiary, etc.) | |
| 889886f | 31 | | `plum-core/src/lib.rs` | Create | `pub mod` declarations + re-exports | |
| 889886f | 32 | | `plum-core/src/ast.rs` | Move from `src/ast.rs` | Typed AST types (unchanged) | |
| 889886f | 33 | | `plum-core/src/parser.rs` | Move from `src/parser.rs` | CST → AST walking (unchanged) | |
| 889886f | 34 | | `plum-core/src/formatter.rs` | Create | Topiary wrapper — `format_source` | |
| 889886f | 35 | | `plum-core/tests/formatter_test.rs` | Create | Integration tests for `format_source` | |
| 889886f | 36 | | `plum-cli/Cargo.toml` | Create | Binary dependencies (plum-core, clap, anyhow) | |
| 889886f | 37 | | `plum-cli/src/main.rs` | Create | clap CLI, file I/O, calls `plum_core::format_source` | |
| 889886f | 38 | | `tooling/tree-sitter-plum/queries/plum/format.scm` | Create | Topiary formatting rules | |
| 889886f | 39 | |
| 889886f | 40 | --- |
| 889886f | 41 | |
| 889886f | 42 | ### Task 1: Restructure as Cargo Workspace |
| 889886f | 43 | |
| 889886f | 44 | **Files:** |
| 889886f | 45 | - Replace: `Cargo.toml` |
| 889886f | 46 | - Create: `plum-core/Cargo.toml` |
| 889886f | 47 | - Create: `plum-core/src/lib.rs` |
| 889886f | 48 | - Move: `src/ast.rs` → `plum-core/src/ast.rs` |
| 889886f | 49 | - Move: `src/parser.rs` → `plum-core/src/parser.rs` |
| 889886f | 50 | |
| 889886f | 51 | **Interfaces:** |
| 889886f | 52 | - Produces: `plum-core` crate compilable with `cargo build -p plum-core` |
| 889886f | 53 | |
| 889886f | 54 | - [ ] **Step 1: Replace root Cargo.toml with workspace definition** |
| 889886f | 55 | |
| 889886f | 56 | ```toml |
| 889886f | 57 | # Cargo.toml |
| 889886f | 58 | [workspace] |
| 889886f | 59 | members = ["plum-core", "plum-cli"] |
| 889886f | 60 | resolver = "2" |
| 889886f | 61 | ``` |
| 889886f | 62 | |
| 889886f | 63 | - [ ] **Step 2: Create `plum-core/Cargo.toml`** |
| 889886f | 64 | |
| 889886f | 65 | ```toml |
| 889886f | 66 | [package] |
| 889886f | 67 | name = "plum-core" |
| 889886f | 68 | version = "0.1.0" |
| 889886f | 69 | edition = "2021" |
| 889886f | 70 | |
| 889886f | 71 | [dependencies] |
| 889886f | 72 | tree-sitter = "0.26" |
| 889886f | 73 | tree-sitter-plum = { path = "../tooling/tree-sitter-plum" } |
| 889886f | 74 | topiary-core = "0.7.3" |
| 889886f | 75 | topiary-tree-sitter-facade = "0.7.3" |
| 889886f | 76 | ``` |
| 889886f | 77 | |
| 889886f | 78 | - [ ] **Step 3: Move source files into plum-core** |
| 889886f | 79 | |
| 889886f | 80 | ```bash |
| 889886f | 81 | mkdir -p plum-core/src |
| 889886f | 82 | cp src/ast.rs plum-core/src/ast.rs |
| 889886f | 83 | cp src/parser.rs plum-core/src/parser.rs |
| 889886f | 84 | ``` |
| 889886f | 85 | |
| 889886f | 86 | - [ ] **Step 4: Create `plum-core/src/lib.rs`** |
| 889886f | 87 | |
| 889886f | 88 | ```rust |
| 889886f | 89 | pub mod ast; |
| 889886f | 90 | pub mod parser; |
| 889886f | 91 | pub mod formatter; |
| 889886f | 92 | |
| 889886f | 93 | pub use formatter::{format_source, FormatterError}; |
| 889886f | 94 | pub use parser::AstParser; |
| 889886f | 95 | ``` |
| 889886f | 96 | |
| 889886f | 97 | - [ ] **Step 5: Verify plum-core compiles (formatter module is absent — add stub)** |
| 889886f | 98 | |
| 889886f | 99 | Add an empty `plum-core/src/formatter.rs` so lib.rs compiles: |
| 889886f | 100 | |
| 889886f | 101 | ```rust |
| 889886f | 102 | // plum-core/src/formatter.rs |
| 889886f | 103 | #[derive(Debug)] |
| 889886f | 104 | pub enum FormatterError { |
| 889886f | 105 | TopiaryCoreError(String), |
| 889886f | 106 | } |
| 889886f | 107 | |
| 889886f | 108 | impl std::fmt::Display for FormatterError { |
| 889886f | 109 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 889886f | 110 | match self { |
| 889886f | 111 | FormatterError::TopiaryCoreError(msg) => write!(f, "Topiary error: {msg}"), |
| 889886f | 112 | } |
| 889886f | 113 | } |
| 889886f | 114 | } |
| 889886f | 115 | |
| 889886f | 116 | impl std::error::Error for FormatterError {} |
| 889886f | 117 | |
| 889886f | 118 | pub fn format_source(_source: &str) -> Result<String, FormatterError> { |
| 889886f | 119 | unimplemented!() |
| 889886f | 120 | } |
| 889886f | 121 | ``` |
| 889886f | 122 | |
| 889886f | 123 | Run: `cargo build -p plum-core` |
| 889886f | 124 | Expected: compiles (formatter fn is `unimplemented!()` — no test calls it yet) |
| 889886f | 125 | |
| 889886f | 126 | - [ ] **Step 6: Commit** |
| 889886f | 127 | |
| 889886f | 128 | ```bash |
| 889886f | 129 | git add Cargo.toml plum-core/ |
| 889886f | 130 | git commit -m "refactor: restructure as Cargo workspace, add plum-core skeleton" |
| 889886f | 131 | ``` |
| 889886f | 132 | |
| 889886f | 133 | --- |
| 889886f | 134 | |
| 889886f | 135 | ### Task 2: Write `format.scm` Topiary Formatting Rules |
| 889886f | 136 | |
| 889886f | 137 | **Files:** |
| 889886f | 138 | - Create: `tooling/tree-sitter-plum/queries/plum/format.scm` |
| 889886f | 139 | |
| 889886f | 140 | **Interfaces:** |
| 889886f | 141 | - Produces: formatting query embedded at compile time via `include_str!("../../../../tooling/tree-sitter-plum/queries/plum/format.scm")` from `plum-core/src/formatter.rs` |
| 889886f | 142 | |
| 889886f | 143 | Topiary annotation reference: |
| 889886f | 144 | - `@append_space` — insert a space after the matched node |
| 889886f | 145 | - `@prepend_space` — insert a space before the matched node |
| 889886f | 146 | - `@prepend_hardline` — start a new line before the matched node |
| 889886f | 147 | - `@append_hardline` — start a new line after the matched node |
| 889886f | 148 | - `@append_indent_start` — increase indent after this node |
| 889886f | 149 | - `@append_indent_end` — decrease indent after this node |
| 889886f | 150 | - `@append_empty_softline` — newline if multiline context, nothing if single-line |
| 889886f | 151 | - `@delete` — remove this node from output (used to strip indent/dedent tokens) |
| 889886f | 152 | - `@allow_blank_line_before` — permit blank lines before this node |
| 889886f | 153 | |
| 889886f | 154 | - [ ] **Step 1: Create `tooling/tree-sitter-plum/queries/plum/format.scm`** |
| 889886f | 155 | |
| 889886f | 156 | ```scheme |
| 889886f | 157 | ; ============================================================ |
| 889886f | 158 | ; Top-level items — blank line between each |
| 889886f | 159 | ; ============================================================ |
| 889886f | 160 | |
| 889886f | 161 | (source |
| 889886f | 162 | (fn) @allow_blank_line_before) |
| 889886f | 163 | |
| 889886f | 164 | (source |
| 889886f | 165 | (class) @allow_blank_line_before) |
| 889886f | 166 | |
| 889886f | 167 | (source |
| 889886f | 168 | (enum) @allow_blank_line_before) |
| 889886f | 169 | |
| 889886f | 170 | (source |
| 889886f | 171 | (trait) @allow_blank_line_before) |
| 889886f | 172 | |
| 889886f | 173 | (source |
| 889886f | 174 | (const) @allow_blank_line_before) |
| 889886f | 175 | |
| 889886f | 176 | ; ============================================================ |
| 889886f | 177 | ; Binary operators — space before and after |
| 889886f | 178 | ; ============================================================ |
| 889886f | 179 | |
| 889886f | 180 | [ |
| 889886f | 181 | "+" |
| 889886f | 182 | "-" |
| 889886f | 183 | "*" |
| 889886f | 184 | "/" |
| 889886f | 185 | "%" |
| 889886f | 186 | "^" |
| 889886f | 187 | "|" |
| 889886f | 188 | "&" |
| 889886f | 189 | "<<" |
| 889886f | 190 | ">>" |
| 889886f | 191 | ".." |
| 889886f | 192 | ] @prepend_space @append_space |
| 889886f | 193 | |
| 889886f | 194 | [ |
| 889886f | 195 | "==" |
| 889886f | 196 | "!=" |
| 889886f | 197 | "<" |
| 889886f | 198 | "<=" |
| 889886f | 199 | ">" |
| 889886f | 200 | ">=" |
| 889886f | 201 | "<>" |
| 889886f | 202 | "&&" |
| 889886f | 203 | "||" |
| 889886f | 204 | ] @prepend_space @append_space |
| 889886f | 205 | |
| 889886f | 206 | ; ============================================================ |
| 889886f | 207 | ; Assignment and arrows |
| 889886f | 208 | ; ============================================================ |
| 889886f | 209 | |
| 889886f | 210 | "=" @prepend_space @append_space |
| 889886f | 211 | |
| 889886f | 212 | "->" @prepend_space @append_space |
| 889886f | 213 | |
| 889886f | 214 | "=>" @prepend_space @append_space |
| 889886f | 215 | |
| 889886f | 216 | ; ============================================================ |
| 889886f | 217 | ; Separators — no space before, one space after |
| 889886f | 218 | ; ============================================================ |
| 889886f | 219 | |
| 889886f | 220 | "," @append_space |
| 889886f | 221 | |
| 889886f | 222 | ":" @append_space |
| 889886f | 223 | |
| 889886f | 224 | ; ============================================================ |
| 889886f | 225 | ; Enum variant separators — new line before each | |
| 889886f | 226 | ; ============================================================ |
| 889886f | 227 | |
| 889886f | 228 | (enum_variant |
| 889886f | 229 | "|" @prepend_hardline) |
| 889886f | 230 | |
| 889886f | 231 | ; ============================================================ |
| 889886f | 232 | ; Function body — each statement on its own line |
| 889886f | 233 | ; ============================================================ |
| 889886f | 234 | |
| 889886f | 235 | (body |
| 889886f | 236 | (_) @prepend_hardline) |
| 889886f | 237 | |
| 889886f | 238 | ; ============================================================ |
| 889886f | 239 | ; Indent / dedent tokens — strip them (Topiary regenerates indentation) |
| 889886f | 240 | ; ============================================================ |
| 889886f | 241 | |
| 889886f | 242 | (indent) @delete |
| 889886f | 243 | |
| 889886f | 244 | (dedent) @delete |
| 889886f | 245 | |
| 889886f | 246 | (newline) @delete |
| 889886f | 247 | ``` |
| 889886f | 248 | |
| 889886f | 249 | - [ ] **Step 2: Commit** |
| 889886f | 250 | |
| 889886f | 251 | ```bash |
| 889886f | 252 | git add tooling/tree-sitter-plum/queries/plum/format.scm |
| 889886f | 253 | git commit -m "feat: add Topiary format.scm formatting rules for Plum" |
| 889886f | 254 | ``` |
| 889886f | 255 | |
| 889886f | 256 | --- |
| 889886f | 257 | |
| 889886f | 258 | ### Task 3: Implement `formatter.rs` |
| 889886f | 259 | |
| 889886f | 260 | **Files:** |
| 889886f | 261 | - Modify: `plum-core/src/formatter.rs` |
| 889886f | 262 | |
| 889886f | 263 | **Interfaces:** |
| 889886f | 264 | - Consumes: `topiary-core`, `topiary-tree-sitter-facade`, `tree-sitter-plum::LANGUAGE` |
| 889886f | 265 | - Produces: `pub fn format_source(source: &str) -> Result<String, FormatterError>` |
| 889886f | 266 | |
| 889886f | 267 | The Topiary 0.7.3 API: |
| 889886f | 268 | ```rust |
| 889886f | 269 | // topiary_core::formatter |
| 889886f | 270 | pub fn formatter( |
| 889886f | 271 | input: &mut impl std::io::Read, |
| 889886f | 272 | output: &mut impl std::io::Write, |
| 889886f | 273 | query: &topiary_core::TopiaryQuery, |
| 889886f | 274 | grammar: topiary_tree_sitter_facade::Language, |
| 889886f | 275 | operation: topiary_core::Operation, |
| 889886f | 276 | ) -> Result<(), topiary_core::FormatterError> |
| 889886f | 277 | ``` |
| 889886f | 278 | |
| 889886f | 279 | `topiary_core::Operation::Format { skip_idempotence, tolerate_parsing_errors }` is the format variant. |
| 889886f | 280 | |
| 889886f | 281 | `topiary_core::TopiaryQuery::new(grammar, query_text)` constructs the query. |
| 889886f | 282 | |
| 889886f | 283 | `tree_sitter_plum::LANGUAGE` is `LanguageFn`; convert to `tree_sitter::Language` via `.into()`, then to `topiary_tree_sitter_facade::Language` via `.into()`. |
| 889886f | 284 | |
| 889886f | 285 | - [ ] **Step 1: Replace the stub in `plum-core/src/formatter.rs`** |
| 889886f | 286 | |
| 889886f | 287 | ```rust |
| 889886f | 288 | use std::io::Cursor; |
| 889886f | 289 | |
| 889886f | 290 | use topiary_core::{formatter, Operation, TopiaryQuery}; |
| 889886f | 291 | use topiary_tree_sitter_facade::Language; |
| 889886f | 292 | |
| 889886f | 293 | const FORMAT_QUERY: &str = |
| 889886f | 294 | include_str!("../../../../tooling/tree-sitter-plum/queries/plum/format.scm"); |
| 889886f | 295 | |
| 889886f | 296 | #[derive(Debug)] |
| 889886f | 297 | pub enum FormatterError { |
| 889886f | 298 | TopiaryCoreError(topiary_core::FormatterError), |
| 889886f | 299 | } |
| 889886f | 300 | |
| 889886f | 301 | impl std::fmt::Display for FormatterError { |
| 889886f | 302 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 889886f | 303 | match self { |
| 889886f | 304 | FormatterError::TopiaryCoreError(e) => write!(f, "Topiary error: {e}"), |
| 889886f | 305 | } |
| 889886f | 306 | } |
| 889886f | 307 | } |
| 889886f | 308 | |
| 889886f | 309 | impl std::error::Error for FormatterError { |
| 889886f | 310 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { |
| 889886f | 311 | match self { |
| 889886f | 312 | FormatterError::TopiaryCoreError(e) => Some(e), |
| 889886f | 313 | } |
| 889886f | 314 | } |
| 889886f | 315 | } |
| 889886f | 316 | |
| 889886f | 317 | impl From<topiary_core::FormatterError> for FormatterError { |
| 889886f | 318 | fn from(e: topiary_core::FormatterError) -> Self { |
| 889886f | 319 | FormatterError::TopiaryCoreError(e) |
| 889886f | 320 | } |
| 889886f | 321 | } |
| 889886f | 322 | |
| 889886f | 323 | pub fn format_source(source: &str) -> Result<String, FormatterError> { |
| 889886f | 324 | let ts_language: tree_sitter::Language = tree_sitter_plum::LANGUAGE.into(); |
| 889886f | 325 | let grammar: Language = ts_language.into(); |
| 889886f | 326 | let query = TopiaryQuery::new(&grammar, FORMAT_QUERY)?; |
| 889886f | 327 | let mut input = Cursor::new(source.as_bytes()); |
| 889886f | 328 | let mut output = Vec::new(); |
| 889886f | 329 | formatter( |
| 889886f | 330 | &mut input, |
| 889886f | 331 | &mut output, |
| 889886f | 332 | &query, |
| 889886f | 333 | grammar, |
| 889886f | 334 | Operation::Format { |
| 889886f | 335 | skip_idempotence: false, |
| 889886f | 336 | tolerate_parsing_errors: false, |
| 889886f | 337 | }, |
| 889886f | 338 | )?; |
| 889886f | 339 | Ok(String::from_utf8(output).expect("Topiary output is valid UTF-8")) |
| 889886f | 340 | } |
| 889886f | 341 | ``` |
| 889886f | 342 | |
| 889886f | 343 | - [ ] **Step 2: Verify it compiles** |
| 889886f | 344 | |
| 889886f | 345 | Run: `cargo build -p plum-core` |
| 889886f | 346 | Expected: compiles without errors |
| 889886f | 347 | |
| 889886f | 348 | If the `TopiaryQuery::new` signature or `formatter` signature differs, check `cargo doc --open -p topiary-core` and adjust accordingly. The 0.7.3 API may use a builder; the key types are `TopiaryQuery`, `Language` (facade), and `Operation`. |
| 889886f | 349 | |
| 889886f | 350 | - [ ] **Step 3: Commit** |
| 889886f | 351 | |
| 889886f | 352 | ```bash |
| 889886f | 353 | git add plum-core/src/formatter.rs |
| 889886f | 354 | git commit -m "feat: implement format_source wrapping topiary-core" |
| 889886f | 355 | ``` |
| 889886f | 356 | |
| 889886f | 357 | --- |
| 889886f | 358 | |
| 889886f | 359 | ### Task 4: Integration Tests for `format_source` |
| 889886f | 360 | |
| 889886f | 361 | **Files:** |
| 889886f | 362 | - Create: `plum-core/tests/formatter_test.rs` |
| 889886f | 363 | |
| 889886f | 364 | **Interfaces:** |
| 889886f | 365 | - Consumes: `plum_core::format_source` |
| 889886f | 366 | |
| 889886f | 367 | - [ ] **Step 1: Create `plum-core/tests/formatter_test.rs`** |
| 889886f | 368 | |
| 889886f | 369 | ```rust |
| 889886f | 370 | use plum_core::format_source; |
| 889886f | 371 | |
| 889886f | 372 | #[test] |
| 889886f | 373 | fn formats_simple_function() { |
| 889886f | 374 | let input = "main() =\n x = 1\n"; |
| 889886f | 375 | let result = format_source(input).expect("format_source should succeed"); |
| 889886f | 376 | // formatted output has the same content structure (exact spacing may vary) |
| 889886f | 377 | assert!(result.contains("main()")); |
| 889886f | 378 | assert!(result.contains("x =")); |
| 889886f | 379 | } |
| 889886f | 380 | |
| 889886f | 381 | #[test] |
| 889886f | 382 | fn formats_binary_operator_spacing() { |
| 889886f | 383 | let input = "add<Int>(a: Int, b: Int) -> Int =\n a+b\n"; |
| 889886f | 384 | let result = format_source(input).expect("format_source should succeed"); |
| 889886f | 385 | // binary operator gets spaces around it |
| 889886f | 386 | assert!(result.contains("a + b")); |
| 889886f | 387 | } |
| 889886f | 388 | |
| 889886f | 389 | #[test] |
| 889886f | 390 | fn rejects_syntax_error() { |
| 889886f | 391 | let input = "fn @@invalid@@\n"; |
| 889886f | 392 | let result = format_source(input); |
| 889886f | 393 | assert!(result.is_err()); |
| 889886f | 394 | } |
| 889886f | 395 | |
| 889886f | 396 | #[test] |
| 889886f | 397 | fn idempotent_on_already_formatted() { |
| 889886f | 398 | let input = "main() =\n x = 1 + 2\n"; |
| 889886f | 399 | let first = format_source(input).expect("first pass"); |
| 889886f | 400 | let second = format_source(&first).expect("second pass"); |
| 889886f | 401 | assert_eq!(first, second, "formatting should be idempotent"); |
| 889886f | 402 | } |
| 889886f | 403 | ``` |
| 889886f | 404 | |
| 889886f | 405 | - [ ] **Step 2: Run the tests** |
| 889886f | 406 | |
| 889886f | 407 | Run: `cargo test -p plum-core` |
| 889886f | 408 | Expected: all 4 tests pass (the idempotence test verifies Topiary's double-format check) |
| 889886f | 409 | |
| 889886f | 410 | If `rejects_syntax_error` fails because Topiary tolerates the error anyway, change that test to verify it returns an `Err` with a descriptive message. |
| 889886f | 411 | |
| 889886f | 412 | - [ ] **Step 3: Commit** |
| 889886f | 413 | |
| 889886f | 414 | ```bash |
| 889886f | 415 | git add plum-core/tests/formatter_test.rs |
| 889886f | 416 | git commit -m "test: add integration tests for format_source" |
| 889886f | 417 | ``` |
| 889886f | 418 | |
| 889886f | 419 | --- |
| 889886f | 420 | |
| 889886f | 421 | ### Task 5: Create `plum-cli` Binary |
| 889886f | 422 | |
| 889886f | 423 | **Files:** |
| 889886f | 424 | - Create: `plum-cli/Cargo.toml` |
| 889886f | 425 | - Create: `plum-cli/src/main.rs` |
| 889886f | 426 | |
| 889886f | 427 | **Interfaces:** |
| 889886f | 428 | - Consumes: `plum_core::format_source`, `plum_core::FormatterError` |
| 889886f | 429 | - Produces: `plum format <file>`, `plum format --check <file>`, `plum format --stdin` |
| 889886f | 430 | |
| 889886f | 431 | - [ ] **Step 1: Create `plum-cli/Cargo.toml`** |
| 889886f | 432 | |
| 889886f | 433 | ```toml |
| 889886f | 434 | [package] |
| 889886f | 435 | name = "plum-cli" |
| 889886f | 436 | version = "0.1.0" |
| 889886f | 437 | edition = "2021" |
| 889886f | 438 | |
| 889886f | 439 | [[bin]] |
| 889886f | 440 | name = "plum" |
| 889886f | 441 | path = "src/main.rs" |
| 889886f | 442 | |
| 889886f | 443 | [dependencies] |
| 889886f | 444 | plum-core = { path = "../plum-core" } |
| 889886f | 445 | clap = { version = "4", features = ["derive"] } |
| 889886f | 446 | anyhow = "1" |
| 889886f | 447 | ``` |
| 889886f | 448 | |
| 889886f | 449 | - [ ] **Step 2: Create `plum-cli/src/main.rs`** |
| 889886f | 450 | |
| 889886f | 451 | ```rust |
| 889886f | 452 | use std::fs; |
| 889886f | 453 | use std::io::{self, Read}; |
| 889886f | 454 | use std::process; |
| 889886f | 455 | |
| 889886f | 456 | use anyhow::{Context, Result}; |
| 889886f | 457 | use clap::{Parser, Subcommand}; |
| 889886f | 458 | |
| 889886f | 459 | use plum_core::format_source; |
| 889886f | 460 | |
| 889886f | 461 | #[derive(Parser)] |
| 889886f | 462 | #[command(name = "plum", about = "The Plum language toolchain")] |
| 889886f | 463 | struct Cli { |
| 889886f | 464 | #[command(subcommand)] |
| 889886f | 465 | command: Command, |
| 889886f | 466 | } |
| 889886f | 467 | |
| 889886f | 468 | #[derive(Subcommand)] |
| 889886f | 469 | enum Command { |
| 889886f | 470 | /// Format a Plum source file |
| 889886f | 471 | Format { |
| 889886f | 472 | /// File to format (omit to use --stdin) |
| 889886f | 473 | file: Option<std::path::PathBuf>, |
| 889886f | 474 | /// Check if file is formatted; exit 1 if it would change |
| 889886f | 475 | #[arg(long)] |
| 889886f | 476 | check: bool, |
| 889886f | 477 | /// Read from stdin and write formatted source to stdout |
| 889886f | 478 | #[arg(long)] |
| 889886f | 479 | stdin: bool, |
| 889886f | 480 | }, |
| 889886f | 481 | } |
| 889886f | 482 | |
| 889886f | 483 | fn main() { |
| 889886f | 484 | if let Err(e) = run() { |
| 889886f | 485 | eprintln!("error: {e:#}"); |
| 889886f | 486 | process::exit(1); |
| 889886f | 487 | } |
| 889886f | 488 | } |
| 889886f | 489 | |
| 889886f | 490 | fn run() -> Result<()> { |
| 889886f | 491 | let cli = Cli::parse(); |
| 889886f | 492 | match cli.command { |
| 889886f | 493 | Command::Format { file, check, stdin } => cmd_format(file, check, stdin), |
| 889886f | 494 | } |
| 889886f | 495 | } |
| 889886f | 496 | |
| 889886f | 497 | fn cmd_format( |
| 889886f | 498 | file: Option<std::path::PathBuf>, |
| 889886f | 499 | check: bool, |
| 889886f | 500 | use_stdin: bool, |
| 889886f | 501 | ) -> Result<()> { |
| 889886f | 502 | if use_stdin { |
| 889886f | 503 | let mut source = String::new(); |
| 889886f | 504 | io::stdin() |
| 889886f | 505 | .read_to_string(&mut source) |
| 889886f | 506 | .context("failed to read stdin")?; |
| 889886f | 507 | let formatted = format_source(&source).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 889886f | 508 | print!("{formatted}"); |
| 889886f | 509 | return Ok(()); |
| 889886f | 510 | } |
| 889886f | 511 | |
| 889886f | 512 | let path = file.ok_or_else(|| anyhow::anyhow!("provide a file path or --stdin"))?; |
| 889886f | 513 | let source = |
| 889886f | 514 | fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; |
| 889886f | 515 | let formatted = format_source(&source).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 889886f | 516 | |
| 889886f | 517 | if check { |
| 889886f | 518 | if source != formatted { |
| 889886f | 519 | eprintln!("{}: would reformat", path.display()); |
| 889886f | 520 | process::exit(1); |
| 889886f | 521 | } |
| 889886f | 522 | return Ok(()); |
| 889886f | 523 | } |
| 889886f | 524 | |
| 889886f | 525 | if source != formatted { |
| 889886f | 526 | fs::write(&path, formatted.as_bytes()) |
| 889886f | 527 | .with_context(|| format!("failed to write {}", path.display()))?; |
| 889886f | 528 | } |
| 889886f | 529 | Ok(()) |
| 889886f | 530 | } |
| 889886f | 531 | ``` |
| 889886f | 532 | |
| 889886f | 533 | - [ ] **Step 3: Verify build** |
| 889886f | 534 | |
| 889886f | 535 | Run: `cargo build -p plum-cli` |
| 889886f | 536 | Expected: produces `target/debug/plum` binary |
| 889886f | 537 | |
| 889886f | 538 | - [ ] **Step 4: Smoke-test the binary** |
| 889886f | 539 | |
| 889886f | 540 | ```bash |
| 889886f | 541 | echo 'main() =\n x=1+2\n' > /tmp/smoke.plum |
| 889886f | 542 | cargo run -p plum-cli -- format /tmp/smoke.plum |
| 889886f | 543 | cat /tmp/smoke.plum |
| 889886f | 544 | ``` |
| 889886f | 545 | |
| 889886f | 546 | Expected: `x = 1 + 2` (spaces around `=` and `+`) |
| 889886f | 547 | |
| 889886f | 548 | ```bash |
| 889886f | 549 | cargo run -p plum-cli -- format --check /tmp/smoke.plum |
| 889886f | 550 | echo "exit: $?" |
| 889886f | 551 | ``` |
| 889886f | 552 | |
| 889886f | 553 | Expected: exits 0 (file is already formatted after the previous run) |
| 889886f | 554 | |
| 889886f | 555 | ```bash |
| 889886f | 556 | echo 'main()=\n x=1\n' | cargo run -p plum-cli -- format --stdin |
| 889886f | 557 | ``` |
| 889886f | 558 | |
| 889886f | 559 | Expected: formatted source printed to stdout |
| 889886f | 560 | |
| 889886f | 561 | - [ ] **Step 5: Commit** |
| 889886f | 562 | |
| 889886f | 563 | ```bash |
| 889886f | 564 | git add plum-cli/ |
| 889886f | 565 | git commit -m "feat: add plum-cli binary with format subcommand" |
| 889886f | 566 | ``` |
| 889886f | 567 | |
| 889886f | 568 | --- |
| 889886f | 569 | |
| 889886f | 570 | ### Task 6: Clean Up Old Root Crate |
| 889886f | 571 | |
| 889886f | 572 | **Files:** |
| 889886f | 573 | - Delete: `src/` directory (the old root `src/ast.rs`, `src/parser.rs`, `src/main.rs`) |
| 889886f | 574 | |
| 889886f | 575 | **Interfaces:** |
| 889886f | 576 | - Consumes: nothing (cleanup only) |
| 889886f | 577 | - Produces: clean workspace with no dangling `src/` |
| 889886f | 578 | |
| 889886f | 579 | - [ ] **Step 1: Verify `src/` is no longer referenced** |
| 889886f | 580 | |
| 889886f | 581 | Run: `cargo build` from workspace root |
| 889886f | 582 | Expected: builds both `plum-core` and `plum-cli` with no errors |
| 889886f | 583 | |
| 889886f | 584 | - [ ] **Step 2: Remove old `src/` directory** |
| 889886f | 585 | |
| 889886f | 586 | ```bash |
| 889886f | 587 | rm -rf src/ |
| 889886f | 588 | ``` |
| 889886f | 589 | |
| 889886f | 590 | - [ ] **Step 3: Run full workspace check** |
| 889886f | 591 | |
| 889886f | 592 | Run: `cargo test` |
| 889886f | 593 | Expected: all tests pass; `src/` is gone |
| 889886f | 594 | |
| 889886f | 595 | - [ ] **Step 4: Commit** |
| 889886f | 596 | |
| 889886f | 597 | ```bash |
| 889886f | 598 | git add -A |
| 889886f | 599 | git commit -m "chore: remove old root src/ after workspace migration" |
| 889886f | 600 | ``` |
| 889886f | 601 | |
| 889886f | 602 | --- |
| 889886f | 603 | |
| 889886f | 604 | ## Self-Review |
| 889886f | 605 | |
| 889886f | 606 | **Spec coverage check:** |
| 889886f | 607 | |
| 889886f | 608 | | Spec requirement | Covered by | |
| 889886f | 609 | |---|---| |
| 889886f | 610 | | Workspace restructure | Task 1 | |
| 889886f | 611 | | `plum-core` with ast, parser, formatter | Tasks 1, 3 | |
| 889886f | 612 | | `format.scm` Topiary rules | Task 2 | |
| 889886f | 613 | | `format_source` wrapping topiary-core | Task 3 | |
| 889886f | 614 | | Formatter integration tests | Task 4 | |
| 889886f | 615 | | `plum format <file>` in-place | Task 5 | |
| 889886f | 616 | | `plum format --check <file>` | Task 5 | |
| 889886f | 617 | | `plum format --stdin` | Task 5 | |
| 889886f | 618 | | `tolerate_parsing_errors = false` | Task 3 (`Operation::Format` field) | |
| 889886f | 619 | | Errors to stderr, exit 1 | Task 5 (`run()` + `process::exit(1)`) | |
| 889886f | 620 | | tree-sitter upgrade to 0.26 | Task 1 (`plum-core/Cargo.toml`) | |
| 889886f | 621 | | Old `src/` removed | Task 6 | |
| 889886f | 622 | |
| 889886f | 623 | **tree-sitter version note:** `topiary-core = "0.7.3"` resolves `tree-sitter = "0.26.11"`. `plum-core/Cargo.toml` must declare `tree-sitter = "0.26"` (not `"0.24.5"`) so Cargo uses a single copy. The `tree-sitter-plum` grammar crate uses `tree-sitter-language = "0.1"` at runtime (no direct tree-sitter dependency), so no patch is needed. |
| 889886f | 624 | |
| 889886f | 625 | **`include_str!` path:** `format.scm` is embedded via a path relative to `plum-core/src/formatter.rs`, which is `../../../../tooling/tree-sitter-plum/queries/plum/format.scm`. Verify this resolves correctly after the workspace move. If it doesn't, an alternative is to add a build script that copies the file into `OUT_DIR`. |