From d431df12877d804de89268b2f2a1dafc932a6c01 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Tue, 21 Apr 2026 23:23:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20perry=20dev=20=E2=80=94=20watch-mod?= =?UTF-8?q?e=20subcommand=20for=20auto-recompile=20on=20save=20(v0.5.143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `perry dev ` subcommand: watches the nearest project root (package.json / perry.toml, falls back to entry's parent) recursively via the `notify` crate, debounces 300ms, recompiles on any .ts|.tsx|.mts|.cts|.json|.toml change, kills the running child, and relaunches the fresh binary. Ignores node_modules, target, .git, dist, build, .perry-dev, .perry-cache. Output defaults to .perry-dev/. Args after `--` forward to the child (perry dev src/main.ts -- --port 3000). Smoke-tested: initial build ~15s (cold auto-optimize), post-edit rebuild ~330ms (hot libs cached). 8 unit tests cover the pure helpers (is_trigger_path, is_relevant, find_project_root). tempfile added as dev-dep. V2 follow-ups planned: in-memory AST cache + per-module .o reuse for incremental compilation. (Rebased onto main and bumped from v0.5.142 → v0.5.143; the 0.5.142 slot was already taken by two fixes on main — fs/roundtrip cross-platform path and async-closure Promise return — between PR author and merge.) --- CLAUDE.md | 3 +- Cargo.lock | 84 +++++- Cargo.toml | 2 +- crates/perry/Cargo.toml | 4 + crates/perry/src/commands/dev.rs | 443 +++++++++++++++++++++++++++++++ crates/perry/src/commands/mod.rs | 1 + crates/perry/src/main.rs | 8 +- 7 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 crates/perry/src/commands/dev.rs diff --git a/CLAUDE.md b/CLAUDE.md index a70d6bcb..9d6f762e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.142 +**Current Version:** 0.5.143 ## TypeScript Parity Status @@ -149,6 +149,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re Keep entries to 1-2 lines max. Full details in CHANGELOG.md. +- **v0.5.143** — New `perry dev ` subcommand (V1 watch mode, PR #126 by @TheHypnoo). Watches the nearest project root (package.json/perry.toml, falls back to entry dir) recursively via the `notify` crate, debounces 300ms, recompiles on any `.ts|.tsx|.mts|.cts|.json|.toml` change, kills the running child, and relaunches the fresh binary. Ignores `node_modules`, `target`, `.git`, `dist`, `build`, `.perry-dev`. Output defaults to `.perry-dev/`. Args after `--` forward to the child (`perry dev src/main.ts -- --port 3000`). Smoke-tested: initial build ~15s (cold auto-optimize), post-edit rebuild ~330ms (hot libs cached). 8 unit tests cover the pure helpers (`is_trigger_path`, `is_relevant`, `find_project_root`); `tempfile` added as dev-dep. Docs added to `docs/src/cli/commands.md` on merge. V2 follow-ups planned: in-memory AST cache + per-module `.o` reuse for incremental compilation. - **v0.5.142** — Make `stdlib/fs/roundtrip.ts` cross-platform. v0.5.140 introduced the example with a hardcoded `/tmp/perry_fs_demo.txt` path, which has no equivalent on Windows; the Windows doc-tests run correctly wrote to `C:\Users\…\tmp\…` (or wherever write fell back to) but the subsequent `readFileSync(path, "utf-8")` returned empty because the file wasn't at `/tmp`, so `roundtrip ok: false`. Switched the example to `os.tmpdir() + path.join`, re-blessed the expected stdout (dropped the absolute path from the "wrote N bytes" line since it varies per OS). Also removes the same bug if the example is ever run on a Windows dev box. - **v0.5.142** — Fix async arrow / closure return values (closes #125). `compile_closure` (`crates/perry-codegen/src/codegen.rs:1657, 1818`) had been dropping the `is_async` flag from `Expr::Closure` — the FnCtx was always constructed with `is_async_fn: false`, so `Stmt::Return` inside an async arrow never wrapped its value in `js_promise_resolved`. Consumers that rely on the closure returning a real Promise pointer (Fastify's server runtime inspects handler results with `js_is_promise` → `js_promise_value`) got back a raw NaN-boxed object pointer, treated it as a Promise, and read the object's field memory as `Promise.value` — surfacing as a gibberish decimal (a raw heap address reinterpreted as f64) in the HTTP response body. Threaded `is_async` through the destructure, set `is_async_fn: is_async`, and wrapped the no-explicit-return fallback too. Also stripped ~10 stale debug `eprintln!`s from `crates/perry-stdlib/src/fastify/context.rs` + `app.rs` (including a hardcoded "email" field probe that was spamming unrelated output on every JSON body parse). Side effect: gap suite jumped from 14/28 @ 117 diffs to 22/28 @ 29 diffs since async closures are load-bearing across test_gap_class_advanced/node_*/typeof_instanceof/etc. - **v0.5.141** — Two follow-ups from the v0.5.140 CI run: (1) Ubuntu `ui/styling/counter_card.ts` hit `undefined reference to perry_ui_widget_set_border_color` / `_set_border_width` — same pattern as v0.5.136's `buttonSetContentTintColor` (declared in perry's dispatch table, not exported by perry-ui-gtk4 because GTK4 borders are CSS-driven). Dropped the two calls from the example, documented inline. (2) Windows `cargo build -p perry-ui-windows` hit `E0505: cannot move out of out because it is borrowed` in `encode_png_rgba` — the early-return-on-header-error kept `encoder`'s borrow of `out` alive across the `return`. Restructured to commit the encoder's success/failure to a local `bool` and clear `out` after the borrow ends. diff --git a/Cargo.lock b/Cargo.lock index 7f246b28..36b86ed0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,6 +1836,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fslock" version = "0.2.1" @@ -2891,6 +2900,26 @@ dependencies = [ "web-time", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3075,6 +3104,26 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3426,6 +3475,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -3592,6 +3653,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4071,6 +4151,7 @@ dependencies = [ "indicatif", "jsonwebtoken", "log", + "notify", "perry-codegen", "perry-codegen-glance", "perry-codegen-js", @@ -4089,6 +4170,7 @@ dependencies = [ "serde_json", "swc_ecma_ast", "tar", + "tempfile", "tokio", "tokio-tungstenite 0.28.0", "toml 0.8.23", @@ -6590,7 +6672,7 @@ checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", diff --git a/Cargo.toml b/Cargo.toml index a0a2b87b..0ee89ec0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.142" +version = "0.5.143" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry/Cargo.toml b/crates/perry/Cargo.toml index 6560148c..900bfdfb 100644 --- a/crates/perry/Cargo.toml +++ b/crates/perry/Cargo.toml @@ -35,6 +35,7 @@ console.workspace = true dialoguer.workspace = true toml.workspace = true walkdir.workspace = true +notify.workspace = true atty.workspace = true reqwest.workspace = true tokio.workspace = true @@ -49,3 +50,6 @@ zip.workspace = true jsonwebtoken = "9.3" dotenvy = "0.15" rayon = "1.10" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs new file mode 100644 index 00000000..5e482a07 --- /dev/null +++ b/crates/perry/src/commands/dev.rs @@ -0,0 +1,443 @@ +//! Dev command - watch TypeScript source and auto-recompile on changes. +//! +//! Usage: +//! perry dev src/main.ts +//! perry dev src/server.ts --watch extra/dir -- --port 8080 +//! +//! Watches the project tree (rooted at the nearest `package.json` / `perry.toml`, +//! or the entry's parent directory), recompiles on any `.ts` / `.tsx` / `.json` +//! / `.toml` change, kills the previous child process, and relaunches the new +//! binary. Events are debounced over a short window so editor "save storms" +//! trigger a single rebuild. +//! +//! This is the V1 watch mode: it shells out to the existing compile pipeline +//! and relies on Perry's auto-optimize library cache for speed. A future V2 +//! may add in-memory AST caching and per-module `.o` reuse. + +use anyhow::{anyhow, Result}; +use clap::Args; +use console::style; +use notify::{Event, EventKind, RecursiveMode, Watcher}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use super::compile::CompileArgs; +use crate::OutputFormat; + +/// How long to wait after the first event before triggering a rebuild, +/// so that a burst of save events collapses into one build. +const DEBOUNCE: Duration = Duration::from_millis(300); + +/// Directory names that are never watched and never trigger rebuilds. +const IGNORED_DIRS: &[&str] = &[ + "node_modules", + "target", + ".git", + "dist", + "build", + ".perry-dev", + ".perry-cache", +]; + +/// File extensions whose changes should trigger a rebuild. +const TRIGGER_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "json", "toml"]; + +#[derive(Args, Debug)] +pub struct DevArgs { + /// Entry TypeScript file + pub input: PathBuf, + + /// Output executable path (default: .perry-dev/) + #[arg(short, long)] + pub output: Option, + + /// Extra directories to watch (comma-separated or repeated) + #[arg(long, value_delimiter = ',')] + pub watch: Vec, + + /// Arguments to forward to the compiled binary. Place after `--`. + /// Example: perry dev src/main.ts -- --port 3000 + #[arg(last = true)] + pub child_args: Vec, +} + +pub fn run(args: DevArgs, _format: OutputFormat, use_color: bool, verbose: u8) -> Result<()> { + let input = args + .input + .canonicalize() + .map_err(|e| anyhow!("cannot resolve entry '{}': {}", args.input.display(), e))?; + if !input.is_file() { + return Err(anyhow!("entry is not a file: {}", input.display())); + } + if input.extension().and_then(|e| e.to_str()) != Some("ts") { + return Err(anyhow!("entry must be a .ts file: {}", input.display())); + } + + let output_path = resolve_output(&args.output, &input)?; + + let entry_dir = input + .parent() + .ok_or_else(|| anyhow!("entry has no parent directory"))? + .to_path_buf(); + let project_root = find_project_root(&entry_dir); + + let mut watch_roots = vec![project_root.clone()]; + for extra in &args.watch { + match extra.canonicalize() { + Ok(p) => watch_roots.push(p), + Err(e) => eprintln!( + "{} skipping --watch {}: {}", + paint("!", "yellow", use_color), + extra.display(), + e + ), + } + } + + print_banner(&project_root, &input, &output_path, &watch_roots, use_color); + + // Initial build + spawn. + let mut child: Option = match build_once(&input, &output_path, verbose) { + Ok(()) => spawn_child(&output_path, &args.child_args, use_color).ok(), + Err(e) => { + eprintln!( + "{} initial build failed: {:#}", + paint("✗", "red", use_color), + e + ); + eprintln!( + " {}", + paint("waiting for changes...", "dim", use_color) + ); + None + } + }; + + // Set up the watcher. Events arrive on `rx`. + let (tx, rx) = mpsc::channel::>(); + let mut watcher = notify::recommended_watcher(tx) + .map_err(|e| anyhow!("failed to create watcher: {}", e))?; + for root in &watch_roots { + watcher + .watch(root, RecursiveMode::Recursive) + .map_err(|e| anyhow!("failed to watch {}: {}", root.display(), e))?; + } + + // Main loop: wait for relevant change, debounce, rebuild, relaunch. + loop { + // Block until something happens. + let first = match rx.recv() { + Ok(ev) => ev, + Err(_) => break, // sender dropped; exit cleanly + }; + if !is_relevant(&first) { + continue; + } + + // Debounce: swallow any follow-up events within DEBOUNCE. + let deadline = Instant::now() + DEBOUNCE; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match rx.recv_timeout(remaining) { + Ok(_) => continue, + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + cleanup_child(&mut child); + return Ok(()); + } + } + } + + eprintln!( + "{} change detected — rebuilding...", + paint("⟳", "yellow", use_color) + ); + + // Kill any running child before we rebuild — the binary file is about + // to be overwritten and a running child would block the link step on + // some OSes (and race on all of them). + cleanup_child(&mut child); + + let started = Instant::now(); + match build_once(&input, &output_path, verbose) { + Ok(()) => { + let ms = started.elapsed().as_millis(); + eprintln!( + "{} rebuilt in {}ms", + paint("✓", "green", use_color), + ms + ); + match spawn_child(&output_path, &args.child_args, use_color) { + Ok(c) => child = Some(c), + Err(e) => eprintln!( + "{} failed to launch: {:#}", + paint("✗", "red", use_color), + e + ), + } + } + Err(e) => { + eprintln!( + "{} build failed: {:#}", + paint("✗", "red", use_color), + e + ); + eprintln!( + " {}", + paint("waiting for next change...", "dim", use_color) + ); + } + } + } + + cleanup_child(&mut child); + Ok(()) +} + +fn resolve_output(output: &Option, input: &Path) -> Result { + if let Some(o) = output { + return Ok(o.clone()); + } + let cwd = std::env::current_dir().map_err(|e| anyhow!("cannot read cwd: {}", e))?; + let dir = cwd.join(".perry-dev"); + std::fs::create_dir_all(&dir) + .map_err(|e| anyhow!("cannot create {}: {}", dir.display(), e))?; + let stem = input + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("app"); + Ok(dir.join(stem)) +} + +fn find_project_root(start: &Path) -> PathBuf { + let mut cur = start.to_path_buf(); + loop { + if cur.join("package.json").is_file() || cur.join("perry.toml").is_file() { + return cur; + } + if !cur.pop() { + return start.to_path_buf(); + } + } +} + +/// Decide whether a raw watcher event should trigger a rebuild. We accept +/// modify/create/remove events on files whose extension is in `TRIGGER_EXTS` +/// and whose path does not traverse any ignored directory. +fn is_relevant(res: ¬ify::Result) -> bool { + let event = match res { + Ok(e) => e, + Err(_) => return false, + }; + match event.kind { + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => {} + _ => return false, + } + event.paths.iter().any(|p| is_trigger_path(p)) +} + +fn is_trigger_path(path: &Path) -> bool { + for comp in path.components() { + if let Component::Normal(name) = comp { + if let Some(s) = name.to_str() { + if IGNORED_DIRS.contains(&s) { + return false; + } + } + } + } + match path.extension().and_then(|e| e.to_str()) { + Some(ext) => TRIGGER_EXTS.contains(&ext), + None => false, + } +} + +fn build_once(input: &Path, output: &Path, verbose: u8) -> Result<()> { + let args = CompileArgs { + input: input.to_path_buf(), + output: Some(output.to_path_buf()), + keep_intermediates: false, + print_hir: false, + no_link: false, + enable_js_runtime: false, + target: None, + app_bundle_id: None, + output_type: "executable".to_string(), + bundle_extensions: None, + type_check: false, + minify: false, + features: None, + enable_geisterhand: false, + geisterhand_port: None, + minimal_stdlib: false, + no_auto_optimize: false, + }; + super::compile::run(args, OutputFormat::Text, true, verbose)?; + Ok(()) +} + +fn spawn_child(bin: &Path, child_args: &[String], use_color: bool) -> Result { + eprintln!( + "{} launching {}", + paint("▶", "cyan", use_color), + bin.display() + ); + Command::new(bin) + .args(child_args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| anyhow!("failed to spawn {}: {}", bin.display(), e)) +} + +fn cleanup_child(child: &mut Option) { + if let Some(mut c) = child.take() { + let _ = c.kill(); + let _ = c.wait(); + } +} + +fn print_banner( + project_root: &Path, + input: &Path, + output: &Path, + watch_roots: &[PathBuf], + use_color: bool, +) { + eprintln!( + "{} {} watching {}", + paint("●", "cyan", use_color), + paint("perry dev", "bold", use_color), + project_root.display() + ); + eprintln!(" entry: {}", input.display()); + eprintln!(" output: {}", output.display()); + if watch_roots.len() > 1 { + for extra in &watch_roots[1..] { + eprintln!(" watch: {}", extra.display()); + } + } + eprintln!(); +} + +fn paint(s: &str, color: &str, enabled: bool) -> String { + if !enabled { + return s.to_string(); + } + let styled = match color { + "red" => style(s).red().bold(), + "green" => style(s).green().bold(), + "yellow" => style(s).yellow().bold(), + "cyan" => style(s).cyan().bold(), + "dim" => style(s).dim(), + "bold" => style(s).bold(), + _ => style(s), + }; + styled.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use notify::event::{CreateKind, DataChange, ModifyKind, RemoveKind}; + use std::fs; + + fn ev(kind: EventKind, path: &str) -> notify::Result { + Ok(Event::new(kind).add_path(PathBuf::from(path))) + } + + #[test] + fn trigger_path_accepts_ts_family() { + assert!(is_trigger_path(Path::new("src/main.ts"))); + assert!(is_trigger_path(Path::new("src/App.tsx"))); + assert!(is_trigger_path(Path::new("src/lib.mts"))); + assert!(is_trigger_path(Path::new("src/lib.cts"))); + assert!(is_trigger_path(Path::new("package.json"))); + assert!(is_trigger_path(Path::new("perry.toml"))); + } + + #[test] + fn trigger_path_rejects_other_extensions() { + assert!(!is_trigger_path(Path::new("src/main.js"))); + assert!(!is_trigger_path(Path::new("README.md"))); + assert!(!is_trigger_path(Path::new("image.png"))); + assert!(!is_trigger_path(Path::new("no_extension"))); + } + + #[test] + fn trigger_path_rejects_ignored_dirs() { + assert!(!is_trigger_path(Path::new("node_modules/pkg/index.ts"))); + assert!(!is_trigger_path(Path::new("target/debug/build.ts"))); + assert!(!is_trigger_path(Path::new(".git/HEAD.ts"))); + assert!(!is_trigger_path(Path::new("project/dist/out.ts"))); + assert!(!is_trigger_path(Path::new(".perry-dev/app.ts"))); + assert!(!is_trigger_path(Path::new(".perry-cache/x.ts"))); + assert!(!is_trigger_path(Path::new("a/build/b/c.ts"))); + } + + #[test] + fn relevant_accepts_modify_create_remove_of_ts() { + assert!(is_relevant(&ev( + EventKind::Modify(ModifyKind::Data(DataChange::Content)), + "src/main.ts" + ))); + assert!(is_relevant(&ev( + EventKind::Create(CreateKind::File), + "src/new.ts" + ))); + assert!(is_relevant(&ev( + EventKind::Remove(RemoveKind::File), + "src/gone.ts" + ))); + } + + #[test] + fn relevant_rejects_non_trigger_paths_and_kinds() { + assert!(!is_relevant(&ev( + EventKind::Modify(ModifyKind::Data(DataChange::Content)), + "src/main.js" + ))); + assert!(!is_relevant(&ev( + EventKind::Modify(ModifyKind::Data(DataChange::Content)), + "node_modules/x.ts" + ))); + assert!(!is_relevant(&ev(EventKind::Access( + notify::event::AccessKind::Read + ), "src/main.ts"))); + assert!(!is_relevant(&Err(notify::Error::generic("boom")))); + } + + #[test] + fn project_root_finds_package_json() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let nested = root.join("a").join("b").join("c"); + fs::create_dir_all(&nested).unwrap(); + fs::write(root.join("package.json"), "{}").unwrap(); + assert_eq!(find_project_root(&nested), root); + } + + #[test] + fn project_root_finds_perry_toml() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let nested = root.join("src"); + fs::create_dir_all(&nested).unwrap(); + fs::write(root.join("perry.toml"), "").unwrap(); + assert_eq!(find_project_root(&nested), root); + } + + #[test] + fn project_root_falls_back_to_start_when_no_marker() { + let tmp = tempfile::tempdir().unwrap(); + let start = tmp.path().canonicalize().unwrap().join("lonely"); + fs::create_dir_all(&start).unwrap(); + assert_eq!(find_project_root(&start), start); + } +} diff --git a/crates/perry/src/commands/mod.rs b/crates/perry/src/commands/mod.rs index fe04d0ba..5bfdd32b 100644 --- a/crates/perry/src/commands/mod.rs +++ b/crates/perry/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod audit; pub mod check; pub mod compile; pub mod deps; +pub mod dev; pub mod doctor; pub mod explain; pub mod fix_applier; diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index af095431..4bd942ea 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -90,6 +90,9 @@ enum Commands { /// Compile and run a TypeScript file in one step Run(commands::run::RunArgs), + /// Watch TypeScript source and auto-recompile on changes + Dev(commands::dev::DevArgs), + /// Internationalization tools (extract strings, manage locales) I18n(commands::i18n::I18nArgs), @@ -117,7 +120,7 @@ fn is_legacy_invocation(args: &[String]) -> bool { // If it's a known subcommand, not legacy if matches!( arg.as_str(), - "compile" | "check" | "init" | "doctor" | "explain" | "publish" | "update" | "setup" | "audit" | "verify" | "run" | "appstore" | "types" | "help" + "compile" | "check" | "init" | "doctor" | "explain" | "publish" | "update" | "setup" | "audit" | "verify" | "run" | "dev" | "appstore" | "types" | "help" ) { return false; } @@ -216,6 +219,9 @@ fn main_inner() -> Result<()> { Commands::Run(args) => { commands::run::run(args, cli.format, use_color, cli.verbose) } + Commands::Dev(args) => { + commands::dev::run(args, cli.format, use_color, cli.verbose) + } Commands::Check(args) => { commands::check::run(args, cli.format, use_color, cli.verbose) } From 0d80146e1091136de7fe9760f04b983e9afe6028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 22 Apr 2026 03:18:48 +0200 Subject: [PATCH 2/2] docs: add `perry dev` to CLI commands reference PR #126 shipped the `perry dev` watch-mode subcommand but not the docs page. Adding it on the contributor's behalf as part of the merge so the CLI reference stays in sync with what ships. Covers: usage examples, flag table, mechanics (project-root discovery, debounce, kill-then-rebuild order), trigger extension allowlist and ignored-directory list, benchmark table, and the V2 incremental-cache roadmap called out in the PR description. --- Cargo.lock | 52 ++++++++++++++++++++-------------------- docs/src/cli/commands.md | 44 +++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36b86ed0..8b54c240 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4135,7 +4135,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "atty", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "log", @@ -4192,7 +4192,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "perry-hir", @@ -4200,7 +4200,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "perry-hir", @@ -4209,7 +4209,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "perry-hir", @@ -4218,7 +4218,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "base64", @@ -4230,7 +4230,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "perry-hir", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.142" +version = "0.5.143" dependencies = [ "serde", "serde_json", @@ -4246,7 +4246,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "clap", @@ -4260,7 +4260,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "perry-diagnostics", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "deno_core", @@ -4291,7 +4291,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "perry-diagnostics", @@ -4303,7 +4303,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "base64", @@ -4324,7 +4324,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.142" +version = "0.5.143" dependencies = [ "aes", "aes-gcm", @@ -4388,7 +4388,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "perry-hir", @@ -4398,7 +4398,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.142" +version = "0.5.143" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -4406,11 +4406,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.142" +version = "0.5.143" [[package]] name = "perry-ui-android" -version = "0.5.142" +version = "0.5.143" dependencies = [ "itoa", "jni", @@ -4424,7 +4424,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.142" +version = "0.5.143" dependencies = [ "rand 0.8.5", "serde", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.142" +version = "0.5.143" dependencies = [ "cairo-rs", "gtk4", @@ -4446,7 +4446,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.142" +version = "0.5.143" dependencies = [ "block2", "libc", @@ -4461,7 +4461,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.142" +version = "0.5.143" dependencies = [ "block2", "libc", @@ -4479,11 +4479,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.142" +version = "0.5.143" [[package]] name = "perry-ui-tvos" -version = "0.5.142" +version = "0.5.143" dependencies = [ "block2", "libc", @@ -4498,7 +4498,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.142" +version = "0.5.143" dependencies = [ "block2", "libc", @@ -4511,7 +4511,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.142" +version = "0.5.143" dependencies = [ "libc", "perry-runtime", diff --git a/docs/src/cli/commands.md b/docs/src/cli/commands.md index c18fa27e..cd768a5b 100644 --- a/docs/src/cli/commands.md +++ b/docs/src/cli/commands.md @@ -1,6 +1,6 @@ # CLI Commands -Perry provides 10 commands for compiling, checking, running, publishing, and managing your projects. +Perry provides 11 commands for compiling, checking, running, publishing, and managing your projects. See also: [perry.toml Reference](perry-toml.md) for project configuration. @@ -93,6 +93,48 @@ perry run ios --remote perry run web ``` +## dev + +Watch your TypeScript source tree and auto-recompile + relaunch on every save. + +```bash +perry dev src/main.ts # watch + rebuild + relaunch on save +perry dev src/server.ts -- --port 3000 # forward args to the child +perry dev src/app.ts --watch shared/ # watch an extra directory +perry dev src/app.ts -o build/dev-app # override output path +``` + +| Flag | Description | +|------|-------------| +| `-o, --output ` | Output binary path (default: `.perry-dev/`) | +| `--watch ` | Extra directories to watch (comma-separated or repeated) | +| `--` | Separator — everything after is forwarded to the compiled binary | + +**How it works:** + +1. Resolves the entry, computes the **project root** (walks up until it finds a `package.json` or `perry.toml`; falls back to the entry's parent directory). +2. Does an initial `perry compile`, then spawns the resulting binary with stdio inherited. +3. Watches the project root (plus any `--watch` dirs) recursively using the `notify` crate. A 300 ms **debounce** window collapses editor "save storms" into one rebuild. +4. On each relevant change: kill the running child, recompile, relaunch. A failed build leaves the old child dead and waits for the next change; no crash loop. + +**What counts as a "relevant" change:** +- **Trigger extensions:** `.ts`, `.tsx`, `.mts`, `.cts`, `.json`, `.toml` +- **Ignored directories (not watched, never retrigger):** `node_modules`, `target`, `.git`, `dist`, `build`, `.perry-dev`, `.perry-cache` + +**Benchmarks** (trivial single-file program, macOS): + +| Phase | Time | +|---|---| +| Initial build (cold — runtime + stdlib rebuilt by auto-optimize) | ~15 s | +| Post-edit rebuild (hot libs cached on disk) | **~330 ms** | + +The speedup on hot rebuilds comes from Perry's existing auto-optimize library cache. Multi-module projects will still recompile every changed module on each save — see the V2 note below for planned incremental work. + +**Not yet in scope (V2+):** +- In-memory AST cache (reuse SWC parses across rebuilds). +- Per-module `.o` cache on disk (only re-codegen the changed module). +- State preservation across rebuilds / HMR — "fast restart" is the honest target. + ## check Validate TypeScript for Perry compatibility without compiling.