From 652ea7960fa1b5b649b3e7b03e616d5cf8cb5e35 Mon Sep 17 00:00:00 2001 From: Surma Date: Wed, 11 Nov 2026 21:57:37 +0000 Subject: [PATCH] Support Cargo scaffolding and human output, and find boolean expressions --- host/src/builder/README.md | 6 +- host/src/builder/mod.rs | 30 ++-- host/src/builder/tests.rs | 12 +- host/tests/it/builder.rs | 75 ++++++++++ userland/cargo/src/lib.rs | 6 +- userland/findutils/src/find.rs | 250 ++++++++++++++++++++------------- userland/findutils/test.nu | 4 +- 7 files changed, 267 insertions(+), 116 deletions(-) diff --git a/host/src/builder/README.md b/host/src/builder/README.md index d126eb6..5784136 100644 --- a/host/src/builder/README.md +++ b/host/src/builder/README.md @@ -7,7 +7,11 @@ The response contains newline-delimited JSON events. The final `exit` event contains a code and component locations. Each location contains an executable path and working directory. -The CLI forwards the bytes immediately. +The CLI forwards human output immediately. +The builder captures compiler JSON for artifact discovery without forwarding it to stdout. +Compiler diagnostics remain on stderr. `cargo run` reserves stdout for the program. +`cargo new` and `cargo init` use native Cargo, including `--lib`, `--name`, and `--edition`. +They need no existing manifest. Subsequent builds use the world's default WASI target. For `test` and `run`, it executes the artifacts through the process interface. Cargo selects run binaries through a capture runner, so `--bin` and `default-run` retain their normal behavior. The test runner uses each package's root directory, even when the caller runs Cargo from a subdirectory. diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index b8b9cbb..f79c64c 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -66,7 +66,7 @@ impl Builder { ensure!( [ "build", "check", "clippy", "test", "run", "fmt", "fetch", "add", "remove", - "update", "tree", "metadata", "clean" + "update", "tree", "metadata", "clean", "new", "init" ] .contains(&action), "unsupported Cargo command" @@ -96,7 +96,14 @@ impl Builder { .checked_add(Duration::from_secs(seconds)) .context("timeout too large")?; let world_cwd = sausage_vfs::resolve("/", &request.cwd); - let workspace = files::workspace(self.root.as_ref(), &world_cwd).await?; + let scaffold = matches!(action, "new" | "init"); + let workspace = if scaffold { + files::workspace(self.root.as_ref(), &world_cwd) + .await + .unwrap_or_else(|_| world_cwd.clone()) + } else { + files::workspace(self.root.as_ref(), &world_cwd).await? + }; let world_target = sausage_vfs::resolve(&workspace, "target"); use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); @@ -140,10 +147,10 @@ impl Builder { fetch.arg("fetch"); } fetch.current_dir(&cwd).env("CARGO_HOME", ®istry); - let mut code = if matches!(action, "clean" | "fmt" | "remove") { + let mut code = if scaffold || matches!(action, "clean" | "fmt" | "remove") { 0 } else { - run(fetch, tx, deadline).await?.0 + run(fetch, tx, deadline, false).await?.0 }; if code == 0 && action == "add" { let mut fetch = Command::new("cargo"); @@ -151,7 +158,7 @@ impl Builder { .arg("fetch") .current_dir(&cwd) .env("CARGO_HOME", ®istry); - code = run(fetch, tx, deadline).await?.0; + code = run(fetch, tx, deadline, false).await?.0; } let mut executables = Vec::new(); if code == 0 { @@ -191,7 +198,7 @@ impl Builder { )?; let mut startup = Command::new("nu"); startup.arg(tools.join("build.nu")); - let (startup_code, _) = run(startup, tx, deadline).await?; + let (startup_code, _) = run(startup, tx, deadline, false).await?; if startup_code != 0 { files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?; self.evict(20 * 1024 * 1024 * 1024)?; @@ -303,7 +310,7 @@ impl Builder { ]); } cmd.args(&args[split..]); - let (status, stdout) = run(cmd, tx, deadline).await?; + let (status, stdout) = run(cmd, tx, deadline, compiles).await?; code = status; if action == "clean" && code == 0 { files::clean_artifacts(self.root.as_ref(), &target, &world_target).await?; @@ -439,6 +446,7 @@ async fn pump( mut reader: impl tokio::io::AsyncRead + Unpin, tx: &mpsc::Sender, stderr: bool, + silent_stdout: bool, ) -> Result> { let mut all = Vec::new(); let mut bytes = vec![0; 8192]; @@ -450,6 +458,9 @@ async fn pump( if !stderr { all.extend_from_slice(&bytes[..n]); } + if !stderr && silent_stdout { + continue; + } tx.send(if stderr { Event::Stderr { bytes: bytes[..n].to_vec(), @@ -466,6 +477,7 @@ async fn run( mut cmd: Command, tx: &mpsc::Sender, deadline: Instant, + silent_stdout: bool, ) -> Result<(i32, Vec)> { cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -479,8 +491,8 @@ async fn run( let collect = async { let (status, out, _) = tokio::try_join!( async { Ok::<_, anyhow::Error>(child.wait().await?) }, - pump(out, tx, false), - pump(err, tx, true) + pump(out, tx, false, silent_stdout), + pump(err, tx, true, false) )?; Ok::<_, anyhow::Error>((status.code().unwrap_or(1), out)) }; diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs index c373593..a3a63eb 100644 --- a/host/src/builder/tests.rs +++ b/host/src/builder/tests.rs @@ -191,10 +191,9 @@ async fn timeout_kills_before_output_can_drain() -> Result<()> { let output = tx.clone(); let mut cmd = Command::new("sh"); cmd.args(["-c", "echo $$; exec sleep 1000"]); - let task = - tokio::spawn( - async move { run(cmd, &output, Instant::now() + Duration::from_secs(1)).await }, - ); + let task = tokio::spawn(async move { + run(cmd, &output, Instant::now() + Duration::from_secs(1), false).await + }); let Some(Event::Stdout { bytes }) = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await? else { @@ -219,8 +218,9 @@ async fn closed_receiver_cancels_command() -> Result<()> { let (tx, mut rx) = mpsc::channel(1); let mut cmd = Command::new("sh"); cmd.args(["-c", "printf ready; exec sleep 1000"]); - let task = - tokio::spawn(async move { run(cmd, &tx, Instant::now() + Duration::from_secs(60)).await }); + let task = tokio::spawn(async move { + run(cmd, &tx, Instant::now() + Duration::from_secs(60), false).await + }); // Output must arrive before the command exits, not after collection completes. let event = tokio::time::timeout(Duration::from_secs(2), rx.recv()) .await? diff --git a/host/tests/it/builder.rs b/host/tests/it/builder.rs index 35be6ce..699351e 100644 --- a/host/tests/it/builder.rs +++ b/host/tests/it/builder.rs @@ -54,6 +54,81 @@ async fn cargo_at( .await?; Ok((code, String::from_utf8_lossy(&bytes).into_owned())) } +#[tokio::test(flavor = "multi_thread")] +async fn cargo_scaffold_and_human_output() -> anyhow::Result<()> { + let binaries = programs(); + let temp = tempfile::tempdir()?; + let world = World::open(temp.path())?; + let bin = temp.path().join("world/bin"); + std::fs::create_dir_all(&bin)?; + std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?; + std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?; + let runtime = Runtime::new(world)?; + for (script, cwd, expected, success) in [ + ( + "cargo new hello && cd hello && cargo run", + "/", + "Hello, world!\n", + true, + ), + ("cargo build", "/hello", "", true), + ( + "cargo new library --lib --name named_lib --edition 2021", + "/", + "", + true, + ), + ("cargo check", "/library", "", true), + ( + "cargo init --lib --name initialized --edition 2024", + "/empty", + "", + true, + ), + ("cargo check", "/empty", "", true), + ("cargo new broken --edition 1900", "/", "", false), + ] { + std::fs::create_dir_all(temp.path().join("world/empty"))?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec!["/bin/sh.wasm".into(), "-c".into(), script.into()]), + Options { + cwd: cwd.into(), + timeout: Some(Duration::from_secs(120)), + ..Default::default() + }, + &mut |stream, bytes| match stream { + sausage::exec::Stream::Stdout => stdout.extend_from_slice(bytes), + sausage::exec::Stream::Stderr => stderr.extend_from_slice(bytes), + }, + ) + .await?; + let stderr = String::from_utf8_lossy(&stderr); + assert_eq!(code == 0, success, "{script}: {stderr}"); + assert_eq!( + String::from_utf8_lossy(&stdout), + expected, + "{script}: {stderr}" + ); + assert!( + !stderr.contains("\"reason\":\"compiler-artifact\""), + "{stderr}" + ); + if script == "cargo build" { + assert!(stderr.contains("Finished"), "{stderr}"); + } + } + let root = temp.path().join("world"); + let manifest: toml::Value = + toml::from_str(&std::fs::read_to_string(root.join("library/Cargo.toml"))?)?; + assert_eq!(manifest["package"]["name"].as_str(), Some("named_lib")); + assert_eq!(manifest["package"]["edition"].as_str(), Some("2021")); + assert!(root.join("library/src/lib.rs").is_file()); + assert!(root.join("empty/src/lib.rs").is_file()); + Ok(()) +} fn cache_hits(base: &Path) -> anyhow::Result { let stats = std::process::Command::new("kache") .arg("stats") diff --git a/userland/cargo/src/lib.rs b/userland/cargo/src/lib.rs index 656cf4b..d130e4d 100644 --- a/userland/cargo/src/lib.rs +++ b/userland/cargo/src/lib.rs @@ -25,7 +25,7 @@ fn error(value: impl std::fmt::Debug) -> io::Error { } fn local_help(argv: &[String]) -> Option<&'static str> { matches!(argv.first().map(String::as_str), Some("--help" | "-h" | "help")).then_some( - "Usage: cargo [OPTIONS]\n\nWorld commands: build, check, clippy, test, run, fmt, fetch, add, remove\n", + "Usage: cargo [OPTIONS]\n\nWorld commands: build, check, clippy, test, run, fmt, fetch, add, remove, new, init\n", ) } pub fn run() -> io::Result { @@ -123,7 +123,9 @@ fn help_is_local_and_lists_world_commands() { for flag in ["--help", "-h", "help"] { let help = local_help(&[flag.into()]).expect("local help"); assert!(help.starts_with("Usage: cargo [OPTIONS]\n")); - assert!(help.contains("build, check, clippy, test, run, fmt, fetch, add, remove")); + assert!( + help.contains("build, check, clippy, test, run, fmt, fetch, add, remove, new, init") + ); } assert!(local_help(&["build".into()]).is_none()); assert!(local_help(&[]).is_none()); diff --git a/userland/findutils/src/find.rs b/userland/findutils/src/find.rs index 8c0b0bf..4aafec6 100644 --- a/userland/findutils/src/find.rs +++ b/userland/findutils/src/find.rs @@ -1,11 +1,15 @@ -//! Find world paths with short-circuit AND/OR predicates and directory pruning. +//! Find world paths with precedence-aware, short-circuit predicates and directory pruning. use sausage_spawn::Command; use std::{ io::{self, Write}, path::PathBuf, }; - +type Result = std::result::Result>; enum Predicate { + True, + Not(Box), + And(Box, Box), + Or(Box, Box), Name(globset::GlobMatcher), Path(globset::GlobMatcher), Type(String), @@ -15,75 +19,181 @@ enum Predicate { Print(bool), Exec(Vec), } -fn run() -> Result> { - let args: Vec<_> = std::env::args().skip(1).collect(); - let mut i = 0; - let mut roots = Vec::new(); - while i < args.len() && !args[i].starts_with('-') { - roots.push(PathBuf::from(&args[i])); - i += 1; +struct Parser<'a> { + args: &'a [String], + i: usize, + maxdepth: usize, + mindepth: usize, + explicit_action: bool, +} +impl Parser<'_> { + fn peek(&self) -> Option<&str> { + self.args.get(self.i).map(String::as_str) } - if roots.is_empty() { - roots.push(".".into()); + fn or(&mut self) -> Result { + let mut value = self.and()?; + while matches!(self.peek(), Some("-o" | "-or")) { + self.i += 1; + value = Predicate::Or(Box::new(value), Box::new(self.and()?)); + } + Ok(value) } - let mut groups = vec![Vec::new()]; - let mut maxdepth = usize::MAX; - let mut mindepth = 0; - let mut explicit_action = false; - while i < args.len() { - let flag = &args[i]; - i += 1; - let predicate = match flag.as_str() { - "-o" | "-or" => { - groups.push(Vec::new()); - continue; + fn and(&mut self) -> Result { + let mut value = self.unary()?; + while !matches!(self.peek(), None | Some("-o" | "-or" | ")")) { + if matches!(self.peek(), Some("-a" | "-and")) { + self.i += 1; + } + value = Predicate::And(Box::new(value), Box::new(self.unary()?)); + } + Ok(value) + } + fn unary(&mut self) -> Result { + let flag = self.args.get(self.i).ok_or("missing expression")?; + self.i += 1; + Ok(match flag.as_str() { + "!" | "-not" => Predicate::Not(Box::new(self.unary()?)), + "(" => { + let value = self.or()?; + if self.peek() != Some(")") { + return Err("missing closing parenthesis".into()); + } + self.i += 1; + value } - "-a" | "-and" => continue, "-prune" => Predicate::Prune, "-empty" => Predicate::Empty, "-print" | "-print0" => { - explicit_action = true; + self.explicit_action = true; Predicate::Print(flag == "-print0") } "-name" | "-path" | "-type" | "-maxdepth" | "-mindepth" | "-newer" => { - let value = args.get(i).ok_or("missing predicate argument")?; - i += 1; + let value = self.args.get(self.i).ok_or("missing predicate argument")?; + self.i += 1; match flag.as_str() { "-name" => Predicate::Name(globset::Glob::new(value)?.compile_matcher()), "-path" => Predicate::Path(globset::Glob::new(value)?.compile_matcher()), "-type" => Predicate::Type(value.clone()), "-newer" => Predicate::Newer(std::fs::metadata(value)?.modified()?), "-maxdepth" => { - maxdepth = value.parse()?; - continue; + self.maxdepth = value.parse()?; + Predicate::True } _ => { - mindepth = value.parse()?; - continue; + self.mindepth = value.parse()?; + Predicate::True } } } "-exec" => { - let start = i; - while i < args.len() && args[i] != ";" { - i += 1; + let start = self.i; + while self.i < self.args.len() && self.args[self.i] != ";" { + self.i += 1; } - if i == args.len() || i == start { + if self.i == self.args.len() || self.i == start { return Err("-exec requires a command and ;".into()); } - explicit_action = true; - let argv = args[start..i].to_vec(); - i += 1; + self.explicit_action = true; + let argv = self.args[start..self.i].to_vec(); + self.i += 1; Predicate::Exec(argv) } _ => return Err(format!("unsupported predicate: {flag}").into()), - }; - groups.last_mut().unwrap().push(predicate); + }) + } +} +fn matches( + predicate: &Predicate, + entry: &walkdir::DirEntry, + prune: &mut bool, + status: &mut i32, + out: &mut impl Write, +) -> Result { + Ok(match predicate { + Predicate::True => true, + Predicate::Not(value) => !matches(value, entry, prune, status, out)?, + Predicate::And(left, right) => { + matches(left, entry, prune, status, out)? && matches(right, entry, prune, status, out)? + } + Predicate::Or(left, right) => { + matches(left, entry, prune, status, out)? || matches(right, entry, prune, status, out)? + } + Predicate::Name(g) => g.is_match(entry.file_name()), + Predicate::Path(g) => g.is_match(entry.path()), + Predicate::Type(t) => match t.as_str() { + "f" => entry.file_type().is_file(), + "d" => entry.file_type().is_dir(), + "l" => entry.file_type().is_symlink(), + _ => return Err("unsupported file type".into()), + }, + Predicate::Newer(time) => entry.metadata()?.modified()? > *time, + Predicate::Empty => { + if entry.file_type().is_dir() { + std::fs::read_dir(entry.path())?.next().is_none() + } else { + entry.file_type().is_file() && entry.metadata()?.len() == 0 + } + } + Predicate::Prune => { + *prune = true; + true + } + Predicate::Print(null) => { + out.write_all(entry.path().as_os_str().as_encoded_bytes())?; + out.write_all(if *null { b"\0" } else { b"\n" })?; + true + } + Predicate::Exec(argv) => { + out.flush()?; + let argv: Vec<_> = argv + .iter() + .map(|arg| arg.replace("{}", &entry.path().to_string_lossy())) + .collect(); + match Command::new(&argv[0]).args(&argv[1..]).status() { + Ok(code) => code == 0, + Err(e) => { + eprintln!("find: {e}"); + *status = 1; + false + } + } + } + }) +} +fn run() -> Result { + let args: Vec<_> = std::env::args().skip(1).collect(); + let mut parser = Parser { + args: &args, + i: 0, + maxdepth: usize::MAX, + mindepth: 0, + explicit_action: false, + }; + let mut roots = Vec::new(); + while parser + .peek() + .is_some_and(|s| !s.starts_with('-') && !matches!(s, "!" | "(" | ")")) + { + roots.push(PathBuf::from(&args[parser.i])); + parser.i += 1; + } + if roots.is_empty() { + roots.push(".".into()); + } + let predicate = if parser.peek().is_none() { + Predicate::True + } else { + parser.or()? + }; + if parser.peek().is_some() { + return Err("unexpected closing parenthesis".into()); } let mut status = 0; let mut out = io::BufWriter::new(io::stdout().lock()); for root in roots { - let mut walk = walkdir::WalkDir::new(root).max_depth(maxdepth).into_iter(); + let mut walk = walkdir::WalkDir::new(root) + .max_depth(parser.maxdepth) + .into_iter(); while let Some(entry) = walk.next() { let entry = match entry { Ok(e) => e, @@ -93,69 +203,15 @@ fn run() -> Result> { continue; } }; - if entry.depth() < mindepth { + if entry.depth() < parser.mindepth { continue; } let mut prune = false; - let mut matches = false; - for group in &groups { - let mut matched = true; - for predicate in group { - matched = match predicate { - Predicate::Name(g) => g.is_match(entry.file_name()), - Predicate::Path(g) => g.is_match(entry.path()), - Predicate::Type(t) => match t.as_str() { - "f" => entry.file_type().is_file(), - "d" => entry.file_type().is_dir(), - "l" => entry.file_type().is_symlink(), - _ => return Err("unsupported file type".into()), - }, - Predicate::Newer(time) => entry.metadata()?.modified()? > *time, - Predicate::Empty => { - if entry.file_type().is_dir() { - std::fs::read_dir(entry.path())?.next().is_none() - } else { - entry.file_type().is_file() && entry.metadata()?.len() == 0 - } - } - Predicate::Prune => { - prune = true; - true - } - Predicate::Print(null) => { - out.write_all(entry.path().as_os_str().as_encoded_bytes())?; - out.write_all(if *null { b"\0" } else { b"\n" })?; - true - } - Predicate::Exec(argv) => { - out.flush()?; - let argv: Vec<_> = argv - .iter() - .map(|arg| arg.replace("{}", &entry.path().to_string_lossy())) - .collect(); - match Command::new(&argv[0]).args(&argv[1..]).status() { - Ok(code) => code == 0, - Err(e) => { - eprintln!("find: {e}"); - status = 1; - false - } - } - } - }; - if !matched { - break; - } - } - if matched { - matches = true; - break; - } - } + let matched = matches(&predicate, &entry, &mut prune, &mut status, &mut out)?; if prune && entry.file_type().is_dir() { walk.skip_current_dir(); } - if matches && !explicit_action { + if matched && !parser.explicit_action { out.write_all(entry.path().as_os_str().as_encoded_bytes())?; out.write_all(b"\n")?; } diff --git a/userland/findutils/test.nu b/userland/findutils/test.nu index 0d7b2a4..e1328e5 100644 --- a/userland/findutils/test.nu +++ b/userland/findutils/test.nu @@ -8,10 +8,12 @@ def main [host: path, bundle: path] { mkdir ($fixture | path join skip/deep) ($fixture | path join keep/deep) ($fixture | path join empty) 'hidden' | save ($fixture | path join skip/deep/file) 'shown' | save ($fixture | path join keep/deep/file) + mkdir ($fixture | path join target) + 'artifact' | save ($fixture | path join target output) '' | save ($fixture | path join zero) '' | save ($fixture | path join reference) ^touch -d '2001-01-01 UTC' ($fixture | path join reference) - for args in [[-name skip -prune -o -type f -print] [-path './skip' -prune -o -print] [-name skip -prune -o -type f] [-maxdepth 1] [-mindepth 1 -maxdepth 2] [-empty] [-newer reference] [-path './keep/*']] { + for args in [[-name skip -prune -o -type f -print] [-path './skip' -prune -o -print] [-name skip -prune -o -type f] [-maxdepth 1] [-mindepth 1 -maxdepth 2] [-empty] [-newer reference] [-path './keep/*'] [-type f -not -path './target/*'] ['!' -type d] [-not -not -type f] ['(' -name zero -o -name reference ')' -a -type f] [-name zero -o -name reference -a -type d] [-not '(' -name skip -o -name keep ')' -a -type d] [-name skip -prune -o '(' -type f -a '!' -name reference ')' -print] [-name zero -o -name reference -print] ['(' -name zero -o -name reference ')' -print] [-not] ['(' -type f] [-type f -a]] { let native = (do { cd $fixture; ^find . ...$args | complete }) let command = ($args | each {|arg| $arg | into string | to json -r} | str join ' ') let guest = (^$host sh $world -c $"cd /fixture; find . ($command)" | complete)