Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[env]
# Test threads get the same 8 MiB stack build.rs reserves for the rtk main
# thread. Parsing the full clap command graph in an unoptimized Windows build
# needs more than the 2 MiB a spawned test thread gets by default, so every
# new subcommand risked a STATUS_STACK_OVERFLOW in `cargo test` on Windows.
# An explicit RUST_MIN_STACK in the environment still wins.
RUST_MIN_STACK = { value = "8388608", force = false }
9 changes: 6 additions & 3 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ use std::fs;
use std::path::Path;

fn main() {
#[cfg(windows)]
{
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
// Clap + the full command graph can exceed the default 1 MiB Windows
// main-thread stack during process startup. Reserve a larger stack for
// the CLI binary so `rtk.exe --version`, `--help`, and hook entry
// points start reliably without requiring ad-hoc RUSTFLAGS.
println!("cargo:rustc-link-arg=/STACK:8388608");
let stack_arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg={stack_arg}");
}

check_cmds_modules_declared();
Expand Down
43 changes: 43 additions & 0 deletions src/cmds/system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,49 @@
- `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module)
- `summary.rs` executes argv directly before summarizing output. Shell-specific syntax requires `--shell <name> '<script>'`; it never guesses from `$SHELL`.

## Windows / no-coreutils fallback

`ls`, `grep`, `wc`, and `tree` shell out to Unix binaries that aren't present on
a stock Windows install. Each of these now has a **native Rust execution path**
used when the underlying binary is unavailable (detected via
`core::utils::tool_exists`; `tree` also always goes native on Windows because
`tree.com` rejects the flags rtk uses):

- `ls.rs` — `run_native` synthesizes `ls -la`-style lines from `std::fs` and
reuses `compact_ls`. POSIX permission bits don't exist on Windows, so the `-l`
octal column is approximate there.
- `wc_cmd.rs` — `run_native` counts lines/words/bytes/chars in Rust and reuses
`filter_wc_output`.
- `tree.rs` — `run_native` does a recursive `std::fs` walk, pruning `NOISE_DIRS`
unless `-a`.
- `search.rs` — `native_grep` (final fallback after `rg` then `grep`) walks
files with the `ignore` crate and matches with `regex`, emitting the same
NUL-separated `path\0line:content` format the parser expects. It respects
`.gitignore` and skips hidden files, which differs slightly from
`rg --no-ignore-vcs`.

These native paths reuse the existing compression filters, so token-savings
behavior is identical to the Unix spawn path.

## PowerShell cmdlet rewrites

`powershell_cmd.rs` maps common, **non-piped** PowerShell cmdlets to their rtk
equivalents so the hook saves tokens on Windows:

| Cmdlet (and aliases) | rtk equivalent |
|---|---|
| `Get-Content` / `gc` / `type` | `rtk read` (`-TotalCount`→`--max-lines`, `-Tail`→`--tail-lines`) |
| `Get-ChildItem` / `gci` / `dir` | `rtk ls` (`-Recurse`→`rtk tree`, `-Force`→`-a`) |
| `Select-String` / `sls` | `rtk grep` (`-i` added unless `-CaseSensitive`) |

Piped/compound cmdlet invocations are intentionally left untouched to avoid
breaking PowerShell pipeline semantics.

The `rtk powershell` and `rtk pwsh` wrappers also recognize safe `-Command`
forms and dispatch those same cmdlets through RTK. Scripts, `-File`,
`-EncodedCommand`, pipelines, interpolation, and unsupported parameters run
unchanged through the requested PowerShell executable.

## Cross-command

- `format_cmd` routes to `cmds/js/prettier_cmd` and `cmds/python/ruff_cmd`
189 changes: 188 additions & 1 deletion src/cmds/system/ls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ use crate::core::arg_tokenizer::{self, Attachment, Dialect, Token, TokenKind, Va
use crate::core::args_utils;
use crate::core::runner::{self, RunOptions};
use crate::core::shell::display_args;
use crate::core::tracking::TimedExecution;
use crate::core::truncate::CAP_INVENTORY;
use crate::core::utils::{ChildArgExt, resolved_command};
use crate::core::utils::{ChildArgExt, resolved_command, tool_exists};
use anyhow::Result;
use regex::Regex;
use std::sync::LazyLock;
Expand Down Expand Up @@ -303,6 +304,17 @@ pub fn run(args: &[String], verbose: u8) -> Result<i32> {
child_args,
} = plan(args);

// On Windows (and any host lacking the Unix `ls` binary) fall back to a
// native Rust listing so `rtk ls` works without coreutils installed.
if !tool_exists("ls") {
let paths: Vec<&str> = child_args
.iter()
.filter(|a| !a.starts_with('-'))
.map(String::as_str)
.collect();
return run_native(&paths, show_all, show_long, verbose);
}

let mut cmd = resolved_command("ls");
cmd.env("LC_ALL", "C");
cmd.child_args(&child_args);
Expand Down Expand Up @@ -356,6 +368,116 @@ pub fn run(args: &[String], verbose: u8) -> Result<i32> {
)
}

/// Native `ls` implementation used when the Unix binary is unavailable
/// (e.g. on stock Windows). Synthesizes `ls -la`-style lines from the
/// filesystem and reuses [`compact_ls`] so output matches the spawn path.
///
/// Note: POSIX permission bits don't exist on Windows, so synthetic perms
/// (`drwxr-xr-x` for dirs, `-rw-r--r--` for files) are emitted; the `-l`
/// octal column is therefore approximate on Windows.
fn run_native(paths: &[&str], show_all: bool, show_long: bool, verbose: u8) -> Result<i32> {
let timer = TimedExecution::start();
let targets: Vec<&str> = if paths.is_empty() {
vec!["."]
} else {
paths.to_vec()
};

let mut raw = String::new();
let mut exit_code = 0;

for target in &targets {
match std::fs::metadata(target) {
Ok(meta) if meta.is_dir() => match std::fs::read_dir(target) {
Ok(entries) => {
let mut lines: Vec<String> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !show_all && is_hidden_entry(&entry, &name) {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
lines.push(synth_ls_line(&name, is_dir, size));
}
lines.sort();
for line in lines {
raw.push_str(&line);
raw.push('\n');
}
}
Err(e) => {
eprintln!("ls: {target}: {e}");
exit_code = 2;
}
},
Ok(meta) => {
// A file argument: ls prints just the name.
raw.push_str(&synth_ls_line(target, false, meta.len()));
raw.push('\n');
}
Err(e) => {
eprintln!("ls: {target}: {e}");
exit_code = 2;
}
}
}

let (entries, _parsed, truncated, hidden) = compact_ls(&raw, show_all, show_long);
// Every target failed: the errors are already on stderr, so print no
// "(empty)" placeholder after them.
let mut filtered = if raw.is_empty() && exit_code != 0 {
String::new()
} else {
entries
};
if let Some(hint) = hidden_hint(&truncated, &hidden) {
filtered.push_str(&hint);
filtered.push('\n');
}

if verbose > 0 {
eprintln!("ls (native): {} target(s)", targets.len());
}

print!("{filtered}");
timer.track(
&format!("ls {}", display_args(&targets)),
"rtk ls",
&raw,
&filtered,
);
Ok(exit_code)
}

/// Whether the native listing (ls and tree) hides `entry` without `-a`.
///
/// On Windows the native path stands in for `Get-ChildItem` (PowerShell's `ls`
/// and `dir` are aliases of it), which hides entries by the Hidden/System
/// attributes, not by a leading dot: `.gitignore` is listed, while `.git`
/// (which git marks Hidden) is not. Elsewhere it is POSIX `ls`.
#[cfg(windows)]
pub(crate) fn is_hidden_entry(entry: &std::fs::DirEntry, _name: &str) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4;
entry
.metadata()
.is_ok_and(|m| m.file_attributes() & (FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM) != 0)
}

#[cfg(not(windows))]
pub(crate) fn is_hidden_entry(_entry: &std::fs::DirEntry, name: &str) -> bool {
name.starts_with('.')
}

/// Build a synthetic `ls -la` line that [`parse_ls_line`] can parse.
/// The date is a fixed valid anchor (not shown in compact output).
fn synth_ls_line(name: &str, is_dir: bool, size: u64) -> String {
let perms = if is_dir { "drwxr-xr-x" } else { "-rw-r--r--" };
format!("{perms} 1 user group {size} Jan 1 00:00 {name}")
}

/// Build the recovery hint for entries dropped from the listing —
/// truncated past the display cap and/or RTK-filtered noise.
///
Expand Down Expand Up @@ -600,6 +722,71 @@ fn compact_ls(
mod tests {
use super::*;

#[test]
fn native_listing_hides_by_the_platform_rule() {
let dir = tempfile::tempdir().expect("tempdir");
for name in [".gitignore", "plain.txt", "hid.txt"] {
std::fs::write(dir.path().join(name), "x").expect("write fixture");
}
#[cfg(windows)]
{
let status = std::process::Command::new("attrib")
.arg("+h")
.arg(dir.path().join("hid.txt"))
.status()
.expect("run attrib");
assert!(status.success());
}
let mut hidden: Vec<String> = std::fs::read_dir(dir.path())
.expect("read_dir")
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
is_hidden_entry(&e, &name).then_some(name)
})
.collect();
hidden.sort();
// Windows follows Get-ChildItem (Hidden attribute), elsewhere POSIX ls (leading dot).
#[cfg(windows)]
assert_eq!(hidden, vec!["hid.txt"]);
#[cfg(not(windows))]
assert_eq!(hidden, vec![".gitignore"]);
}

#[test]
fn test_synth_ls_line_parses() {
// A synthetic native line must be parseable by parse_ls_line.
let line = synth_ls_line("constants.rs", false, 542);
let parsed = parse_ls_line(&line).expect("synth line should parse");
let (file_type, _perms, size, name) = parsed;
assert_eq!(file_type, '-');
assert_eq!(size, 542);
assert_eq!(name, "constants.rs");
}

#[test]
fn test_synth_ls_line_dir() {
let line = synth_ls_line("src", true, 0);
let (file_type, _perms, _size, name) = parse_ls_line(&line).expect("dir line parses");
assert_eq!(file_type, 'd');
assert_eq!(name, "src");
}

#[test]
fn test_native_raw_round_trips_through_compact_ls() {
// Simulate native run_native output and verify compact_ls compresses it.
let mut raw = String::new();
raw.push_str(&synth_ls_line("src", true, 0));
raw.push('\n');
raw.push_str(&synth_ls_line("Cargo.toml", false, 1234));
raw.push('\n');
let (entries, parsed, _, _) = compact_ls(&raw, false, false);
assert_eq!(parsed, 2);
assert!(entries.contains("src/"));
assert!(entries.contains("Cargo.toml"));
assert!(entries.contains("1.2K"));
}

#[test]
fn test_compact_basic() {
let input = "total 48\n\
Expand Down
1 change: 1 addition & 0 deletions src/cmds/system/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod local_llm;
pub mod log_cmd;
pub mod ls;
pub mod pipe_cmd;
pub mod powershell_cmd;
pub mod read;
pub mod search;
pub mod summary;
Expand Down
Loading
Loading