From 41710a0a5f3fa39bd11b80a3700734e05ffe1710 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 11 May 2026 03:43:58 +0200 Subject: [PATCH] feat(cross-cutting): added pi-ast/pi-shell crates and rewired pi-natives - Added `pi-ast` as a new crate with public `language`, `ops`, and `summary` modules and exported `SupportLang`. - Added AST summary and rewrite APIs in `pi-ast`, including language inference, overlap-safe edits, and span normalization. - Added `pi-shell` crate with new `Shell` and cross-platform `Process` APIs, session execution, and cancellation-aware runs. - Updated `pi-natives` to use shared `pi_ast`/`pi_shell` APIs, switched to local path deps, and moved minimizer assets to `pi-shell`. --- Cargo.toml | 14 +- crates/pi-ast/Cargo.toml | 74 + .../src/language/mod.rs | 4 + .../src/language/parsers.rs | 0 crates/pi-ast/src/lib.rs | 5 + crates/pi-ast/src/ops.rs | 305 +++ crates/pi-ast/src/summary.rs | 1044 +++++++++++ crates/pi-natives/Cargo.toml | 62 +- crates/pi-natives/src/ast.rs | 73 +- crates/pi-natives/src/lib.rs | 2 +- crates/pi-natives/src/shell.rs | 1627 ++--------------- crates/pi-natives/src/summary.rs | 1023 +---------- crates/pi-natives/src/task.rs | 166 +- crates/pi-shell/Cargo.toml | 37 + crates/pi-shell/build.rs | 62 + crates/pi-shell/src/cancel.rs | 161 ++ crates/pi-shell/src/lib.rs | 12 + .../src/shell => pi-shell/src}/minimizer.rs | 0 .../src}/minimizer/config.rs | 4 +- .../src}/minimizer/defs/ansible-playbook.toml | 0 .../src}/minimizer/defs/ansible.toml | 0 .../src}/minimizer/defs/basedpyright.toml | 0 .../src}/minimizer/defs/biome.toml | 0 .../src}/minimizer/defs/brew-install.toml | 0 .../src}/minimizer/defs/bundle-install.toml | 0 .../src}/minimizer/defs/composer-install.toml | 0 .../src}/minimizer/defs/df.toml | 0 .../src}/minimizer/defs/dotnet-build.toml | 0 .../src}/minimizer/defs/du.toml | 0 .../src}/minimizer/defs/fail2ban-client.toml | 0 .../src}/minimizer/defs/fail2ban.toml | 0 .../src}/minimizer/defs/gcc.toml | 0 .../src}/minimizer/defs/gcloud.toml | 0 .../src}/minimizer/defs/gradle.toml | 0 .../src}/minimizer/defs/hadolint.toml | 0 .../src}/minimizer/defs/helm.toml | 0 .../src}/minimizer/defs/iptables.toml | 0 .../src}/minimizer/defs/jira.toml | 0 .../src}/minimizer/defs/jj.toml | 0 .../src}/minimizer/defs/jq.toml | 0 .../src}/minimizer/defs/just.toml | 0 .../src}/minimizer/defs/liquibase.toml | 0 .../src}/minimizer/defs/make.toml | 0 .../src}/minimizer/defs/markdownlint.toml | 0 .../src}/minimizer/defs/maven.toml | 0 .../src}/minimizer/defs/mise.toml | 0 .../src}/minimizer/defs/mix-compile.toml | 0 .../src}/minimizer/defs/mix-format.toml | 0 .../src}/minimizer/defs/mix.toml | 0 .../src}/minimizer/defs/mvn-build.toml | 0 .../src}/minimizer/defs/nx.toml | 0 .../src}/minimizer/defs/ollama.toml | 0 .../src}/minimizer/defs/oxlint.toml | 0 .../src}/minimizer/defs/ping.toml | 0 .../src}/minimizer/defs/pio-run.toml | 0 .../src}/minimizer/defs/pio.toml | 0 .../src}/minimizer/defs/poetry-install.toml | 0 .../src}/minimizer/defs/pre-commit.toml | 0 .../src}/minimizer/defs/ps.toml | 0 .../src}/minimizer/defs/quarto-render.toml | 0 .../src}/minimizer/defs/quarto.toml | 0 .../src}/minimizer/defs/rsync.toml | 0 .../src}/minimizer/defs/shellcheck.toml | 0 .../src}/minimizer/defs/shopify-theme.toml | 0 .../src}/minimizer/defs/skopeo.toml | 0 .../src}/minimizer/defs/sops.toml | 0 .../src}/minimizer/defs/spring-boot.toml | 0 .../src}/minimizer/defs/ssh.toml | 0 .../src}/minimizer/defs/stat.toml | 0 .../src}/minimizer/defs/swift-build.toml | 0 .../src}/minimizer/defs/systemctl-status.toml | 0 .../src}/minimizer/defs/systemctl.toml | 0 .../src}/minimizer/defs/task.toml | 0 .../src}/minimizer/defs/terraform-plan.toml | 0 .../src}/minimizer/defs/terraform.toml | 0 .../src}/minimizer/defs/tofu-fmt.toml | 0 .../src}/minimizer/defs/tofu-init.toml | 0 .../src}/minimizer/defs/tofu-plan.toml | 0 .../src}/minimizer/defs/tofu-validate.toml | 0 .../src}/minimizer/defs/trunk-build.toml | 0 .../src}/minimizer/defs/trunk.toml | 0 .../src}/minimizer/defs/turbo.toml | 0 .../src}/minimizer/defs/ty.toml | 0 .../src}/minimizer/defs/uv-sync.toml | 0 .../src}/minimizer/defs/xcodebuild.toml | 0 .../src}/minimizer/defs/yadm.toml | 0 .../src}/minimizer/defs/yamllint.toml | 0 .../src}/minimizer/detect.rs | 0 .../src}/minimizer/engine.rs | 4 +- .../src}/minimizer/filters/bun.rs | 4 +- .../src}/minimizer/filters/cargo.rs | 4 +- .../src}/minimizer/filters/cloud.rs | 4 +- .../src}/minimizer/filters/cpp.rs | 4 +- .../src}/minimizer/filters/docker.rs | 4 +- .../src}/minimizer/filters/dotnet.rs | 4 +- .../src}/minimizer/filters/generic.rs | 2 +- .../src}/minimizer/filters/gh.rs | 4 +- .../src}/minimizer/filters/git.rs | 4 +- .../src}/minimizer/filters/go.rs | 4 +- .../src}/minimizer/filters/gt.rs | 4 +- .../src}/minimizer/filters/js_tools.rs | 4 +- .../src}/minimizer/filters/lint.rs | 2 +- .../src}/minimizer/filters/listing.rs | 4 +- .../src}/minimizer/filters/mod.rs | 4 +- .../src}/minimizer/filters/node_tests.rs | 2 +- .../src}/minimizer/filters/pkg.rs | 2 +- .../src}/minimizer/filters/python.rs | 4 +- .../src}/minimizer/filters/ruby.rs | 4 +- .../src}/minimizer/filters/system.rs | 4 +- .../src}/minimizer/pipeline.rs | 2 +- .../shell => pi-shell/src}/minimizer/plan.rs | 0 .../src}/minimizer/primitives.rs | 0 crates/pi-shell/src/process.rs | 1499 +++++++++++++++ crates/pi-shell/src/shell.rs | 1506 +++++++++++++++ .../src/shell => pi-shell/src}/windows.rs | 0 115 files changed, 5035 insertions(+), 2723 deletions(-) create mode 100644 crates/pi-ast/Cargo.toml rename crates/{pi-natives => pi-ast}/src/language/mod.rs (99%) rename crates/{pi-natives => pi-ast}/src/language/parsers.rs (100%) create mode 100644 crates/pi-ast/src/lib.rs create mode 100644 crates/pi-ast/src/ops.rs create mode 100644 crates/pi-ast/src/summary.rs create mode 100644 crates/pi-shell/Cargo.toml create mode 100644 crates/pi-shell/build.rs create mode 100644 crates/pi-shell/src/cancel.rs create mode 100644 crates/pi-shell/src/lib.rs rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer.rs (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/config.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/ansible-playbook.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/ansible.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/basedpyright.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/biome.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/brew-install.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/bundle-install.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/composer-install.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/df.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/dotnet-build.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/du.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/fail2ban-client.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/fail2ban.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/gcc.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/gcloud.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/gradle.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/hadolint.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/helm.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/iptables.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/jira.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/jj.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/jq.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/just.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/liquibase.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/make.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/markdownlint.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/maven.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/mise.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/mix-compile.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/mix-format.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/mix.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/mvn-build.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/nx.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/ollama.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/oxlint.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/ping.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/pio-run.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/pio.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/poetry-install.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/pre-commit.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/ps.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/quarto-render.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/quarto.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/rsync.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/shellcheck.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/shopify-theme.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/skopeo.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/sops.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/spring-boot.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/ssh.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/stat.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/swift-build.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/systemctl-status.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/systemctl.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/task.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/terraform-plan.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/terraform.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/tofu-fmt.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/tofu-init.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/tofu-plan.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/tofu-validate.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/trunk-build.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/trunk.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/turbo.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/ty.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/uv-sync.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/xcodebuild.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/yadm.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/defs/yamllint.toml (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/detect.rs (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/engine.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/bun.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/cargo.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/cloud.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/cpp.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/docker.rs (97%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/dotnet.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/generic.rs (86%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/gh.rs (97%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/git.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/go.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/gt.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/js_tools.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/lint.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/listing.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/mod.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/node_tests.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/pkg.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/python.rs (98%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/ruby.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/filters/system.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/pipeline.rs (99%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/plan.rs (100%) rename crates/{pi-natives/src/shell => pi-shell/src}/minimizer/primitives.rs (100%) create mode 100644 crates/pi-shell/src/process.rs create mode 100644 crates/pi-shell/src/shell.rs rename crates/{pi-natives/src/shell => pi-shell/src}/windows.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 5bafe96ba..b6e6ef67c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,9 +37,17 @@ incremental = true strip = false [profile.dev] -opt-level = 3 -lto = "thin" -codegen-units = 16 +opt-level = 0 +lto = false +codegen-units = 256 +incremental = true +debug = "line-tables-only" +split-debuginfo = "unpacked" + +# Deps compile optimized once and cache; your own crates stay fast. +[profile.dev.package."*"] +opt-level = 2 +debug = false [workspace.lints.clippy] # Base Lint Levels diff --git a/crates/pi-ast/Cargo.toml b/crates/pi-ast/Cargo.toml new file mode 100644 index 000000000..0cf991a42 --- /dev/null +++ b/crates/pi-ast/Cargo.toml @@ -0,0 +1,74 @@ +[package] +name = "pi-ast" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0" +ast-grep-core = { version = "0.39", default-features = false, features = ["tree-sitter"] } +globset = "0.4" +ignore = "0.4" +phf = { version = "0.13", features = ["macros"] } +serde = { version = "1.0", features = ["derive"] } +tree-sitter = "0.25" +tree-sitter-astro = { version = "0.1.1", package = "tree-sitter-astro-next" } +tree-sitter-bash = "0.25" +tree-sitter-c = "0.24" +tree-sitter-clojure = "0.1" +tree-sitter-cmake = "0.7.1" +tree-sitter-c-sharp = "0.23" +tree-sitter-cpp = "0.23" +tree-sitter-dart = "0.2" +tree-sitter-css = "0.25" +tree-sitter-diff = "0.1" +tree-sitter-dockerfile = { version = "0.2.0", package = "tree-sitter-dockerfile-updated" } +tree-sitter-elixir = "0.3" +tree-sitter-erlang = "0.16.0" +tree-sitter-go = "0.25" +tree-sitter-graphql = "0.1.0" +tree-sitter-haskell = "0.23" +tree-sitter-hcl = "1.1" +tree-sitter-html = "0.23" +tree-sitter-ini = "1.4.0" +tree-sitter-java = "0.23" +tree-sitter-javascript = "0.25" +tree-sitter-json = "0.24" +tree-sitter-just = "0.2.0" +tree-sitter-julia = "0.23" +tree-sitter-kotlin = { version = "0.4", package = "tree-sitter-kotlin-sg" } +tree-sitter-lua = "0.5" +tree-sitter-make = "1.1" +tree-sitter-md = "0.5" +tree-sitter-nix = "0.3" +tree-sitter-objc = "3.0" +tree-sitter-ocaml = "0.24.2" +tree-sitter-odin = "1.3" +tree-sitter-perl = { version = "0.1.0", package = "tree-sitter-perl-next" } +tree-sitter-php = "0.24" +tree-sitter-powershell = "0.26.4" +tree-sitter-proto = "0.4.0" +tree-sitter-python = "0.25" +tree-sitter-r = "1.2.0" +tree-sitter-regex = "0.25" +tree-sitter-ruby = "0.23" +tree-sitter-rust = "0.24" +tree-sitter-scala = "0.26" +tree-sitter-solidity = "1.2" +tree-sitter-sql = { version = "0.3.11", package = "tree-sitter-sequel" } +tree-sitter-starlark = "1.3" +tree-sitter-svelte = { version = "0.1.1", package = "tree-sitter-svelte-next" } +tree-sitter-swift = "0.7" +tree-sitter-toml-ng = "0.7" +tree-sitter-tlaplus = "1.5" +tree-sitter-typescript = "0.23" +tree-sitter-verilog = "1.0" +tree-sitter-vue = { version = "0.1.0", package = "tree-sitter-vue-next" } +tree-sitter-xml = "0.7" +tree-sitter-yaml = "0.7" +tree-sitter-zig = "1.1" diff --git a/crates/pi-natives/src/language/mod.rs b/crates/pi-ast/src/language/mod.rs similarity index 99% rename from crates/pi-natives/src/language/mod.rs rename to crates/pi-ast/src/language/mod.rs index 45cb63fc1..a7b93768b 100644 --- a/crates/pi-natives/src/language/mod.rs +++ b/crates/pi-ast/src/language/mod.rs @@ -411,6 +411,10 @@ impl SupportLang { LANG_ALIASES.get(lowered.as_str()).copied() } + pub fn from_path(path: &Path) -> Option { + from_extension(path) + } + pub fn sorted_aliases() -> &'static [&'static str] { &SORTED_ALIASES } diff --git a/crates/pi-natives/src/language/parsers.rs b/crates/pi-ast/src/language/parsers.rs similarity index 100% rename from crates/pi-natives/src/language/parsers.rs rename to crates/pi-ast/src/language/parsers.rs diff --git a/crates/pi-ast/src/lib.rs b/crates/pi-ast/src/lib.rs new file mode 100644 index 000000000..51f21642f --- /dev/null +++ b/crates/pi-ast/src/lib.rs @@ -0,0 +1,5 @@ +pub mod language; +pub mod ops; +pub mod summary; + +pub use language::SupportLang; diff --git a/crates/pi-ast/src/ops.rs b/crates/pi-ast/src/ops.rs new file mode 100644 index 000000000..bc0fce9c7 --- /dev/null +++ b/crates/pi-ast/src/ops.rs @@ -0,0 +1,305 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use ast_grep_core::{ + MatchStrictness, Position, + matcher::{Pattern, PatternError}, + source::Edit, + tree_sitter::{LanguageExt, StrDoc}, +}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use ignore::WalkBuilder; + +use crate::language::SupportLang; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AstMatchStrictness { + Cst, + Smart, + Ast, + Relaxed, + Signature, + Template, +} + +impl From for MatchStrictness { + fn from(value: AstMatchStrictness) -> Self { + match value { + AstMatchStrictness::Cst => Self::Cst, + AstMatchStrictness::Smart => Self::Smart, + AstMatchStrictness::Ast => Self::Ast, + AstMatchStrictness::Relaxed => Self::Relaxed, + AstMatchStrictness::Signature => Self::Signature, + AstMatchStrictness::Template => Self::Template, + } + } +} + +#[derive(Debug, Clone)] +pub struct AstMatch { + pub line: usize, + pub column: usize, + pub end_line: usize, + pub end_column: usize, + pub byte_start: usize, + pub byte_end: usize, + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct MatchedFile { + pub absolute_path: PathBuf, + pub relative_path: String, +} + +#[derive(Debug, Clone)] +pub struct CompiledRewrite { + pub out: String, + pub patterns: Vec, +} + +#[must_use] +pub fn resolve_strictness(value: Option) -> MatchStrictness { + value.map_or(MatchStrictness::Smart, Into::into) +} + +#[must_use] +pub fn supported_lang_list() -> String { + SupportLang::sorted_aliases().join(", ") +} + +pub fn resolve_supported_lang(value: &str) -> Result { + SupportLang::from_alias(value).ok_or_else(|| { + anyhow!("Unsupported language '{value}'. Supported: {}", supported_lang_list()) + }) +} + +pub fn resolve_language(lang: Option<&str>, file_path: &Path) -> Result { + if let Some(lang) = lang.map(str::trim).filter(|lang| !lang.is_empty()) { + return resolve_supported_lang(lang); + } + SupportLang::from_path(file_path).ok_or_else(|| { + anyhow!( + "Unable to infer language from file extension: {}. Specify `lang` explicitly.", + file_path.display() + ) + }) +} + +#[must_use] +pub fn is_supported_file(file_path: &Path, explicit_lang: Option<&str>) -> bool { + if explicit_lang.is_some() { + return true; + } + resolve_language(None, file_path).is_ok() +} + +pub fn compile_pattern( + pattern: &str, + selector: Option<&str>, + strictness: &MatchStrictness, + lang: SupportLang, +) -> Result { + let mut compiled = if let Some(selector) = selector.map(str::trim).filter(|s| !s.is_empty()) { + Pattern::contextual(pattern, selector, lang) + } else { + Pattern::try_new(pattern, lang) + } + .map_err(|err| anyhow!("Invalid pattern: {err}"))?; + compiled.strictness = strictness.clone(); + Ok(compiled) +} + +pub fn compile_search_patterns( + pattern: &str, + language: SupportLang, +) -> Result, PatternError> { + let mut compiled = vec![Pattern::try_new(pattern, language)?]; + if language == SupportLang::Rust { + let trimmed = pattern.trim_end(); + if let Some(contextual) = compile_rust_contextual_pattern(trimmed) { + compiled.push(contextual); + } + } + Ok(compiled) +} + +pub fn compile_rewrite_rules( + rules: &[(String, String)], + language: SupportLang, +) -> Result, (usize, PatternError)> { + rules + .iter() + .enumerate() + .map(|(index, (pattern, out))| { + compile_search_patterns(pattern, language) + .map(|patterns| CompiledRewrite { out: out.clone(), patterns }) + .map_err(|error| (index, error)) + }) + .collect() +} + +#[must_use] +pub fn collect_matches(source: &str, language: SupportLang, patterns: &[Pattern]) -> Vec { + let ast = language.ast_grep(source); + let mut matches = Vec::new(); + for pattern in patterns { + for matched in ast.root().find_all(pattern.clone()) { + let start = matched.start_pos(); + let end = matched.end_pos(); + let range = matched.range(); + matches.push(AstMatch { + line: start.line() + 1, + column: char_column(start, matched.get_node()) + 1, + end_line: end.line() + 1, + end_column: char_column(end, matched.get_node()) + 1, + byte_start: range.start, + byte_end: range.end, + text: matched.text().into_owned(), + }); + } + } + matches +} + +pub fn rewrite_source( + source: &str, + language: SupportLang, + ops: &[CompiledRewrite], +) -> Result<(String, u32), String> { + let mut ast = language.ast_grep(source); + let mut replacements = 0_u32; + for op in ops { + for pattern in &op.patterns { + let edits = ast.root().replace_all(pattern.clone(), op.out.as_str()); + if edits.is_empty() { + continue; + } + replacements = replacements.saturating_add(edits.len() as u32); + let updated = + apply_edits(ast.root().text().as_ref(), &edits).map_err(|error| error.to_string())?; + ast = language.ast_grep(updated); + } + } + Ok((ast.root().text().into_owned(), replacements)) +} + +pub fn apply_edits(content: &str, edits: &[Edit]) -> Result { + let mut sorted: Vec<&Edit> = edits.iter().collect(); + sorted.sort_by_key(|edit| edit.position); + let mut prev_end = 0usize; + for edit in &sorted { + if edit.position < prev_end { + return Err(anyhow!( + "Overlapping replacements detected; refine pattern to avoid ambiguous edits" + )); + } + prev_end = edit.position.saturating_add(edit.deleted_length); + } + + let mut output = content.to_string(); + for edit in sorted.into_iter().rev() { + let start = edit.position; + let end = edit.position.saturating_add(edit.deleted_length); + if end > output.len() || start > end { + return Err(anyhow!("Computed edit range is out of bounds")); + } + let replacement = String::from_utf8(edit.inserted_text.clone()) + .map_err(|err| anyhow!("Replacement text is not valid UTF-8: {err}"))?; + output.replace_range(start..end, &replacement); + } + Ok(output) +} + +pub fn collect_matched_files( + cwd: &Path, + patterns: &[String], +) -> Result, std::io::Error> { + let globset = build_globset(patterns)?; + let mut builder = WalkBuilder::new(cwd); + builder + .hidden(false) + .git_ignore(true) + .git_global(true) + .git_exclude(true); + let mut files = Vec::new(); + for entry in builder.build() { + let entry = match entry { + Ok(entry) => entry, + Err(error) => return Err(std::io::Error::other(error)), + }; + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + let absolute_path = entry.into_path(); + let relative_path = absolute_path + .strip_prefix(cwd) + .unwrap_or(&absolute_path) + .to_string_lossy() + .replace('\\', "/"); + if globset.is_match(&relative_path) + || patterns.iter().any(|pattern| pattern == &relative_path) + { + files.push(MatchedFile { absolute_path, relative_path }); + } + } + files.sort_unstable_by(|left, right| left.relative_path.cmp(&right.relative_path)); + Ok(files) +} + +fn build_globset(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + if has_glob_syntax(pattern) { + let glob = Glob::new(pattern).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid glob `{pattern}`: {error}"), + ) + })?; + builder.add(glob); + } + } + builder.build().map_err(std::io::Error::other) +} + +#[must_use] +pub fn has_glob_syntax(pattern: &str) -> bool { + pattern.contains('*') || pattern.contains('?') || pattern.contains('[') +} + +fn char_column(position: Position, node: &ast_grep_core::Node<'_, StrDoc>) -> usize { + position.column(node) +} + +fn compile_rust_contextual_pattern(pattern: &str) -> Option { + let language = SupportLang::Rust; + let context = format!("fn __rwp_wrapper() {{ {pattern}; }}"); + let ast = language.ast_grep(&context); + let selector = ast.root().find("expression_statement")?; + Pattern::contextual(pattern, selector.kind().as_ref(), language).ok() +} + +#[cfg(test)] +mod tests { + use ast_grep_core::source::Edit; + + use super::{SupportLang, apply_edits, compile_search_patterns}; + + #[test] + fn compile_search_patterns_compiles_rust_patterns() { + let patterns = compile_search_patterns("foo($$$ARGS)", SupportLang::Rust) + .expect("rust pattern should compile"); + assert!(!patterns.is_empty()); + } + + #[test] + fn apply_edits_rejects_overlaps() { + let source = "abcdef"; + let edits = vec![ + Edit:: { position: 1, deleted_length: 3, inserted_text: b"x".to_vec() }, + Edit:: { position: 2, deleted_length: 1, inserted_text: b"y".to_vec() }, + ]; + assert!(apply_edits(source, &edits).is_err()); + } +} diff --git a/crates/pi-ast/src/summary.rs b/crates/pi-ast/src/summary.rs new file mode 100644 index 000000000..a917e1723 --- /dev/null +++ b/crates/pi-ast/src/summary.rs @@ -0,0 +1,1044 @@ +//! Structural source summaries powered by tree-sitter. + +use std::{collections::BTreeSet, path::Path}; + +use anyhow::{Result, anyhow}; +use ast_grep_core::tree_sitter::LanguageExt; +use serde::{Deserialize, Serialize}; +use tree_sitter::{Node, Parser}; + +use crate::language::SupportLang; + +const DEFAULT_MIN_BODY_LINES: u32 = 4; +const DEFAULT_MIN_COMMENT_LINES: u32 = 6; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SummaryOptions { + /// Source code to summarize. + pub code: String, + /// Language alias (e.g. "rust", "typescript") used before path inference. + pub lang: Option, + /// File path used to infer language by extension when `lang` is omitted. + pub path: Option, + /// Minimum total node lines before eliding a body/literal node. + pub min_body_lines: Option, + /// Minimum total comment lines before eliding a multiline block comment. + pub min_comment_lines: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SummarySegment { + /// "kept" or "elided". + pub kind: String, + /// 1-based inclusive start line. + pub start_line: u32, + /// 1-based inclusive end line. + pub end_line: u32, + /// Verbatim text for kept segments; absent for elided segments. + pub text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SummaryResult { + /// Canonical language name when parsing succeeded. + pub language: Option, + /// True when tree-sitter parsed the source without syntax errors. + pub parsed: bool, + /// True when at least one elision span was emitted. + pub elided: bool, + /// Total source lines. + pub total_lines: u32, + /// Kept/elided segments in source order. + pub segments: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct LineSpan { + start: u32, + end: u32, +} + +pub fn summarize_code(options: SummaryOptions) -> Result { + let source = options.code; + let total_lines = count_lines(&source); + if source.is_empty() { + return Ok(unparsed_result(source, total_lines)); + } + + let Some(language) = resolve_language(options.lang.as_deref(), options.path.as_deref()) else { + return Ok(unparsed_result(source, total_lines)); + }; + + let mut parser = Parser::new(); + parser + .set_language(&language.get_ts_language()) + .map_err(|err| anyhow!("Failed to load tree-sitter language: {err}"))?; + let Some(tree) = parser.parse(&source, None) else { + return Ok(unparsed_result(source, total_lines)); + }; + let root = tree.root_node(); + if root.has_error() { + return Ok(unparsed_result(source, total_lines)); + } + + let min_body_lines = options + .min_body_lines + .unwrap_or(DEFAULT_MIN_BODY_LINES) + .max(2); + let min_comment_lines = options + .min_comment_lines + .unwrap_or(DEFAULT_MIN_COMMENT_LINES) + .max(4); + let mut spans = Vec::new(); + collect_elisions(root, language, min_body_lines, min_comment_lines, &mut spans); + let spans = normalize_spans(spans, total_lines); + let segments = build_segments(&source, total_lines, &spans); + + Ok(SummaryResult { + language: Some(language.canonical_name().to_string()), + parsed: true, + elided: !spans.is_empty(), + total_lines, + segments, + }) +} + +fn resolve_language(lang: Option<&str>, path: Option<&str>) -> Option { + if let Some(lang) = lang.map(str::trim).filter(|lang| !lang.is_empty()) { + return SupportLang::from_alias(lang); + } + let path = path?.trim(); + if path.is_empty() { + return None; + } + SupportLang::from_path(Path::new(path)) +} + +fn unparsed_result(source: String, total_lines: u32) -> SummaryResult { + let segments = if source.is_empty() { + Vec::new() + } else { + vec![SummarySegment { + kind: "kept".to_string(), + start_line: 1, + end_line: total_lines, + text: Some(source), + }] + }; + SummaryResult { language: None, parsed: false, elided: false, total_lines, segments } +} + +fn count_lines(source: &str) -> u32 { + if source.is_empty() { + 0 + } else { + source.lines().count().max(1).min(u32::MAX as usize) as u32 + } +} + +fn collect_elisions( + node: Node<'_>, + language: SupportLang, + min_body_lines: u32, + min_comment_lines: u32, + spans: &mut Vec, +) { + let total_lines = node_line_count(node); + if is_comment_kind(language, node.kind()) { + if total_lines >= min_comment_lines { + let start_line = node_start_line(node) + 2; + let end_line = node_end_line(node).saturating_sub(1); + if start_line <= end_line { + spans.push(LineSpan { start: start_line, end: end_line }); + } + } + return; + } + + if is_elidable_kind(language, node.kind()) && total_lines >= min_body_lines { + let start_line = node_start_line(node) + 1; + let end_line = node_end_line(node).saturating_sub(1); + if start_line <= end_line { + spans.push(LineSpan { start: start_line, end: end_line }); + return; + } + } + + // Detect consecutive runs of groupable siblings (e.g. import statements). + // When the run's total line span meets `min_body_lines`, elide the lines + // strictly between the first and last sibling's content, leaving the + // boundary statements visible. + let child_count = node.child_count(); + let mut run_first: Option> = None; + let mut run_last: Option> = None; + let mut run_count: u32 = 0; + for index in 0..child_count { + let Some(child) = node.child(index) else { + continue; + }; + if is_groupable_kind(language, child.kind()) { + if run_first.is_none() { + run_first = Some(child); + } + run_last = Some(child); + run_count += 1; + } else { + flush_groupable_run(run_first, run_last, run_count, min_body_lines, spans); + run_first = None; + run_last = None; + run_count = 0; + } + } + flush_groupable_run(run_first, run_last, run_count, min_body_lines, spans); + + for index in 0..child_count { + if let Some(child) = node.child(index) { + collect_elisions(child, language, min_body_lines, min_comment_lines, spans); + } + } +} + +fn flush_groupable_run( + first: Option>, + last: Option>, + count: u32, + min_body_lines: u32, + spans: &mut Vec, +) { + if count < 2 { + return; + } + let (Some(first), Some(last)) = (first, last) else { + return; + }; + let first_start = node_start_line(first); + let last_start = node_start_line(last); + let last_end = node_end_line(last); + let span_lines = last_end.saturating_sub(first_start).saturating_add(1); + if span_lines < min_body_lines { + return; + } + // Use the line of the first node's last visible content as the lower bound + // (some grammars include trailing newlines in the node range, which would + // otherwise place `end_line` on the next sibling's first line). + let first_content_end = node_content_end_line(first).min(last_start.saturating_sub(1)); + let start = first_content_end.saturating_add(1); + let end = last_start.saturating_sub(1); + if start <= end { + spans.push(LineSpan { start, end }); + } +} + +fn node_start_line(node: Node<'_>) -> u32 { + node + .start_position() + .row + .saturating_add(1) + .min(u32::MAX as usize) as u32 +} + +fn node_end_line(node: Node<'_>) -> u32 { + node + .end_position() + .row + .saturating_add(1) + .min(u32::MAX as usize) as u32 +} + +/// Last source line containing a content byte from `node`. +/// +/// Tree-sitter reports `end_position` as the position one past the last byte. +/// When that byte is a newline, the resulting position lands at column 0 of +/// the next row, which makes the naive `row + 1` answer one greater than the +/// row of the last visible content. This helper subtracts that off. +fn node_content_end_line(node: Node<'_>) -> u32 { + let pos = node.end_position(); + let row = if pos.column == 0 && pos.row > 0 { + pos.row - 1 + } else { + pos.row + }; + row.saturating_add(1).min(u32::MAX as usize) as u32 +} + +fn node_line_count(node: Node<'_>) -> u32 { + node_end_line(node) + .saturating_sub(node_start_line(node)) + .saturating_add(1) +} + +fn is_comment_kind(language: SupportLang, kind: &str) -> bool { + match language { + SupportLang::TypeScript | SupportLang::Tsx | SupportLang::JavaScript => kind == "comment", + SupportLang::Rust => kind == "block_comment", + SupportLang::Python => kind == "comment", + SupportLang::Go => kind == "comment", + SupportLang::Java => kind == "block_comment", + SupportLang::C | SupportLang::Cpp | SupportLang::ObjC => kind == "comment", + SupportLang::CSharp => kind == "comment", + SupportLang::Ruby => kind == "comment", + SupportLang::Php => kind == "comment", + SupportLang::Swift => kind == "comment", + SupportLang::Kotlin => kind == "block_comment", + SupportLang::Scala => kind == "block_comment", + SupportLang::Lua => kind == "comment", + _ => false, + } +} + +fn is_elidable_kind(language: SupportLang, kind: &str) -> bool { + match language { + SupportLang::TypeScript | SupportLang::Tsx | SupportLang::JavaScript => matches!( + kind, + "statement_block" + | "function_body" + | "object" + | "array" + | "template_string" + | "class_body" + | "interface_body" + | "enum_body" + | "object_type" + | "switch_body" + | "jsx_element" + | "jsx_self_closing_element" + ), + SupportLang::Rust => matches!( + kind, + "block" + | "array_expression" + | "tuple_expression" + | "struct_expression" + | "match_block" + | "raw_string_literal" + | "declaration_list" + | "field_declaration_list" + | "ordered_field_declaration_list" + | "enum_variant_list" + | "where_clause" + | "use_list" + | "macro_definition" + | "token_tree" + ), + SupportLang::Python => matches!( + kind, + "block" + | "dictionary" + | "list" | "set" + | "string" + | "tuple" + | "argument_list" + | "parameters" + | "parenthesized_expression" + | "list_comprehension" + | "set_comprehension" + | "dictionary_comprehension" + | "generator_expression" + | "import_from_statement" + | "subscript" + ), + SupportLang::Go => matches!( + kind, + "block" + | "composite_literal" + | "interpreted_string_literal" + | "raw_string_literal" + | "import_spec_list" + | "const_declaration" + | "var_declaration" + | "field_declaration_list" + | "interface_type" + | "expression_switch_statement" + | "type_switch_statement" + | "select_statement" + ), + SupportLang::Java => matches!( + kind, + "block" + | "array_initializer" + | "class_body" + | "interface_body" + | "enum_body" + | "annotation_type_body" + | "constructor_body" + | "switch_block" + | "string_literal" + ), + SupportLang::C => matches!( + kind, + "compound_statement" + | "initializer_list" + | "string_literal" + | "field_declaration_list" + | "enumerator_list" + | "concatenated_string" + ), + SupportLang::Cpp => matches!( + kind, + "compound_statement" + | "initializer_list" + | "string_literal" + | "field_declaration_list" + | "enumerator_list" + | "concatenated_string" + | "declaration_list" + | "raw_string_literal" + | "requires_clause" + ), + SupportLang::ObjC => matches!( + kind, + "compound_statement" + | "initializer_list" + | "string_literal" + | "protocol_declaration" + | "class_interface" + | "class_implementation" + | "instance_variables" + | "array_literal" + | "dictionary_literal" + ), + SupportLang::CSharp => matches!( + kind, + "block" + | "initializer_expression" + | "array_initializer_expression" + | "declaration_list" + | "enum_member_declaration_list" + | "switch_expression" + | "raw_string_literal" + | "interpolated_string_expression" + ), + SupportLang::Ruby => matches!( + kind, + "body_statement" + | "method" + | "do_block" + | "array" + | "hash" | "block" + | "case" | "heredoc_body" + ), + SupportLang::Php => matches!( + kind, + "compound_statement" + | "array_creation_expression" + | "declaration_list" + | "enum_declaration_list" + | "match_block" + | "heredoc" + | "nowdoc" + ), + SupportLang::Swift => matches!( + kind, + "function_body" + | "array_literal" + | "dictionary_literal" + | "multi_line_string_literal" + | "class_body" + | "protocol_body" + | "enum_class_body" + | "computed_property" + | "lambda_literal" + ), + SupportLang::Kotlin => matches!( + kind, + "function_body" + | "collection_literal" + | "multi_line_string_literal" + | "class_body" + | "enum_class_body" + | "when_expression" + | "import_list" + ), + SupportLang::Scala => matches!( + kind, + "block" + | "collection_literal" + | "template_body" + | "enum_body" + | "match_expression" + | "for_expression" + | "string" + ), + SupportLang::Lua => matches!(kind, "block" | "table_constructor" | "string"), + SupportLang::Perl => { + matches!(kind, "block" | "list_expression" | "heredoc_content" | "regexp_content") + }, + SupportLang::Dart => matches!( + kind, + "block" + | "function_expression_body" + | "class_body" + | "enum_body" + | "extension_body" + | "mixin_body" + | "list_literal" + | "set_or_map_literal" + | "string_literal" + ), + SupportLang::Bash => matches!( + kind, + "compound_statement" + | "if_statement" + | "case_statement" + | "do_group" + | "subshell" + | "array" + | "heredoc_body" + ), + SupportLang::Powershell => matches!( + kind, + "script_block" + | "statement_block" + | "class_statement" + | "param_block" + | "hash_literal_expression" + | "array_expression" + | "expandable_here_string_literal" + | "verbatim_here_string_characters" + ), + SupportLang::Haskell => matches!( + kind, + "imports" + | "data_type" + | "class" + | "instance" + | "function" + | "do" | "case" + | "let" | "local_binds" + | "list" | "tuple" + ), + SupportLang::Ocaml => matches!( + kind, + "structure" + | "signature" + | "variant_declaration" + | "record_declaration" + | "match_expression" + | "match_case" + | "let_expression" + | "value_definition" + | "list_expression" + ), + SupportLang::Elixir => matches!(kind, "do_block" | "list" | "map" | "string" | "sigil"), + SupportLang::Erlang => matches!( + kind, + "fun_decl" + | "case_expr" + | "if_expr" + | "receive_expr" + | "record_decl" + | "list" | "map_expr" + | "tuple" + ), + SupportLang::Clojure => { + matches!(kind, "list_lit" | "map_lit" | "vec_lit" | "set_lit" | "str_lit") + }, + SupportLang::Solidity => { + matches!(kind, "contract_body" | "function_body" | "struct_body" | "enum_body") + }, + SupportLang::Sql => matches!(kind, "column_definitions" | "case"), + SupportLang::Zig => matches!(kind, "Block" | "ContainerDecl" | "InitList"), + SupportLang::Odin => matches!( + kind, + "block" | "struct_declaration" | "enum_declaration" | "union_declaration" | "struct" + ), + SupportLang::Verilog => matches!( + kind, + "module_declaration" + | "seq_block" + | "case_statement" + | "function_declaration" + | "task_declaration" + | "list_of_port_declarations" + ), + SupportLang::Tlaplus => matches!(kind, "module" | "theorem" | "let_in"), + SupportLang::Nix => matches!( + kind, + "attrset_expression" | "list_expression" | "let_expression" | "indented_string_expression" + ), + SupportLang::Proto => matches!(kind, "message_body" | "enum_body" | "oneof" | "service"), + SupportLang::Julia => matches!( + kind, + "function_definition" + | "struct_definition" + | "module_definition" + | "do_clause" + | "vector_expression" + | "string_literal" + ), + SupportLang::R => matches!(kind, "braced_expression" | "call" | "string"), + SupportLang::Starlark => matches!(kind, "block" | "list" | "dictionary" | "string"), + SupportLang::Astro => { + matches!(kind, "frontmatter_js_block" | "script_element" | "style_element" | "element") + }, + SupportLang::Vue => { + matches!(kind, "template_element" | "script_element" | "style_element" | "element") + }, + SupportLang::Svelte => matches!(kind, "script_element" | "style_element" | "element"), + SupportLang::Html => matches!(kind, "element" | "script_element" | "style_element"), + SupportLang::Css => matches!(kind, "block" | "keyframe_block_list"), + SupportLang::Json => matches!(kind, "object" | "array"), + SupportLang::Xml => kind == "element", + SupportLang::Markdown => matches!(kind, "fenced_code_block" | "pipe_table" | "list"), + SupportLang::Graphql => matches!( + kind, + "fields_definition" + | "enum_values_definition" + | "input_fields_definition" + | "schema_definition" + ), + SupportLang::Hcl => matches!(kind, "body" | "object"), + SupportLang::Dockerfile => kind == "shell_command", + SupportLang::Cmake => matches!(kind, "argument_list" | "body"), + SupportLang::Make => kind == "recipe", + SupportLang::Just => kind == "recipe_body", + // Skip: data formats with no closing-token anchor (Yaml mappings, + // Toml tables, Ini sections), the diff format whose informational + // content IS the lines inside hunks, and the leaf-token-only Regex + // grammar. Eliding any of these deletes the only content worth + // reading. + SupportLang::Yaml + | SupportLang::Toml + | SupportLang::Ini + | SupportLang::Diff + | SupportLang::Regex => false, + } +} + +fn is_groupable_kind(language: SupportLang, kind: &str) -> bool { + match language { + SupportLang::TypeScript | SupportLang::Tsx | SupportLang::JavaScript => { + kind == "import_statement" + }, + SupportLang::Rust => matches!(kind, "use_declaration" | "extern_crate_declaration"), + SupportLang::Python => { + matches!(kind, "import_statement" | "import_from_statement" | "future_import_statement") + }, + SupportLang::Go => kind == "import_declaration", + SupportLang::Java => kind == "import_declaration", + SupportLang::C | SupportLang::Cpp => kind == "preproc_include", + SupportLang::ObjC => matches!(kind, "preproc_include" | "import_declaration"), + SupportLang::CSharp => kind == "using_directive", + SupportLang::Php => kind == "namespace_use_declaration", + SupportLang::Swift => kind == "import_declaration", + SupportLang::Scala => matches!(kind, "import_declaration" | "import"), + SupportLang::Dart => kind == "import_or_export", + SupportLang::Ocaml => kind == "open_module", + SupportLang::Solidity => kind == "import_directive", + SupportLang::Julia => matches!(kind, "import_statement" | "using_statement"), + SupportLang::Proto => kind == "import", + SupportLang::Perl => kind == "use_statement", + // Languages where imports either have no run pattern, are wrapped in a + // single AST node already covered by `is_elidable_kind` (Kotlin's + // `import_list`, Haskell's `imports`), or live inside a too-generic + // container (Powershell `statement_list`). + SupportLang::Kotlin + | SupportLang::Haskell + | SupportLang::Powershell + | SupportLang::Ruby + | SupportLang::Lua + | SupportLang::Elixir + | SupportLang::Erlang + | SupportLang::Clojure + | SupportLang::Sql + | SupportLang::Zig + | SupportLang::Odin + | SupportLang::Verilog + | SupportLang::Tlaplus + | SupportLang::Nix + | SupportLang::R + | SupportLang::Starlark + | SupportLang::Bash + | SupportLang::Astro + | SupportLang::Vue + | SupportLang::Svelte + | SupportLang::Html + | SupportLang::Css + | SupportLang::Json + | SupportLang::Xml + | SupportLang::Markdown + | SupportLang::Graphql + | SupportLang::Hcl + | SupportLang::Dockerfile + | SupportLang::Cmake + | SupportLang::Make + | SupportLang::Just + | SupportLang::Yaml + | SupportLang::Toml + | SupportLang::Ini + | SupportLang::Diff + | SupportLang::Regex => false, + } +} + +fn normalize_spans(mut spans: Vec, total_lines: u32) -> Vec { + if total_lines == 0 { + return Vec::new(); + } + spans.retain(|span| span.start <= span.end && span.start <= total_lines); + for span in &mut spans { + span.end = span.end.min(total_lines); + } + spans.sort_by_key(|span| (span.start, span.end)); + let mut merged: Vec = Vec::new(); + for span in spans { + if let Some(last) = merged.last_mut() + && span.start <= last.end.saturating_add(1) + { + last.end = last.end.max(span.end); + continue; + } + merged.push(span); + } + merged +} + +fn build_segments(source: &str, total_lines: u32, spans: &[LineSpan]) -> Vec { + if total_lines == 0 { + return Vec::new(); + } + let source_lines: Vec<&str> = source.lines().collect(); + let elided_lines = spans + .iter() + .flat_map(|span| span.start..=span.end) + .collect::>(); + let mut segments = Vec::new(); + let mut current_kind: Option<&str> = None; + let mut current_start = 1; + let mut current_lines: Vec<&str> = Vec::new(); + + for line_number in 1..=total_lines { + let is_elided = elided_lines.contains(&line_number); + let kind = if is_elided { "elided" } else { "kept" }; + if current_kind.is_some_and(|existing| existing != kind) { + push_segment( + &mut segments, + current_kind.expect("kind set"), + current_start, + line_number - 1, + ¤t_lines, + ); + current_start = line_number; + current_lines.clear(); + } + current_kind = Some(kind); + if !is_elided { + let index = line_number.saturating_sub(1) as usize; + current_lines.push(source_lines.get(index).copied().unwrap_or_default()); + } + } + + if let Some(kind) = current_kind { + push_segment(&mut segments, kind, current_start, total_lines, ¤t_lines); + } + segments +} + +fn push_segment( + segments: &mut Vec, + kind: &str, + start_line: u32, + end_line: u32, + lines: &[&str], +) { + segments.push(SummarySegment { + kind: kind.to_string(), + start_line, + end_line, + text: (kind == "kept").then(|| lines.join("\n")), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summarize(code: &str, path: &str) -> SummaryResult { + summarize_code(SummaryOptions { + code: code.to_string(), + lang: None, + path: Some(path.to_string()), + min_body_lines: None, + min_comment_lines: None, + }) + .expect("summary succeeds") + } + + fn segment_kinds(result: &SummaryResult) -> Vec<&str> { + result + .segments + .iter() + .map(|segment| segment.kind.as_str()) + .collect() + } + + #[test] + fn summarizes_typescript_function_body() { + let result = summarize( + "export function greet(name: string): string {\n\tconst clean = name.trim();\n\tconst \ + label = clean || 'world';\n\treturn `hello ${label}`;\n}\n", + "fixture.ts", + ); + + assert!(result.parsed); + assert!(result.elided); + assert_eq!(result.language.as_deref(), Some("typescript")); + assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); + assert_eq!( + result.segments[0].text.as_deref(), + Some("export function greet(name: string): string {") + ); + assert_eq!(result.segments[1].start_line, 2); + assert_eq!(result.segments[1].end_line, 4); + assert_eq!(result.segments[2].text.as_deref(), Some("}")); + } + + #[test] + fn summarizes_rust_method_body_but_keeps_impl_boundaries() { + let result = summarize( + "struct Greeter;\n\nimpl Greeter {\n\tfn greet(&self) -> String {\n\t\tlet name = \ + \"world\";\n\t\tlet label = name.to_uppercase();\n\t\tformat!(\"hello \ + {label}\")\n\t}\n}\n", + "fixture.rs", + ); + + assert!(result.parsed); + assert!(result.elided); + let rendered = result + .segments + .iter() + .map(|segment| segment.text.clone().unwrap_or_else(|| "...".to_string())) + .collect::>() + .join("\n"); + assert!(rendered.contains("impl Greeter {\n...\n}")); + } + + #[test] + fn summarizes_python_function_body() { + let result = + summarize( + "class Greeter:\n def greet(self, name: str) -> str:\n clean = \ + name.strip()\n label = clean or 'world'\n return f'hello {label}'\n", + "fixture.py", + ); + + assert!(result.parsed); + assert!(result.elided); + assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); + assert!( + result.segments[0] + .text + .as_deref() + .unwrap_or_default() + .contains("def greet") + ); + assert!( + result.segments[2] + .text + .as_deref() + .unwrap_or_default() + .contains("return") + ); + } + + #[test] + fn min_body_lines_controls_short_body_elision() { + let code = "function small() {\n\treturn 1;\n}\n"; + let default_result = summarize(code, "fixture.ts"); + assert!(default_result.parsed); + assert!(!default_result.elided); + + let override_result = summarize_code(SummaryOptions { + code: code.to_string(), + lang: Some("typescript".to_string()), + path: None, + min_body_lines: Some(3), + min_comment_lines: None, + }) + .expect("summary succeeds"); + assert!(override_result.elided); + } + + #[test] + fn parse_failure_falls_back_to_unparsed() { + let result = summarize("export function broken( {\n", "fixture.ts"); + assert!(!result.parsed); + assert!(!result.elided); + assert_eq!(result.segments.len(), 1); + } + + #[test] + fn unsupported_language_is_unparsed() { + let result = summarize("plain text\nwith lines\n", "fixture.txt"); + assert!(!result.parsed); + assert_eq!(result.segments[0].text.as_deref(), Some("plain text\nwith lines\n")); + } + + #[test] + fn summarizes_typescript_interface_body() { + let result = summarize( + "export interface Args {\n\tcwd?: string;\n\tprovider?: string;\n\tmodel?: \ + string;\n\tapiKey?: string;\n}\n", + "fixture.ts", + ); + + assert!(result.parsed); + assert!(result.elided); + assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); + assert_eq!(result.segments[0].text.as_deref(), Some("export interface Args {")); + assert_eq!(result.segments[2].text.as_deref(), Some("}")); + } + + #[test] + fn summarizes_typescript_class_body() { + let result = summarize( + "export class Greeter {\n\tname: string = \"world\";\n\tlength(): number { return \ + this.name.length; }\n\tgreet(): string { return this.name; }\n\tshout(): string { \ + return this.name.toUpperCase(); }\n}\n", + "fixture.ts", + ); + + assert!(result.parsed); + assert!(result.elided); + assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); + assert!( + result.segments[0] + .text + .as_deref() + .unwrap_or_default() + .contains("class Greeter") + ); + assert_eq!(result.segments[2].text.as_deref(), Some("}")); + } + + #[test] + fn summarizes_rust_trait_declaration_list() { + let result = summarize( + "pub trait Greeter {\n\tfn greet(&self) -> String;\n\tfn length(&self) -> usize;\n\tfn \ + shout(&self) -> String;\n\tfn whisper(&self) -> String;\n}\n", + "fixture.rs", + ); + + assert!(result.parsed); + assert!(result.elided); + assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); + assert_eq!(result.segments[0].text.as_deref(), Some("pub trait Greeter {")); + assert_eq!(result.segments[2].text.as_deref(), Some("}")); + } + + #[test] + fn summarizes_java_class_body() { + let result = summarize( + "public class Greeter {\n\tprivate String name;\n\tpublic Greeter(String n) { this.name \ + = n; }\n\tpublic String greet() { return name; }\n\tpublic int length() { return \ + name.length(); }\n}\n", + "fixture.java", + ); + + assert!(result.parsed); + assert!(result.elided); + assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); + assert!( + result.segments[0] + .text + .as_deref() + .unwrap_or_default() + .contains("class Greeter") + ); + assert_eq!(result.segments[2].text.as_deref(), Some("}")); + } + + #[test] + fn summarizes_typescript_import_run() { + let code = "import a from \"a\";\nimport b from \"b\";\nimport c from \"c\";\nimport d from \ + \"d\";\nimport e from \"e\";\nimport f from \"f\";\n\nexport function main() \ + {}\n"; + let result = summarize(code, "fixture.ts"); + + assert!(result.parsed); + assert!(result.elided); + // Lines 2-5 are between the first and last imports and must be elided. + let elided = result + .segments + .iter() + .find(|seg| seg.kind == "elided") + .expect("elided segment"); + assert_eq!(elided.start_line, 2); + assert_eq!(elided.end_line, 5); + // First import line is kept. + assert!( + result.segments[0] + .text + .as_deref() + .unwrap_or_default() + .starts_with("import a from") + ); + } + + #[test] + fn does_not_elide_short_typescript_import_run() { + // 3 imports → total span 3 lines, below default min_body_lines (4). + let result = summarize( + "import a from \"a\";\nimport b from \"b\";\nimport c from \"c\";\n", + "fixture.ts", + ); + assert!(result.parsed); + assert!(!result.elided); + } + + #[test] + fn summarizes_python_import_run() { + let code = "import os\nimport sys\nfrom typing import List\nfrom pathlib import \ + Path\nimport json\nimport re\n\nprint('go')\n"; + let result = summarize(code, "fixture.py"); + + assert!(result.parsed); + assert!(result.elided); + let elided = result + .segments + .iter() + .find(|seg| seg.kind == "elided") + .expect("elided segment"); + assert_eq!(elided.start_line, 2); + assert_eq!(elided.end_line, 5); + } + + #[test] + fn summarizes_c_preproc_include_run() { + // C grammar puts each #include's `end_position` at column 0 of the next + // row (the trailing `\n`). Without `node_content_end_line`, the run + // elision would emit a span that starts past the second include and + // only collapse the third — verify the boundary statements stay + // visible and the middle is collapsed. + let code = "#include \n#include \"a.h\"\n#include \"b.h\"\n#include \ + \"c.h\"\n#include \nint main(void) { return 0; }\n"; + let result = summarize(code, "fixture.c"); + + assert!(result.parsed); + assert!(result.elided); + let elided = result + .segments + .iter() + .find(|seg| seg.kind == "elided") + .expect("elided segment"); + assert_eq!(elided.start_line, 2); + assert_eq!(elided.end_line, 4); + } + + #[test] + fn summarizes_rust_use_run() { + let code = "use std::fs;\nuse std::path::Path;\nuse std::collections::HashMap;\nuse \ + std::sync::Arc;\nuse std::io;\n\nfn main() {}\n"; + let result = summarize(code, "fixture.rs"); + + assert!(result.parsed); + assert!(result.elided); + let elided = result + .segments + .iter() + .find(|seg| seg.kind == "elided") + .expect("elided segment"); + assert_eq!(elided.start_line, 2); + assert_eq!(elided.end_line, 4); + } +} diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 602dbd7d7..d40c64992 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -18,13 +18,10 @@ tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["full"] } napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] } napi-derive = "3" -brush-core = { version = "0.5.0", path = "../brush-core-vendored" } -brush-builtins = { version = "0.2.0", path = "../brush-builtins-vendored" } -brush-parser = "0.3" +pi-shell = { path = "../pi-shell" } parking_lot = "0.12.5" dashmap = "6.1" clap = { version = "4", features = ["derive"] } -os_pipe = "1" portable-pty = "0.9" grep-regex = "0.1" grep-searcher = "0.1" @@ -35,62 +32,7 @@ rayon = "1.12" ast-grep-core = { version = "0.39", default-features = false, features = [ "tree-sitter", ] } -tree-sitter = "0.25" -tree-sitter-astro = { version = "0.1.1", package = "tree-sitter-astro-next" } -tree-sitter-bash = "0.25" -tree-sitter-c = "0.24" -tree-sitter-clojure = "0.1" -tree-sitter-cmake = "0.7.1" -tree-sitter-c-sharp = "0.23" -tree-sitter-cpp = "0.23" -tree-sitter-dart = "0.2" -tree-sitter-css = "0.25" -tree-sitter-diff = "0.1" -tree-sitter-dockerfile = { version = "0.2.0", package = "tree-sitter-dockerfile-updated" } -tree-sitter-elixir = "0.3" -tree-sitter-erlang = "0.16.0" -tree-sitter-go = "0.25" -tree-sitter-graphql = "0.1.0" -tree-sitter-haskell = "0.23" -tree-sitter-hcl = "1.1" -tree-sitter-html = "0.23" -tree-sitter-ini = "1.4.0" -tree-sitter-java = "0.23" -tree-sitter-javascript = "0.25" -tree-sitter-json = "0.24" -tree-sitter-just = "0.2.0" -tree-sitter-julia = "0.23" -tree-sitter-kotlin = { version = "0.4", package = "tree-sitter-kotlin-sg" } -tree-sitter-lua = "0.5" -tree-sitter-make = "1.1" -tree-sitter-md = "0.5" -tree-sitter-nix = "0.3" -tree-sitter-objc = "3.0" -tree-sitter-ocaml = "0.24.2" -tree-sitter-odin = "1.3" -tree-sitter-perl = { version = "0.1.0", package = "tree-sitter-perl-next" } -tree-sitter-php = "0.24" -tree-sitter-powershell = "0.26.4" -tree-sitter-proto = "0.4.0" -tree-sitter-python = "0.25" -tree-sitter-r = "1.2.0" -tree-sitter-regex = "0.25" -tree-sitter-ruby = "0.23" -tree-sitter-rust = "0.24" -tree-sitter-scala = "0.26" -tree-sitter-solidity = "1.2" -tree-sitter-sql = { version = "0.3.11", package = "tree-sitter-sequel" } -tree-sitter-starlark = "1.3" -tree-sitter-svelte = { version = "0.1.1", package = "tree-sitter-svelte-next" } -tree-sitter-swift = "0.7" -tree-sitter-toml-ng = "0.7" -tree-sitter-tlaplus = "1.5" -tree-sitter-typescript = "0.23" -tree-sitter-verilog = "1.0" -tree-sitter-vue = { version = "0.1.0", package = "tree-sitter-vue-next" } -tree-sitter-xml = "0.7" -tree-sitter-yaml = "0.7" -tree-sitter-zig = "1.1" +pi-ast = { path = "../pi-ast" } inferno = { version = "0.12", default-features = false } image = { version = "0.25", default-features = false, features = [ "png", diff --git a/crates/pi-natives/src/ast.rs b/crates/pi-natives/src/ast.rs index c45f90d89..5803e0aea 100644 --- a/crates/pi-natives/src/ast.rs +++ b/crates/pi-natives/src/ast.rs @@ -5,13 +5,15 @@ use std::{ path::{Path, PathBuf}, }; -use ast_grep_core::{ - Language, MatchStrictness, matcher::Pattern, source::Edit, tree_sitter::LanguageExt, -}; +use ast_grep_core::{MatchStrictness, matcher::Pattern, source::Edit, tree_sitter::LanguageExt}; use napi::bindgen_prelude::*; use napi_derive::napi; +use pi_ast::{ + SupportLang, + ops::{self as shared_ops}, +}; -use crate::{fs_cache, glob_util, language::SupportLang, task}; +use crate::{fs_cache, glob_util, task}; const DEFAULT_FIND_LIMIT: u32 = 50; @@ -227,29 +229,12 @@ fn to_u32(value: usize) -> u32 { value.min(u32::MAX as usize) as u32 } -fn supported_lang_list() -> String { - SupportLang::sorted_aliases().join(", ") -} - fn resolve_supported_lang(value: &str) -> Result { - SupportLang::from_alias(value).ok_or_else(|| { - Error::from_reason(format!( - "Unsupported language '{value}'. Supported: {}", - supported_lang_list() - )) - }) + shared_ops::resolve_supported_lang(value).map_err(|err| Error::from_reason(err.to_string())) } fn resolve_language(lang: Option<&str>, file_path: &Path) -> Result { - if let Some(lang) = lang.map(str::trim).filter(|lang| !lang.is_empty()) { - return resolve_supported_lang(lang); - } - SupportLang::from_path(file_path).ok_or_else(|| { - Error::from_reason(format!( - "Unable to infer language from file extension: {}. Specify `lang` explicitly.", - file_path.display() - )) - }) + shared_ops::resolve_language(lang, file_path).map_err(|err| Error::from_reason(err.to_string())) } /// Returns true if the file's extension resolves to a supported language. @@ -257,10 +242,7 @@ fn resolve_language(lang: Option<&str>, file_path: &Path) -> Result /// (the user chose to treat them as that language). When `lang` is None, /// only files with recognizable code extensions are included. fn is_supported_file(file_path: &Path, explicit_lang: Option<&str>) -> bool { - if explicit_lang.is_some() { - return true; - } - resolve_language(None, file_path).is_ok() + shared_ops::is_supported_file(file_path, explicit_lang) } fn infer_single_replace_lang( @@ -414,43 +396,12 @@ fn compile_pattern( strictness: &MatchStrictness, lang: SupportLang, ) -> Result { - let mut compiled = if let Some(selector) = selector.map(str::trim).filter(|s| !s.is_empty()) { - Pattern::contextual(pattern, selector, lang) - } else { - Pattern::try_new(pattern, lang) - } - .map_err(|err| Error::from_reason(format!("Invalid pattern: {err}")))?; - compiled.strictness = strictness.clone(); - Ok(compiled) + shared_ops::compile_pattern(pattern, selector, strictness, lang) + .map_err(|err| Error::from_reason(err.to_string())) } fn apply_edits(content: &str, edits: &[Edit]) -> Result { - let mut sorted: Vec<&Edit> = edits.iter().collect(); - sorted.sort_by_key(|edit| edit.position); - let mut prev_end = 0usize; - for edit in &sorted { - if edit.position < prev_end { - return Err(Error::from_reason( - "Overlapping replacements detected; refine pattern to avoid ambiguous edits" - .to_string(), - )); - } - prev_end = edit.position.saturating_add(edit.deleted_length); - } - - let mut output = content.to_string(); - for edit in sorted.into_iter().rev() { - let start = edit.position; - let end = edit.position.saturating_add(edit.deleted_length); - if end > output.len() || start > end { - return Err(Error::from_reason("Computed edit range is out of bounds".to_string())); - } - let replacement = String::from_utf8(edit.inserted_text.clone()).map_err(|err| { - Error::from_reason(format!("Replacement text is not valid UTF-8: {err}")) - })?; - output.replace_range(start..end, &replacement); - } - Ok(output) + shared_ops::apply_edits(content, edits).map_err(|err| Error::from_reason(err.to_string())) } fn normalize_pattern_list(patterns: Option>) -> Result> { diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 5ef602144..da9a2295a 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -37,7 +37,7 @@ pub mod highlight; pub mod html; pub mod image; pub mod keys; -pub mod language; +pub use pi_ast::language; pub mod power; diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index ba216013e..f743e8656 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -1,1572 +1,331 @@ //! Brush-based shell execution exported via N-API. -//! -//! # Overview -//! Executes shell commands in a non-interactive brush-core shell, streaming -//! output back to JavaScript via a threadsafe callback. -//! -//! # Example -//! ```ignore -//! const shell = new natives.Shell(); -//! const result = await shell.run({ command: "ls" }, (chunk) => { -//! console.log(chunk); -//! }); -//! ``` -#[cfg(windows)] -use std::collections::HashSet; -use std::{ - collections::HashMap, - fs, - io::{self, Write}, - str, - sync::Arc, - time::Duration, -}; +use std::{collections::HashMap, sync::Arc}; -#[cfg(windows)] -mod windows; - -mod minimizer; - -use brush_builtins::{BuiltinSet, default_builtins}; -use brush_core::{ - ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ProcessGroupPolicy, - ProfileLoadBehavior, RcLoadBehavior, Shell as BrushShell, ShellValue, ShellVariable, SourceInfo, - builtins, - env::EnvironmentScope, - openfiles::{self, OpenFile, OpenFiles}, -}; -use clap::Parser; use napi::{ + Env, Result, bindgen_prelude::*, threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, - tokio::{ - self, - sync::{Mutex as TokioMutex, mpsc}, - time, - }, + tokio::sync::mpsc, }; use napi_derive::napi; -#[cfg(not(unix))] -use tokio::io::AsyncReadExt as _; -use tokio_util::sync::CancellationToken; -#[cfg(windows)] -use windows::configure_windows_path; +use pi_shell::{ + MinimizerResult as CoreMinimizerResult, Shell as CoreShell, + ShellExecuteOptions as CoreShellExecuteOptions, ShellOptions as CoreShellOptions, + ShellRunOptions as CoreShellRunOptions, ShellRunResult as CoreShellRunResult, + execute_shell as core_execute_shell, minimizer, +}; -use crate::{ps, task}; +use crate::task; -struct ShellSessionCore { - shell: BrushShell, +#[napi(object)] +#[derive(Debug, Clone, Default)] +pub struct MinimizerOptions { + pub enabled: Option, + pub settings_path: Option, + pub settings_hash: Option, + pub only: Option>, + pub except: Option>, + pub max_capture_bytes: Option, } -#[derive(Clone, Default)] -struct ShellAbortState(Arc>>); - -impl ShellAbortState { - async fn set(&self, abort_token: task::AbortToken) { - *self.0.lock().await = Some(abort_token); - } - - async fn clear(&self) { - *self.0.lock().await = None; - } - - async fn abort(&self) { - let abort_token = self.0.lock().await.clone(); - if let Some(abort_token) = abort_token { - abort_token.abort(task::AbortReason::Signal); +impl From for minimizer::MinimizerOptions { + fn from(value: MinimizerOptions) -> Self { + Self { + enabled: value.enabled, + settings_path: value.settings_path, + settings_hash: value.settings_hash, + only: value.only, + except: value.except, + max_capture_bytes: value.max_capture_bytes, } } } -#[derive(Clone)] -struct ShellConfig { - session_env: Option>, - snapshot_path: Option, - minimizer: Option, -} - -/// Options for configuring a persistent shell session. #[napi(object)] pub struct ShellOptions { - /// Environment variables to apply once per session. pub session_env: Option>, - /// Optional snapshot file to source on session creation. pub snapshot_path: Option, - /// Optional per-command output minimizer configuration. - pub minimizer: Option, + pub minimizer: Option, } -/// Options for running a shell command (internal, lifetime-free). -struct ShellRunConfig { - /// Command string to execute in the shell. - command: String, - /// Working directory for the command. - cwd: Option, - /// Environment variables to apply for this command only. - env: Option>, - /// Resolved output minimizer config for this command. - minimizer: Option, +impl From for CoreShellOptions { + fn from(value: ShellOptions) -> Self { + Self { + session_env: value.session_env, + snapshot_path: value.snapshot_path, + minimizer: value.minimizer.map(Into::into), + } + } } -/// Options for running a shell command. #[napi(object)] pub struct ShellRunOptions<'env> { - /// Command string to execute in the shell. pub command: String, - /// Working directory for the command. pub cwd: Option, - /// Environment variables to apply for this command only. pub env: Option>, - /// Timeout in milliseconds before cancelling the command. pub timeout_ms: Option, - /// Abort signal for cancelling the operation. pub signal: Option>, } -/// Telemetry for a single minimization. -/// -/// Surfaced when the minimizer actually rewrote the command's output. The -/// session layer is expected to persist `original_text` via its -/// `ArtifactManager`, splice the resulting `artifact://` reference -/// into `text`, and replace any previously streamed raw output with the -/// minimized text. +#[napi(object)] +pub struct ShellExecuteOptions<'env> { + pub command: String, + pub cwd: Option, + pub env: Option>, + pub session_env: Option>, + pub timeout_ms: Option, + pub snapshot_path: Option, + pub minimizer: Option, + pub signal: Option>, +} + #[napi(object)] pub struct MinimizerResult { - /// Dispatch label produced by the minimizer (e.g. `"git"`, - /// `"pipeline:gradle"`, `"pipeline+builtin"`). pub filter: String, - /// The minimized replacement text. Callers that streamed raw chunks - /// during execution should clear and replace their accumulated output - /// with this text. pub text: String, - /// The full original capture, before minimization. pub original_text: String, - /// Captured byte length before minimization. pub input_bytes: u32, - /// Byte length of the minimized text the consumer received. pub output_bytes: u32, } -/// Result of running a shell command. +impl From for MinimizerResult { + fn from(value: CoreMinimizerResult) -> Self { + Self { + filter: value.filter, + text: value.text, + original_text: value.original_text, + input_bytes: value.input_bytes, + output_bytes: value.output_bytes, + } + } +} + #[napi(object)] pub struct ShellRunResult { - /// Exit code when the command completes normally. pub exit_code: Option, - /// Whether the command was cancelled via abort. pub cancelled: bool, - /// Whether the command timed out before completion. pub timed_out: bool, - /// When the minimizer rewrote the captured output, this carries the - /// original buffer + telemetry so the session layer can persist it as - /// an artifact and splice an `artifact://` reference into the - /// minimized text shown to the agent. `None` when nothing was rewritten. pub minimized: Option, } -/// Persistent brush-core shell session. +impl From for ShellRunResult { + fn from(value: CoreShellRunResult) -> Self { + Self { + exit_code: value.exit_code, + cancelled: value.cancelled, + timed_out: value.timed_out, + minimized: value.minimized.map(Into::into), + } + } +} + #[napi] pub struct Shell { - session: Arc>>, - abort_state: ShellAbortState, - config: ShellConfig, + inner: Arc, } #[napi] impl Shell { #[napi(constructor)] - /// Create a new shell session from optional configuration. - /// - /// The options set session-scoped environment variables and a snapshot path. pub fn new(options: Option) -> Self { - let config = match options { - None => ShellConfig { session_env: None, snapshot_path: None, minimizer: None }, - Some(opt) => { - let minimizer = opt - .minimizer - .as_ref() - .map(minimizer::MinimizerConfig::from_options); - ShellConfig { - session_env: opt.session_env, - snapshot_path: opt.snapshot_path, - minimizer, - } - }, - }; - Self { - session: Arc::new(TokioMutex::new(None)), - abort_state: ShellAbortState::default(), - config, - } + Self { inner: Arc::new(CoreShell::new(options.map(Into::into))) } } - /// Run a shell command using the provided options. - /// - /// The `on_chunk` callback receives streamed stdout/stderr output. Returns - /// the exit code when the command completes, or flags when cancelled or - /// timed out. #[napi] - pub fn run<'e>( + pub fn run<'env>( &self, - env: &'e Env, - options: ShellRunOptions<'e>, + env: &'env Env, + options: ShellRunOptions<'env>, #[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")] on_chunk: Option>, - ) -> Result> { - let ct = task::CancelToken::new(options.timeout_ms, options.signal); - let session = self.session.clone(); - let abort_state = self.abort_state.clone(); - let config = self.config.clone(); - - let run_config = ShellRunConfig { - command: options.command, - cwd: options.cwd, - env: options.env, - minimizer: config.minimizer.clone(), + ) -> Result> { + let cancel_token = task::CancelToken::new(options.timeout_ms, options.signal); + let inner = Arc::clone(&self.inner); + let run_options = CoreShellRunOptions { + command: options.command, + cwd: options.cwd, + env: options.env, + pty: false, + timeout_ms: options.timeout_ms, }; - task::future(env, "shell.run", async move { - run_shell_session(session, abort_state, config, run_config, on_chunk, ct).await + let chunk_tx = bridge_chunks(on_chunk); + inner + .run(run_options, chunk_tx, cancel_token.into_core()) + .await + .map(Into::into) + .map_err(|err| Error::from_reason(err.to_string())) }) } - /// Abort all running commands for this shell session. - /// - /// Returns `Ok(())` even when no commands are running. #[napi] pub async fn abort(&self) -> Result<()> { - self.abort_state.abort().await; + self.inner.abort().await; Ok(()) } } -/// Run a shell command within a persistent session. -async fn run_shell_session( - session: Arc>>, - abort_state: ShellAbortState, - config: ShellConfig, - run_config: ShellRunConfig, - on_chunk: Option>, - mut ct: task::CancelToken, -) -> Result { - let tokio_cancel = CancellationToken::new(); - - let mut run_task = tokio::spawn({ - let session = session.clone(); - let abort_state = abort_state.clone(); - let tokio_cancel = tokio_cancel.clone(); - let at = ct.emplace_abort_token(); - async move { - let mut session_guard = session.lock().await; - - let session = match &mut *session_guard { - Some(session) => session, - None => session_guard.insert(create_session(&config).await?), - }; - abort_state.set(at).await; - run_shell_command(session, &run_config, on_chunk, tokio_cancel).await - } - }); - - let res = tokio::select! { - res = &mut run_task => res, - reason = ct.wait() => { - tokio_cancel.cancel(); - let graceful = time::timeout(Duration::from_secs(2), &mut run_task).await; - if graceful.is_err() { - run_task.abort(); - let _ = run_task.await; - } - abort_state.clear().await; - // Use try_lock to avoid deadlocking if another task holds the session. - // If we can't acquire the lock, the session will be cleaned up when the - // holding task finishes. - if let Ok(mut guard) = session.try_lock() { - *guard = None; - } - return Ok(ShellRunResult { - exit_code: None, - cancelled: matches!(reason, task::AbortReason::Signal), - timed_out: matches!(reason, task::AbortReason::Timeout), - minimized: None, - }); - } - }; - let res = - res.unwrap_or_else(|e| Err(Error::from_reason(format!("Shell execution task failed: {e}")))); - abort_state.clear().await; - - let keepalive = res.as_ref().is_ok_and(|pair| session_keepalive(&pair.0)); - if !keepalive { - *session.lock().await = None; - } - let (exec, minimized) = res?; - Ok(ShellRunResult { - exit_code: Some(exit_code(&exec)), - cancelled: false, - timed_out: false, - minimized, - }) -} - -/// Options for executing a shell command via brush-core. -#[napi(object)] -pub struct ShellExecuteOptions<'env> { - /// Command string to execute in the shell. - pub command: String, - /// Working directory for the command. - pub cwd: Option, - /// Environment variables to apply for this command only. - pub env: Option>, - /// Environment variables to apply once per session. - pub session_env: Option>, - /// Timeout in milliseconds before cancelling the command. - pub timeout_ms: Option, - /// Optional snapshot file to source on session creation. - pub snapshot_path: Option, - /// Optional per-command output minimizer configuration. - pub minimizer: Option, - /// Abort signal for cancelling the operation. - pub signal: Option>, -} - -/// Result of executing a shell command via brush-core. -#[napi(object)] -pub struct ShellExecuteResult { - /// Exit code when the command completes normally. - pub exit_code: Option, - /// Whether the command was cancelled via abort. - pub cancelled: bool, - /// Whether the command timed out before completion. - pub timed_out: bool, - /// See [`ShellRunResult::minimized`]. - pub minimized: Option, -} - -/// Execute a brush shell command. -/// -/// Creates a fresh session for each call. The `on_chunk` callback receives -/// streamed stdout/stderr output. Returns the exit code when the command -/// completes, or flags when cancelled or timed out. #[napi] pub fn execute_shell<'env>( env: &'env Env, options: ShellExecuteOptions<'env>, #[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")] on_chunk: Option>, -) -> Result> { - let minimizer = options - .minimizer - .as_ref() - .map(minimizer::MinimizerConfig::from_options); - let config = ShellConfig { +) -> Result> { + let cancel_token = task::CancelToken::new(options.timeout_ms, options.signal); + let exec_options = CoreShellExecuteOptions { + command: options.command, + cwd: options.cwd, + env: options.env, session_env: options.session_env, + timeout_ms: options.timeout_ms, snapshot_path: options.snapshot_path, - minimizer: minimizer.clone(), + minimizer: options.minimizer.map(Into::into), + pty: false, }; - let run_config = - ShellRunConfig { command: options.command, cwd: options.cwd, env: options.env, minimizer }; - - let ct = task::CancelToken::new(options.timeout_ms, options.signal); task::future(env, "shell.execute", async move { - run_shell_oneshot(config, run_config, on_chunk, ct).await + let chunk_tx = bridge_chunks(on_chunk); + core_execute_shell(exec_options, chunk_tx, cancel_token.into_core()) + .await + .map(Into::into) + .map_err(|err| Error::from_reason(err.to_string())) }) } -/// Run a shell command in a fresh session (one-shot execution). -async fn run_shell_oneshot( - config: ShellConfig, - run_config: ShellRunConfig, +fn bridge_chunks( on_chunk: Option>, - ct: task::CancelToken, -) -> Result { - let tokio_cancel = CancellationToken::new(); - - let mut task = tokio::spawn({ - let tokio_cancel = tokio_cancel.clone(); - async move { - let mut session = create_session(&config).await?; - run_shell_command(&mut session, &run_config, on_chunk, tokio_cancel).await +) -> Option> { + let on_chunk = on_chunk?; + let (tx, mut rx) = mpsc::unbounded_channel::(); + napi::tokio::spawn(async move { + while let Some(chunk) = rx.recv().await { + on_chunk.call(Ok(chunk), ThreadsafeFunctionCallMode::NonBlocking); } }); - - let run_result = tokio::select! { - result = &mut task => result, - reason = ct.wait() => { - tokio_cancel.cancel(); - let graceful = time::timeout(Duration::from_secs(2), &mut task).await; - if graceful.is_err() { - task.abort(); - let _ = task.await; - } - return Ok(ShellExecuteResult { - exit_code: None, - cancelled: matches!(reason, task::AbortReason::Signal), - timed_out: matches!(reason, task::AbortReason::Timeout), - minimized: None, - }) - }, - }; - - let res = run_result - .unwrap_or_else(|e| Err(Error::from_reason(format!("Shell execution task failed: {e}")))); - - let (exec, minimized) = res?; - Ok(ShellExecuteResult { - exit_code: Some(exit_code(&exec)), - cancelled: false, - timed_out: false, - minimized, - }) -} - -fn null_file() -> Result { - openfiles::null().map_err(|err| Error::from_reason(format!("Failed to create null file: {err}"))) -} - -const fn exit_code(result: &ExecutionResult) -> i32 { - match result.exit_code { - ExecutionExitCode::Success => 0, - ExecutionExitCode::GeneralError => 1, - ExecutionExitCode::InvalidUsage => 2, - ExecutionExitCode::Unimplemented => 99, - ExecutionExitCode::CannotExecute => 126, - ExecutionExitCode::NotFound => 127, - ExecutionExitCode::Interrupted => 130, - ExecutionExitCode::BrokenPipe => 141, - ExecutionExitCode::Custom(code) => code as i32, - } -} - -#[cfg(windows)] -const fn normalize_env_key(key: &str) -> &str { - if key.eq_ignore_ascii_case("PATH") { - "PATH" - } else { - key - } -} - -#[cfg(not(windows))] -const fn normalize_env_key(key: &str) -> &str { - key -} - -#[cfg(windows)] -fn merge_path_values(existing: &str, incoming: &str) -> String { - let mut merged = Vec::new(); - let mut seen = HashSet::new(); - push_unique_paths(&mut merged, &mut seen, existing); - push_unique_paths(&mut merged, &mut seen, incoming); - - std::env::join_paths(merged.iter()) - .map_or_else(|_| merged.join(";"), |paths| paths.to_string_lossy().into_owned()) -} - -#[cfg(windows)] -fn push_unique_paths(merged: &mut Vec, seen: &mut HashSet, value: &str) { - for segment in std::env::split_paths(value) { - let segment_str = segment.to_string_lossy().into_owned(); - let normalized = normalize_path_segment(&segment_str); - if normalized.is_empty() { - continue; - } - if seen.insert(normalized) { - merged.push(segment_str); - } - } -} - -#[cfg(windows)] -fn normalize_path_segment(segment: &str) -> String { - let trimmed = segment.trim().trim_matches('"'); - if trimmed.is_empty() { - return String::new(); - } - - let mut normalized = std::path::PathBuf::new(); - for component in std::path::Path::new(trimmed).components() { - normalized.push(component.as_os_str()); - } - - normalized.to_string_lossy().to_ascii_lowercase() -} - -#[cfg(not(windows))] -fn merge_path_values(_existing: &str, incoming: &str) -> String { - incoming.to_string() -} - -async fn create_session(config: &ShellConfig) -> Result { - let mut shell = BrushShell::builder() - .do_not_inherit_env(true) - .profile(ProfileLoadBehavior::Skip) - .rc(RcLoadBehavior::Skip) - .builtins(default_builtins(BuiltinSet::BashMode)) - .build() - .await - .map_err(|err| Error::from_reason(format!("Failed to initialize shell: {err}")))?; - - if let Some(exec_builtin) = shell.builtin_mut("exec") { - exec_builtin.disabled = true; - } - if let Some(suspend_builtin) = shell.builtin_mut("suspend") { - suspend_builtin.disabled = true; - } - shell.register_builtin("sleep", builtins::builtin::()); - shell.register_builtin("timeout", builtins::builtin::()); - - let mut merged_path: Option = None; - for (key, value) in std::env::vars() { - let normalized_key = normalize_env_key(&key); - if should_skip_env_var(normalized_key) { - continue; - } - if normalized_key == "PATH" { - merged_path = Some(match merged_path { - Some(existing) => merge_path_values(&existing, &value), - None => value, - }); - continue; - } - let mut var = ShellVariable::new(ShellValue::String(value)); - var.export(); - shell - .env_mut() - .set_global(normalized_key, var) - .map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?; - } - - #[cfg(windows)] - if merged_path.is_none() - && let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH")) - { - merged_path = Some(value.to_string_lossy().into_owned()); - } - - if let Some(path_value) = merged_path { - let mut var = ShellVariable::new(ShellValue::String(path_value)); - var.export(); - shell - .env_mut() - .set_global("PATH", var) - .map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?; - } - - if let Some(env) = config.session_env.as_ref() { - for (key, value) in env { - let normalized_key = normalize_env_key(key); - if should_skip_env_var(normalized_key) { - continue; - } - let mut var = ShellVariable::new(ShellValue::String(value.clone())); - var.export(); - shell - .env_mut() - .set_global(normalized_key, var) - .map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?; - } - } - - #[cfg(windows)] - configure_windows_path(&mut shell)?; - - if let Some(snapshot_path) = config.snapshot_path.as_ref() { - source_snapshot(&mut shell, snapshot_path).await?; - } - - Ok(ShellSessionCore { shell }) -} - -async fn source_snapshot(shell: &mut BrushShell, snapshot_path: &str) -> Result<()> { - let mut params = shell.default_exec_params(); - let source_info = SourceInfo::from("pi-natives:snapshot"); - params.set_fd(OpenFiles::STDIN_FD, null_file()?); - params.set_fd(OpenFiles::STDOUT_FD, null_file()?); - params.set_fd(OpenFiles::STDERR_FD, null_file()?); - - let escaped = snapshot_path.replace('\'', "'\\''"); - let command = format!("source '{escaped}'"); - shell - .run_string(command, &source_info, ¶ms) - .await - .map_err(|err| Error::from_reason(format!("Failed to source snapshot: {err}")))?; - Ok(()) -} - -async fn run_shell_command( - session: &mut ShellSessionCore, - options: &ShellRunConfig, - on_chunk: Option>, - cancel_token: CancellationToken, -) -> Result<(ExecutionResult, Option)> { - if let Some(cwd) = options.cwd.as_deref() { - session - .shell - .set_working_dir(cwd) - .map_err(|err| Error::from_reason(format!("Failed to set cwd: {err}")))?; - } - - let (reader_file, writer_file) = pipe_to_files("output")?; - - let stdout_file = OpenFile::from( - writer_file - .try_clone() - .map_err(|err| Error::from_reason(format!("Failed to clone pipe: {err}")))?, - ); - let stderr_file = OpenFile::from(writer_file); - - let mut params = session.shell.default_exec_params(); - params.set_fd(OpenFiles::STDIN_FD, null_file()?); - params.set_fd(OpenFiles::STDOUT_FD, stdout_file); - params.set_fd(OpenFiles::STDERR_FD, stderr_file); - params.process_group_policy = ProcessGroupPolicy::NewProcessGroup; - params.set_cancel_token(cancel_token.clone()); - - let mut env_scope_pushed = false; - if let Some(env) = options.env.as_ref() { - session - .shell - .env_mut() - .push_scope(EnvironmentScope::Command); - env_scope_pushed = true; - for (key, value) in env { - let normalized_key = normalize_env_key(key); - if should_skip_env_var(normalized_key) { - continue; - } - let mut var = ShellVariable::new(ShellValue::String(value.clone())); - var.export(); - if let Err(err) = - session - .shell - .env_mut() - .add(normalized_key, var, EnvironmentScope::Command) - { - let _ = session.shell.env_mut().pop_scope(EnvironmentScope::Command); - return Err(Error::from_reason(format!("Failed to set env: {err}"))); - } - } - } - - let minimizer_mode = if let Some(config) = options.minimizer.as_ref() { - minimizer::engine::mode_for(&options.command, config) - } else { - minimizer::engine::MinimizerMode::None - }; - let should_minimize = !matches!(minimizer_mode, minimizer::engine::MinimizerMode::None); - let max_capture_bytes = if let Some(config) = options.minimizer.as_ref() { - config.max_capture_bytes as usize - } else { - 0 - }; - - let reader_cancel = CancellationToken::new(); - let (activity_tx, mut activity_rx) = mpsc::channel::<()>(1); - // Stream every raw chunk to the caller live, regardless of whether - // minimization is enabled. When minimization actually transforms the - // output, we propagate the replacement text via `MinimizerResult.text` - // so the caller can swap their accumulated buffer for the minimized - // version without losing intermediate progress updates. - let reader_callback = on_chunk; - let mut reader_handle = tokio::spawn({ - let reader_cancel = reader_cancel.clone(); - async move { - if should_minimize { - let output = read_output_buffered( - reader_file, - reader_callback, - reader_cancel, - activity_tx, - max_capture_bytes, - ) - .await; - Result::::Ok(OutputRead::Buffered(output)) - } else { - Box::pin(read_output(reader_file, reader_callback, reader_cancel, activity_tx)).await; - Result::::Ok(OutputRead::Streaming) - } - } - }); - let cancel_bridge = tokio::spawn({ - let cancel_token = cancel_token.clone(); - let reader_cancel = reader_cancel.clone(); - async move { - cancel_token.cancelled().await; - reader_cancel.cancel(); - } - }); - let source_info = SourceInfo::from("pi-natives:command"); - let result = session - .shell - .run_string(options.command.clone(), &source_info, ¶ms) - .await; - - if cancel_token.is_cancelled() { - terminate_background_jobs(&session.shell); - } - - if env_scope_pushed { - session - .shell - .env_mut() - .pop_scope(EnvironmentScope::Command) - .map_err(|err| Error::from_reason(format!("Failed to pop env scope: {err}")))?; - } - - drop(params); - - // The foreground command can complete while background jobs keep the - // stdout/stderr pipe open. Don't hang forever waiting for EOF; drain output - // for a short period, then cancel. - const POST_EXIT_IDLE: Duration = Duration::from_millis(250); - const POST_EXIT_MAX: Duration = Duration::from_secs(2); - const READER_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); - - let mut reader_finished = false; - let mut reader_output = None; - let mut idle_timer = Box::pin(time::sleep(POST_EXIT_IDLE)); - let mut max_timer = Box::pin(time::sleep(POST_EXIT_MAX)); - - loop { - tokio::select! { - res = &mut reader_handle => { - if let Ok(Ok(output)) = res { - reader_output = Some(output); - } - reader_finished = true; - break; - } - msg = activity_rx.recv() => { - if msg.is_none() { - break; - } - idle_timer.as_mut().reset(time::Instant::now() + POST_EXIT_IDLE); - } - () = &mut idle_timer => break, - () = &mut max_timer => break, - } - } - - if !reader_finished { - reader_cancel.cancel(); - if let Ok(res) = time::timeout(READER_SHUTDOWN_TIMEOUT, &mut reader_handle).await { - if let Ok(output) = res - && let Ok(output) = output - { - reader_output = Some(output); - } - } else { - reader_handle.abort(); - let _ = reader_handle.await; - } - } - cancel_bridge.abort(); - let _ = cancel_bridge.await; - - let result = - result.map_err(|err| Error::from_reason(format!("Shell execution failed: {err}")))?; - let mut minimized_out: Option = None; - if let Some(OutputRead::Buffered(output)) = reader_output - && let Some(config) = options.minimizer.as_ref() - && !output.exceeded - { - let minimized = match minimizer_mode { - minimizer::engine::MinimizerMode::WholeCommand => { - minimizer::apply(&options.command, &output.text, exit_code(&result), config) - }, - minimizer::engine::MinimizerMode::None => { - minimizer::MinimizerOutput::passthrough(&output.text) - }, - }; - if minimized.changed - && let Some(original) = minimized.original_text - { - let output_bytes = u32::try_from(minimized.text.len()).unwrap_or(u32::MAX); - minimized_out = Some(MinimizerResult { - filter: minimized.filter.to_string(), - text: minimized.text, - original_text: original, - input_bytes: u32::try_from(minimized.input_bytes).unwrap_or(u32::MAX), - output_bytes, - }); - } - } - Ok((result, minimized_out)) -} - -fn terminate_background_jobs(shell: &BrushShell) { - if shell.jobs().jobs.is_empty() { - return; - } - let mut targets = ps::TerminationTargets::new(); - for job in &shell.jobs().jobs { - if let Some(pgid) = job.process_group_id() { - targets.add_pgid(pgid); - } - if let Some(pid) = job.representative_pid() { - targets.add_pid(pid); - } - } - if targets.is_empty() { - return; - } - - targets.signal(ps::TERM_SIGNAL); - tokio::spawn(async move { - time::sleep(Duration::from_millis(500)).await; - targets.signal(ps::KILL_SIGNAL); - }); -} -fn should_skip_env_var(key: &str) -> bool { - if key.starts_with("BASH_FUNC_") && key.ends_with("%%") { - return true; - } - - matches!( - key, - "BASH_ENV" - | "ENV" - | "HISTFILE" - | "HISTTIMEFORMAT" - | "HISTCMD" - | "PS0" - | "PS1" - | "PS2" - | "PS4" - | "BRUSH_PS_ALT" - | "READLINE_LINE" - | "READLINE_POINT" - | "BRUSH_VERSION" - | "BASH" - | "BASHOPTS" - | "BASH_ALIASES" - | "BASH_ARGV0" - | "BASH_CMDS" - | "BASH_SOURCE" - | "BASH_SUBSHELL" - | "BASH_VERSINFO" - | "BASH_VERSION" - | "SHELLOPTS" - | "SHLVL" - | "SHELL" - | "COMP_WORDBREAKS" - | "DIRSTACK" - | "EPOCHREALTIME" - | "EPOCHSECONDS" - | "FUNCNAME" - | "GROUPS" - | "IFS" - | "LINENO" - | "MACHTYPE" - | "OSTYPE" - | "OPTERR" - | "OPTIND" - | "PIPESTATUS" - | "PPID" - | "PWD" - | "OLDPWD" - | "RANDOM" - | "SRANDOM" - | "SECONDS" - | "UID" - | "EUID" - | "HOSTNAME" - | "HOSTTYPE" - ) -} - -const fn session_keepalive(result: &ExecutionResult) -> bool { - match result.next_control_flow { - ExecutionControlFlow::Normal => true, - ExecutionControlFlow::BreakLoop { .. } => false, - ExecutionControlFlow::ContinueLoop { .. } => false, - ExecutionControlFlow::ReturnFromFunctionOrScript => false, - ExecutionControlFlow::ExitShell => false, - } -} - -enum OutputRead { - Streaming, - Buffered(BufferedOutput), -} - -struct BufferedOutput { - text: String, - exceeded: bool, -} - -async fn read_output( - reader: fs::File, - on_chunk: Option>, - cancel_token: CancellationToken, - activity: mpsc::Sender<()>, -) { - const REPLACEMENT: &str = "\u{FFFD}"; - const BUF: usize = 65536; - let mut buf = vec![0u8; BUF + 4]; // +4 for max UTF-8 char - let mut it = 0; - - #[cfg(unix)] - let Ok(reader) = register_nonblocking_pipe(reader) else { - return; - }; - #[cfg(not(unix))] - let reader = tokio::fs::File::from_std(reader); - #[cfg(not(unix))] - tokio::pin!(reader); - - loop { - #[cfg(unix)] - let n = { - let Ok(mut readiness) = (tokio::select! { - ready = reader.readable() => ready, - () = cancel_token.cancelled() => break, - }) else { - break; - }; - match readiness.try_io(|inner| read_nonblocking(inner.get_ref(), &mut buf[it..BUF])) { - Ok(Ok(0)) => break, - Ok(Ok(n)) => n, - Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue, - Ok(Err(_)) => break, - Err(_would_block) => continue, - } - }; - #[cfg(not(unix))] - let n = { - let read_future = reader.read(&mut buf[it..BUF]); - tokio::pin!(read_future); - match tokio::select! { - res = &mut read_future => res, - () = cancel_token.cancelled() => break, - } { - Ok(0) => break, // EOF - Ok(n) => n, - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, - Err(_) => break, - } - }; - if n > 0 { - let _ = activity.try_send(()); - } - it += n; - - // Consume as much of `pending` as is decodable *right now*. - while it > 0 { - let pending = &buf[..it]; - match str::from_utf8(pending) { - Ok(text) => { - emit_chunk(text, on_chunk.as_ref()); - it = 0; - break; - }, - Err(err) => { - let p = err.valid_up_to(); - if p > 0 { - // SAFETY: [..p] is guaranteed valid UTF-8 by valid_up_to(). - let text = unsafe { str::from_utf8_unchecked(&pending[..p]) }; - emit_chunk(text, on_chunk.as_ref()); - // copy p..it to the beginning of the buffer - buf.copy_within(p..it, 0); - it -= p; - } - - match err.error_len() { - Some(p) => { - // Invalid byte sequence: emit replacement and drop those bytes. - emit_chunk(REPLACEMENT, on_chunk.as_ref()); - // copy p..it to the beginning of the buffer - buf.copy_within(p..it, 0); - it -= p; - // continue loop in case more bytes remain after the - // invalid sequence - }, - None => { - // Incomplete UTF-8 sequence at end: keep bytes for next read. - break; - }, - } - }, - } - } - } - - // Flush whatever is left at EOF (including an incomplete final sequence). - for chunk in buf[..it].utf8_chunks() { - let valid = chunk.valid(); - if !valid.is_empty() { - emit_chunk(valid, on_chunk.as_ref()); - } - if !chunk.invalid().is_empty() { - emit_chunk(REPLACEMENT, on_chunk.as_ref()); - } - } -} - -async fn read_output_buffered( - reader: fs::File, - on_chunk: Option>, - cancel_token: CancellationToken, - activity: mpsc::Sender<()>, - max_capture_bytes: usize, -) -> BufferedOutput { - const REPLACEMENT: &str = "\u{FFFD}"; - const BUF: usize = 65536; - let mut buf = vec![0u8; BUF]; - let mut captured = Vec::new(); - let mut exceeded = false; - // Pending bytes from a prior read that ended mid-UTF-8 sequence. We hold - // them back so we emit only valid UTF-8 to the streaming callback while - // still capturing every byte into `captured` for post-processing. - let mut pending = Vec::::new(); - - #[cfg(unix)] - let Ok(reader) = register_nonblocking_pipe(reader) else { - return BufferedOutput { text: String::new(), exceeded: true }; - }; - #[cfg(not(unix))] - let reader = tokio::fs::File::from_std(reader); - #[cfg(not(unix))] - tokio::pin!(reader); - - loop { - #[cfg(unix)] - let n = { - let Ok(mut readiness) = (tokio::select! { - ready = reader.readable() => ready, - () = cancel_token.cancelled() => break, - }) else { - break; - }; - match readiness.try_io(|inner| read_nonblocking(inner.get_ref(), &mut buf)) { - Ok(Ok(0)) => break, - Ok(Ok(n)) => n, - Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue, - Ok(Err(_)) => break, - Err(_would_block) => continue, - } - }; - #[cfg(not(unix))] - let n = { - let read_future = reader.read(&mut buf); - tokio::pin!(read_future); - match tokio::select! { - res = &mut read_future => res, - () = cancel_token.cancelled() => break, - } { - Ok(0) => break, - Ok(n) => n, - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, - Err(_) => break, - } - }; - if n > 0 { - let _ = activity.try_send(()); - } - // Once `exceeded`, the post-process minimizer is bypassed (see the - // `!output.exceeded` gate at the call site), so further appends just - // grow `captured` without serving any purpose. Stop accumulating to - // bound peak memory on commands that produce very large output. - if !exceeded { - if captured.len().saturating_add(n) > max_capture_bytes { - exceeded = true; - } else { - captured.extend_from_slice(&buf[..n]); - } - } - - // Stream whatever is validly decodable *right now* to the callback, - // carrying incomplete trailing UTF-8 bytes over to the next iteration. - if let Some(cb) = on_chunk.as_ref() { - pending.extend_from_slice(&buf[..n]); - while !pending.is_empty() { - match str::from_utf8(&pending) { - Ok(text) => { - emit_chunk(text, Some(cb)); - pending.clear(); - break; - }, - Err(err) => { - let p = err.valid_up_to(); - if p > 0 { - // SAFETY: [..p] is valid UTF-8 per valid_up_to(). - let text = unsafe { str::from_utf8_unchecked(&pending[..p]) }; - emit_chunk(text, Some(cb)); - pending.drain(..p); - } - match err.error_len() { - Some(skip) => { - emit_chunk(REPLACEMENT, Some(cb)); - pending.drain(..skip); - }, - None => break, - } - }, - } - } - } - } - - // Flush any trailing bytes the streaming decoder held back at EOF. - if let Some(cb) = on_chunk.as_ref() { - for chunk in pending.utf8_chunks() { - let valid = chunk.valid(); - if !valid.is_empty() { - emit_chunk(valid, Some(cb)); - } - if !chunk.invalid().is_empty() { - emit_chunk(REPLACEMENT, Some(cb)); - } - } - } - - BufferedOutput { text: String::from_utf8_lossy(&captured).into_owned(), exceeded } -} - -#[cfg(unix)] -fn register_nonblocking_pipe(reader: fs::File) -> io::Result> { - set_nonblocking(&reader)?; - tokio::io::unix::AsyncFd::new(reader) -} - -#[cfg(unix)] -fn set_nonblocking(file: &T) -> io::Result<()> { - let fd = file.as_raw_fd(); - // SAFETY: `fd` is owned by `file` and remains valid for the duration of - // these `fcntl` calls. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; - if flags < 0 { - return Err(io::Error::last_os_error()); - } - if flags & libc::O_NONBLOCK != 0 { - return Ok(()); - } - - // SAFETY: `fd` remains valid here and we are only toggling `O_NONBLOCK`. - let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; - if result < 0 { - Err(io::Error::last_os_error()) - } else { - Ok(()) - } -} - -#[cfg(unix)] -fn read_nonblocking(file: &T, buf: &mut [u8]) -> io::Result { - // SAFETY: `buf` is writable for `buf.len()` bytes, and the raw fd obtained - // from `file` stays valid for the duration of the syscall. - let read = unsafe { libc::read(file.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) }; - if read < 0 { - Err(io::Error::last_os_error()) - } else { - Ok(read as usize) - } -} - -fn emit_chunk(text: &str, callback: Option<&ThreadsafeFunction>) { - if let Some(callback) = callback { - callback.call(Ok(text.to_string()), ThreadsafeFunctionCallMode::NonBlocking); - } -} - -fn pipe_to_files(label: &str) -> Result<(fs::File, fs::File)> { - let (r, w) = os_pipe::pipe() - .map_err(|err| Error::from_reason(format!("Failed to create {label} pipe: {err}")))?; - - #[cfg(unix)] - let (r, w): (fs::File, fs::File) = { - use std::os::unix::io::{FromRawFd, IntoRawFd}; - let r = r.into_raw_fd(); - let w = w.into_raw_fd(); - // SAFETY: We just obtained these fds from os_pipe and own them exclusively. - unsafe { (FromRawFd::from_raw_fd(r), FromRawFd::from_raw_fd(w)) } - }; - - #[cfg(windows)] - let (r, w): (fs::File, fs::File) = { - use std::os::windows::io::{FromRawHandle, IntoRawHandle}; - let r = r.into_raw_handle(); - let w = w.into_raw_handle(); - // SAFETY: We just obtained these handles from os_pipe and own them exclusively. - unsafe { (FromRawHandle::from_raw_handle(r), FromRawHandle::from_raw_handle(w)) } - }; - - Ok((r, w)) -} - -#[derive(Parser)] -#[command(disable_help_flag = true)] -struct SleepCommand { - #[arg(required = true)] - durations: Vec, -} - -impl builtins::Command for SleepCommand { - type Error = brush_core::Error; - - fn execute( - &self, - context: ExecutionContext<'_, SE>, - ) -> impl Future> + Send { - let durations = self.durations.clone(); - async move { - if context.is_cancelled() { - return Ok(ExecutionExitCode::Interrupted.into()); - } - let mut total = Duration::from_millis(0); - for duration in &durations { - let Some(parsed) = parse_duration(duration) else { - let _ = writeln!(context.stderr(), "sleep: invalid time interval '{duration}'"); - return Ok(ExecutionResult::new(1)); - }; - total += parsed; - } - let sleep = time::sleep(total); - tokio::pin!(sleep); - if let Some(cancel_token) = context.cancel_token() { - tokio::select! { - () = &mut sleep => Ok(ExecutionResult::success()), - () = cancel_token.cancelled() => Ok(ExecutionExitCode::Interrupted.into()), - } - } else { - sleep.await; - Ok(ExecutionResult::success()) - } - } - } -} - -#[derive(Parser)] -#[command(disable_help_flag = true)] -struct TimeoutCommand { - #[arg(required = true)] - duration: String, - #[arg(required = true, num_args = 1.., trailing_var_arg = true)] - command: Vec, -} - -impl builtins::Command for TimeoutCommand { - type Error = brush_core::Error; - - fn execute( - &self, - context: ExecutionContext<'_, SE>, - ) -> impl Future> + Send { - let duration = self.duration.clone(); - let command = self.command.clone(); - async move { - if context.is_cancelled() { - return Ok(ExecutionExitCode::Interrupted.into()); - } - let Some(timeout) = parse_duration(&duration) else { - let _ = writeln!(context.stderr(), "timeout: invalid time interval '{duration}'"); - return Ok(ExecutionResult::new(125)); - }; - if command.is_empty() { - let _ = writeln!(context.stderr(), "timeout: missing command"); - return Ok(ExecutionResult::new(125)); - } - - let child_cancel = CancellationToken::new(); - let mut params = context.params.clone(); - params.process_group_policy = ProcessGroupPolicy::NewProcessGroup; - params.set_cancel_token(child_cancel.clone()); - - let mut command_line = String::new(); - for (idx, arg) in command.iter().enumerate() { - if idx > 0 { - command_line.push(' '); - } - command_line.push_str("e_arg(arg)); - } - - let cancel_token = context.cancel_token(); - let source_info = SourceInfo::from("pi-natives:timeout"); - let run_future = context - .shell - .run_string(command_line, &source_info, ¶ms); - tokio::pin!(run_future); - - if let Some(cancel_token) = cancel_token { - tokio::select! { - result = &mut run_future => result, - () = time::sleep(timeout) => { - child_cancel.cancel(); - // Wait briefly for the child to exit after cancellation. - let _ = time::timeout(Duration::from_secs(2), &mut run_future).await; - Ok(ExecutionResult::new(124)) - }, - () = cancel_token.cancelled() => { - child_cancel.cancel(); - Ok(ExecutionExitCode::Interrupted.into()) - }, - } - } else { - tokio::select! { - result = &mut run_future => result, - () = time::sleep(timeout) => { - child_cancel.cancel(); - // Wait briefly for the child to exit after cancellation. - let _ = time::timeout(Duration::from_secs(2), &mut run_future).await; - Ok(ExecutionResult::new(124)) - }, - } - } - } - } -} -fn parse_duration(input: &str) -> Option { - let trimmed = input.trim(); - if trimmed.is_empty() { - return None; - } - let (number, multiplier) = match trimmed.chars().last()? { - 's' => (&trimmed[..trimmed.len() - 1], 1.0), - 'm' => (&trimmed[..trimmed.len() - 1], 60.0), - 'h' => (&trimmed[..trimmed.len() - 1], 3600.0), - 'd' => (&trimmed[..trimmed.len() - 1], 86400.0), - ch if ch.is_ascii_alphabetic() => return None, - _ => (trimmed, 1.0), - }; - let value = number.parse::().ok()?; - if value.is_sign_negative() { - return None; - } - let millis = value * multiplier * 1000.0; - if !millis.is_finite() || millis < 0.0 { - return None; - } - Some(Duration::from_millis(millis.round() as u64)) -} - -fn quote_arg(arg: &str) -> String { - if arg.is_empty() { - return "''".to_string(); - } - let safe = arg - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '+')); - if safe { - return arg.to_string(); - } - let escaped = arg.replace('\'', "'\"'\"'"); - format!("'{escaped}'") + Some(tx) } #[cfg(test)] mod tests { - use super::*; + use std::time::Duration; - /// Truth-table coverage for `brush_core::commands::child_session_action`. - /// - /// Lives in `pi-natives` because the brush-core crate is excluded from the - /// workspace (vendored upstream) and cannot be tested standalone — its tokio - /// dependency only resolves the `net` feature via feature-unification with - /// other workspace members. - mod child_session_action { - use brush_core::commands::{ChildSessionAction, child_session_action}; + use pi_shell::{ + ShellRunOptions as CoreShellRunOptions, + cancel::{AbortReason, CancelToken}, + }; + use tokio::{sync::mpsc, time}; + + use super::CoreShell; + + mod child_session_action_tests { + use pi_shell::{ChildSessionAction, child_session_action}; - /// Interactive brush, leading its own pgroup, terminal stdin: foreground. #[test] fn interactive_with_terminal_stdin_takes_foreground() { - assert_eq!(child_session_action(true, true, false), ChildSessionAction::TakeForeground,); - // Terminal foregrounding wins even when this is the first stage of a - // pipeline; no detach is attempted. - assert_eq!(child_session_action(true, true, true), ChildSessionAction::TakeForeground,); + assert_eq!(child_session_action(true, true, false), ChildSessionAction::TakeForeground); + assert_eq!(child_session_action(true, true, true), ChildSessionAction::TakeForeground); } - /// Brush leading a new pgroup with non-terminal stdin detaches only when - /// it is not part of a multi-command pipeline. Pipeline leaders must stay - /// in the parent session so later stages can join their process group. #[test] fn non_terminal_stdin_leading_new_pgroup_detaches_unless_pipeline() { - assert_eq!(child_session_action(true, false, false), ChildSessionAction::DetachSession,); - assert_eq!(child_session_action(true, false, true), ChildSessionAction::None,); + assert_eq!(child_session_action(true, false, false), ChildSessionAction::DetachSession); + assert_eq!(child_session_action(true, false, true), ChildSessionAction::None); } - /// Non-interactive brush, terminal stdin, no pipeline: nothing to do. #[test] fn non_interactive_with_terminal_stdin_does_nothing() { - assert_eq!(child_session_action(false, true, false), ChildSessionAction::None,); + assert_eq!(child_session_action(false, true, false), ChildSessionAction::None); } - /// Non-interactive brush, terminal stdin, joining a pipeline pgroup: - /// nothing to do (parent already wired pgroup membership). #[test] fn non_interactive_terminal_stdin_in_pipeline_does_nothing() { - assert_eq!(child_session_action(false, true, true), ChildSessionAction::None,); + assert_eq!(child_session_action(false, true, true), ChildSessionAction::None); } - /// **Embedded host bug fix.** Non-interactive brush, non-terminal stdin, - /// no pipeline pgroup: detach so the child cannot SIGTTIN/SIGTTOU the - /// host. This is the case that regressed before this fix and is the - /// motivating bug for PR #895. #[test] fn embedded_host_with_non_terminal_stdin_detaches() { - assert_eq!(child_session_action(false, false, false), ChildSessionAction::DetachSession,); + assert_eq!(child_session_action(false, false, false), ChildSessionAction::DetachSession); } - /// **Pipeline carve-out.** Non-interactive brush, non-terminal stdin - /// (pipe), and a multi-command pipeline: MUST NOT detach. For the first - /// external stage, `setsid()` puts the process-group leader into a - /// different session, so later stages fail to join its group with - /// EPERM. For later stages, `setsid()` would either fail with EPERM or - /// move the child into a new session, breaking the pipeline's shared - /// process group and job-control signal propagation. #[test] fn pipeline_stage_does_not_detach() { - assert_eq!(child_session_action(false, false, true), ChildSessionAction::None,); + assert_eq!(child_session_action(false, false, true), ChildSessionAction::None); } } - /// End-to-end verification that brush, when embedded as a non-interactive - /// library (`interactive: false`, exactly what `create_session` produces), - /// spawns external commands in a **separate session** from the host. - /// - /// The truth-table tests in `child_session_action` cover the decision in - /// isolation. This test covers the wiring: it boots a real `BrushShell`, - /// runs a child that prints its PID then sleeps, and asks the kernel for - /// that PID's session via `getsid(2)` while the child is still alive. - /// Pre-fix (`new_pg=false` skipped `detach_session`), the child inherited - /// the host's session, so `getsid(child_pid) == getsid(0)`. Post-fix, - /// `setsid` ran and the child is its own session leader - /// (`getsid(child_pid) == child_pid`). #[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn embedded_external_command_runs_in_its_own_session() { - use std::io::Read as _; - - // SAFETY: `getsid(0)` only queries the current process session; the return - // value is checked. + let shell = CoreShell::new(None); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let handle = tokio::spawn(async move { + shell + .run( + CoreShellRunOptions { + command: "/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'".to_string(), + cwd: None, + env: None, + pty: false, + timeout_ms: None, + }, + Some(tx), + CancelToken::default(), + ) + .await + }); + let child_pid = time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("timed out waiting for child pid") + .expect("missing child pid chunk") + .trim() + .parse::() + .expect("child pid parses"); + // SAFETY: `getsid(0)` only queries the current process session; the + // return value is checked below. let host_sid = unsafe { libc::getsid(0) }; assert!(host_sid > 0, "getsid(0) failed: {}", std::io::Error::last_os_error()); - - // Build the same kind of session pi-natives uses in production. - let config = ShellConfig { session_env: None, snapshot_path: None, minimizer: None }; - let mut session = create_session(&config).await.expect("create_session"); - - // Output pipe shared between the brush child and a concurrent reader. The - // reader runs on a blocking thread because `os_pipe` reads are blocking. - let (mut reader, writer) = pipe_to_files("e2e").expect("pipe"); - let stdout_file = OpenFile::from(writer.try_clone().expect("clone")); - let stderr_file = OpenFile::from(writer); - - let mut params = session.shell.default_exec_params(); - params.set_fd(OpenFiles::STDIN_FD, null_file().expect("null stdin")); - params.set_fd(OpenFiles::STDOUT_FD, stdout_file); - params.set_fd(OpenFiles::STDERR_FD, stderr_file); - - // (pid_tx, pid_rx) — reader task signals the test as soon as it has the PID. - let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); - let reader_handle = tokio::task::spawn_blocking(move || { - let mut buf = Vec::new(); - // Read just enough to capture the PID line. The child sleeps after - // printing so the pipe will not back-pressure. - let mut chunk = [0u8; 64]; - let mut pid_tx = Some(pid_tx); - while let Ok(n) = reader.read(&mut chunk) - && n > 0 - { - buf.extend_from_slice(&chunk[..n]); - if pid_tx.is_some() - && let Some(line_end) = buf.iter().position(|&byte| byte == b'\n') - && let Ok(line) = std::str::from_utf8(&buf[..line_end]) - && let Ok(pid) = line.trim().parse::() - { - let _ = pid_tx - .take() - .expect("pid sender should be present") - .send(pid); - } - } - buf - }); - - // Run brush in the background so we can call `getsid(child_pid)` while - // the child is still alive. - let shell_handle = tokio::spawn(async move { - let source_info = SourceInfo::from("pi-natives:test"); - // `printf '%d\n' "$$"` then `sleep 0.5`. Long enough for our `getsid`. - let exec = session - .shell - .run_string("/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'", &source_info, ¶ms) - .await - .expect("run_string"); - drop(params); - (session, exec) - }); - - let child_pid = time::timeout(Duration::from_secs(5), pid_rx) - .await - .expect("timed out waiting for child PID") - .expect("reader closed pid channel without sending"); - assert!(child_pid > 0, "got non-positive child pid: {child_pid}"); - - // Snapshot the child's session ID immediately, while the child is still - // in `sleep`. POSIX guarantees `getsid` against a live PID returns the - // session of that process. - // SAFETY: `child_pid` is a positive PID from the child; errors are reported via - // the checked return value. + // SAFETY: `child_pid` is a live positive PID reported by the child; the + // return value is checked below. let child_sid = unsafe { libc::getsid(child_pid) }; - assert!( - child_sid > 0, - "getsid({child_pid}) failed: {} (child may have already exited)", - std::io::Error::last_os_error(), - ); - - // Drain the brush task and the pipe reader. - let (_session, exec) = time::timeout(Duration::from_secs(5), shell_handle) + assert!(child_sid > 0, "getsid({child_pid}) failed: {}", std::io::Error::last_os_error()); + let result = handle .await - .expect("shell timed out") - .expect("shell task panicked"); - assert!( - matches!(exec.exit_code, ExecutionExitCode::Success), - "unexpected exit: {}", - exit_code(&exec), - ); - let _ = time::timeout(Duration::from_secs(2), reader_handle).await; - - assert_ne!( - child_sid, host_sid, - "child PID {child_pid} inherited host session {host_sid}; setsid() did not run — the \ - embedded-host bug is back", - ); - assert_eq!( - child_sid, child_pid, - "child PID {child_pid} should be its own session leader after setsid", - ); + .expect("shell task panicked") + .expect("shell run"); + assert_eq!(result.exit_code, Some(0)); + assert_ne!(child_sid, host_sid); + assert_eq!(child_sid, child_pid); } - #[tokio::test] - async fn abort_state_signals_cancel_token() { - let abort_state = ShellAbortState::default(); - let mut cancel_token = task::CancelToken::default(); - let abort_token = cancel_token.emplace_abort_token(); - - abort_state.set(abort_token).await; - abort_state.abort().await; - - let reason = time::timeout(Duration::from_millis(100), cancel_token.wait()) - .await - .expect("cancel token should be signalled"); - assert!(matches!(reason, task::AbortReason::Signal)); - } - - #[cfg(unix)] #[tokio::test] async fn read_output_stops_when_cancelled_before_pipe_eof() { - let (reader, _writer) = pipe_to_files("test").expect("test pipe should be created"); - let cancel = CancellationToken::new(); - let (activity_tx, _activity_rx) = mpsc::channel(1); - let handle = tokio::spawn(read_output(reader, None, cancel.clone(), activity_tx)); + let shell = CoreShell::new(None); + let mut cancel = CancelToken::default(); + let abort = cancel.emplace_abort_token(); + let handle = tokio::spawn(async move { + shell + .run( + CoreShellRunOptions { + command: "sh -c 'sleep 30 & wait'".to_string(), + cwd: None, + env: None, + pty: false, + timeout_ms: None, + }, + None, + cancel, + ) + .await + }); time::sleep(Duration::from_millis(10)).await; - cancel.cancel(); - - time::timeout(Duration::from_millis(100), handle) + abort.abort(AbortReason::Signal); + let result = time::timeout(Duration::from_secs(3), handle) .await - .expect("reader task should stop after cancellation") - .expect("reader task should not panic"); + .expect("shell run should stop after cancellation") + .expect("shell task should not panic") + .expect("shell run should return"); + assert!(result.cancelled); } } diff --git a/crates/pi-natives/src/summary.rs b/crates/pi-natives/src/summary.rs index db1e4199f..1298fbcb6 100644 --- a/crates/pi-natives/src/summary.rs +++ b/crates/pi-natives/src/summary.rs @@ -1,16 +1,7 @@ //! Structural source summaries powered by tree-sitter. -use std::{collections::BTreeSet, path::Path}; - -use ast_grep_core::{Language, tree_sitter::LanguageExt}; use napi::bindgen_prelude::*; use napi_derive::napi; -use tree_sitter::{Node, Parser}; - -use crate::language::SupportLang; - -const DEFAULT_MIN_BODY_LINES: u32 = 4; -const DEFAULT_MIN_COMMENT_LINES: u32 = 6; #[napi(object)] pub struct SummaryOptions { @@ -52,994 +43,38 @@ pub struct SummaryResult { pub segments: Vec, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct LineSpan { - start: u32, - end: u32, +impl From for SummarySegment { + fn from(value: pi_ast::summary::SummarySegment) -> Self { + Self { + kind: value.kind, + start_line: value.start_line, + end_line: value.end_line, + text: value.text, + } + } +} + +impl From for SummaryResult { + fn from(value: pi_ast::summary::SummaryResult) -> Self { + Self { + language: value.language, + parsed: value.parsed, + elided: value.elided, + total_lines: value.total_lines, + segments: value.segments.into_iter().map(Into::into).collect(), + } + } } #[napi] pub fn summarize_code(options: SummaryOptions) -> Result { - let source = options.code; - let total_lines = count_lines(&source); - if source.is_empty() { - return Ok(unparsed_result(source, total_lines)); - } - - let Some(language) = resolve_language(options.lang.as_deref(), options.path.as_deref()) else { - return Ok(unparsed_result(source, total_lines)); - }; - - let mut parser = Parser::new(); - parser - .set_language(&language.get_ts_language()) - .map_err(|err| Error::from_reason(format!("Failed to load tree-sitter language: {err}")))?; - let Some(tree) = parser.parse(&source, None) else { - return Ok(unparsed_result(source, total_lines)); - }; - let root = tree.root_node(); - if root.has_error() { - return Ok(unparsed_result(source, total_lines)); - } - - let min_body_lines = options - .min_body_lines - .unwrap_or(DEFAULT_MIN_BODY_LINES) - .max(2); - let min_comment_lines = options - .min_comment_lines - .unwrap_or(DEFAULT_MIN_COMMENT_LINES) - .max(4); - let mut spans = Vec::new(); - collect_elisions(root, language, min_body_lines, min_comment_lines, &mut spans); - let spans = normalize_spans(spans, total_lines); - let segments = build_segments(&source, total_lines, &spans); - - Ok(SummaryResult { - language: Some(language.canonical_name().to_string()), - parsed: true, - elided: !spans.is_empty(), - total_lines, - segments, + pi_ast::summary::summarize_code(pi_ast::summary::SummaryOptions { + code: options.code, + lang: options.lang, + path: options.path, + min_body_lines: options.min_body_lines, + min_comment_lines: options.min_comment_lines, }) -} - -fn resolve_language(lang: Option<&str>, path: Option<&str>) -> Option { - if let Some(lang) = lang.map(str::trim).filter(|lang| !lang.is_empty()) { - return SupportLang::from_alias(lang); - } - let path = path?.trim(); - if path.is_empty() { - return None; - } - SupportLang::from_path(Path::new(path)) -} - -fn unparsed_result(source: String, total_lines: u32) -> SummaryResult { - let segments = if source.is_empty() { - Vec::new() - } else { - vec![SummarySegment { - kind: "kept".to_string(), - start_line: 1, - end_line: total_lines, - text: Some(source), - }] - }; - SummaryResult { language: None, parsed: false, elided: false, total_lines, segments } -} - -fn count_lines(source: &str) -> u32 { - if source.is_empty() { - 0 - } else { - source.lines().count().max(1).min(u32::MAX as usize) as u32 - } -} - -fn collect_elisions( - node: Node<'_>, - language: SupportLang, - min_body_lines: u32, - min_comment_lines: u32, - spans: &mut Vec, -) { - let total_lines = node_line_count(node); - if is_comment_kind(language, node.kind()) { - if total_lines >= min_comment_lines { - let start_line = node_start_line(node) + 2; - let end_line = node_end_line(node).saturating_sub(1); - if start_line <= end_line { - spans.push(LineSpan { start: start_line, end: end_line }); - } - } - return; - } - - if is_elidable_kind(language, node.kind()) && total_lines >= min_body_lines { - let start_line = node_start_line(node) + 1; - let end_line = node_end_line(node).saturating_sub(1); - if start_line <= end_line { - spans.push(LineSpan { start: start_line, end: end_line }); - return; - } - } - - // Detect consecutive runs of groupable siblings (e.g. import statements). - // When the run's total line span meets `min_body_lines`, elide the lines - // strictly between the first and last sibling's content, leaving the - // boundary statements visible. - let child_count = node.child_count(); - let mut run_first: Option> = None; - let mut run_last: Option> = None; - let mut run_count: u32 = 0; - for index in 0..child_count { - let Some(child) = node.child(index) else { - continue; - }; - if is_groupable_kind(language, child.kind()) { - if run_first.is_none() { - run_first = Some(child); - } - run_last = Some(child); - run_count += 1; - } else { - flush_groupable_run(run_first, run_last, run_count, min_body_lines, spans); - run_first = None; - run_last = None; - run_count = 0; - } - } - flush_groupable_run(run_first, run_last, run_count, min_body_lines, spans); - - for index in 0..child_count { - if let Some(child) = node.child(index) { - collect_elisions(child, language, min_body_lines, min_comment_lines, spans); - } - } -} - -fn flush_groupable_run( - first: Option>, - last: Option>, - count: u32, - min_body_lines: u32, - spans: &mut Vec, -) { - if count < 2 { - return; - } - let (Some(first), Some(last)) = (first, last) else { - return; - }; - let first_start = node_start_line(first); - let last_start = node_start_line(last); - let last_end = node_end_line(last); - let span_lines = last_end.saturating_sub(first_start).saturating_add(1); - if span_lines < min_body_lines { - return; - } - // Use the line of the first node's last visible content as the lower bound - // (some grammars include trailing newlines in the node range, which would - // otherwise place `end_line` on the next sibling's first line). - let first_content_end = node_content_end_line(first).min(last_start.saturating_sub(1)); - let start = first_content_end.saturating_add(1); - let end = last_start.saturating_sub(1); - if start <= end { - spans.push(LineSpan { start, end }); - } -} - -fn node_start_line(node: Node<'_>) -> u32 { - node - .start_position() - .row - .saturating_add(1) - .min(u32::MAX as usize) as u32 -} - -fn node_end_line(node: Node<'_>) -> u32 { - node - .end_position() - .row - .saturating_add(1) - .min(u32::MAX as usize) as u32 -} - -/// Last source line containing a content byte from `node`. -/// -/// Tree-sitter reports `end_position` as the position one past the last byte. -/// When that byte is a newline, the resulting position lands at column 0 of -/// the next row, which makes the naive `row + 1` answer one greater than the -/// row of the last visible content. This helper subtracts that off. -fn node_content_end_line(node: Node<'_>) -> u32 { - let pos = node.end_position(); - let row = if pos.column == 0 && pos.row > 0 { - pos.row - 1 - } else { - pos.row - }; - row.saturating_add(1).min(u32::MAX as usize) as u32 -} - -fn node_line_count(node: Node<'_>) -> u32 { - node_end_line(node) - .saturating_sub(node_start_line(node)) - .saturating_add(1) -} - -fn is_comment_kind(language: SupportLang, kind: &str) -> bool { - match language { - SupportLang::TypeScript | SupportLang::Tsx | SupportLang::JavaScript => kind == "comment", - SupportLang::Rust => kind == "block_comment", - SupportLang::Python => kind == "comment", - SupportLang::Go => kind == "comment", - SupportLang::Java => kind == "block_comment", - SupportLang::C | SupportLang::Cpp | SupportLang::ObjC => kind == "comment", - SupportLang::CSharp => kind == "comment", - SupportLang::Ruby => kind == "comment", - SupportLang::Php => kind == "comment", - SupportLang::Swift => kind == "comment", - SupportLang::Kotlin => kind == "block_comment", - SupportLang::Scala => kind == "block_comment", - SupportLang::Lua => kind == "comment", - _ => false, - } -} - -fn is_elidable_kind(language: SupportLang, kind: &str) -> bool { - match language { - SupportLang::TypeScript | SupportLang::Tsx | SupportLang::JavaScript => matches!( - kind, - "statement_block" - | "function_body" - | "object" - | "array" - | "template_string" - | "class_body" - | "interface_body" - | "enum_body" - | "object_type" - | "switch_body" - | "jsx_element" - | "jsx_self_closing_element" - ), - SupportLang::Rust => matches!( - kind, - "block" - | "array_expression" - | "tuple_expression" - | "struct_expression" - | "match_block" - | "raw_string_literal" - | "declaration_list" - | "field_declaration_list" - | "ordered_field_declaration_list" - | "enum_variant_list" - | "where_clause" - | "use_list" - | "macro_definition" - | "token_tree" - ), - SupportLang::Python => matches!( - kind, - "block" - | "dictionary" - | "list" | "set" - | "string" - | "tuple" - | "argument_list" - | "parameters" - | "parenthesized_expression" - | "list_comprehension" - | "set_comprehension" - | "dictionary_comprehension" - | "generator_expression" - | "import_from_statement" - | "subscript" - ), - SupportLang::Go => matches!( - kind, - "block" - | "composite_literal" - | "interpreted_string_literal" - | "raw_string_literal" - | "import_spec_list" - | "const_declaration" - | "var_declaration" - | "field_declaration_list" - | "interface_type" - | "expression_switch_statement" - | "type_switch_statement" - | "select_statement" - ), - SupportLang::Java => matches!( - kind, - "block" - | "array_initializer" - | "class_body" - | "interface_body" - | "enum_body" - | "annotation_type_body" - | "constructor_body" - | "switch_block" - | "string_literal" - ), - SupportLang::C => matches!( - kind, - "compound_statement" - | "initializer_list" - | "string_literal" - | "field_declaration_list" - | "enumerator_list" - | "concatenated_string" - ), - SupportLang::Cpp => matches!( - kind, - "compound_statement" - | "initializer_list" - | "string_literal" - | "field_declaration_list" - | "enumerator_list" - | "concatenated_string" - | "declaration_list" - | "raw_string_literal" - | "requires_clause" - ), - SupportLang::ObjC => matches!( - kind, - "compound_statement" - | "initializer_list" - | "string_literal" - | "protocol_declaration" - | "class_interface" - | "class_implementation" - | "instance_variables" - | "array_literal" - | "dictionary_literal" - ), - SupportLang::CSharp => matches!( - kind, - "block" - | "initializer_expression" - | "array_initializer_expression" - | "declaration_list" - | "enum_member_declaration_list" - | "switch_expression" - | "raw_string_literal" - | "interpolated_string_expression" - ), - SupportLang::Ruby => matches!( - kind, - "body_statement" - | "method" - | "do_block" - | "array" - | "hash" | "block" - | "case" | "heredoc_body" - ), - SupportLang::Php => matches!( - kind, - "compound_statement" - | "array_creation_expression" - | "declaration_list" - | "enum_declaration_list" - | "match_block" - | "heredoc" - | "nowdoc" - ), - SupportLang::Swift => matches!( - kind, - "function_body" - | "array_literal" - | "dictionary_literal" - | "multi_line_string_literal" - | "class_body" - | "protocol_body" - | "enum_class_body" - | "computed_property" - | "lambda_literal" - ), - SupportLang::Kotlin => matches!( - kind, - "function_body" - | "collection_literal" - | "multi_line_string_literal" - | "class_body" - | "enum_class_body" - | "when_expression" - | "import_list" - ), - SupportLang::Scala => matches!( - kind, - "block" - | "collection_literal" - | "template_body" - | "enum_body" - | "match_expression" - | "for_expression" - | "string" - ), - SupportLang::Lua => matches!(kind, "block" | "table_constructor" | "string"), - SupportLang::Perl => { - matches!(kind, "block" | "list_expression" | "heredoc_content" | "regexp_content") - }, - SupportLang::Dart => matches!( - kind, - "block" - | "function_expression_body" - | "class_body" - | "enum_body" - | "extension_body" - | "mixin_body" - | "list_literal" - | "set_or_map_literal" - | "string_literal" - ), - SupportLang::Bash => matches!( - kind, - "compound_statement" - | "if_statement" - | "case_statement" - | "do_group" - | "subshell" - | "array" - | "heredoc_body" - ), - SupportLang::Powershell => matches!( - kind, - "script_block" - | "statement_block" - | "class_statement" - | "param_block" - | "hash_literal_expression" - | "array_expression" - | "expandable_here_string_literal" - | "verbatim_here_string_characters" - ), - SupportLang::Haskell => matches!( - kind, - "imports" - | "data_type" - | "class" - | "instance" - | "function" - | "do" | "case" - | "let" | "local_binds" - | "list" | "tuple" - ), - SupportLang::Ocaml => matches!( - kind, - "structure" - | "signature" - | "variant_declaration" - | "record_declaration" - | "match_expression" - | "match_case" - | "let_expression" - | "value_definition" - | "list_expression" - ), - SupportLang::Elixir => matches!(kind, "do_block" | "list" | "map" | "string" | "sigil"), - SupportLang::Erlang => matches!( - kind, - "fun_decl" - | "case_expr" - | "if_expr" - | "receive_expr" - | "record_decl" - | "list" | "map_expr" - | "tuple" - ), - SupportLang::Clojure => { - matches!(kind, "list_lit" | "map_lit" | "vec_lit" | "set_lit" | "str_lit") - }, - SupportLang::Solidity => { - matches!(kind, "contract_body" | "function_body" | "struct_body" | "enum_body") - }, - SupportLang::Sql => matches!(kind, "column_definitions" | "case"), - SupportLang::Zig => matches!(kind, "Block" | "ContainerDecl" | "InitList"), - SupportLang::Odin => matches!( - kind, - "block" | "struct_declaration" | "enum_declaration" | "union_declaration" | "struct" - ), - SupportLang::Verilog => matches!( - kind, - "module_declaration" - | "seq_block" - | "case_statement" - | "function_declaration" - | "task_declaration" - | "list_of_port_declarations" - ), - SupportLang::Tlaplus => matches!(kind, "module" | "theorem" | "let_in"), - SupportLang::Nix => matches!( - kind, - "attrset_expression" | "list_expression" | "let_expression" | "indented_string_expression" - ), - SupportLang::Proto => matches!(kind, "message_body" | "enum_body" | "oneof" | "service"), - SupportLang::Julia => matches!( - kind, - "function_definition" - | "struct_definition" - | "module_definition" - | "do_clause" - | "vector_expression" - | "string_literal" - ), - SupportLang::R => matches!(kind, "braced_expression" | "call" | "string"), - SupportLang::Starlark => matches!(kind, "block" | "list" | "dictionary" | "string"), - SupportLang::Astro => { - matches!(kind, "frontmatter_js_block" | "script_element" | "style_element" | "element") - }, - SupportLang::Vue => { - matches!(kind, "template_element" | "script_element" | "style_element" | "element") - }, - SupportLang::Svelte => matches!(kind, "script_element" | "style_element" | "element"), - SupportLang::Html => matches!(kind, "element" | "script_element" | "style_element"), - SupportLang::Css => matches!(kind, "block" | "keyframe_block_list"), - SupportLang::Json => matches!(kind, "object" | "array"), - SupportLang::Xml => kind == "element", - SupportLang::Markdown => matches!(kind, "fenced_code_block" | "pipe_table" | "list"), - SupportLang::Graphql => matches!( - kind, - "fields_definition" - | "enum_values_definition" - | "input_fields_definition" - | "schema_definition" - ), - SupportLang::Hcl => matches!(kind, "body" | "object"), - SupportLang::Dockerfile => kind == "shell_command", - SupportLang::Cmake => matches!(kind, "argument_list" | "body"), - SupportLang::Make => kind == "recipe", - SupportLang::Just => kind == "recipe_body", - // Skip: data formats with no closing-token anchor (Yaml mappings, - // Toml tables, Ini sections), the diff format whose informational - // content IS the lines inside hunks, and the leaf-token-only Regex - // grammar. Eliding any of these deletes the only content worth - // reading. - SupportLang::Yaml - | SupportLang::Toml - | SupportLang::Ini - | SupportLang::Diff - | SupportLang::Regex => false, - } -} - -fn is_groupable_kind(language: SupportLang, kind: &str) -> bool { - match language { - SupportLang::TypeScript | SupportLang::Tsx | SupportLang::JavaScript => { - kind == "import_statement" - }, - SupportLang::Rust => matches!(kind, "use_declaration" | "extern_crate_declaration"), - SupportLang::Python => { - matches!(kind, "import_statement" | "import_from_statement" | "future_import_statement") - }, - SupportLang::Go => kind == "import_declaration", - SupportLang::Java => kind == "import_declaration", - SupportLang::C | SupportLang::Cpp => kind == "preproc_include", - SupportLang::ObjC => matches!(kind, "preproc_include" | "import_declaration"), - SupportLang::CSharp => kind == "using_directive", - SupportLang::Php => kind == "namespace_use_declaration", - SupportLang::Swift => kind == "import_declaration", - SupportLang::Scala => matches!(kind, "import_declaration" | "import"), - SupportLang::Dart => kind == "import_or_export", - SupportLang::Ocaml => kind == "open_module", - SupportLang::Solidity => kind == "import_directive", - SupportLang::Julia => matches!(kind, "import_statement" | "using_statement"), - SupportLang::Proto => kind == "import", - SupportLang::Perl => kind == "use_statement", - // Languages where imports either have no run pattern, are wrapped in a - // single AST node already covered by `is_elidable_kind` (Kotlin's - // `import_list`, Haskell's `imports`), or live inside a too-generic - // container (Powershell `statement_list`). - SupportLang::Kotlin - | SupportLang::Haskell - | SupportLang::Powershell - | SupportLang::Ruby - | SupportLang::Lua - | SupportLang::Elixir - | SupportLang::Erlang - | SupportLang::Clojure - | SupportLang::Sql - | SupportLang::Zig - | SupportLang::Odin - | SupportLang::Verilog - | SupportLang::Tlaplus - | SupportLang::Nix - | SupportLang::R - | SupportLang::Starlark - | SupportLang::Bash - | SupportLang::Astro - | SupportLang::Vue - | SupportLang::Svelte - | SupportLang::Html - | SupportLang::Css - | SupportLang::Json - | SupportLang::Xml - | SupportLang::Markdown - | SupportLang::Graphql - | SupportLang::Hcl - | SupportLang::Dockerfile - | SupportLang::Cmake - | SupportLang::Make - | SupportLang::Just - | SupportLang::Yaml - | SupportLang::Toml - | SupportLang::Ini - | SupportLang::Diff - | SupportLang::Regex => false, - } -} - -fn normalize_spans(mut spans: Vec, total_lines: u32) -> Vec { - if total_lines == 0 { - return Vec::new(); - } - spans.retain(|span| span.start <= span.end && span.start <= total_lines); - for span in &mut spans { - span.end = span.end.min(total_lines); - } - spans.sort_by_key(|span| (span.start, span.end)); - let mut merged: Vec = Vec::new(); - for span in spans { - if let Some(last) = merged.last_mut() - && span.start <= last.end.saturating_add(1) - { - last.end = last.end.max(span.end); - continue; - } - merged.push(span); - } - merged -} - -fn build_segments(source: &str, total_lines: u32, spans: &[LineSpan]) -> Vec { - if total_lines == 0 { - return Vec::new(); - } - let source_lines: Vec<&str> = source.lines().collect(); - let elided_lines = spans - .iter() - .flat_map(|span| span.start..=span.end) - .collect::>(); - let mut segments = Vec::new(); - let mut current_kind: Option<&str> = None; - let mut current_start = 1; - let mut current_lines: Vec<&str> = Vec::new(); - - for line_number in 1..=total_lines { - let is_elided = elided_lines.contains(&line_number); - let kind = if is_elided { "elided" } else { "kept" }; - if current_kind.is_some_and(|existing| existing != kind) { - push_segment( - &mut segments, - current_kind.expect("kind set"), - current_start, - line_number - 1, - ¤t_lines, - ); - current_start = line_number; - current_lines.clear(); - } - current_kind = Some(kind); - if !is_elided { - let index = line_number.saturating_sub(1) as usize; - current_lines.push(source_lines.get(index).copied().unwrap_or_default()); - } - } - - if let Some(kind) = current_kind { - push_segment(&mut segments, kind, current_start, total_lines, ¤t_lines); - } - segments -} - -fn push_segment( - segments: &mut Vec, - kind: &str, - start_line: u32, - end_line: u32, - lines: &[&str], -) { - segments.push(SummarySegment { - kind: kind.to_string(), - start_line, - end_line, - text: (kind == "kept").then(|| lines.join("\n")), - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn summarize(code: &str, path: &str) -> SummaryResult { - summarize_code(SummaryOptions { - code: code.to_string(), - lang: None, - path: Some(path.to_string()), - min_body_lines: None, - min_comment_lines: None, - }) - .expect("summary succeeds") - } - - fn segment_kinds(result: &SummaryResult) -> Vec<&str> { - result - .segments - .iter() - .map(|segment| segment.kind.as_str()) - .collect() - } - - #[test] - fn summarizes_typescript_function_body() { - let result = summarize( - "export function greet(name: string): string {\n\tconst clean = name.trim();\n\tconst \ - label = clean || 'world';\n\treturn `hello ${label}`;\n}\n", - "fixture.ts", - ); - - assert!(result.parsed); - assert!(result.elided); - assert_eq!(result.language.as_deref(), Some("typescript")); - assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); - assert_eq!( - result.segments[0].text.as_deref(), - Some("export function greet(name: string): string {") - ); - assert_eq!(result.segments[1].start_line, 2); - assert_eq!(result.segments[1].end_line, 4); - assert_eq!(result.segments[2].text.as_deref(), Some("}")); - } - - #[test] - fn summarizes_rust_method_body_but_keeps_impl_boundaries() { - let result = summarize( - "struct Greeter;\n\nimpl Greeter {\n\tfn greet(&self) -> String {\n\t\tlet name = \ - \"world\";\n\t\tlet label = name.to_uppercase();\n\t\tformat!(\"hello \ - {label}\")\n\t}\n}\n", - "fixture.rs", - ); - - assert!(result.parsed); - assert!(result.elided); - let rendered = result - .segments - .iter() - .map(|segment| segment.text.clone().unwrap_or_else(|| "...".to_string())) - .collect::>() - .join("\n"); - assert!(rendered.contains("impl Greeter {\n...\n}")); - } - - #[test] - fn summarizes_python_function_body() { - let result = - summarize( - "class Greeter:\n def greet(self, name: str) -> str:\n clean = \ - name.strip()\n label = clean or 'world'\n return f'hello {label}'\n", - "fixture.py", - ); - - assert!(result.parsed); - assert!(result.elided); - assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); - assert!( - result.segments[0] - .text - .as_deref() - .unwrap_or_default() - .contains("def greet") - ); - assert!( - result.segments[2] - .text - .as_deref() - .unwrap_or_default() - .contains("return") - ); - } - - #[test] - fn min_body_lines_controls_short_body_elision() { - let code = "function small() {\n\treturn 1;\n}\n"; - let default_result = summarize(code, "fixture.ts"); - assert!(default_result.parsed); - assert!(!default_result.elided); - - let override_result = summarize_code(SummaryOptions { - code: code.to_string(), - lang: Some("typescript".to_string()), - path: None, - min_body_lines: Some(3), - min_comment_lines: None, - }) - .expect("summary succeeds"); - assert!(override_result.elided); - } - - #[test] - fn parse_failure_falls_back_to_unparsed() { - let result = summarize("export function broken( {\n", "fixture.ts"); - assert!(!result.parsed); - assert!(!result.elided); - assert_eq!(result.segments.len(), 1); - } - - #[test] - fn unsupported_language_is_unparsed() { - let result = summarize("plain text\nwith lines\n", "fixture.txt"); - assert!(!result.parsed); - assert_eq!(result.segments[0].text.as_deref(), Some("plain text\nwith lines\n")); - } - - #[test] - fn summarizes_typescript_interface_body() { - let result = summarize( - "export interface Args {\n\tcwd?: string;\n\tprovider?: string;\n\tmodel?: \ - string;\n\tapiKey?: string;\n}\n", - "fixture.ts", - ); - - assert!(result.parsed); - assert!(result.elided); - assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); - assert_eq!(result.segments[0].text.as_deref(), Some("export interface Args {")); - assert_eq!(result.segments[2].text.as_deref(), Some("}")); - } - - #[test] - fn summarizes_typescript_class_body() { - let result = summarize( - "export class Greeter {\n\tname: string = \"world\";\n\tlength(): number { return \ - this.name.length; }\n\tgreet(): string { return this.name; }\n\tshout(): string { \ - return this.name.toUpperCase(); }\n}\n", - "fixture.ts", - ); - - assert!(result.parsed); - assert!(result.elided); - assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); - assert!( - result.segments[0] - .text - .as_deref() - .unwrap_or_default() - .contains("class Greeter") - ); - assert_eq!(result.segments[2].text.as_deref(), Some("}")); - } - - #[test] - fn summarizes_rust_trait_declaration_list() { - let result = summarize( - "pub trait Greeter {\n\tfn greet(&self) -> String;\n\tfn length(&self) -> usize;\n\tfn \ - shout(&self) -> String;\n\tfn whisper(&self) -> String;\n}\n", - "fixture.rs", - ); - - assert!(result.parsed); - assert!(result.elided); - assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); - assert_eq!(result.segments[0].text.as_deref(), Some("pub trait Greeter {")); - assert_eq!(result.segments[2].text.as_deref(), Some("}")); - } - - #[test] - fn summarizes_java_class_body() { - let result = summarize( - "public class Greeter {\n\tprivate String name;\n\tpublic Greeter(String n) { this.name \ - = n; }\n\tpublic String greet() { return name; }\n\tpublic int length() { return \ - name.length(); }\n}\n", - "fixture.java", - ); - - assert!(result.parsed); - assert!(result.elided); - assert_eq!(segment_kinds(&result), vec!["kept", "elided", "kept"]); - assert!( - result.segments[0] - .text - .as_deref() - .unwrap_or_default() - .contains("class Greeter") - ); - assert_eq!(result.segments[2].text.as_deref(), Some("}")); - } - - #[test] - fn summarizes_typescript_import_run() { - let code = "import a from \"a\";\nimport b from \"b\";\nimport c from \"c\";\nimport d from \ - \"d\";\nimport e from \"e\";\nimport f from \"f\";\n\nexport function main() \ - {}\n"; - let result = summarize(code, "fixture.ts"); - - assert!(result.parsed); - assert!(result.elided); - // Lines 2-5 are between the first and last imports and must be elided. - let elided = result - .segments - .iter() - .find(|seg| seg.kind == "elided") - .expect("elided segment"); - assert_eq!(elided.start_line, 2); - assert_eq!(elided.end_line, 5); - // First import line is kept. - assert!( - result.segments[0] - .text - .as_deref() - .unwrap_or_default() - .starts_with("import a from") - ); - } - - #[test] - fn does_not_elide_short_typescript_import_run() { - // 3 imports → total span 3 lines, below default min_body_lines (4). - let result = summarize( - "import a from \"a\";\nimport b from \"b\";\nimport c from \"c\";\n", - "fixture.ts", - ); - assert!(result.parsed); - assert!(!result.elided); - } - - #[test] - fn summarizes_python_import_run() { - let code = "import os\nimport sys\nfrom typing import List\nfrom pathlib import \ - Path\nimport json\nimport re\n\nprint('go')\n"; - let result = summarize(code, "fixture.py"); - - assert!(result.parsed); - assert!(result.elided); - let elided = result - .segments - .iter() - .find(|seg| seg.kind == "elided") - .expect("elided segment"); - assert_eq!(elided.start_line, 2); - assert_eq!(elided.end_line, 5); - } - - #[test] - fn summarizes_c_preproc_include_run() { - // C grammar puts each #include's `end_position` at column 0 of the next - // row (the trailing `\n`). Without `node_content_end_line`, the run - // elision would emit a span that starts past the second include and - // only collapse the third — verify the boundary statements stay - // visible and the middle is collapsed. - let code = "#include \n#include \"a.h\"\n#include \"b.h\"\n#include \ - \"c.h\"\n#include \nint main(void) { return 0; }\n"; - let result = summarize(code, "fixture.c"); - - assert!(result.parsed); - assert!(result.elided); - let elided = result - .segments - .iter() - .find(|seg| seg.kind == "elided") - .expect("elided segment"); - assert_eq!(elided.start_line, 2); - assert_eq!(elided.end_line, 4); - } - - #[test] - fn summarizes_rust_use_run() { - let code = "use std::fs;\nuse std::path::Path;\nuse std::collections::HashMap;\nuse \ - std::sync::Arc;\nuse std::io;\n\nfn main() {}\n"; - let result = summarize(code, "fixture.rs"); - - assert!(result.parsed); - assert!(result.elided); - let elided = result - .segments - .iter() - .find(|seg| seg.kind == "elided") - .expect("elided segment"); - assert_eq!(elided.start_line, 2); - assert_eq!(elided.end_line, 4); - } + .map(Into::into) + .map_err(|error| Error::from_reason(error.to_string())) } diff --git a/crates/pi-natives/src/task.rs b/crates/pi-natives/src/task.rs index 62619f25f..3f75edd6e 100644 --- a/crates/pi-natives/src/task.rs +++ b/crates/pi-natives/src/task.rs @@ -27,17 +27,10 @@ //! } //! ``` -use std::{ - future::Future, - sync::{ - Arc, Weak, - atomic::{AtomicU8, Ordering}, - }, - time::{Duration, Instant}, -}; +use std::future::Future; use napi::{Env, Error, Result, Task, bindgen_prelude::*}; -use tokio::sync::Notify; +use pi_shell::cancel as core_cancel; use crate::prof::profile_region; @@ -47,55 +40,31 @@ use crate::prof::profile_region; /// Reason for task abortion. #[derive(Debug, Clone, Copy)] -#[repr(u8)] pub enum AbortReason { - Unknown = 1, - Timeout = 2, - Signal = 3, - User = 4, + Unknown, + Timeout, + Signal, + User, } -impl TryFrom for AbortReason { - type Error = (); - - fn try_from(value: u8) -> std::result::Result { +impl From for AbortReason { + fn from(value: core_cancel::AbortReason) -> Self { match value { - 0 => Err(()), - 2 => Ok(Self::Timeout), - 3 => Ok(Self::Signal), - 4 => Ok(Self::User), - _ => Ok(Self::Unknown), + core_cancel::AbortReason::Unknown => Self::Unknown, + core_cancel::AbortReason::Timeout => Self::Timeout, + core_cancel::AbortReason::Signal => Self::Signal, + core_cancel::AbortReason::User => Self::User, } } } -#[derive(Default)] -struct Flag { - reason: AtomicU8, - notifier: Notify, -} - -impl Flag { - fn cause(&self) -> Option { - self.reason.load(Ordering::Relaxed).try_into().ok() - } - - async fn wait(&self) -> AbortReason { - if let Some(reason) = self.cause() { - return reason; - } - let notifier = self.notifier.notified(); - if let Some(reason) = self.cause() { - return reason; - } - notifier.await; - self.cause().unwrap_or(AbortReason::Unknown) - } - - fn abort(&self, reason: AbortReason) { - let old = self.reason.swap(reason as u8, Ordering::SeqCst); - if old == 0 { - self.notifier.notify_waiters(); +impl From for core_cancel::AbortReason { + fn from(value: AbortReason) -> Self { + match value { + AbortReason::Unknown => Self::Unknown, + AbortReason::Timeout => Self::Timeout, + AbortReason::Signal => Self::Signal, + AbortReason::User => Self::User, } } } @@ -106,8 +75,7 @@ impl Flag { /// cancellation requests from timeouts or abort signals. #[derive(Clone, Default)] pub struct CancelToken { - deadline: Option, - flag: Option>, + core: core_cancel::CancelToken, } impl From<()> for CancelToken { @@ -119,21 +87,10 @@ impl From<()> for CancelToken { impl CancelToken { /// Create a new cancel token from optional timeout and abort signal. pub fn new(timeout_ms: Option, signal: Option) -> Self { - let mut result = Self::default(); - if let Some(signal) = signal.and_then(|s| AbortSignal::from_unknown(s).ok()) { - let flag = Arc::new(Flag::default()); - signal.on_abort({ - let weak = Arc::downgrade(&flag); - move || { - if let Some(flag) = weak.upgrade() { - flag.abort(AbortReason::Signal); - } - } - }); - result.flag = Some(flag); - } - if let Some(timeout_ms) = timeout_ms { - result.deadline = Some(Instant::now() + Duration::from_millis(timeout_ms as u64)); + let mut result = Self { core: core_cancel::CancelToken::new(timeout_ms) }; + if let Some(signal) = signal.and_then(|value| AbortSignal::from_unknown(value).ok()) { + let abort_token = result.emplace_abort_token(); + signal.on_abort(move || abort_token.abort(AbortReason::Signal)); } result } @@ -143,92 +100,45 @@ impl CancelToken { /// Returns `Ok(())` if work should continue, or an error if cancelled. /// Call this periodically in long-running loops. pub fn heartbeat(&self) -> Result<()> { - if let Some(flag) = &self.flag - && let Some(reason) = flag.cause() - { - return Err(Error::from_reason(format!("Aborted: {reason:?}"))); - } - if let Some(deadline) = self.deadline - && deadline < Instant::now() - { - return Err(Error::from_reason("Aborted: Timeout")); - } - Ok(()) + self + .core + .heartbeat() + .map_err(|err| Error::from_reason(err.to_string())) } /// Wait for the cancel token to be aborted. pub async fn wait(&self) -> AbortReason { - let flag = self.flag.as_ref(); - if let Some(flag) = flag.and_then(|f| f.cause()) { - return flag; - } - let fflag = async { - let Some(flag) = self.flag.as_ref() else { - return std::future::pending().await; - }; - flag.wait().await - }; - - let fttl = async { - let Some(ttl) = self.deadline else { - return std::future::pending().await; - }; - tokio::time::sleep_until(ttl.into()).await; - AbortReason::Timeout - }; - - let fuser = async { - if tokio::signal::ctrl_c().await.is_err() { - return std::future::pending().await; - } - AbortReason::User - }; - - tokio::select! { - reason = fflag => reason, - reason = fttl => reason, - reason = fuser => reason, - } + self.core.wait().await.into() } /// Get an abort token for external cancellation. pub fn abort_token(&self) -> AbortToken { - AbortToken(self.flag.as_ref().map(Arc::downgrade)) + AbortToken(self.core.abort_token()) } /// Emplaces a cancel token if there is none, returns the abort token. pub fn emplace_abort_token(&mut self) -> AbortToken { - AbortToken(Some(Arc::downgrade(self.flag.get_or_insert_default()))) + AbortToken(self.core.emplace_abort_token()) } /// Check if already aborted (non-blocking). pub fn aborted(&self) -> bool { - if let Some(flag) = &self.flag - && flag.cause().is_some() - { - return true; - } - if let Some(deadline) = self.deadline - && deadline < Instant::now() - { - return true; - } - false + self.core.aborted() + } + + pub fn into_core(self) -> core_cancel::CancelToken { + self.core } } /// Token for requesting cancellation from outside the task. #[derive(Clone, Default)] -pub struct AbortToken(Option>); +pub struct AbortToken(core_cancel::AbortToken); impl AbortToken { /// Request cancellation of the associated task. pub fn abort(&self, reason: AbortReason) { - if let Some(flag) = &self.0 - && let Some(flag) = flag.upgrade() - { - flag.abort(reason); - } + self.0.abort(reason.into()); } } diff --git a/crates/pi-shell/Cargo.toml b/crates/pi-shell/Cargo.toml new file mode 100644 index 000000000..053033f17 --- /dev/null +++ b/crates/pi-shell/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "pi-shell" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["full"] } +brush-core = { version = "0.5.0", path = "../brush-core-vendored" } +brush-builtins = { version = "0.2.0", path = "../brush-builtins-vendored" } +brush-parser = "0.3" +clap = { version = "4", features = ["derive"] } +os_pipe = "1" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +toml = "1.1" +regex = "1" +xxhash-rust = { version = "0.8", features = ["xxh64"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +winreg = "0.56" +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_ProjectedFileSystem", + "Win32_System_Com", + "Win32_System_LibraryLoader", +] } diff --git a/crates/pi-shell/build.rs b/crates/pi-shell/build.rs new file mode 100644 index 000000000..397e1a54c --- /dev/null +++ b/crates/pi-shell/build.rs @@ -0,0 +1,62 @@ +use std::{ + env, + fmt::Write as _, + fs, + path::{Path, PathBuf}, +}; + +fn main() { + generate_minimizer_builtin_filters(); +} + +fn generate_minimizer_builtin_filters() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR should be set"); + let defs_dir = Path::new(&manifest_dir) + .join("src") + .join("minimizer") + .join("defs"); + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR should be set")); + let output_path = out_dir.join("builtin_filters.toml"); + + println!("cargo:rerun-if-changed={}", defs_dir.display()); + + let mut concatenated = + String::from("# Auto-generated by build.rs -- do not edit.\nschema_version = 1\n\n"); + + let mut entries: Vec = Vec::new(); + if let Ok(read_dir) = fs::read_dir(&defs_dir) { + for entry in read_dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) == Some("toml") { + entries.push(path); + } + } + } + entries.sort(); + + for path in entries { + println!("cargo:rerun-if-changed={}", path.display()); + match fs::read_to_string(&path) { + Ok(body) => { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown"); + writeln!(concatenated, "# --- {filename} ---").expect("write to String"); + for line in body.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("schema_version") { + continue; + } + concatenated.push_str(line); + concatenated.push('\n'); + } + concatenated.push('\n'); + }, + Err(error) => panic!("failed to read filter definition {}: {error}", path.display()), + } + } + + fs::write(&output_path, concatenated) + .unwrap_or_else(|error| panic!("failed to write {}: {error}", output_path.display())); +} diff --git a/crates/pi-shell/src/cancel.rs b/crates/pi-shell/src/cancel.rs new file mode 100644 index 000000000..b27c05212 --- /dev/null +++ b/crates/pi-shell/src/cancel.rs @@ -0,0 +1,161 @@ +use std::{ + sync::{ + Arc, Weak, + atomic::{AtomicU8, Ordering}, + }, + time::{Duration, Instant}, +}; + +use anyhow::{Error, Result}; +use tokio::sync::Notify; + +#[derive(Debug, Clone, Copy)] +#[repr(u8)] +pub enum AbortReason { + Unknown = 1, + Timeout = 2, + Signal = 3, + User = 4, +} + +impl TryFrom for AbortReason { + type Error = (); + + fn try_from(value: u8) -> std::result::Result { + match value { + 0 => Err(()), + 2 => Ok(Self::Timeout), + 3 => Ok(Self::Signal), + 4 => Ok(Self::User), + _ => Ok(Self::Unknown), + } + } +} + +#[derive(Default)] +struct Flag { + reason: AtomicU8, + notifier: Notify, +} + +impl Flag { + fn cause(&self) -> Option { + self.reason.load(Ordering::Relaxed).try_into().ok() + } + + async fn wait(&self) -> AbortReason { + if let Some(reason) = self.cause() { + return reason; + } + let notifier = self.notifier.notified(); + if let Some(reason) = self.cause() { + return reason; + } + notifier.await; + self.cause().unwrap_or(AbortReason::Unknown) + } + + fn abort(&self, reason: AbortReason) { + let old = self.reason.swap(reason as u8, Ordering::SeqCst); + if old == 0 { + self.notifier.notify_waiters(); + } + } +} + +#[derive(Clone, Default)] +pub struct CancelToken { + deadline: Option, + flag: Option>, +} + +impl From<()> for CancelToken { + fn from((): ()) -> Self { + Self::default() + } +} + +impl CancelToken { + pub fn new(timeout_ms: Option) -> Self { + Self::with_timeout(timeout_ms.map(|ms| Duration::from_millis(u64::from(ms)))) + } + + pub fn with_timeout(timeout: Option) -> Self { + Self { deadline: timeout.map(|duration| Instant::now() + duration), flag: None } + } + + pub fn heartbeat(&self) -> Result<()> { + if let Some(flag) = &self.flag + && let Some(reason) = flag.cause() + { + return Err(Error::msg(format!("Aborted: {reason:?}"))); + } + if let Some(deadline) = self.deadline + && deadline < Instant::now() + { + return Err(Error::msg("Aborted: Timeout")); + } + Ok(()) + } + + pub async fn wait(&self) -> AbortReason { + if let Some(flag) = self.flag.as_ref().and_then(|flag| flag.cause()) { + return flag; + } + + let by_flag = async { + let Some(flag) = self.flag.as_ref() else { + return std::future::pending().await; + }; + flag.wait().await + }; + + let by_timeout = async { + let Some(deadline) = self.deadline else { + return std::future::pending().await; + }; + tokio::time::sleep_until(deadline.into()).await; + AbortReason::Timeout + }; + + tokio::select! { + reason = by_flag => reason, + reason = by_timeout => reason, + } + } + + pub fn abort_token(&self) -> AbortToken { + AbortToken(self.flag.as_ref().map(Arc::downgrade)) + } + + pub fn emplace_abort_token(&mut self) -> AbortToken { + AbortToken(Some(Arc::downgrade(self.flag.get_or_insert_default()))) + } + + pub fn aborted(&self) -> bool { + if let Some(flag) = &self.flag + && flag.cause().is_some() + { + return true; + } + if let Some(deadline) = self.deadline + && deadline < Instant::now() + { + return true; + } + false + } +} + +#[derive(Clone, Default)] +pub struct AbortToken(Option>); + +impl AbortToken { + pub fn abort(&self, reason: AbortReason) { + if let Some(flag) = &self.0 + && let Some(flag) = flag.upgrade() + { + flag.abort(reason); + } + } +} diff --git a/crates/pi-shell/src/lib.rs b/crates/pi-shell/src/lib.rs new file mode 100644 index 000000000..e1571845d --- /dev/null +++ b/crates/pi-shell/src/lib.rs @@ -0,0 +1,12 @@ +pub mod cancel; +pub mod minimizer; +pub mod process; +pub mod shell; +#[cfg(windows)] +pub mod windows; + +pub use brush_core::commands::{ChildSessionAction, child_session_action}; +pub use shell::{ + MinimizerResult, Shell, ShellExecuteOptions, ShellExecuteResult, ShellOptions, ShellRunOptions, + ShellRunResult, execute_shell, +}; diff --git a/crates/pi-natives/src/shell/minimizer.rs b/crates/pi-shell/src/minimizer.rs similarity index 100% rename from crates/pi-natives/src/shell/minimizer.rs rename to crates/pi-shell/src/minimizer.rs diff --git a/crates/pi-natives/src/shell/minimizer/config.rs b/crates/pi-shell/src/minimizer/config.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/config.rs rename to crates/pi-shell/src/minimizer/config.rs index a52b3d4de..e19bc7736 100644 --- a/crates/pi-natives/src/shell/minimizer/config.rs +++ b/crates/pi-shell/src/minimizer/config.rs @@ -12,15 +12,13 @@ use std::{ sync::Arc, }; -use napi_derive::napi; use serde::Deserialize; -use crate::shell::minimizer::pipeline::{self, PipelineRegistry, SUPPORTED_SCHEMA_VERSION}; +use crate::minimizer::pipeline::{self, PipelineRegistry, SUPPORTED_SCHEMA_VERSION}; const DEFAULT_MAX_CAPTURE_BYTES: u32 = 4 * 1024 * 1024; /// N-API opt-in handle for the minimizer. -#[napi(object)] #[derive(Debug, Clone, Default)] pub struct MinimizerOptions { /// Master switch. Absent / false = disabled. diff --git a/crates/pi-natives/src/shell/minimizer/defs/ansible-playbook.toml b/crates/pi-shell/src/minimizer/defs/ansible-playbook.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/ansible-playbook.toml rename to crates/pi-shell/src/minimizer/defs/ansible-playbook.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/ansible.toml b/crates/pi-shell/src/minimizer/defs/ansible.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/ansible.toml rename to crates/pi-shell/src/minimizer/defs/ansible.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/basedpyright.toml b/crates/pi-shell/src/minimizer/defs/basedpyright.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/basedpyright.toml rename to crates/pi-shell/src/minimizer/defs/basedpyright.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/biome.toml b/crates/pi-shell/src/minimizer/defs/biome.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/biome.toml rename to crates/pi-shell/src/minimizer/defs/biome.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/brew-install.toml b/crates/pi-shell/src/minimizer/defs/brew-install.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/brew-install.toml rename to crates/pi-shell/src/minimizer/defs/brew-install.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/bundle-install.toml b/crates/pi-shell/src/minimizer/defs/bundle-install.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/bundle-install.toml rename to crates/pi-shell/src/minimizer/defs/bundle-install.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/composer-install.toml b/crates/pi-shell/src/minimizer/defs/composer-install.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/composer-install.toml rename to crates/pi-shell/src/minimizer/defs/composer-install.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/df.toml b/crates/pi-shell/src/minimizer/defs/df.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/df.toml rename to crates/pi-shell/src/minimizer/defs/df.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/dotnet-build.toml b/crates/pi-shell/src/minimizer/defs/dotnet-build.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/dotnet-build.toml rename to crates/pi-shell/src/minimizer/defs/dotnet-build.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/du.toml b/crates/pi-shell/src/minimizer/defs/du.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/du.toml rename to crates/pi-shell/src/minimizer/defs/du.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/fail2ban-client.toml b/crates/pi-shell/src/minimizer/defs/fail2ban-client.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/fail2ban-client.toml rename to crates/pi-shell/src/minimizer/defs/fail2ban-client.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/fail2ban.toml b/crates/pi-shell/src/minimizer/defs/fail2ban.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/fail2ban.toml rename to crates/pi-shell/src/minimizer/defs/fail2ban.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/gcc.toml b/crates/pi-shell/src/minimizer/defs/gcc.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/gcc.toml rename to crates/pi-shell/src/minimizer/defs/gcc.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/gcloud.toml b/crates/pi-shell/src/minimizer/defs/gcloud.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/gcloud.toml rename to crates/pi-shell/src/minimizer/defs/gcloud.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/gradle.toml b/crates/pi-shell/src/minimizer/defs/gradle.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/gradle.toml rename to crates/pi-shell/src/minimizer/defs/gradle.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/hadolint.toml b/crates/pi-shell/src/minimizer/defs/hadolint.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/hadolint.toml rename to crates/pi-shell/src/minimizer/defs/hadolint.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/helm.toml b/crates/pi-shell/src/minimizer/defs/helm.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/helm.toml rename to crates/pi-shell/src/minimizer/defs/helm.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/iptables.toml b/crates/pi-shell/src/minimizer/defs/iptables.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/iptables.toml rename to crates/pi-shell/src/minimizer/defs/iptables.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/jira.toml b/crates/pi-shell/src/minimizer/defs/jira.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/jira.toml rename to crates/pi-shell/src/minimizer/defs/jira.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/jj.toml b/crates/pi-shell/src/minimizer/defs/jj.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/jj.toml rename to crates/pi-shell/src/minimizer/defs/jj.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/jq.toml b/crates/pi-shell/src/minimizer/defs/jq.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/jq.toml rename to crates/pi-shell/src/minimizer/defs/jq.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/just.toml b/crates/pi-shell/src/minimizer/defs/just.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/just.toml rename to crates/pi-shell/src/minimizer/defs/just.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/liquibase.toml b/crates/pi-shell/src/minimizer/defs/liquibase.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/liquibase.toml rename to crates/pi-shell/src/minimizer/defs/liquibase.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/make.toml b/crates/pi-shell/src/minimizer/defs/make.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/make.toml rename to crates/pi-shell/src/minimizer/defs/make.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/markdownlint.toml b/crates/pi-shell/src/minimizer/defs/markdownlint.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/markdownlint.toml rename to crates/pi-shell/src/minimizer/defs/markdownlint.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/maven.toml b/crates/pi-shell/src/minimizer/defs/maven.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/maven.toml rename to crates/pi-shell/src/minimizer/defs/maven.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/mise.toml b/crates/pi-shell/src/minimizer/defs/mise.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/mise.toml rename to crates/pi-shell/src/minimizer/defs/mise.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/mix-compile.toml b/crates/pi-shell/src/minimizer/defs/mix-compile.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/mix-compile.toml rename to crates/pi-shell/src/minimizer/defs/mix-compile.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/mix-format.toml b/crates/pi-shell/src/minimizer/defs/mix-format.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/mix-format.toml rename to crates/pi-shell/src/minimizer/defs/mix-format.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/mix.toml b/crates/pi-shell/src/minimizer/defs/mix.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/mix.toml rename to crates/pi-shell/src/minimizer/defs/mix.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/mvn-build.toml b/crates/pi-shell/src/minimizer/defs/mvn-build.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/mvn-build.toml rename to crates/pi-shell/src/minimizer/defs/mvn-build.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/nx.toml b/crates/pi-shell/src/minimizer/defs/nx.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/nx.toml rename to crates/pi-shell/src/minimizer/defs/nx.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/ollama.toml b/crates/pi-shell/src/minimizer/defs/ollama.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/ollama.toml rename to crates/pi-shell/src/minimizer/defs/ollama.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/oxlint.toml b/crates/pi-shell/src/minimizer/defs/oxlint.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/oxlint.toml rename to crates/pi-shell/src/minimizer/defs/oxlint.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/ping.toml b/crates/pi-shell/src/minimizer/defs/ping.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/ping.toml rename to crates/pi-shell/src/minimizer/defs/ping.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/pio-run.toml b/crates/pi-shell/src/minimizer/defs/pio-run.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/pio-run.toml rename to crates/pi-shell/src/minimizer/defs/pio-run.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/pio.toml b/crates/pi-shell/src/minimizer/defs/pio.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/pio.toml rename to crates/pi-shell/src/minimizer/defs/pio.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/poetry-install.toml b/crates/pi-shell/src/minimizer/defs/poetry-install.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/poetry-install.toml rename to crates/pi-shell/src/minimizer/defs/poetry-install.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/pre-commit.toml b/crates/pi-shell/src/minimizer/defs/pre-commit.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/pre-commit.toml rename to crates/pi-shell/src/minimizer/defs/pre-commit.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/ps.toml b/crates/pi-shell/src/minimizer/defs/ps.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/ps.toml rename to crates/pi-shell/src/minimizer/defs/ps.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/quarto-render.toml b/crates/pi-shell/src/minimizer/defs/quarto-render.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/quarto-render.toml rename to crates/pi-shell/src/minimizer/defs/quarto-render.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/quarto.toml b/crates/pi-shell/src/minimizer/defs/quarto.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/quarto.toml rename to crates/pi-shell/src/minimizer/defs/quarto.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/rsync.toml b/crates/pi-shell/src/minimizer/defs/rsync.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/rsync.toml rename to crates/pi-shell/src/minimizer/defs/rsync.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/shellcheck.toml b/crates/pi-shell/src/minimizer/defs/shellcheck.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/shellcheck.toml rename to crates/pi-shell/src/minimizer/defs/shellcheck.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/shopify-theme.toml b/crates/pi-shell/src/minimizer/defs/shopify-theme.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/shopify-theme.toml rename to crates/pi-shell/src/minimizer/defs/shopify-theme.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/skopeo.toml b/crates/pi-shell/src/minimizer/defs/skopeo.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/skopeo.toml rename to crates/pi-shell/src/minimizer/defs/skopeo.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/sops.toml b/crates/pi-shell/src/minimizer/defs/sops.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/sops.toml rename to crates/pi-shell/src/minimizer/defs/sops.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/spring-boot.toml b/crates/pi-shell/src/minimizer/defs/spring-boot.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/spring-boot.toml rename to crates/pi-shell/src/minimizer/defs/spring-boot.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/ssh.toml b/crates/pi-shell/src/minimizer/defs/ssh.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/ssh.toml rename to crates/pi-shell/src/minimizer/defs/ssh.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/stat.toml b/crates/pi-shell/src/minimizer/defs/stat.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/stat.toml rename to crates/pi-shell/src/minimizer/defs/stat.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/swift-build.toml b/crates/pi-shell/src/minimizer/defs/swift-build.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/swift-build.toml rename to crates/pi-shell/src/minimizer/defs/swift-build.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/systemctl-status.toml b/crates/pi-shell/src/minimizer/defs/systemctl-status.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/systemctl-status.toml rename to crates/pi-shell/src/minimizer/defs/systemctl-status.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/systemctl.toml b/crates/pi-shell/src/minimizer/defs/systemctl.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/systemctl.toml rename to crates/pi-shell/src/minimizer/defs/systemctl.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/task.toml b/crates/pi-shell/src/minimizer/defs/task.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/task.toml rename to crates/pi-shell/src/minimizer/defs/task.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/terraform-plan.toml b/crates/pi-shell/src/minimizer/defs/terraform-plan.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/terraform-plan.toml rename to crates/pi-shell/src/minimizer/defs/terraform-plan.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/terraform.toml b/crates/pi-shell/src/minimizer/defs/terraform.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/terraform.toml rename to crates/pi-shell/src/minimizer/defs/terraform.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-fmt.toml b/crates/pi-shell/src/minimizer/defs/tofu-fmt.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/tofu-fmt.toml rename to crates/pi-shell/src/minimizer/defs/tofu-fmt.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-init.toml b/crates/pi-shell/src/minimizer/defs/tofu-init.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/tofu-init.toml rename to crates/pi-shell/src/minimizer/defs/tofu-init.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-plan.toml b/crates/pi-shell/src/minimizer/defs/tofu-plan.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/tofu-plan.toml rename to crates/pi-shell/src/minimizer/defs/tofu-plan.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/tofu-validate.toml b/crates/pi-shell/src/minimizer/defs/tofu-validate.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/tofu-validate.toml rename to crates/pi-shell/src/minimizer/defs/tofu-validate.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/trunk-build.toml b/crates/pi-shell/src/minimizer/defs/trunk-build.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/trunk-build.toml rename to crates/pi-shell/src/minimizer/defs/trunk-build.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/trunk.toml b/crates/pi-shell/src/minimizer/defs/trunk.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/trunk.toml rename to crates/pi-shell/src/minimizer/defs/trunk.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/turbo.toml b/crates/pi-shell/src/minimizer/defs/turbo.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/turbo.toml rename to crates/pi-shell/src/minimizer/defs/turbo.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/ty.toml b/crates/pi-shell/src/minimizer/defs/ty.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/ty.toml rename to crates/pi-shell/src/minimizer/defs/ty.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/uv-sync.toml b/crates/pi-shell/src/minimizer/defs/uv-sync.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/uv-sync.toml rename to crates/pi-shell/src/minimizer/defs/uv-sync.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/xcodebuild.toml b/crates/pi-shell/src/minimizer/defs/xcodebuild.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/xcodebuild.toml rename to crates/pi-shell/src/minimizer/defs/xcodebuild.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/yadm.toml b/crates/pi-shell/src/minimizer/defs/yadm.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/yadm.toml rename to crates/pi-shell/src/minimizer/defs/yadm.toml diff --git a/crates/pi-natives/src/shell/minimizer/defs/yamllint.toml b/crates/pi-shell/src/minimizer/defs/yamllint.toml similarity index 100% rename from crates/pi-natives/src/shell/minimizer/defs/yamllint.toml rename to crates/pi-shell/src/minimizer/defs/yamllint.toml diff --git a/crates/pi-natives/src/shell/minimizer/detect.rs b/crates/pi-shell/src/minimizer/detect.rs similarity index 100% rename from crates/pi-natives/src/shell/minimizer/detect.rs rename to crates/pi-shell/src/minimizer/detect.rs diff --git a/crates/pi-natives/src/shell/minimizer/engine.rs b/crates/pi-shell/src/minimizer/engine.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/engine.rs rename to crates/pi-shell/src/minimizer/engine.rs index 49ae98195..0f5bd7f4d 100644 --- a/crates/pi-natives/src/shell/minimizer/engine.rs +++ b/crates/pi-shell/src/minimizer/engine.rs @@ -8,7 +8,7 @@ use std::{ }, }; -use crate::shell::minimizer::{ +use crate::minimizer::{ MinimizerConfig, MinimizerCtx, MinimizerOutput, detect, filters, pipeline::{self, CompiledPipeline, PipelineRegistry}, plan, @@ -420,7 +420,7 @@ mod tests { #[cfg(test)] mod pipeline_integration_tests { use super::*; - use crate::shell::minimizer::MinimizerOptions; + use crate::minimizer::MinimizerOptions; #[test] fn builtin_filters_parse_and_pass_inline_tests() { diff --git a/crates/pi-natives/src/shell/minimizer/filters/bun.rs b/crates/pi-shell/src/minimizer/filters/bun.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/bun.rs rename to crates/pi-shell/src/minimizer/filters/bun.rs index 342627011..e13bc98c8 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/bun.rs +++ b/crates/pi-shell/src/minimizer/filters/bun.rs @@ -1,7 +1,7 @@ //! Bun package-manager, test-runner, and tool output filters. use super::{cpp, generic, js_tools, lint, node_tests, pkg}; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; const BUN_PACKAGE_SUBCOMMANDS: &[&str] = &[ "install", "i", "add", "update", "up", "upgrade", "remove", "rm", "outdated", "pm", "audit", @@ -142,7 +142,7 @@ fn is_important(line: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn ctx<'a>( program: &'a str, diff --git a/crates/pi-natives/src/shell/minimizer/filters/cargo.rs b/crates/pi-shell/src/minimizer/filters/cargo.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/cargo.rs rename to crates/pi-shell/src/minimizer/filters/cargo.rs index 812690bff..b8df0bb8f 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/cargo.rs +++ b/crates/pi-shell/src/minimizer/filters/cargo.rs @@ -1,6 +1,6 @@ //! Cargo build/test output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(subcommand: Option<&str>) -> bool { matches!( @@ -293,7 +293,7 @@ fn is_general_cargo_noise(line: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; #[test] fn strips_compiling_noise() { diff --git a/crates/pi-natives/src/shell/minimizer/filters/cloud.rs b/crates/pi-shell/src/minimizer/filters/cloud.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/filters/cloud.rs rename to crates/pi-shell/src/minimizer/filters/cloud.rs index f1c2413c8..69b4289d5 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/cloud.rs +++ b/crates/pi-shell/src/minimizer/filters/cloud.rs @@ -1,6 +1,6 @@ //! Cloud and data command output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; const MAX_PSQL_ROWS: usize = 30; const MAX_LINE_CHARS: usize = 500; @@ -401,7 +401,7 @@ fn join_lines(lines: Vec) -> String { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn ctx<'a>(program: &'a str, cfg: &'a MinimizerConfig) -> MinimizerCtx<'a> { MinimizerCtx { program, subcommand: None, command: program, config: cfg } diff --git a/crates/pi-natives/src/shell/minimizer/filters/cpp.rs b/crates/pi-shell/src/minimizer/filters/cpp.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/cpp.rs rename to crates/pi-shell/src/minimizer/filters/cpp.rs index e7a9cdef4..a9c0131af 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/cpp.rs +++ b/crates/pi-shell/src/minimizer/filters/cpp.rs @@ -2,7 +2,7 @@ use std::path::Path; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CppTool { @@ -249,7 +249,7 @@ fn push_line(out: &mut String, line: &str) { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn ctx<'a>( program: &'a str, diff --git a/crates/pi-natives/src/shell/minimizer/filters/docker.rs b/crates/pi-shell/src/minimizer/filters/docker.rs similarity index 97% rename from crates/pi-natives/src/shell/minimizer/filters/docker.rs rename to crates/pi-shell/src/minimizer/filters/docker.rs index d781746e3..7b6c50c4a 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/docker.rs +++ b/crates/pi-shell/src/minimizer/filters/docker.rs @@ -1,6 +1,6 @@ //! Container and cloud command output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(subcommand: Option<&str>) -> bool { matches!( @@ -168,7 +168,7 @@ fn head_tail_dedup(input: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; #[test] fn dedups_repeated_log_lines_before_truncation() { diff --git a/crates/pi-natives/src/shell/minimizer/filters/dotnet.rs b/crates/pi-shell/src/minimizer/filters/dotnet.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/dotnet.rs rename to crates/pi-shell/src/minimizer/filters/dotnet.rs index 4ef9fecc7..b49c3db60 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/dotnet.rs +++ b/crates/pi-shell/src/minimizer/filters/dotnet.rs @@ -1,6 +1,6 @@ //! .NET CLI output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(program: &str, subcommand: Option<&str>) -> bool { program == "dotnet" && matches!(subcommand, Some("build" | "test" | "restore" | "format")) @@ -326,7 +326,7 @@ fn contains_diagnostic_signal(lower: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; #[test] fn keeps_dotnet_build_diagnostic_and_strips_restore_noise() { diff --git a/crates/pi-natives/src/shell/minimizer/filters/generic.rs b/crates/pi-shell/src/minimizer/filters/generic.rs similarity index 86% rename from crates/pi-natives/src/shell/minimizer/filters/generic.rs rename to crates/pi-shell/src/minimizer/filters/generic.rs index 1ad46959e..19ecd7c1e 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/generic.rs +++ b/crates/pi-shell/src/minimizer/filters/generic.rs @@ -1,6 +1,6 @@ //! Generic fallback transforms. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn filter(_ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> MinimizerOutput { let stripped = primitives::strip_ansi(input); diff --git a/crates/pi-natives/src/shell/minimizer/filters/gh.rs b/crates/pi-shell/src/minimizer/filters/gh.rs similarity index 97% rename from crates/pi-natives/src/shell/minimizer/filters/gh.rs rename to crates/pi-shell/src/minimizer/filters/gh.rs index 5444b3f53..9a7fa19c2 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/gh.rs +++ b/crates/pi-shell/src/minimizer/filters/gh.rs @@ -1,6 +1,6 @@ //! GitHub CLI output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(subcommand: Option<&str>) -> bool { matches!( @@ -157,7 +157,7 @@ fn head_tail_dedup(input: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn test_ctx<'a>( subcommand: Option<&'a str>, diff --git a/crates/pi-natives/src/shell/minimizer/filters/git.rs b/crates/pi-shell/src/minimizer/filters/git.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/filters/git.rs rename to crates/pi-shell/src/minimizer/filters/git.rs index 145d3bfb7..e09a1693c 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/git.rs +++ b/crates/pi-shell/src/minimizer/filters/git.rs @@ -1,6 +1,6 @@ //! Git output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(subcommand: Option<&str>) -> bool { matches!( @@ -370,7 +370,7 @@ fn condense_noisy_output(input: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn test_ctx<'a>( subcommand: Option<&'a str>, diff --git a/crates/pi-natives/src/shell/minimizer/filters/go.rs b/crates/pi-shell/src/minimizer/filters/go.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/filters/go.rs rename to crates/pi-shell/src/minimizer/filters/go.rs index 57a3bd35b..2c4913f29 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/go.rs +++ b/crates/pi-shell/src/minimizer/filters/go.rs @@ -1,6 +1,6 @@ //! Go toolchain output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(program: &str, subcommand: Option<&str>) -> bool { match program { @@ -326,7 +326,7 @@ fn is_golangci_noise(line: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; #[test] fn keeps_go_test_failure_from_json_lines() { diff --git a/crates/pi-natives/src/shell/minimizer/filters/gt.rs b/crates/pi-shell/src/minimizer/filters/gt.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/gt.rs rename to crates/pi-shell/src/minimizer/filters/gt.rs index b8a85a74c..76b1f4da3 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/gt.rs +++ b/crates/pi-shell/src/minimizer/filters/gt.rs @@ -1,7 +1,7 @@ //! Graphite (`gt`) output filters. use super::git; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; const GT_SUBCOMMANDS: &[&str] = &[ "log", "submit", "sync", "restack", "create", "branch", "diff", "show", "add", "push", "pull", @@ -177,7 +177,7 @@ fn is_low_value_status(line: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn test_ctx<'a>(subcommand: Option<&'a str>, config: &'a MinimizerConfig) -> MinimizerCtx<'a> { test_ctx_with_command(subcommand, "gt", config) diff --git a/crates/pi-natives/src/shell/minimizer/filters/js_tools.rs b/crates/pi-shell/src/minimizer/filters/js_tools.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/filters/js_tools.rs rename to crates/pi-shell/src/minimizer/filters/js_tools.rs index 97c1b4c1a..9c7d56be5 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/js_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/js_tools.rs @@ -3,7 +3,7 @@ //! Covers command output that is not already handled by the package-manager, //! test-runner, or lint filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; const SUPPORTED_TOOLS: &[&str] = &["next", "prettier", "prisma"]; const NPX_ROUTABLE_TOOLS: &[&str] = &["tsc", "eslint", "prisma", "prettier", "next"]; @@ -402,7 +402,7 @@ fn push_line(out: &mut String, line: &str) { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn ctx<'a>( program: &'a str, diff --git a/crates/pi-natives/src/shell/minimizer/filters/lint.rs b/crates/pi-shell/src/minimizer/filters/lint.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/lint.rs rename to crates/pi-shell/src/minimizer/filters/lint.rs index d9111f306..10014ee18 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/lint.rs +++ b/crates/pi-shell/src/minimizer/filters/lint.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(subcommand: Option<&str>) -> bool { supports_program("", subcommand) diff --git a/crates/pi-natives/src/shell/minimizer/filters/listing.rs b/crates/pi-shell/src/minimizer/filters/listing.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/filters/listing.rs rename to crates/pi-shell/src/minimizer/filters/listing.rs index 226db4283..c98277c96 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/listing.rs +++ b/crates/pi-shell/src/minimizer/filters/listing.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, path::Path}; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { let cleaned = primitives::strip_ansi(input); @@ -724,7 +724,7 @@ fn has_content(text: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn ctx<'a>(program: &'a str, cfg: &'a MinimizerConfig) -> MinimizerCtx<'a> { MinimizerCtx { program, subcommand: None, command: program, config: cfg } diff --git a/crates/pi-natives/src/shell/minimizer/filters/mod.rs b/crates/pi-shell/src/minimizer/filters/mod.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/mod.rs rename to crates/pi-shell/src/minimizer/filters/mod.rs index 0e0d5eb6d..715ae5e1c 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/mod.rs +++ b/crates/pi-shell/src/minimizer/filters/mod.rs @@ -1,6 +1,6 @@ //! Filter dispatch table for built-in minimizer strategies. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput}; pub mod cloud; pub mod cpp; @@ -133,7 +133,7 @@ fn wrapper_invokes(ctx: &MinimizerCtx<'_>, tools: &[&str]) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn ctx<'a>( program: &'a str, diff --git a/crates/pi-natives/src/shell/minimizer/filters/node_tests.rs b/crates/pi-shell/src/minimizer/filters/node_tests.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/node_tests.rs rename to crates/pi-shell/src/minimizer/filters/node_tests.rs index 3bdaf6212..72c294f38 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/node_tests.rs +++ b/crates/pi-shell/src/minimizer/filters/node_tests.rs @@ -1,6 +1,6 @@ //! Jest, Vitest, and Playwright output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn filter(_ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { let cleaned = primitives::strip_ansi(input); diff --git a/crates/pi-natives/src/shell/minimizer/filters/pkg.rs b/crates/pi-shell/src/minimizer/filters/pkg.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/pkg.rs rename to crates/pi-shell/src/minimizer/filters/pkg.rs index 173355b76..505589f5a 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/pkg.rs +++ b/crates/pi-shell/src/minimizer/filters/pkg.rs @@ -1,6 +1,6 @@ //! Package manager output filters. -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(subcommand: Option<&str>) -> bool { matches!( diff --git a/crates/pi-natives/src/shell/minimizer/filters/python.rs b/crates/pi-shell/src/minimizer/filters/python.rs similarity index 98% rename from crates/pi-natives/src/shell/minimizer/filters/python.rs rename to crates/pi-shell/src/minimizer/filters/python.rs index b8ab0486a..2d16fef6a 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/python.rs +++ b/crates/pi-shell/src/minimizer/filters/python.rs @@ -1,7 +1,7 @@ //! Python test, type-check, and lint output filters. use super::lint; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(program: &str, subcommand: Option<&str>) -> bool { matches!(program, "pytest" | "ruff" | "mypy") @@ -243,7 +243,7 @@ fn has_content(text: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; #[test] fn supports_direct_and_python_module_tools() { diff --git a/crates/pi-natives/src/shell/minimizer/filters/ruby.rs b/crates/pi-shell/src/minimizer/filters/ruby.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/filters/ruby.rs rename to crates/pi-shell/src/minimizer/filters/ruby.rs index 5a0682a20..eb921e448 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/ruby.rs +++ b/crates/pi-shell/src/minimizer/filters/ruby.rs @@ -1,7 +1,7 @@ //! Ruby test and lint output filters. use super::lint; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(program: &str, subcommand: Option<&str>) -> bool { matches!(program, "rspec" | "rubocop") @@ -334,7 +334,7 @@ fn has_content(text: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; #[test] fn supports_rspec_minitest_and_rubocop() { diff --git a/crates/pi-natives/src/shell/minimizer/filters/system.rs b/crates/pi-shell/src/minimizer/filters/system.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/filters/system.rs rename to crates/pi-shell/src/minimizer/filters/system.rs index f43038e17..76a0bdfc8 100644 --- a/crates/pi-natives/src/shell/minimizer/filters/system.rs +++ b/crates/pi-shell/src/minimizer/filters/system.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; -use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; +use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; pub fn supports(program: &str) -> bool { matches!( @@ -596,7 +596,7 @@ fn compact_sops_output(input: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::shell::minimizer::MinimizerConfig; + use crate::minimizer::MinimizerConfig; fn ctx<'a>(program: &'a str, cfg: &'a MinimizerConfig) -> MinimizerCtx<'a> { MinimizerCtx { program, subcommand: None, command: program, config: cfg } diff --git a/crates/pi-natives/src/shell/minimizer/pipeline.rs b/crates/pi-shell/src/minimizer/pipeline.rs similarity index 99% rename from crates/pi-natives/src/shell/minimizer/pipeline.rs rename to crates/pi-shell/src/minimizer/pipeline.rs index 87d8f236d..7e1cf7fbd 100644 --- a/crates/pi-natives/src/shell/minimizer/pipeline.rs +++ b/crates/pi-shell/src/minimizer/pipeline.rs @@ -25,7 +25,7 @@ use std::borrow::Cow; use regex::{Regex, RegexSet}; use serde::Deserialize; -use crate::shell::minimizer::primitives; +use crate::minimizer::primitives; /// Raw TOML shape for a single filter definition. #[derive(Debug, Deserialize, Default)] diff --git a/crates/pi-natives/src/shell/minimizer/plan.rs b/crates/pi-shell/src/minimizer/plan.rs similarity index 100% rename from crates/pi-natives/src/shell/minimizer/plan.rs rename to crates/pi-shell/src/minimizer/plan.rs diff --git a/crates/pi-natives/src/shell/minimizer/primitives.rs b/crates/pi-shell/src/minimizer/primitives.rs similarity index 100% rename from crates/pi-natives/src/shell/minimizer/primitives.rs rename to crates/pi-shell/src/minimizer/primitives.rs diff --git a/crates/pi-shell/src/process.rs b/crates/pi-shell/src/process.rs new file mode 100644 index 000000000..01fe403d8 --- /dev/null +++ b/crates/pi-shell/src/process.rs @@ -0,0 +1,1499 @@ +//! Cross-platform process tree management. + +use std::{collections::HashSet, time::Duration}; + +use anyhow::Result; + +use crate::cancel::CancelToken; + +/// Current state of a process reference. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProcessStatus { + /// The referenced process is still running. + Running, + /// The referenced process has exited or is no longer observable. + Exited, +} + +#[cfg(target_os = "linux")] +mod platform { + use std::{ + collections::HashSet, + ffi::OsStr, + fs, + os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + ptr, + sync::Arc, + }; + + use super::ProcessStatus; + + /// Stable Linux process reference backed by a pidfd. + #[derive(Clone)] + pub struct Process { + pid: i32, + pidfd: Arc, + start_time: u64, + } + + impl Process { + pub fn from_pid(pid: i32) -> Option { + if pid <= 0 { + return None; + } + let pidfd = open_pidfd(pid)?; + let start_time = read_start_time(pid)?; + Some(Self { pid, pidfd, start_time }) + } + + pub const fn pid(&self) -> i32 { + self.pid + } + + pub fn children(&self) -> Vec { + if !self.live_identity() { + return Vec::new(); + } + + // `/proc/{pid}/task/{tid}/children` is per-task: a child fork()ed from a + // worker thread appears under that thread's `tid`, not the tgid. Walk + // every task subdir and union the lists, then re-validate parentage. + let task_dir = format!("/proc/{}/task", self.pid); + let Ok(entries) = fs::read_dir(&task_dir) else { + return Vec::new(); + }; + + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(tid_str) = name.to_str() else { + continue; + }; + if tid_str.parse::().is_err() { + continue; + } + let children_path = format!("/proc/{}/task/{}/children", self.pid, tid_str); + let Ok(content) = fs::read_to_string(&children_path) else { + continue; + }; + for part in content.split_whitespace() { + let Ok(child_pid) = part.parse::() else { + continue; + }; + if !seen.insert(child_pid) { + continue; + } + let Some(child) = Self::from_pid(child_pid) else { + continue; + }; + if child.status() == ProcessStatus::Running + && current_parent_pid(child.pid) == Some(self.pid) + { + out.push(child); + } + } + } + out + } + + pub fn parent_pid(&self) -> Option { + if self.status() == ProcessStatus::Running { + current_parent_pid(self.pid) + } else { + None + } + } + + pub fn args(&self) -> Vec { + if !self.live_identity() { + return Vec::new(); + } + + let cmdline_path = format!("/proc/{}/cmdline", self.pid); + let Ok(content) = fs::read(cmdline_path) else { + return Vec::new(); + }; + // Re-validate after the read: PID reuse between identity check and read + // would otherwise leak an impostor's command line to callers. + if !self.live_identity() { + return Vec::new(); + } + split_nul_arguments(&content) + } + + pub fn kill(&self, signal: i32) -> bool { + // SAFETY: `self.pidfd` is an owned file descriptor returned by a successful + // `pidfd_open` call and remains open for the duration of this syscall. A null + // `siginfo_t` pointer is explicitly accepted by `pidfd_send_signal` and makes + // the kernel synthesize the same signal metadata as `kill(2)`. Flags are zero, + // which is the documented default behavior. + let ret = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + self.pidfd.as_raw_fd(), + signal, + ptr::null::(), + 0, + ) + }; + ret == 0 + } + + pub fn group_id(&self) -> Option { + if self.status() != ProcessStatus::Running { + return None; + } + + // SAFETY: `self.pid` names the process currently referenced by `self.pidfd` + // unless it exits concurrently. If it exits, `getpgid` reports failure rather + // than dereferencing caller-owned memory. + let pgid = unsafe { libc::getpgid(self.pid) }; + if pgid > 0 { Some(pgid) } else { None } + } + + pub fn status(&self) -> ProcessStatus { + loop { + let mut pollfd = + libc::pollfd { fd: self.pidfd.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: `pollfd` points to one initialized `pollfd` element, and the pidfd + // remains open for the duration of the call. Timeout zero makes this a + // non-blocking readiness probe. + let ready = unsafe { libc::poll(&raw mut pollfd, 1, 0) }; + if ready < 0 { + // Retry on EINTR; for any other transient poll error treat the pidfd as + // still running. The pidfd is still owned and the kernel has not reported + // the process gone — a spurious `Exited` here makes every downstream + // signal/kill fall through silently. + if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { + continue; + } + return ProcessStatus::Running; + } + if ready == 0 { + return ProcessStatus::Running; + } + if (pollfd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR | libc::POLLNVAL)) + != 0 + { + return ProcessStatus::Exited; + } + return ProcessStatus::Running; + } + } + + /// Walk the descendant tree in post-order (leaves first), de-duplicating + /// by PID so concurrent reparenting cannot trap us in a cycle. + pub fn descendants(&self) -> Vec { + let mut out = Vec::new(); + let mut visited = HashSet::new(); + visited.insert(self.pid); + self.descendants_into(&mut out, &mut visited); + out + } + + fn descendants_into(&self, out: &mut Vec, visited: &mut HashSet) { + for child in self.children() { + if visited.insert(child.pid) { + child.descendants_into(out, visited); + out.push(child); + } + } + } + + fn live_identity(&self) -> bool { + self.status() == ProcessStatus::Running + && read_start_time(self.pid) == Some(self.start_time) + } + } + + fn split_nul_arguments(content: &[u8]) -> Vec { + content + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect() + } + + fn current_parent_pid(pid: i32) -> Option { + let status_path = format!("/proc/{pid}/status"); + let content = fs::read_to_string(status_path).ok()?; + content.lines().find_map(|line| { + line + .strip_prefix("PPid:") + .and_then(|ppid| ppid.trim().parse::().ok()) + }) + } + + fn read_start_time(pid: i32) -> Option { + // `/proc/[pid]/stat` field 22 is the process start time in clock ticks since + // boot. The comm field (between parens) may itself contain spaces and parens, + // so locate the *last* `)` and split the trailing whitespace-separated fields. + let stat_path = format!("/proc/{pid}/stat"); + let content = fs::read_to_string(stat_path).ok()?; + let last_paren = content.rfind(')')?; + let rest = &content[last_paren + 1..]; + rest.split_whitespace().nth(19)?.parse().ok() + } + + fn open_pidfd(pid: i32) -> Option> { + // SAFETY: `pidfd_open` takes the PID by value and does not read caller-owned + // memory. Flags are zero, which is valid. On success the returned descriptor is + // newly owned by this process and is immediately wrapped in `OwnedFd` below. + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + if fd < 0 { + return None; + } + + // SAFETY: `fd` is non-negative and was just returned by `pidfd_open`, so it is + // an open descriptor owned by this process. `OwnedFd` takes sole ownership and + // will close it exactly once. + Some(Arc::new(unsafe { OwnedFd::from_raw_fd(fd as RawFd) })) + } + + /// Send `signal` to the process group `pgid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + // SAFETY: `kill` takes integer identifiers by value and does not access + // caller-owned memory. A negative PID is the POSIX process-group form. + unsafe { libc::kill(-pgid, signal) == 0 } + } + + /// Find processes whose `/proc/{pid}/exe` symlink resolves to exactly + /// `target`. + pub fn find_by_path(target: &str) -> Vec { + let mut matches = Vec::new(); + let Ok(entries) = fs::read_dir("/proc") else { + return matches; + }; + let target_os = OsStr::new(target); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + let exe_path = format!("/proc/{pid}/exe"); + let Ok(resolved) = fs::read_link(&exe_path) else { + continue; + }; + if resolved.as_os_str() == target_os + && let Some(process) = Process::from_pid(pid) + { + matches.push(process); + } + } + matches + } +} + +#[cfg(target_os = "macos")] +mod platform { + use std::{collections::HashSet, ptr}; + + use super::ProcessStatus; + + #[link(name = "proc", kind = "dylib")] + unsafe extern "C" { + fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32; + fn proc_listallpids(buffer: *mut i32, buffersize: i32) -> i32; + fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffersize: u32) -> i32; + } + + /// macOS does not expose pidfds; identity is pinned via the kernel-reported + /// process start time so a recycled PID does not silently impersonate the + /// original target. + #[derive(Clone)] + pub struct Process { + pid: i32, + start_tvsec: u64, + start_tvusec: u64, + } + + impl Process { + pub fn from_pid(pid: i32) -> Option { + if pid <= 0 { + return None; + } + let info = read_bsdinfo(pid)?; + if i32::try_from(info.pbi_pid).ok()? != pid { + return None; + } + Some(Self { pid, start_tvsec: info.pbi_start_tvsec, start_tvusec: info.pbi_start_tvusec }) + } + + pub const fn pid(&self) -> i32 { + self.pid + } + + pub fn children(&self) -> Vec { + if self.live_bsdinfo().is_none() { + return Vec::new(); + } + + // SAFETY: Passing a null buffer with size 0 is the documented libproc query + // form for obtaining the byte count needed for child PIDs; libproc does not + // dereference the null pointer in this mode. + let bytes = unsafe { proc_listchildpids(self.pid, ptr::null_mut(), 0) }; + if bytes <= 0 { + return Vec::new(); + } + + let count = bytes as usize / size_of::(); + let mut buffer = vec![0i32; count]; + // SAFETY: `buffer` is valid for `buffer.len() * size_of::()` bytes and + // is properly aligned for `i32`; libproc writes at most the supplied size. + let actual = unsafe { + proc_listchildpids( + self.pid, + buffer.as_mut_ptr(), + (buffer.len() * size_of::()) as i32, + ) + }; + if actual <= 0 { + return Vec::new(); + } + + let child_count = ((actual as usize) / size_of::()).min(buffer.len()); + buffer[..child_count] + .iter() + .copied() + .filter_map(Self::from_pid) + .collect() + } + + pub fn parent_pid(&self) -> Option { + let info = self.live_bsdinfo()?; + i32::try_from(info.pbi_ppid).ok().filter(|ppid| *ppid > 0) + } + + pub fn args(&self) -> Vec { + if self.live_bsdinfo().is_none() { + return Vec::new(); + } + process_args(self.pid) + } + + pub fn kill(&self, signal: i32) -> bool { + // Re-validate identity right before signaling. There is no atomic + // "kill iff start_time matches" primitive on macOS, so a vanishingly small + // window remains between this check and the syscall — but matching against + // the recorded `(pid, start_tvsec, start_tvusec)` triple eliminates the + // PID-reuse race in every practical case. + if self.live_bsdinfo().is_none() { + return false; + } + // SAFETY: `kill` takes integer identifiers by value and does not access + // caller-owned memory. + unsafe { libc::kill(self.pid, signal) == 0 } + } + + pub fn group_id(&self) -> Option { + let info = self.live_bsdinfo()?; + i32::try_from(info.pbi_pgid).ok().filter(|pgid| *pgid > 0) + } + + /// Walk the descendant tree in post-order (leaves first), de-duplicating + /// by PID so concurrent reparenting cannot trap us in a cycle. + pub fn descendants(&self) -> Vec { + let mut out = Vec::new(); + let mut visited = HashSet::new(); + visited.insert(self.pid); + self.descendants_into(&mut out, &mut visited); + out + } + + fn descendants_into(&self, out: &mut Vec, visited: &mut HashSet) { + for child in self.children() { + if visited.insert(child.pid) { + child.descendants_into(out, visited); + out.push(child); + } + } + } + + pub fn status(&self) -> ProcessStatus { + if self.live_bsdinfo().is_some() { + ProcessStatus::Running + } else { + ProcessStatus::Exited + } + } + + /// Returns the current `proc_bsdinfo` only if it still describes the same + /// process this reference was opened on — i.e. the start time has not + /// changed. + fn live_bsdinfo(&self) -> Option { + let info = read_bsdinfo(self.pid)?; + if info.pbi_start_tvsec == self.start_tvsec && info.pbi_start_tvusec == self.start_tvusec { + Some(info) + } else { + None + } + } + } + + /// Send `signal` to the process group `pgid`. + /// Returns true when the signal is delivered successfully. + pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + // SAFETY: `kill` takes integer identifiers by value and does not access + // caller-owned memory. A negative PID is the POSIX process-group form. + unsafe { libc::kill(-pgid, signal) == 0 } + } + + const KERN_PROCARGS2: libc::c_int = 49; + + const PROC_PIDPATHINFO_MAXSIZE: usize = 4096; + + /// Find processes whose libproc-reported executable path equals `target`. + pub fn find_by_path(target: &str) -> Vec { + // SAFETY: Passing a null buffer with size 0 is the documented libproc query + // form for obtaining the byte count needed for all PIDs; libproc does not + // dereference the null pointer in this mode. + let bytes = unsafe { proc_listallpids(ptr::null_mut(), 0) }; + if bytes <= 0 { + return Vec::new(); + } + // macOS truncates the second `proc_listallpids` call's result tightly to + // the buffer size we report — even when the buffer is large enough on paper — + // so a near-fit buffer can silently lose ~half the pids. Pad generously. + let count = (bytes as usize) / size_of::(); + let cap = count.saturating_mul(4).max(2048); + let mut buffer = vec![0i32; cap]; + // SAFETY: `buffer` is valid for `buffer.len() * size_of::()` bytes and + // is properly aligned for `i32`; libproc writes at most the supplied size. + let actual = + unsafe { proc_listallpids(buffer.as_mut_ptr(), (buffer.len() * size_of::()) as i32) }; + if actual <= 0 { + return Vec::new(); + } + let pid_count = ((actual as usize) / size_of::()).min(buffer.len()); + + let mut path_buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE]; + let mut matches = Vec::new(); + for &pid in &buffer[..pid_count] { + if pid <= 0 { + continue; + } + // SAFETY: `path_buf` is valid for `path_buf.len()` bytes; libproc writes a + // NUL-terminated path no longer than the supplied capacity and returns the + // number of bytes written. + let len = unsafe { + proc_pidpath( + pid, + path_buf.as_mut_ptr().cast::(), + path_buf.len() as u32, + ) + }; + if len <= 0 { + continue; + } + let path_bytes = &path_buf[..len as usize]; + let path_bytes = match path_bytes.iter().position(|byte| *byte == 0) { + Some(end) => &path_bytes[..end], + None => path_bytes, + }; + let Ok(path) = std::str::from_utf8(path_bytes) else { + continue; + }; + if path == target + && let Some(process) = Process::from_pid(pid) + { + matches.push(process); + } + } + matches + } + + fn read_bsdinfo(pid: i32) -> Option { + // SAFETY: `proc_bsdinfo` is a plain C data struct. Zero initialization is + // valid because every field is an integer or fixed-size integer array, and + // libproc fully overwrites the fields it reports on a successful call. + let mut info = unsafe { std::mem::zeroed::() }; + // SAFETY: `info` is a writable `proc_bsdinfo` buffer whose exact byte size is + // supplied to libproc. The PID, flavor, and arg are scalar values passed by + // value; libproc writes at most the supplied buffer size. + let actual = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + (&raw mut info).cast::(), + size_of::() as i32, + ) + }; + if actual < size_of::() as i32 { + return None; + } + Some(info) + } + + fn process_args(pid: i32) -> Vec { + let mut mib = [libc::CTL_KERN, KERN_PROCARGS2, pid]; + let mut size = 0usize; + // SAFETY: `mib` points to three initialized integers and the old-value buffer + // is null with a zero-length query, which is the documented `sysctl` sizing + // pattern. `size` is a valid out-parameter for the required byte count. + let sizing_ok = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + ptr::null_mut(), + &raw mut size, + ptr::null_mut(), + 0, + ) + } == 0; + if !sizing_ok || size <= size_of::() { + return Vec::new(); + } + + let mut buffer = vec![0u8; size]; + // SAFETY: `mib` still points to three initialized integers. `buffer` is + // writable for `size` bytes, and `size` is provided as the in/out byte count. + let read_ok = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + buffer.as_mut_ptr().cast::(), + &raw mut size, + ptr::null_mut(), + 0, + ) + } == 0; + if !read_ok { + return Vec::new(); + } + buffer.truncate(size); + parse_macos_procargs(&buffer) + } + + fn parse_macos_procargs(buffer: &[u8]) -> Vec { + // KERN_PROCARGS2 layout: `argc: i32 | exec_path: NUL-padded | argv[0..argc] | + // env[..]`. argc covers only argv, so we must skip the exec_path NUL padding + // and stop after exactly argc entries — otherwise environment variables leak + // into the arg list (each NUL-terminated env=value is indistinguishable from + // an arg). + let argc_size = size_of::(); + if buffer.len() <= argc_size { + return Vec::new(); + } + + let argc_bytes: [u8; 4] = match buffer[..argc_size].try_into() { + Ok(bytes) => bytes, + Err(_) => return Vec::new(), + }; + let argc = libc::c_int::from_ne_bytes(argc_bytes); + if argc <= 0 { + return Vec::new(); + } + + let mut offset = argc_size; + while offset < buffer.len() && buffer[offset] != 0 { + offset += 1; + } + while offset < buffer.len() && buffer[offset] == 0 { + offset += 1; + } + + let mut args = Vec::with_capacity(argc as usize); + while offset < buffer.len() && args.len() < argc as usize { + let end = buffer[offset..] + .iter() + .position(|byte| *byte == 0) + .map_or(buffer.len(), |position| offset + position); + if end == offset { + break; + } + args.push(String::from_utf8_lossy(&buffer[offset..end]).into_owned()); + offset = end + 1; + } + args + } +} +#[cfg(target_os = "windows")] +mod platform { + use std::{ + collections::{HashMap, HashSet}, + ffi::c_void, + mem, + sync::Arc, + }; + + use smallvec::SmallVec; + + use super::ProcessStatus; + + #[repr(C)] + #[allow(non_snake_case, reason = "Windows PROCESSENTRY32W field names must match Win32 ABI")] + struct PROCESSENTRY32W { + dwSize: u32, + cntUsage: u32, + th32ProcessID: u32, + th32DefaultHeapID: usize, + th32ModuleID: u32, + cntThreads: u32, + th32ParentProcessID: u32, + pcPriClassBase: i32, + dwFlags: u32, + szExeFile: [u16; 260], + } + + #[repr(C)] + struct ProcessBasicInformation { + exit_status: i32, + peb_base_address: usize, + affinity_mask: usize, + base_priority: i32, + unique_process_id: usize, + inherited_from_unique_process_id: usize, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: usize, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct PebPartial { + reserved1: [u8; 2], + being_debugged: u8, + reserved2: [u8; 1], + reserved3: [usize; 2], + loader: usize, + process_parameters: usize, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct UserProcessParametersPartial { + reserved1: [u8; 16], + reserved2: [usize; 10], + image_path_name: UnicodeString, + command_line: UnicodeString, + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct Filetime { + dw_low_date_time: u32, + dw_high_date_time: u32, + } + + type Handle = *mut c_void; + type NtStatus = i32; + const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; + const PROCESS_QUERY_INFORMATION: u32 = 0x0400; + const PROCESS_VM_READ: u32 = 0x0010; + const PROCESS_BASIC_INFORMATION_CLASS: u32 = 0; + const STATUS_SUCCESS: NtStatus = 0; + const TH32CS_SNAPPROCESS: u32 = 0x00000002; + const PROCESS_TERMINATE: u32 = 0x0001; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const SYNCHRONIZE: u32 = 0x00100000; + const PROCESS_REFERENCE_ACCESS: u32 = + PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE; + const WAIT_OBJECT_0: u32 = 0; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> Handle; + fn Process32FirstW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn Process32NextW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn CloseHandle(hObject: Handle) -> i32; + fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle; + fn TerminateProcess(hProcess: Handle, uExitCode: u32) -> i32; + fn QueryFullProcessImageNameW( + hProcess: Handle, + dwFlags: u32, + lpExeName: *mut u16, + lpdwSize: *mut u32, + ) -> i32; + fn WaitForSingleObject(hHandle: Handle, dwMilliseconds: u32) -> u32; + fn GetProcessTimes( + hProcess: Handle, + lpCreationTime: *mut Filetime, + lpExitTime: *mut Filetime, + lpKernelTime: *mut Filetime, + lpUserTime: *mut Filetime, + ) -> i32; + fn ReadProcessMemory( + hProcess: Handle, + lpBaseAddress: *const c_void, + lpBuffer: *mut c_void, + nSize: usize, + lpNumberOfBytesRead: *mut usize, + ) -> i32; + fn LocalFree(hMem: Handle) -> Handle; + } + + #[link(name = "shell32")] + unsafe extern "system" { + fn CommandLineToArgvW(lpCmdLine: *const u16, pNumArgs: *mut i32) -> *mut *mut u16; + } + + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtQueryInformationProcess( + ProcessHandle: Handle, + ProcessInformationClass: u32, + ProcessInformation: *mut c_void, + ProcessInformationLength: u32, + ReturnLength: *mut u32, + ) -> NtStatus; + } + + struct OwnedHandle { + raw: isize, + } + + impl OwnedHandle { + fn from_raw(raw: Handle) -> Option { + if raw.is_null() || raw == INVALID_HANDLE_VALUE { + None + } else { + Some(Self { raw: raw as isize }) + } + } + + fn as_raw(&self) -> Handle { + self.raw as Handle + } + } + + impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: `self.raw` was returned by a successful Win32 handle-producing + // function and stored only in this `OwnedHandle`. `Drop` runs once, so this + // closes the owned handle exactly once and no code uses it afterward. + let _ = unsafe { CloseHandle(self.as_raw()) }; + } + } + + #[derive(Clone)] + /// Stable Windows process reference backed by an owned process handle plus + /// the kernel-reported creation time, which pins identity even if the PID is + /// recycled while we hold the handle. + pub struct Process { + pid: i32, + handle: Arc, + creation_time: u64, + } + + impl Process { + pub fn from_pid(pid: i32) -> Option { + if pid <= 0 { + return None; + } + let pid_u32 = u32::try_from(pid).ok()?; + let handle = open_process(pid_u32, PROCESS_REFERENCE_ACCESS)?; + let creation_time = process_creation_time(handle.as_raw())?; + Some(Self { pid, handle, creation_time }) + } + + pub const fn pid(&self) -> i32 { + self.pid + } + + pub fn parent_pid(&self) -> Option { + process_basic_information(self.handle.as_raw()) + .and_then(|info| i32::try_from(info.inherited_from_unique_process_id).ok()) + .filter(|pid| *pid > 0) + } + + pub fn args(&self) -> Vec { + process_command_line(self) + .as_deref() + .map(split_windows_command_line) + .unwrap_or_default() + } + + pub fn children(&self) -> Vec { + let tree = build_process_tree(); + Self::children_from_tree(self.pid, &tree) + } + + /// Walk the entire descendant tree using a single Toolhelp snapshot. + /// + /// `children()` recursing per-node would re-snapshot the whole process + /// table for every visited descendant, making tree termination + /// `O(N · D)` snapshots. One snapshot per termination wave is enough. + pub fn descendants(&self) -> Vec { + let tree = build_process_tree(); + let Ok(root) = u32::try_from(self.pid) else { + return Vec::new(); + }; + let mut visited: HashSet = HashSet::new(); + visited.insert(root); + let mut out = Vec::new(); + Self::collect_descendants_from_tree(root, &tree, &mut visited, &mut out); + out + } + + fn children_from_tree(pid: i32, tree: &HashMap>) -> Vec { + let Ok(pid_u32) = u32::try_from(pid) else { + return Vec::new(); + }; + tree + .get(&pid_u32) + .into_iter() + .flatten() + .filter_map(|&child_pid| { + let child = Self::from_pid(i32::try_from(child_pid).ok()?)?; + (child.status() == ProcessStatus::Running).then_some(child) + }) + .collect() + } + + fn collect_descendants_from_tree( + parent: u32, + tree: &HashMap>, + visited: &mut HashSet, + out: &mut Vec, + ) { + let Some(children) = tree.get(&parent) else { + return; + }; + for &child_pid in children { + if !visited.insert(child_pid) { + continue; + } + let Ok(child_pid_i) = i32::try_from(child_pid) else { + continue; + }; + let Some(child) = Self::from_pid(child_pid_i) else { + continue; + }; + if child.status() != ProcessStatus::Running { + continue; + } + // Post-order: collect grandchildren first so leaves are signalled before + // their parents during tree termination. + Self::collect_descendants_from_tree(child_pid, tree, visited, out); + out.push(child); + } + } + + pub fn kill(&self, _signal: i32) -> bool { + // The handle pins the original kernel process object even after the PID is + // recycled, so `TerminateProcess` cannot accidentally hit a different + // process. SAFETY: `self.handle` is an owned process handle opened with + // `PROCESS_TERMINATE` access and remains valid for the duration of this + // call. The exit code is passed by value. + unsafe { TerminateProcess(self.handle.as_raw(), 1) != 0 } + } + + pub const fn group_id(&self) -> Option { + None + } + + pub fn status(&self) -> ProcessStatus { + // `WaitForSingleObject` on a process handle opened with `SYNCHRONIZE` is + // the definitive liveness probe: the handle becomes signalled iff the + // process has exited. This avoids the `STILL_ACTIVE == 259` pitfall in + // `GetExitCodeProcess`, where a process that legitimately exits with code + // 259 is indistinguishable from a still-running one. + // + // SAFETY: `self.handle` is an owned process handle opened with + // `SYNCHRONIZE` access. A zero timeout makes this a non-blocking probe. + let result = unsafe { WaitForSingleObject(self.handle.as_raw(), 0) }; + if result == WAIT_OBJECT_0 { + ProcessStatus::Exited + } else { + ProcessStatus::Running + } + } + } + + fn process_basic_information(handle: Handle) -> Option { + let mut info = ProcessBasicInformation { + exit_status: 0, + peb_base_address: 0, + affinity_mask: 0, + base_priority: 0, + unique_process_id: 0, + inherited_from_unique_process_id: 0, + }; + let mut returned = 0u32; + // SAFETY: `handle` is a valid process handle. `info` is writable for exactly + // `size_of::()` bytes, and `returned` is a valid + // optional out-parameter for the byte count. + let status = unsafe { + NtQueryInformationProcess( + handle, + PROCESS_BASIC_INFORMATION_CLASS, + (&raw mut info).cast::(), + mem::size_of::() as u32, + &raw mut returned, + ) + }; + (status == STATUS_SUCCESS).then_some(info) + } + + fn process_command_line(process: &Process) -> Option { + let pid_u32 = u32::try_from(process.pid).ok()?; + let read_handle = open_process(pid_u32, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)?; + // PID-reuse defense: `OpenProcess` resolves a PID to *whichever* process owns + // it right now, which need not be the one our original handle pinned. Compare + // the freshly opened handle's creation time against the recorded value to + // reject reads from an unrelated process that happens to share the PID. + if process_creation_time(read_handle.as_raw())? != process.creation_time { + return None; + } + let info = process_basic_information(read_handle.as_raw())?; + let peb: PebPartial = read_remote(read_handle.as_raw(), info.peb_base_address)?; + if peb.process_parameters == 0 { + return None; + } + let params: UserProcessParametersPartial = + read_remote(read_handle.as_raw(), peb.process_parameters)?; + read_remote_unicode_string(read_handle.as_raw(), params.command_line) + } + + fn process_creation_time(handle: Handle) -> Option { + let mut creation = Filetime::default(); + let mut exit = Filetime::default(); + let mut kernel = Filetime::default(); + let mut user = Filetime::default(); + // SAFETY: `handle` is a valid process handle opened with at least + // `PROCESS_QUERY_LIMITED_INFORMATION`. All four out-parameters point to + // initialized, writable `Filetime` values that live until the call returns. + let ok = unsafe { + GetProcessTimes(handle, &raw mut creation, &raw mut exit, &raw mut kernel, &raw mut user) + != 0 + }; + if !ok { + return None; + } + Some((u64::from(creation.dw_high_date_time) << 32) | u64::from(creation.dw_low_date_time)) + } + + fn read_remote(handle: Handle, address: usize) -> Option { + if address == 0 { + return None; + } + let mut value = mem::MaybeUninit::::uninit(); + let mut bytes_read = 0usize; + // SAFETY: `handle` is opened with `PROCESS_VM_READ`. `address` comes from + // kernel-reported process structures for that same process. `value` points to + // uninitialized local storage large enough for `T`, and `bytes_read` is a valid + // out-parameter. The value is only assumed initialized after the OS reports a + // full-size successful read. + let ok = unsafe { + ReadProcessMemory( + handle, + address as *const c_void, + value.as_mut_ptr().cast::(), + mem::size_of::(), + &raw mut bytes_read, + ) != 0 + }; + if ok && bytes_read == mem::size_of::() { + // SAFETY: The successful `ReadProcessMemory` call above initialized exactly + // `size_of::()` bytes in `value`. + Some(unsafe { value.assume_init() }) + } else { + None + } + } + + fn read_remote_unicode_string(handle: Handle, value: UnicodeString) -> Option { + if value.length == 0 || value.buffer == 0 || value.length % 2 != 0 { + return None; + } + let code_units = usize::from(value.length) / size_of::(); + let mut buffer = vec![0u16; code_units]; + let mut bytes_read = 0usize; + // SAFETY: `handle` is opened with `PROCESS_VM_READ`. `value.buffer` and + // `value.length` come from the remote process' own `UNICODE_STRING`. `buffer` + // is writable for exactly `value.length` bytes, and `bytes_read` is a valid + // out-parameter. The string is decoded only after a full successful read. + let ok = unsafe { + ReadProcessMemory( + handle, + value.buffer as *const c_void, + buffer.as_mut_ptr().cast::(), + usize::from(value.length), + &raw mut bytes_read, + ) != 0 + }; + if ok && bytes_read == usize::from(value.length) { + Some(String::from_utf16_lossy(&buffer)) + } else { + None + } + } + + fn split_windows_command_line(command_line: &str) -> Vec { + use std::os::windows::ffi::OsStringExt; + + let mut wide: Vec = command_line.encode_utf16().chain([0]).collect(); + let mut argc = 0i32; + // SAFETY: `wide` is a local, NUL-terminated UTF-16 buffer that remains alive + // for the duration of the call. `argc` is a valid out-parameter. The returned + // argv block is released with `LocalFree` below as required by + // `CommandLineToArgvW`. + let argv = unsafe { CommandLineToArgvW(wide.as_mut_ptr(), &raw mut argc) }; + if argv.is_null() || argc <= 0 { + return Vec::new(); + } + let argc = argc as usize; + // SAFETY: `CommandLineToArgvW` returned a non-null pointer to `argc` argument + // pointers, valid until freed with `LocalFree`. + let pointers = unsafe { std::slice::from_raw_parts(argv, argc) }; + let args = pointers + .iter() + .filter_map(|&arg| { + if arg.is_null() { + return None; + } + let mut len = 0usize; + // SAFETY: Each pointer in the argv block is a NUL-terminated UTF-16 + // string owned by the argv block and valid until `LocalFree` below. + while unsafe { *arg.add(len) } != 0 { + len += 1; + } + // SAFETY: The loop above found the terminating NUL, so the preceding + // `len` code units form a valid readable slice. + let slice = unsafe { std::slice::from_raw_parts(arg, len) }; + Some( + std::ffi::OsString::from_wide(slice) + .to_string_lossy() + .into_owned(), + ) + }) + .collect(); + // SAFETY: `argv` is the allocation returned by `CommandLineToArgvW` and has + // not been freed yet. No pointers into it are used after this call. + let _ = unsafe { LocalFree(argv.cast::()) }; + args + } + + fn open_process(pid: u32, access: u32) -> Option> { + // SAFETY: `OpenProcess` takes the PID and access mask by value and does not + // dereference caller-owned memory. Handle inheritance is disabled. Identity + // is established by the caller (typically `Process::from_pid`) capturing the + // creation time immediately after a successful open and re-checking it on + // every subsequent operation that re-resolves the PID. + let handle = unsafe { OpenProcess(access, 0, pid) }; + OwnedHandle::from_raw(handle).map(Arc::new) + } + + fn create_process_snapshot() -> Option { + // SAFETY: The process snapshot API takes flags and a process ID by value and + // does not dereference caller-owned memory. PID zero requests all processes. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + OwnedHandle::from_raw(snapshot) + } + + fn process_entry() -> PROCESSENTRY32W { + PROCESSENTRY32W { + dwSize: mem::size_of::() as u32, + cntUsage: 0, + th32ProcessID: 0, + th32DefaultHeapID: 0, + th32ModuleID: 0, + cntThreads: 0, + th32ParentProcessID: 0, + pcPriClassBase: 0, + dwFlags: 0, + szExeFile: [0; 260], + } + } + + /// Build a map of `parent_pid` -> [`child_pids`] for all processes. + fn build_process_tree() -> HashMap> { + let mut tree: HashMap> = HashMap::new(); + let Some(snapshot) = create_process_snapshot() else { + return tree; + }; + + let mut entry = process_entry(); + // SAFETY: `snapshot` is a valid Toolhelp snapshot handle. `entry` points to a + // writable `PROCESSENTRY32W` whose `dwSize` field was initialized to the exact + // ABI size before the call. + if unsafe { Process32FirstW(snapshot.as_raw(), &raw mut entry) } == 0 { + return tree; + } + + loop { + tree + .entry(entry.th32ParentProcessID) + .or_default() + .push(entry.th32ProcessID); + + // SAFETY: `snapshot` remains a valid Toolhelp snapshot handle, and `entry` + // remains a writable `PROCESSENTRY32W` with its ABI size preserved. + if unsafe { Process32NextW(snapshot.as_raw(), &raw mut entry) } == 0 { + break; + } + } + + tree + } + + /// Process groups are not exposed on Windows. + /// Always returns `false`. + pub const fn kill_process_group(_pgid: i32, _signal: i32) -> bool { + false + } + + /// Find processes whose `QueryFullProcessImageNameW` result equals `target`. + pub fn find_by_path(target: &str) -> Vec { + use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + + let mut matches = Vec::new(); + let Some(snapshot) = create_process_snapshot() else { + return matches; + }; + + let mut entry = process_entry(); + let mut buf = vec![0u16; 32_768]; + let target = OsString::from(target); + + // SAFETY: `snapshot` is a valid Toolhelp snapshot handle. `entry` points to a + // writable `PROCESSENTRY32W` whose `dwSize` field was initialized to the exact + // ABI size before the call. + if unsafe { Process32FirstW(snapshot.as_raw(), &raw mut entry) } == 0 { + return matches; + } + + loop { + let pid = entry.th32ProcessID; + if let Some(handle) = open_process(pid, PROCESS_QUERY_LIMITED_INFORMATION) { + let mut size = buf.len() as u32; + // SAFETY: `handle` was opened with query access and remains valid for the + // call. `buf` is writable for `size` UTF-16 code units, and `size` is a valid + // in/out parameter initialized to that capacity. + let ok = unsafe { + QueryFullProcessImageNameW(handle.as_raw(), 0, buf.as_mut_ptr(), &raw mut size) != 0 + }; + if ok { + let path = OsString::from_wide(&buf[..size as usize]); + if path == target + && let Some(process) = Process::from_pid(i32::try_from(pid).unwrap_or_default()) + { + matches.push(process); + } + } + } + + // SAFETY: `snapshot` remains a valid Toolhelp snapshot handle, and `entry` + // remains a writable `PROCESSENTRY32W` with its ABI size preserved. + if unsafe { Process32NextW(snapshot.as_raw(), &raw mut entry) } == 0 { + break; + } + } + + matches + } +} + +/// Stable process reference. +#[derive(Clone)] +pub struct Process { + inner: platform::Process, +} + +impl Process { + /// Open a stable process reference from a PID. + pub fn from_pid(pid: i32) -> Option { + platform::Process::from_pid(pid).map(Self::from_inner) + } + + /// Open stable process references whose executable path matches exactly. + pub fn from_path(path: String) -> Vec { + platform::find_by_path(&path) + .into_iter() + .map(Self::from_inner) + .collect() + } + + /// Operating-system process identifier for this process reference. + pub const fn pid(&self) -> i32 { + self.inner.pid() + } + + /// Parent process id for this process, when available. + pub fn ppid(&self) -> Option { + self.inner.parent_pid() + } + + /// Launch arguments for this process. + pub fn args(&self) -> Vec { + self.inner.args() + } + + /// Send `signal` to this process and its descendants, children first. + /// + /// On Linux and macOS the signal is forwarded as-is. On Windows there is no + /// signal abstraction, so the `signal` argument is ignored and the entire + /// tree is hard-killed via `TerminateProcess`. Defaults to the POSIX + /// hard-kill signal. + pub fn kill_tree(&self, signal: Option) -> u32 { + self.signal_tree(signal.unwrap_or(KILL_SIGNAL)) + } + + /// Process group id for this process, when supported by the platform. + pub fn group_id(&self) -> Option { + self.inner.group_id() + } + + /// Direct children of this process as stable process references. + pub fn children(&self) -> Vec { + self + .inner + .children() + .into_iter() + .map(Self::from_inner) + .collect() + } + + /// Current status of this process reference. + pub fn status(&self) -> ProcessStatus { + self.inner.status() + } +} + +impl Process { + const fn from_inner(inner: platform::Process) -> Self { + Self { inner } + } + + /// Walk the live descendant tree from scratch. Cheap and idempotent — call + /// it again before each signal wave so grandchildren spawned during a grace + /// period are not missed. + fn live_descendants(&self) -> Vec { + self + .inner + .descendants() + .into_iter() + .map(Self::from_inner) + .collect() + } + + fn signal_tree(&self, signal: i32) -> u32 { + let descendants = self.live_descendants(); + let mut signaled = 0u32; + // If self leads its own process group, also signal the group — this catches + // grandchildren reparented to init when their immediate parent died inside + // the descendant walk. + if let Some(pgid) = self.inner.group_id() + && pgid == self.inner.pid() + { + let _ = kill_process_group(pgid, signal); + } + for child in &descendants { + if child.inner.kill(signal) { + signaled += 1; + } + } + if self.inner.kill(signal) { + signaled += 1; + } + signaled + } + + #[allow(dead_code, reason = "shared core keeps termination API for future callers")] + async fn terminate_tree( + &self, + group: bool, + graceful_ms: i32, + timeout_ms: u32, + ct: CancelToken, + ) -> Result { + if self.status() != ProcessStatus::Running { + return Ok(true); + } + + let process_group = if group { self.group_id() } else { None }; + + // Polite wave: SIGTERM the group, every live descendant, then the root. + if let Some(pgid) = process_group { + let _ = kill_process_group(pgid, TERM_SIGNAL); + } + let mut descendants = self.live_descendants(); + for child in &descendants { + let _ = child.inner.kill(TERM_SIGNAL); + } + let _ = self.inner.kill(TERM_SIGNAL); + + // Optional grace wait. A negative `graceful_ms` skips the wait entirely + // (we still emit the polite signal so cleanup handlers can run before KILL). + if graceful_ms >= 0 { + let exited = wait_for_exit( + self, + &descendants, + Some(Duration::from_millis(graceful_ms as u64)), + ct.clone(), + ) + .await?; + if exited { + return Ok(true); + } + } + + // Hard wave. Re-walk the tree so any grandchild spawned during the grace + // period — or any process re-parented to the root — is signalled too. + if let Some(pgid) = process_group { + let _ = kill_process_group(pgid, KILL_SIGNAL); + } + descendants = self.live_descendants(); + for child in &descendants { + let _ = child.inner.kill(KILL_SIGNAL); + } + let _ = self.inner.kill(KILL_SIGNAL); + + wait_for_exit(self, &descendants, Some(Duration::from_millis(u64::from(timeout_ms))), ct) + .await + } +} + +#[allow(dead_code, reason = "shared core keeps wait helper for future callers")] +async fn wait_for_exit( + root: &Process, + descendants: &[Process], + timeout: Option, + ct: CancelToken, +) -> Result { + ct.heartbeat()?; + if root.status() != ProcessStatus::Running + && descendants + .iter() + .all(|process| process.status() != ProcessStatus::Running) + { + return Ok(true); + } + + let poll_interval = Duration::from_millis(50); + let mut elapsed = Duration::ZERO; + while timeout.is_none_or(|limit| elapsed < limit) { + let sleep_for = + timeout.map_or(poll_interval, |limit| limit.saturating_sub(elapsed).min(poll_interval)); + if sleep_for.is_zero() { + break; + } + ct.heartbeat()?; + tokio::time::sleep(sleep_for).await; + elapsed += sleep_for; + + if root.status() != ProcessStatus::Running + && descendants + .iter() + .all(|process| process.status() != ProcessStatus::Running) + { + return Ok(true); + } + } + + Ok(false) +} + +/// Send `signal` to the process group `pgid`. +/// Returns false when process groups are unsupported on the platform. +#[allow(clippy::missing_const_for_fn, reason = "Dispatches to platform-specific implementation")] +pub fn kill_process_group(pgid: i32, signal: i32) -> bool { + platform::kill_process_group(pgid, signal) +} + +/// POSIX `SIGTERM` / Windows polite termination sentinel. +pub const TERM_SIGNAL: i32 = 15; + +/// POSIX `SIGKILL` / Windows hard-termination sentinel. +pub const KILL_SIGNAL: i32 = 9; + +/// A collection of process groups and process trees scheduled for +/// termination together. +/// +/// Built incrementally from job records or PTY metadata, then signalled +/// in escalating waves (typically `TERM_SIGNAL` followed by +/// `KILL_SIGNAL` after a grace period). Process-group calls are no-ops +/// on platforms that do not expose process groups. +#[derive(Default)] +pub struct TerminationTargets { + pgids: Vec, + processes: Vec, + seen_pids: HashSet, +} + +impl TerminationTargets { + /// Create an empty target set. + pub fn new() -> Self { + Self::default() + } + + /// Record a process group id. Duplicates are ignored. + pub fn add_pgid(&mut self, pgid: i32) { + if pgid > 0 && !self.pgids.contains(&pgid) { + self.pgids.push(pgid); + } + } + + /// Record a pid. Duplicates are ignored. If the pid is alive, opens + /// a stable [`Process`] reference so the descendant tree can be + /// killed even if the original pid is reused later. + pub fn add_pid(&mut self, pid: i32) { + if self.seen_pids.insert(pid) + && let Some(process) = Process::from_pid(pid) + { + self.processes.push(process); + } + } + + /// True when no targets have been recorded. + pub const fn is_empty(&self) -> bool { + self.pgids.is_empty() && self.processes.is_empty() + } + + /// Send `signal` to every recorded target. Failures are swallowed: + /// targets routinely exit between collection and signalling, and + /// the caller's policy is "best effort". + pub fn signal(&self, signal: i32) { + for &pgid in &self.pgids { + let _ = kill_process_group(pgid, signal); + } + for process in &self.processes { + let _ = process.signal_tree(signal); + } + } +} + +#[must_use] +pub fn current_descendant_pids() -> HashSet { + Process::from_pid(i32::try_from(std::process::id()).unwrap_or_default()).map_or_else( + HashSet::new, + |process| { + process + .live_descendants() + .into_iter() + .map(|child| child.pid()) + .collect() + }, + ) +} + +pub fn add_new_descendants( + targets: &mut TerminationTargets, + baseline: &HashSet, +) { + let Some(process) = Process::from_pid(i32::try_from(std::process::id()).unwrap_or_default()) + else { + return; + }; + for child in process.live_descendants() { + let pid = child.pid(); + if baseline.contains(&pid) { + continue; + } + if let Some(pgid) = child.group_id() { + targets.add_pgid(pgid); + } + targets.add_pid(pid); + } +} diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs new file mode 100644 index 000000000..6ec446058 --- /dev/null +++ b/crates/pi-shell/src/shell.rs @@ -0,0 +1,1506 @@ +//! Runtime-agnostic brush shell execution. + +use std::{ + collections::{HashMap, HashSet}, + fs, + future::Future, + io::{self, Write}, + str, + sync::Arc, + time::Duration, +}; + +use anyhow::{Error, Result}; +use brush_builtins::{BuiltinSet, default_builtins}; +use brush_core::{ + ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ProcessGroupPolicy, + ProfileLoadBehavior, RcLoadBehavior, Shell as BrushShell, ShellValue, ShellVariable, SourceInfo, + builtins, + env::EnvironmentScope, + openfiles::{self, OpenFile, OpenFiles}, +}; +use clap::Parser; +#[cfg(not(unix))] +use tokio::io::AsyncReadExt as _; +use tokio::{ + sync::{Mutex as TokioMutex, mpsc}, + time, +}; +use tokio_util::sync::CancellationToken; + +#[cfg(windows)] +use crate::windows::configure_windows_path; +use crate::{ + cancel::{AbortReason, AbortToken, CancelToken}, + minimizer, process, +}; + +struct ShellSessionCore { + shell: BrushShell, +} + +#[derive(Clone, Default)] +struct ShellAbortState(Arc>>); + +impl ShellAbortState { + async fn set(&self, abort_token: AbortToken) { + *self.0.lock().await = Some(abort_token); + } + + async fn clear(&self) { + *self.0.lock().await = None; + } + + async fn abort(&self) { + let abort_token = self.0.lock().await.clone(); + if let Some(abort_token) = abort_token { + abort_token.abort(AbortReason::Signal); + } + } +} + +#[derive(Clone)] +struct ShellConfig { + session_env: Option>, + snapshot_path: Option, + minimizer: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ShellOptions { + pub session_env: Option>, + pub snapshot_path: Option, + pub minimizer: Option, +} + +struct ShellRunConfig { + command: String, + cwd: Option, + env: Option>, + pty: bool, + minimizer: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ShellRunOptions { + pub command: String, + pub cwd: Option, + pub env: Option>, + pub pty: bool, + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MinimizerResult { + pub filter: String, + pub text: String, + pub original_text: String, + pub input_bytes: u32, + pub output_bytes: u32, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ShellRunResult { + pub exit_code: Option, + pub cancelled: bool, + pub timed_out: bool, + pub minimized: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ShellExecuteOptions { + pub command: String, + pub cwd: Option, + pub env: Option>, + pub session_env: Option>, + pub timeout_ms: Option, + pub snapshot_path: Option, + pub minimizer: Option, + pub pty: bool, +} + +pub type ShellExecuteResult = ShellRunResult; + +pub struct Shell { + session: Arc>>, + abort_state: ShellAbortState, + config: ShellConfig, +} + +impl Shell { + #[must_use] + pub fn new(options: Option) -> Self { + let config = match options { + None => ShellConfig { session_env: None, snapshot_path: None, minimizer: None }, + Some(opt) => { + let minimizer = opt + .minimizer + .as_ref() + .map(minimizer::MinimizerConfig::from_options); + ShellConfig { + session_env: opt.session_env, + snapshot_path: opt.snapshot_path, + minimizer, + } + }, + }; + Self { + session: Arc::new(TokioMutex::new(None)), + abort_state: ShellAbortState::default(), + config, + } + } + + pub async fn run( + &self, + options: ShellRunOptions, + on_chunk: Option>, + mut cancel_token: CancelToken, + ) -> Result { + let run_config = ShellRunConfig { + command: options.command, + cwd: options.cwd, + env: options.env, + pty: options.pty, + minimizer: self.config.minimizer.clone(), + }; + run_shell_session( + self.session.clone(), + self.abort_state.clone(), + self.config.clone(), + run_config, + on_chunk, + &mut cancel_token, + ) + .await + } + + pub async fn abort(&self) { + self.abort_state.abort().await; + } +} + +pub async fn execute_shell( + options: ShellExecuteOptions, + on_chunk: Option>, + cancel_token: CancelToken, +) -> Result { + let minimizer = options + .minimizer + .as_ref() + .map(minimizer::MinimizerConfig::from_options); + let config = ShellConfig { + session_env: options.session_env, + snapshot_path: options.snapshot_path, + minimizer: minimizer.clone(), + }; + let run_config = ShellRunConfig { + command: options.command, + cwd: options.cwd, + env: options.env, + pty: options.pty, + minimizer, + }; + run_shell_oneshot(config, run_config, on_chunk, cancel_token).await +} + +async fn run_shell_session( + session: Arc>>, + abort_state: ShellAbortState, + config: ShellConfig, + run_config: ShellRunConfig, + on_chunk: Option>, + ct: &mut CancelToken, +) -> Result { + let tokio_cancel = CancellationToken::new(); + + let mut run_task = tokio::spawn({ + let session = session.clone(); + let abort_state = abort_state.clone(); + let tokio_cancel = tokio_cancel.clone(); + let at = ct.emplace_abort_token(); + async move { + let mut session_guard = session.lock().await; + + let session = match &mut *session_guard { + Some(session) => session, + None => session_guard.insert(create_session(&config).await?), + }; + abort_state.set(at).await; + run_shell_command(session, &run_config, on_chunk, tokio_cancel).await + } + }); + + let res = tokio::select! { + res = &mut run_task => res, + reason = ct.wait() => { + tokio_cancel.cancel(); + let graceful = time::timeout(Duration::from_secs(2), &mut run_task).await; + if graceful.is_err() { + run_task.abort(); + let _ = run_task.await; + } + abort_state.clear().await; + *session.lock().await = None; + return Ok(ShellRunResult { + exit_code: None, + cancelled: matches!(reason, AbortReason::Signal), + timed_out: matches!(reason, AbortReason::Timeout), + minimized: None, + }); + } + }; + let res = + res.unwrap_or_else(|err| Err(Error::msg(format!("Shell execution task failed: {err}")))); + abort_state.clear().await; + + let keepalive = res.as_ref().is_ok_and(|pair| session_keepalive(&pair.0)); + if !keepalive { + *session.lock().await = None; + } + let (exec, minimized) = res?; + Ok(ShellRunResult { + exit_code: Some(exit_code(&exec)), + cancelled: false, + timed_out: false, + minimized, + }) +} + +async fn run_shell_oneshot( + config: ShellConfig, + run_config: ShellRunConfig, + on_chunk: Option>, + ct: CancelToken, +) -> Result { + let tokio_cancel = CancellationToken::new(); + + let mut task = tokio::spawn({ + let tokio_cancel = tokio_cancel.clone(); + async move { + let mut session = create_session(&config).await?; + run_shell_command(&mut session, &run_config, on_chunk, tokio_cancel).await + } + }); + + let run_result = tokio::select! { + result = &mut task => result, + reason = ct.wait() => { + tokio_cancel.cancel(); + let graceful = time::timeout(Duration::from_secs(2), &mut task).await; + if graceful.is_err() { + task.abort(); + let _ = task.await; + } + return Ok(ShellExecuteResult { + exit_code: None, + cancelled: matches!(reason, AbortReason::Signal), + timed_out: matches!(reason, AbortReason::Timeout), + minimized: None, + }); + }, + }; + + let res = run_result + .unwrap_or_else(|err| Err(Error::msg(format!("Shell execution task failed: {err}")))); + let (exec, minimized) = res?; + Ok(ShellExecuteResult { + exit_code: Some(exit_code(&exec)), + cancelled: false, + timed_out: false, + minimized, + }) +} + +fn null_file() -> Result { + openfiles::null().map_err(|err| Error::msg(format!("Failed to create null file: {err}"))) +} + +const fn exit_code(result: &ExecutionResult) -> i32 { + match result.exit_code { + ExecutionExitCode::Success => 0, + ExecutionExitCode::GeneralError => 1, + ExecutionExitCode::InvalidUsage => 2, + ExecutionExitCode::Unimplemented => 99, + ExecutionExitCode::CannotExecute => 126, + ExecutionExitCode::NotFound => 127, + ExecutionExitCode::Interrupted => 130, + ExecutionExitCode::BrokenPipe => 141, + ExecutionExitCode::Custom(code) => code as i32, + } +} + +#[cfg(windows)] +const fn normalize_env_key(key: &str) -> &str { + if key.eq_ignore_ascii_case("PATH") { + "PATH" + } else { + key + } +} + +#[cfg(not(windows))] +const fn normalize_env_key(key: &str) -> &str { + key +} + +#[cfg(windows)] +fn merge_path_values(existing: &str, incoming: &str) -> String { + let mut merged = Vec::new(); + let mut seen = HashSet::new(); + push_unique_paths(&mut merged, &mut seen, existing); + push_unique_paths(&mut merged, &mut seen, incoming); + + std::env::join_paths(merged.iter()) + .map_or_else(|_| merged.join(";"), |paths| paths.to_string_lossy().into_owned()) +} + +#[cfg(windows)] +fn push_unique_paths(merged: &mut Vec, seen: &mut HashSet, value: &str) { + for segment in std::env::split_paths(value) { + let segment_str = segment.to_string_lossy().into_owned(); + let normalized = normalize_path_segment(&segment_str); + if normalized.is_empty() { + continue; + } + if seen.insert(normalized) { + merged.push(segment_str); + } + } +} + +#[cfg(windows)] +fn normalize_path_segment(segment: &str) -> String { + let trimmed = segment.trim().trim_matches('"'); + if trimmed.is_empty() { + return String::new(); + } + + let mut normalized = std::path::PathBuf::new(); + for component in std::path::Path::new(trimmed).components() { + normalized.push(component.as_os_str()); + } + + normalized.to_string_lossy().to_ascii_lowercase() +} + +#[cfg(not(windows))] +fn merge_path_values(_existing: &str, incoming: &str) -> String { + incoming.to_string() +} + +async fn create_session(config: &ShellConfig) -> Result { + let mut shell = BrushShell::builder() + .do_not_inherit_env(true) + .profile(ProfileLoadBehavior::Skip) + .rc(RcLoadBehavior::Skip) + .builtins(default_builtins(BuiltinSet::BashMode)) + .build() + .await + .map_err(|err| Error::msg(format!("Failed to initialize shell: {err}")))?; + + if let Some(exec_builtin) = shell.builtin_mut("exec") { + exec_builtin.disabled = true; + } + if let Some(suspend_builtin) = shell.builtin_mut("suspend") { + suspend_builtin.disabled = true; + } + shell.register_builtin("sleep", builtins::builtin::()); + shell.register_builtin("timeout", builtins::builtin::()); + + let mut merged_path: Option = None; + for (key, value) in std::env::vars() { + let normalized_key = normalize_env_key(&key); + if should_skip_env_var(normalized_key) { + continue; + } + if normalized_key == "PATH" { + merged_path = Some(match merged_path { + Some(existing) => merge_path_values(&existing, &value), + None => value, + }); + continue; + } + let mut var = ShellVariable::new(ShellValue::String(value)); + var.export(); + shell + .env_mut() + .set_global(normalized_key, var) + .map_err(|err| Error::msg(format!("Failed to set env: {err}")))?; + } + + #[cfg(windows)] + if merged_path.is_none() + && let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH")) + { + merged_path = Some(value.to_string_lossy().into_owned()); + } + + if let Some(path_value) = merged_path { + let mut var = ShellVariable::new(ShellValue::String(path_value)); + var.export(); + shell + .env_mut() + .set_global("PATH", var) + .map_err(|err| Error::msg(format!("Failed to set env: {err}")))?; + } + + if let Some(env) = config.session_env.as_ref() { + for (key, value) in env { + let normalized_key = normalize_env_key(key); + if should_skip_env_var(normalized_key) { + continue; + } + let mut var = ShellVariable::new(ShellValue::String(value.clone())); + var.export(); + shell + .env_mut() + .set_global(normalized_key, var) + .map_err(|err| Error::msg(format!("Failed to set env: {err}")))?; + } + } + + #[cfg(windows)] + configure_windows_path(&mut shell)?; + + if let Some(snapshot_path) = config.snapshot_path.as_ref() { + source_snapshot(&mut shell, snapshot_path).await?; + } + + Ok(ShellSessionCore { shell }) +} + +async fn source_snapshot(shell: &mut BrushShell, snapshot_path: &str) -> Result<()> { + let mut params = shell.default_exec_params(); + let source_info = SourceInfo::from("pi-natives:snapshot"); + params.set_fd(OpenFiles::STDIN_FD, null_file()?); + params.set_fd(OpenFiles::STDOUT_FD, null_file()?); + params.set_fd(OpenFiles::STDERR_FD, null_file()?); + + let escaped = snapshot_path.replace('\'', "'\\''"); + let command = format!("source '{escaped}'"); + shell + .run_string(command, &source_info, ¶ms) + .await + .map_err(|err| Error::msg(format!("Failed to source snapshot: {err}")))?; + Ok(()) +} + +async fn run_shell_command( + session: &mut ShellSessionCore, + options: &ShellRunConfig, + on_chunk: Option>, + cancel_token: CancellationToken, +) -> Result<(ExecutionResult, Option)> { + if options.pty { + // TODO: The shared brush path is currently pipe-based. PTY execution + // still falls back to the non-PTY brush path until a native PTY-backed + // brush integration lands. + } + if let Some(cwd) = options.cwd.as_deref() { + session + .shell + .set_working_dir(cwd) + .map_err(|err| Error::msg(format!("Failed to set cwd: {err}")))?; + } + + let (reader_file, writer_file) = pipe_to_files("output")?; + + let stdout_file = OpenFile::from( + writer_file + .try_clone() + .map_err(|err| Error::msg(format!("Failed to clone pipe: {err}")))?, + ); + let stderr_file = OpenFile::from(writer_file); + + let mut params = session.shell.default_exec_params(); + params.set_fd(OpenFiles::STDIN_FD, null_file()?); + params.set_fd(OpenFiles::STDOUT_FD, stdout_file); + params.set_fd(OpenFiles::STDERR_FD, stderr_file); + params.process_group_policy = ProcessGroupPolicy::NewProcessGroup; + params.set_cancel_token(cancel_token.clone()); + + let mut env_scope_pushed = false; + if let Some(env) = options.env.as_ref() { + session + .shell + .env_mut() + .push_scope(EnvironmentScope::Command); + env_scope_pushed = true; + for (key, value) in env { + let normalized_key = normalize_env_key(key); + if should_skip_env_var(normalized_key) { + continue; + } + let mut var = ShellVariable::new(ShellValue::String(value.clone())); + var.export(); + if let Err(err) = + session + .shell + .env_mut() + .add(normalized_key, var, EnvironmentScope::Command) + { + let _ = session.shell.env_mut().pop_scope(EnvironmentScope::Command); + return Err(Error::msg(format!("Failed to set env: {err}"))); + } + } + } + + let minimizer_mode = if let Some(config) = options.minimizer.as_ref() { + minimizer::engine::mode_for(&options.command, config) + } else { + minimizer::engine::MinimizerMode::None + }; + let should_minimize = !matches!(minimizer_mode, minimizer::engine::MinimizerMode::None); + let max_capture_bytes = if let Some(config) = options.minimizer.as_ref() { + config.max_capture_bytes as usize + } else { + 0 + }; + let baseline_descendants = process::current_descendant_pids(); + let tracked_targets = Arc::new(TokioMutex::new(process::TerminationTargets::new())); + let tracking_cancel = CancellationToken::new(); + let tracker_handle = tokio::spawn({ + let baseline_descendants = baseline_descendants.clone(); + let tracked_targets = Arc::clone(&tracked_targets); + let tracking_cancel = tracking_cancel.clone(); + async move { + loop { + { + let mut targets = tracked_targets.lock().await; + process::add_new_descendants(&mut targets, &baseline_descendants); + } + tokio::select! { + () = tracking_cancel.cancelled() => break, + () = time::sleep(Duration::from_millis(50)) => {} + } + } + } + }); + let reader_cancel = CancellationToken::new(); + let (activity_tx, mut activity_rx) = mpsc::channel::<()>(1); + // Stream every raw chunk to the caller live, regardless of whether + // minimization is enabled. When minimization actually transforms the + // output, we propagate the replacement text via `MinimizerResult.text` + // so the caller can swap their accumulated buffer for the minimized + // version without losing intermediate progress updates. + let reader_callback = on_chunk; + let mut reader_handle = tokio::spawn({ + let reader_cancel = reader_cancel.clone(); + async move { + if should_minimize { + let output = read_output_buffered( + reader_file, + reader_callback, + reader_cancel, + activity_tx, + max_capture_bytes, + ) + .await; + Result::::Ok(OutputRead::Buffered(output)) + } else { + Box::pin(read_output(reader_file, reader_callback, reader_cancel, activity_tx)).await; + Result::::Ok(OutputRead::Streaming) + } + } + }); + let cancel_bridge = tokio::spawn({ + let cancel_token = cancel_token.clone(); + let reader_cancel = reader_cancel.clone(); + async move { + cancel_token.cancelled().await; + reader_cancel.cancel(); + } + }); + let process_cancel_bridge = tokio::spawn({ + let cancel_token = cancel_token.clone(); + let baseline_descendants = baseline_descendants.clone(); + let tracked_targets = Arc::clone(&tracked_targets); + async move { + cancel_token.cancelled().await; + { + let mut targets = tracked_targets.lock().await; + process::add_new_descendants(&mut targets, &baseline_descendants); + targets.signal(process::TERM_SIGNAL); + } + time::sleep(Duration::from_millis(500)).await; + tracked_targets.lock().await.signal(process::KILL_SIGNAL); + } + }); + let source_info = SourceInfo::from("pi-natives:command"); + let result = session + .shell + .run_string(options.command.clone(), &source_info, ¶ms) + .await; + + if cancel_token.is_cancelled() { + terminate_background_jobs(&session.shell, &baseline_descendants); + } + + if env_scope_pushed { + session + .shell + .env_mut() + .pop_scope(EnvironmentScope::Command) + .map_err(|err| Error::msg(format!("Failed to pop env scope: {err}")))?; + } + + drop(params); + + // The foreground command can complete while background jobs keep the + // stdout/stderr pipe open. Don't hang forever waiting for EOF; drain output + // for a short period, then cancel. + const POST_EXIT_IDLE: Duration = Duration::from_millis(250); + const POST_EXIT_MAX: Duration = Duration::from_secs(2); + const READER_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); + + let mut reader_finished = false; + let mut reader_output = None; + let mut idle_timer = Box::pin(time::sleep(POST_EXIT_IDLE)); + let mut max_timer = Box::pin(time::sleep(POST_EXIT_MAX)); + + loop { + tokio::select! { + res = &mut reader_handle => { + if let Ok(Ok(output)) = res { + reader_output = Some(output); + } + reader_finished = true; + break; + } + msg = activity_rx.recv() => { + if msg.is_none() { + break; + } + idle_timer.as_mut().reset(time::Instant::now() + POST_EXIT_IDLE); + } + () = &mut idle_timer => break, + () = &mut max_timer => break, + } + } + + if !reader_finished { + reader_cancel.cancel(); + if let Ok(res) = time::timeout(READER_SHUTDOWN_TIMEOUT, &mut reader_handle).await { + if let Ok(output) = res + && let Ok(output) = output + { + reader_output = Some(output); + } + } else { + reader_handle.abort(); + let _ = reader_handle.await; + } + } + cancel_bridge.abort(); + let _ = cancel_bridge.await; + process_cancel_bridge.abort(); + let _ = process_cancel_bridge.await; + tracking_cancel.cancel(); + let _ = tracker_handle.await; + + let result = result.map_err(|err| Error::msg(format!("Shell execution failed: {err}")))?; + let mut minimized_out: Option = None; + if let Some(OutputRead::Buffered(output)) = reader_output + && let Some(config) = options.minimizer.as_ref() + && !output.exceeded + { + let minimized = match minimizer_mode { + minimizer::engine::MinimizerMode::WholeCommand => { + minimizer::apply(&options.command, &output.text, exit_code(&result), config) + }, + minimizer::engine::MinimizerMode::None => { + minimizer::MinimizerOutput::passthrough(&output.text) + }, + }; + if minimized.changed + && let Some(original) = minimized.original_text + { + let output_bytes = u32::try_from(minimized.text.len()).unwrap_or(u32::MAX); + minimized_out = Some(MinimizerResult { + filter: minimized.filter.to_string(), + text: minimized.text, + original_text: original, + input_bytes: u32::try_from(minimized.input_bytes).unwrap_or(u32::MAX), + output_bytes, + }); + } + } + Ok((result, minimized_out)) +} + +fn terminate_background_jobs(shell: &BrushShell, baseline_descendants: &HashSet) { + let mut targets = process::TerminationTargets::new(); + for job in &shell.jobs().jobs { + if let Some(pgid) = job.process_group_id() { + targets.add_pgid(pgid); + } + if let Some(pid) = job.representative_pid() { + targets.add_pid(pid); + } + } + if targets.is_empty() { + process::add_new_descendants(&mut targets, baseline_descendants); + } + if targets.is_empty() { + return; + } + + targets.signal(process::TERM_SIGNAL); + tokio::spawn(async move { + time::sleep(Duration::from_millis(500)).await; + targets.signal(process::KILL_SIGNAL); + }); +} +fn should_skip_env_var(key: &str) -> bool { + if key.starts_with("BASH_FUNC_") && key.ends_with("%%") { + return true; + } + + matches!( + key, + "BASH_ENV" + | "ENV" + | "HISTFILE" + | "HISTTIMEFORMAT" + | "HISTCMD" + | "PS0" + | "PS1" + | "PS2" + | "PS4" + | "BRUSH_PS_ALT" + | "READLINE_LINE" + | "READLINE_POINT" + | "BRUSH_VERSION" + | "BASH" + | "BASHOPTS" + | "BASH_ALIASES" + | "BASH_ARGV0" + | "BASH_CMDS" + | "BASH_SOURCE" + | "BASH_SUBSHELL" + | "BASH_VERSINFO" + | "BASH_VERSION" + | "SHELLOPTS" + | "SHLVL" + | "SHELL" + | "COMP_WORDBREAKS" + | "DIRSTACK" + | "EPOCHREALTIME" + | "EPOCHSECONDS" + | "FUNCNAME" + | "GROUPS" + | "IFS" + | "LINENO" + | "MACHTYPE" + | "OSTYPE" + | "OPTERR" + | "OPTIND" + | "PIPESTATUS" + | "PPID" + | "PWD" + | "OLDPWD" + | "RANDOM" + | "SRANDOM" + | "SECONDS" + | "UID" + | "EUID" + | "HOSTNAME" + | "HOSTTYPE" + ) +} + +const fn session_keepalive(result: &ExecutionResult) -> bool { + match result.next_control_flow { + ExecutionControlFlow::Normal => true, + ExecutionControlFlow::BreakLoop { .. } => false, + ExecutionControlFlow::ContinueLoop { .. } => false, + ExecutionControlFlow::ReturnFromFunctionOrScript => false, + ExecutionControlFlow::ExitShell => false, + } +} + +enum OutputRead { + Streaming, + Buffered(BufferedOutput), +} + +struct BufferedOutput { + text: String, + exceeded: bool, +} + +async fn read_output( + reader: fs::File, + on_chunk: Option>, + cancel_token: CancellationToken, + activity: mpsc::Sender<()>, +) { + const REPLACEMENT: &str = "\u{FFFD}"; + const BUF: usize = 65536; + let mut buf = vec![0u8; BUF + 4]; // +4 for max UTF-8 char + let mut it = 0; + + #[cfg(unix)] + let Ok(reader) = register_nonblocking_pipe(reader) else { + return; + }; + #[cfg(not(unix))] + let reader = tokio::fs::File::from_std(reader); + #[cfg(not(unix))] + tokio::pin!(reader); + + loop { + #[cfg(unix)] + let n = { + let Ok(mut readiness) = (tokio::select! { + ready = reader.readable() => ready, + () = cancel_token.cancelled() => break, + }) else { + break; + }; + match readiness.try_io(|inner| read_nonblocking(inner.get_ref(), &mut buf[it..BUF])) { + Ok(Ok(0)) => break, + Ok(Ok(n)) => n, + Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue, + Ok(Err(_)) => break, + Err(_would_block) => continue, + } + }; + #[cfg(not(unix))] + let n = { + let read_future = reader.read(&mut buf[it..BUF]); + tokio::pin!(read_future); + match tokio::select! { + res = &mut read_future => res, + () = cancel_token.cancelled() => break, + } { + Ok(0) => break, // EOF + Ok(n) => n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + }; + if n > 0 { + let _ = activity.try_send(()); + } + it += n; + + // Consume as much of `pending` as is decodable *right now*. + while it > 0 { + let pending = &buf[..it]; + match str::from_utf8(pending) { + Ok(text) => { + emit_chunk(text, on_chunk.as_ref()); + it = 0; + break; + }, + Err(err) => { + let p = err.valid_up_to(); + if p > 0 { + // SAFETY: [..p] is guaranteed valid UTF-8 by valid_up_to(). + let text = unsafe { str::from_utf8_unchecked(&pending[..p]) }; + emit_chunk(text, on_chunk.as_ref()); + // copy p..it to the beginning of the buffer + buf.copy_within(p..it, 0); + it -= p; + } + + match err.error_len() { + Some(p) => { + // Invalid byte sequence: emit replacement and drop those bytes. + emit_chunk(REPLACEMENT, on_chunk.as_ref()); + // copy p..it to the beginning of the buffer + buf.copy_within(p..it, 0); + it -= p; + // continue loop in case more bytes remain after the + // invalid sequence + }, + None => { + // Incomplete UTF-8 sequence at end: keep bytes for next read. + break; + }, + } + }, + } + } + } + + // Flush whatever is left at EOF (including an incomplete final sequence). + for chunk in buf[..it].utf8_chunks() { + let valid = chunk.valid(); + if !valid.is_empty() { + emit_chunk(valid, on_chunk.as_ref()); + } + if !chunk.invalid().is_empty() { + emit_chunk(REPLACEMENT, on_chunk.as_ref()); + } + } +} + +async fn read_output_buffered( + reader: fs::File, + on_chunk: Option>, + cancel_token: CancellationToken, + activity: mpsc::Sender<()>, + max_capture_bytes: usize, +) -> BufferedOutput { + const REPLACEMENT: &str = "\u{FFFD}"; + const BUF: usize = 65536; + let mut buf = vec![0u8; BUF]; + let mut captured = Vec::new(); + let mut exceeded = false; + // Pending bytes from a prior read that ended mid-UTF-8 sequence. We hold + // them back so we emit only valid UTF-8 to the streaming callback while + // still capturing every byte into `captured` for post-processing. + let mut pending = Vec::::new(); + + #[cfg(unix)] + let Ok(reader) = register_nonblocking_pipe(reader) else { + return BufferedOutput { text: String::new(), exceeded: true }; + }; + #[cfg(not(unix))] + let reader = tokio::fs::File::from_std(reader); + #[cfg(not(unix))] + tokio::pin!(reader); + + loop { + #[cfg(unix)] + let n = { + let Ok(mut readiness) = (tokio::select! { + ready = reader.readable() => ready, + () = cancel_token.cancelled() => break, + }) else { + break; + }; + match readiness.try_io(|inner| read_nonblocking(inner.get_ref(), &mut buf)) { + Ok(Ok(0)) => break, + Ok(Ok(n)) => n, + Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue, + Ok(Err(_)) => break, + Err(_would_block) => continue, + } + }; + #[cfg(not(unix))] + let n = { + let read_future = reader.read(&mut buf); + tokio::pin!(read_future); + match tokio::select! { + res = &mut read_future => res, + () = cancel_token.cancelled() => break, + } { + Ok(0) => break, + Ok(n) => n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + }; + if n > 0 { + let _ = activity.try_send(()); + } + // Once `exceeded`, the post-process minimizer is bypassed (see the + // `!output.exceeded` gate at the call site), so further appends just + // grow `captured` without serving any purpose. Stop accumulating to + // bound peak memory on commands that produce very large output. + if !exceeded { + if captured.len().saturating_add(n) > max_capture_bytes { + exceeded = true; + } else { + captured.extend_from_slice(&buf[..n]); + } + } + + // Stream whatever is validly decodable *right now* to the callback, + // carrying incomplete trailing UTF-8 bytes over to the next iteration. + if let Some(cb) = on_chunk.as_ref() { + pending.extend_from_slice(&buf[..n]); + while !pending.is_empty() { + match str::from_utf8(&pending) { + Ok(text) => { + emit_chunk(text, Some(cb)); + pending.clear(); + break; + }, + Err(err) => { + let p = err.valid_up_to(); + if p > 0 { + // SAFETY: [..p] is valid UTF-8 per valid_up_to(). + let text = unsafe { str::from_utf8_unchecked(&pending[..p]) }; + emit_chunk(text, Some(cb)); + pending.drain(..p); + } + match err.error_len() { + Some(skip) => { + emit_chunk(REPLACEMENT, Some(cb)); + pending.drain(..skip); + }, + None => break, + } + }, + } + } + } + } + + // Flush any trailing bytes the streaming decoder held back at EOF. + if let Some(cb) = on_chunk.as_ref() { + for chunk in pending.utf8_chunks() { + let valid = chunk.valid(); + if !valid.is_empty() { + emit_chunk(valid, Some(cb)); + } + if !chunk.invalid().is_empty() { + emit_chunk(REPLACEMENT, Some(cb)); + } + } + } + + BufferedOutput { text: String::from_utf8_lossy(&captured).into_owned(), exceeded } +} + +#[cfg(unix)] +fn register_nonblocking_pipe(reader: fs::File) -> io::Result> { + set_nonblocking(&reader)?; + tokio::io::unix::AsyncFd::new(reader) +} + +#[cfg(unix)] +fn set_nonblocking(file: &T) -> io::Result<()> { + let fd = file.as_raw_fd(); + // SAFETY: `fd` is owned by `file` and remains valid for the duration of + // these `fcntl` calls. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if flags & libc::O_NONBLOCK != 0 { + return Ok(()); + } + + // SAFETY: `fd` remains valid here and we are only toggling `O_NONBLOCK`. + let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(unix)] +fn read_nonblocking(file: &T, buf: &mut [u8]) -> io::Result { + // SAFETY: `buf` is writable for `buf.len()` bytes, and the raw fd obtained + // from `file` stays valid for the duration of the syscall. + let read = unsafe { libc::read(file.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) }; + if read < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(read as usize) + } +} + +fn emit_chunk(text: &str, callback: Option<&mpsc::UnboundedSender>) { + if let Some(callback) = callback { + let _ = callback.send(text.to_string()); + } +} + +fn pipe_to_files(label: &str) -> Result<(fs::File, fs::File)> { + let (r, w) = + os_pipe::pipe().map_err(|err| Error::msg(format!("Failed to create {label} pipe: {err}")))?; + + #[cfg(unix)] + let (r, w): (fs::File, fs::File) = { + use std::os::unix::io::{FromRawFd, IntoRawFd}; + let r = r.into_raw_fd(); + let w = w.into_raw_fd(); + // SAFETY: We just obtained these fds from os_pipe and own them exclusively. + unsafe { (FromRawFd::from_raw_fd(r), FromRawFd::from_raw_fd(w)) } + }; + + #[cfg(windows)] + let (r, w): (fs::File, fs::File) = { + use std::os::windows::io::{FromRawHandle, IntoRawHandle}; + let r = r.into_raw_handle(); + let w = w.into_raw_handle(); + // SAFETY: We just obtained these handles from os_pipe and own them exclusively. + unsafe { (FromRawHandle::from_raw_handle(r), FromRawHandle::from_raw_handle(w)) } + }; + + Ok((r, w)) +} + +#[derive(Parser)] +#[command(disable_help_flag = true)] +struct SleepCommand { + #[arg(required = true)] + durations: Vec, +} + +impl builtins::Command for SleepCommand { + type Error = brush_core::Error; + + fn execute( + &self, + context: ExecutionContext<'_, SE>, + ) -> impl Future> + Send { + let durations = self.durations.clone(); + async move { + if context.is_cancelled() { + return Ok(ExecutionExitCode::Interrupted.into()); + } + let mut total = Duration::from_millis(0); + for duration in &durations { + let Some(parsed) = parse_duration(duration) else { + let _ = writeln!(context.stderr(), "sleep: invalid time interval '{duration}'"); + return Ok(ExecutionResult::new(1)); + }; + total += parsed; + } + let sleep = time::sleep(total); + tokio::pin!(sleep); + if let Some(cancel_token) = context.cancel_token() { + tokio::select! { + () = &mut sleep => Ok(ExecutionResult::success()), + () = cancel_token.cancelled() => Ok(ExecutionExitCode::Interrupted.into()), + } + } else { + sleep.await; + Ok(ExecutionResult::success()) + } + } + } +} + +#[derive(Parser)] +#[command(disable_help_flag = true)] +struct TimeoutCommand { + #[arg(required = true)] + duration: String, + #[arg(required = true, num_args = 1.., trailing_var_arg = true)] + command: Vec, +} + +impl builtins::Command for TimeoutCommand { + type Error = brush_core::Error; + + fn execute( + &self, + context: ExecutionContext<'_, SE>, + ) -> impl Future> + Send { + let duration = self.duration.clone(); + let command = self.command.clone(); + async move { + if context.is_cancelled() { + return Ok(ExecutionExitCode::Interrupted.into()); + } + let Some(timeout) = parse_duration(&duration) else { + let _ = writeln!(context.stderr(), "timeout: invalid time interval '{duration}'"); + return Ok(ExecutionResult::new(125)); + }; + if command.is_empty() { + let _ = writeln!(context.stderr(), "timeout: missing command"); + return Ok(ExecutionResult::new(125)); + } + + let child_cancel = CancellationToken::new(); + let mut params = context.params.clone(); + params.process_group_policy = ProcessGroupPolicy::NewProcessGroup; + params.set_cancel_token(child_cancel.clone()); + + let mut command_line = String::new(); + for (idx, arg) in command.iter().enumerate() { + if idx > 0 { + command_line.push(' '); + } + command_line.push_str("e_arg(arg)); + } + + let cancel_token = context.cancel_token(); + let source_info = SourceInfo::from("pi-natives:timeout"); + let run_future = context + .shell + .run_string(command_line, &source_info, ¶ms); + tokio::pin!(run_future); + + if let Some(cancel_token) = cancel_token { + tokio::select! { + result = &mut run_future => result, + () = time::sleep(timeout) => { + child_cancel.cancel(); + // Wait briefly for the child to exit after cancellation. + let _ = time::timeout(Duration::from_secs(2), &mut run_future).await; + Ok(ExecutionResult::new(124)) + }, + () = cancel_token.cancelled() => { + child_cancel.cancel(); + Ok(ExecutionExitCode::Interrupted.into()) + }, + } + } else { + tokio::select! { + result = &mut run_future => result, + () = time::sleep(timeout) => { + child_cancel.cancel(); + // Wait briefly for the child to exit after cancellation. + let _ = time::timeout(Duration::from_secs(2), &mut run_future).await; + Ok(ExecutionResult::new(124)) + }, + } + } + } + } +} +fn parse_duration(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + let (number, multiplier) = match trimmed.chars().last()? { + 's' => (&trimmed[..trimmed.len() - 1], 1.0), + 'm' => (&trimmed[..trimmed.len() - 1], 60.0), + 'h' => (&trimmed[..trimmed.len() - 1], 3600.0), + 'd' => (&trimmed[..trimmed.len() - 1], 86400.0), + ch if ch.is_ascii_alphabetic() => return None, + _ => (trimmed, 1.0), + }; + let value = number.parse::().ok()?; + if value.is_sign_negative() { + return None; + } + let millis = value * multiplier * 1000.0; + if !millis.is_finite() || millis < 0.0 { + return None; + } + Some(Duration::from_millis(millis.round() as u64)) +} + +fn quote_arg(arg: &str) -> String { + if arg.is_empty() { + return "''".to_string(); + } + let safe = arg + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '+')); + if safe { + return arg.to_string(); + } + let escaped = arg.replace('\'', "'\"'\"'"); + format!("'{escaped}'") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Truth-table coverage for `brush_core::commands::child_session_action`. + /// + /// Lives in `pi-natives` because the brush-core crate is excluded from the + /// workspace (vendored upstream) and cannot be tested standalone — its tokio + /// dependency only resolves the `net` feature via feature-unification with + /// other workspace members. + mod child_session_action { + use brush_core::commands::{ChildSessionAction, child_session_action}; + + /// Interactive brush, leading its own pgroup, terminal stdin: foreground. + #[test] + fn interactive_with_terminal_stdin_takes_foreground() { + assert_eq!(child_session_action(true, true, false), ChildSessionAction::TakeForeground,); + // Terminal foregrounding wins even when this is the first stage of a + // pipeline; no detach is attempted. + assert_eq!(child_session_action(true, true, true), ChildSessionAction::TakeForeground,); + } + + /// Brush leading a new pgroup with non-terminal stdin detaches only when + /// it is not part of a multi-command pipeline. Pipeline leaders must stay + /// in the parent session so later stages can join their process group. + #[test] + fn non_terminal_stdin_leading_new_pgroup_detaches_unless_pipeline() { + assert_eq!(child_session_action(true, false, false), ChildSessionAction::DetachSession,); + assert_eq!(child_session_action(true, false, true), ChildSessionAction::None,); + } + + /// Non-interactive brush, terminal stdin, no pipeline: nothing to do. + #[test] + fn non_interactive_with_terminal_stdin_does_nothing() { + assert_eq!(child_session_action(false, true, false), ChildSessionAction::None,); + } + + /// Non-interactive brush, terminal stdin, joining a pipeline pgroup: + /// nothing to do (parent already wired pgroup membership). + #[test] + fn non_interactive_terminal_stdin_in_pipeline_does_nothing() { + assert_eq!(child_session_action(false, true, true), ChildSessionAction::None,); + } + + /// **Embedded host bug fix.** Non-interactive brush, non-terminal stdin, + /// no pipeline pgroup: detach so the child cannot SIGTTIN/SIGTTOU the + /// host. This is the case that regressed before this fix and is the + /// motivating bug for PR #895. + #[test] + fn embedded_host_with_non_terminal_stdin_detaches() { + assert_eq!(child_session_action(false, false, false), ChildSessionAction::DetachSession,); + } + + /// **Pipeline carve-out.** Non-interactive brush, non-terminal stdin + /// (pipe), and a multi-command pipeline: MUST NOT detach. For the first + /// external stage, `setsid()` puts the process-group leader into a + /// different session, so later stages fail to join its group with + /// EPERM. For later stages, `setsid()` would either fail with EPERM or + /// move the child into a new session, breaking the pipeline's shared + /// process group and job-control signal propagation. + #[test] + fn pipeline_stage_does_not_detach() { + assert_eq!(child_session_action(false, false, true), ChildSessionAction::None,); + } + } + + /// End-to-end verification that brush, when embedded as a non-interactive + /// library (`interactive: false`, exactly what `create_session` produces), + /// spawns external commands in a **separate session** from the host. + /// + /// The truth-table tests in `child_session_action` cover the decision in + /// isolation. This test covers the wiring: it boots a real `BrushShell`, + /// runs a child that prints its PID then sleeps, and asks the kernel for + /// that PID's session via `getsid(2)` while the child is still alive. + /// Pre-fix (`new_pg=false` skipped `detach_session`), the child inherited + /// the host's session, so `getsid(child_pid) == getsid(0)`. Post-fix, + /// `setsid` ran and the child is its own session leader + /// (`getsid(child_pid) == child_pid`). + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn embedded_external_command_runs_in_its_own_session() { + use std::io::Read as _; + + // SAFETY: `getsid(0)` only queries the current process session; the return + // value is checked. + let host_sid = unsafe { libc::getsid(0) }; + assert!(host_sid > 0, "getsid(0) failed: {}", std::io::Error::last_os_error()); + + // Build the same kind of session pi-natives uses in production. + let config = ShellConfig { session_env: None, snapshot_path: None, minimizer: None }; + let mut session = create_session(&config).await.expect("create_session"); + + // Output pipe shared between the brush child and a concurrent reader. The + // reader runs on a blocking thread because `os_pipe` reads are blocking. + let (mut reader, writer) = pipe_to_files("e2e").expect("pipe"); + let stdout_file = OpenFile::from(writer.try_clone().expect("clone")); + let stderr_file = OpenFile::from(writer); + + let mut params = session.shell.default_exec_params(); + params.set_fd(OpenFiles::STDIN_FD, null_file().expect("null stdin")); + params.set_fd(OpenFiles::STDOUT_FD, stdout_file); + params.set_fd(OpenFiles::STDERR_FD, stderr_file); + + // (pid_tx, pid_rx) — reader task signals the test as soon as it has the PID. + let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); + let reader_handle = tokio::task::spawn_blocking(move || { + let mut buf = Vec::new(); + // Read just enough to capture the PID line. The child sleeps after + // printing so the pipe will not back-pressure. + let mut chunk = [0u8; 64]; + let mut pid_tx = Some(pid_tx); + while let Ok(n) = reader.read(&mut chunk) + && n > 0 + { + buf.extend_from_slice(&chunk[..n]); + if pid_tx.is_some() + && let Some(line_end) = buf.iter().position(|&byte| byte == b'\n') + && let Ok(line) = std::str::from_utf8(&buf[..line_end]) + && let Ok(pid) = line.trim().parse::() + { + let _ = pid_tx + .take() + .expect("pid sender should be present") + .send(pid); + } + } + buf + }); + + // Run brush in the background so we can call `getsid(child_pid)` while + // the child is still alive. + let shell_handle = tokio::spawn(async move { + let source_info = SourceInfo::from("pi-natives:test"); + // `printf '%d\n' "$$"` then `sleep 0.5`. Long enough for our `getsid`. + let exec = session + .shell + .run_string("/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'", &source_info, ¶ms) + .await + .expect("run_string"); + drop(params); + (session, exec) + }); + + let child_pid = time::timeout(Duration::from_secs(5), pid_rx) + .await + .expect("timed out waiting for child PID") + .expect("reader closed pid channel without sending"); + assert!(child_pid > 0, "got non-positive child pid: {child_pid}"); + + // Snapshot the child's session ID immediately, while the child is still + // in `sleep`. POSIX guarantees `getsid` against a live PID returns the + // session of that process. + // SAFETY: `child_pid` is a positive PID from the child; errors are reported via + // the checked return value. + let child_sid = unsafe { libc::getsid(child_pid) }; + assert!( + child_sid > 0, + "getsid({child_pid}) failed: {} (child may have already exited)", + std::io::Error::last_os_error(), + ); + + // Drain the brush task and the pipe reader. + let (_session, exec) = time::timeout(Duration::from_secs(5), shell_handle) + .await + .expect("shell timed out") + .expect("shell task panicked"); + assert!( + matches!(exec.exit_code, ExecutionExitCode::Success), + "unexpected exit: {}", + exit_code(&exec), + ); + let _ = time::timeout(Duration::from_secs(2), reader_handle).await; + + assert_ne!( + child_sid, host_sid, + "child PID {child_pid} inherited host session {host_sid}; setsid() did not run — the \ + embedded-host bug is back", + ); + assert_eq!( + child_sid, child_pid, + "child PID {child_pid} should be its own session leader after setsid", + ); + } + + #[tokio::test] + async fn abort_state_signals_cancel_token() { + let abort_state = ShellAbortState::default(); + let mut cancel_token = CancelToken::default(); + let abort_token = cancel_token.emplace_abort_token(); + + abort_state.set(abort_token).await; + abort_state.abort().await; + + let reason = time::timeout(Duration::from_millis(100), cancel_token.wait()) + .await + .expect("cancel token should be signalled"); + assert!(matches!(reason, AbortReason::Signal)); + } + + #[cfg(unix)] + #[tokio::test] + async fn read_output_stops_when_cancelled_before_pipe_eof() { + let (reader, _writer) = pipe_to_files("test").expect("test pipe should be created"); + let cancel = CancellationToken::new(); + let (activity_tx, _activity_rx) = mpsc::channel(1); + let handle = tokio::spawn(read_output(reader, None, cancel.clone(), activity_tx)); + + time::sleep(Duration::from_millis(10)).await; + cancel.cancel(); + + time::timeout(Duration::from_millis(100), handle) + .await + .expect("reader task should stop after cancellation") + .expect("reader task should not panic"); + } +} diff --git a/crates/pi-natives/src/shell/windows.rs b/crates/pi-shell/src/windows.rs similarity index 100% rename from crates/pi-natives/src/shell/windows.rs rename to crates/pi-shell/src/windows.rs