From b2229bf4851aaad3b72305b37c883ec815604b7f Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 00:19:44 +0200 Subject: [PATCH 01/21] docs: add srchr Rust port design spec --- .../2026-07-07-srchr-rust-port-design.md | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md diff --git a/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md b/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md new file mode 100644 index 0000000..08e65d8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md @@ -0,0 +1,219 @@ +# srchr Rust port — design + +**Date:** 2026-07-07 +**Status:** Approved (design), pending implementation plan + +## Goal + +Replace the two shell implementations (`srchr.fish`, `srchr.sh`) with a single +self-contained Rust binary. The binary depends on **no external runtime tools +except the user's `$EDITOR`** — it embeds the roles currently played by `fd`, +`rg`, `fzf`, and `bat`. + +The Rust binary is the successor. The shell ports remain only until the binary +reaches behavioral parity, then they are retired. + +## Motivation + +The current tool delegates to `fd`, `rg`, `fzf`, and `bat`. The primary driver +for this port is **eliminating those external dependencies** so the tool is a +single binary that works without the user installing four separate programs. +Dropping the dual shell-port maintenance burden (and the byte-identical fzf +snippet invariant) is a secondary benefit. + +## Behavior (settled) + +The port also evolves the interaction model from the shell version's +"term given once, fuzzy-filter over paths" into a **live-grep**: + +- **Live-grep:** the typed query is a live content search; results refresh as + the user types (debounced). +- **Rows = files with match counts.** Each result row is a file plus its match + count. Name-only matches display `[name]` instead of a count. +- **Merged sources (name + content).** A file appears if its **name** matches + OR its **content** matches (preserving today's `fd` + `rg -l` dual nature), + deduped so a file matching both appears once. +- **Smart-case regex** for both content and filename matching: regex, and + case-insensitive unless the query contains an uppercase character (matches + `rg -S` / `fd` defaults). +- **Preview:** syntax-highlighted. For a content hit, highlight the **first** + matching line with ~3 lines of context above. For a name-only hit, show the + file from the top with no highlight. +- **Enter:** launch `$EDITOR` at the first match (`+ `), or just the + file (``) for name-only hits. +- **Path safety:** paths beginning with `+` or `-` are rewritten to `./…` + before being handed to the editor (the AGENTS.md invariant). + +## Architecture + +Approach: a bespoke `ratatui` TUI over the ripgrep-family search crates and +`syntect` for preview. This is chosen over reusing the `skim` crate because the +UX (file rows with counts, live content search, custom preview highlighting) +does not map onto skim's fuzzy-over-static-list model. + +### Project layout + +``` +srchr/ + srchr.fish, srchr.sh # kept until parity, then retired + rust/ + Cargo.toml + src/ + main.rs # arg parsing, terminal setup/teardown, top-level wiring + search.rs # walker + content search + filename match + aggregation + app.rs # TUI state machine (query, results, selection, preview) + ui.rs # ratatui rendering (input box, results list, preview pane) + preview.rs # syntect highlighting + line-range/highlight logic + editor.rs # $EDITOR launch + +/- path-safety guard + tests/ + search.rs # integration tests over a fixture tree +``` + +### Key crates + +`ignore`, `grep-regex`, `grep-searcher`, `regex`, `ratatui`, `crossterm`, +`syntect`. + +### Module boundaries + +- `search` — `fn search(query, root, cancel_flag) -> Vec` where + `FileHit { path, match_count, first_line: Option }`. No UI knowledge. +- `app` — holds state, reacts to events; owns no rendering or blocking I/O. +- `ui` — pure rendering from `app` state. +- `preview`, `editor` — pure functions of a path (+ optional line). + +`search` and `preview` are unit-testable without a terminal. The interactive +TUI itself cannot be auto-tested (same caveat as the shell version). + +## Search pipeline + +Per query: + +1. **Compile query once.** Detect smart-case (any uppercase → case-sensitive). + Build a `grep-regex` matcher (content) and a `regex::Regex` (filename) from + the same pattern and case flag. If the regex fails to compile (user + mid-typing), treat as no results and show a subtle hint; recover when valid. +2. **Walk the tree** with `ignore::WalkBuilder` (gitignore + hidden-file rules, + matching `fd`/`rg` defaults), parallel walk for speed. +3. **Per file → at most one `FileHit`:** + - Filename match: path/file name matches the filename regex → contributes + the file even with 0 content matches. + - Content match: run `grep-searcher`; count matching lines, record the + **first** matching line number (`-m1` equivalent for preview/jump; full + count still displayed). + - Name-only hit: `match_count = 0`, `first_line = None`. + - Binary files: skipped for content (searcher detects), like `rg`. +4. **Aggregate & order:** dedupe by path. Order: content matches first + (descending match count, then path), name-only matches last. +5. **Cancellation & debounce:** debounce ~50–80 ms per keystroke. Searches run + on a background thread with an `AtomicBool` cancel flag; a newer query + cancels the in-flight search. Results stream back via a channel; `app` + replaces the list on completion so the UI never blocks. + +## TUI layout & interaction + +``` +┌ query ────────────────────────────────┐ +│ > search term▏ │ +├ results ──────────────┬ preview ───────┤ +│ src/app.rs (12) │ (syntect- │ +│ src/search.rs (7) │ highlighted │ +│ README.md (3) │ preview of │ +│ Cargo.toml [name] │ selected file)│ +│ ... │ │ +└────────────────────────┴────────────────┘ + 42 files · rg-regex · smart-case +``` + +- Top: input box (live query). +- Left: results list — path + right-aligned match count; name-only → `[name]`. +- Right: preview pane for the selected row. +- Bottom: status line (result count, mode hints). + +**Keybindings:** +- Printable / Backspace → edit query (debounced search). +- `Up`/`Down` or `Ctrl-p`/`Ctrl-n` → move selection (updates preview). +- `Enter` → launch `$EDITOR` at selected hit, exit. +- `Esc` / `Ctrl-c` → quit, no action. + +**Event loop:** `crossterm` event stream. Query change → debounce → background +search. Results arrive via channel → update list, clamp selection, refresh +preview. Selection move → recompute preview only (no re-search). Preview is +computed **only for the selected row** (lazily), not for every row. + +**Deferred (YAGNI):** `PgUp`/`PgDn` preview scrolling. + +## Preview rendering (bat replacement, via syntect) + +- Bundled syntaxes + a fixed default theme (e.g. `base16-ocean.dark`). Syntax + detected by extension, falling back to first-line/plain. These sets are + embedded in the binary (the main binary-size cost — the same assets `bat` + ships). +- **Content hit:** render `start = max(1, first_line - 3)` through the visible + pane height, marking `first_line` with a distinct background. Mirrors + `--highlight-line` + `--line-range "$start:"` with 3 lines of context above. +- **Name-only hit:** render from the top, no highlight (the `else bat …` + branch). +- Convert syntect styled spans into ratatui `Line`/`Span` with matching colors. +- **Guardrails:** cap bytes/lines read to the visible window + margin; binary or + unreadable files show a placeholder. +- **Known tradeoff:** syntect output will not be pixel-identical to the user's + personal `bat` theme/config — inherent to dropping the `bat` dependency. A + theme flag/config can come later. + +## Editor launch & path safety + +- **Resolution:** read `$EDITOR`; if unset, fall back to `vi`. +- **Path safety:** if a path begins with `+` or `-`, rewrite to `./` + before handing it to the editor (vim treats leading `+` as a command, leading + `-` as an option). Also pass paths after `--` where supported. +- **Launch:** + - Content hit → `EDITOR + `. + - Name-only hit → `EDITOR `. +- **Terminal handoff:** before spawning, fully restore the terminal (leave + alternate screen, disable raw mode, show cursor), then run the editor as a + foreground child inheriting our stdio so it owns the tty. On editor exit, + srchr exits (equivalent to the shell version's `exec`; net user-visible + behavior is identical — pick → editor opens → done). +- **`+` portability:** the `+N` convention (vim/nvim/emacs/nano) is + supported now. A per-editor mapping (e.g. VS Code `-g file:line`) is a later + enhancement. + +## Error handling & edge cases + +- **Invalid/partial regex:** no crash — keep last good results or show empty + list with an "invalid pattern" status hint; auto-recover when valid. +- **Empty query:** show nothing (no whole-tree match-everything walk). +- **No matches:** empty list + "no matches" status; Enter does nothing. +- **Unreadable / permission-denied:** skipped silently during the walk. +- **Binary files:** skipped for content; a selected name-only binary file shows + a "binary file" placeholder. +- **Huge files:** preview reads only the needed window; search streams + line-by-line. +- **Terminal too small:** degrade gracefully (collapse preview below a min + width). +- **`$EDITOR` unset:** fall back to `vi`. +- **No TTY (piped/non-interactive):** detect and exit with a clear message. + +## Testing & verification + +- **`cargo test` integration tests** (`tests/search.rs`) over a fixture tree: + - filename-only match appears with `[name]` / `first_line = None` + - content match yields correct count + first line + - file matching both name and content appears once + - smart-case: lowercase query case-insensitive; uppercase makes it sensitive + - gitignore respected + - path-safety: `+`/`-` paths normalized to `./…` +- **Unit tests** for `preview` (line-range/highlight math) and `editor` + (arg-vector construction incl. `+N` and `+`/`-` guard). +- **`cargo clippy` + `cargo fmt --check`** as the lint/syntax gate. +- **CI:** extend GitHub Actions with a Rust job (fmt/clippy/test). +- **Interactive TUI flow:** cannot be auto-tested — manual smoke test by the + user (same caveat as today). + +## Out of scope (for this iteration) + +- Preview scrolling (`PgUp`/`PgDn`). +- Per-editor line-jump mappings beyond `+N`. +- User-configurable preview theme. +- Removal of the shell ports (happens after parity is confirmed). From b8497066ffa4087d38dbe07570156f962d72a137 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 00:21:31 +0200 Subject: [PATCH 02/21] docs: error on unset $EDITOR instead of vi fallback --- docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md b/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md index 08e65d8..7da87ea 100644 --- a/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md +++ b/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md @@ -163,7 +163,8 @@ computed **only for the selected row** (lazily), not for every row. ## Editor launch & path safety -- **Resolution:** read `$EDITOR`; if unset, fall back to `vi`. +- **Resolution:** read `$EDITOR`; if unset (or empty), exit with a clear error + ("$EDITOR is not set") rather than guessing an editor. - **Path safety:** if a path begins with `+` or `-`, rewrite to `./` before handing it to the editor (vim treats leading `+` as a command, leading `-` as an option). Also pass paths after `--` where supported. @@ -192,7 +193,7 @@ computed **only for the selected row** (lazily), not for every row. line-by-line. - **Terminal too small:** degrade gracefully (collapse preview below a min width). -- **`$EDITOR` unset:** fall back to `vi`. +- **`$EDITOR` unset/empty:** exit with a clear error; no editor is guessed. - **No TTY (piped/non-interactive):** detect and exit with a clear message. ## Testing & verification From 766c384a7393293a2a3510c360bc0a79847ef2b6 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 00:23:34 +0200 Subject: [PATCH 03/21] docs: record deferred bat-theme integration decision --- .../specs/2026-07-07-srchr-rust-port-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md b/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md index 7da87ea..879f70a 100644 --- a/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md +++ b/docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md @@ -160,6 +160,14 @@ computed **only for the selected row** (lazily), not for every row. - **Known tradeoff:** syntect output will not be pixel-identical to the user's personal `bat` theme/config — inherent to dropping the `bat` dependency. A theme flag/config can come later. +- **Considered and deferred: honoring the user's bat theme.** bat is built on + syntect and its themes are `.tmTheme` files syntect can load, so honoring + `$BAT_THEME` / bat's config is technically possible. Deferred because + syntect's built-in theme set only bundles a handful of themes; resolving an + arbitrary bat theme name would require embedding bat's larger theme + collection or loading `.tmTheme` files from bat's config dir at runtime — + a soft coupling to bat's config not worth it for this iteration. Revisit via + a `--theme` flag / optional `$BAT_THEME` lookup later. ## Editor launch & path safety From 4204408c391edd546835d2a30f7252bf7ad1ded9 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 00:28:56 +0200 Subject: [PATCH 04/21] docs: add srchr Rust port implementation plan --- .../plans/2026-07-07-srchr-rust-port.md | 1617 +++++++++++++++++ 1 file changed, 1617 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-srchr-rust-port.md diff --git a/docs/superpowers/plans/2026-07-07-srchr-rust-port.md b/docs/superpowers/plans/2026-07-07-srchr-rust-port.md new file mode 100644 index 0000000..07028f1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-srchr-rust-port.md @@ -0,0 +1,1617 @@ +# srchr Rust Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `srchr.fish` / `srchr.sh` shell functions with a single self-contained Rust binary that embeds file+content search, an interactive live-grep TUI, and syntax-highlighted preview, depending on no external runtime tool except `$EDITOR`. + +**Architecture:** A `ratatui`/`crossterm` TUI drives a live content+filename search built on ripgrep's library crates (`ignore`, `grep-regex`, `grep-searcher`, `regex`). Search runs on a debounced, cancellable background thread and streams file-level results (with match counts) to the UI. Preview is rendered with `syntect`. Selection launches `$EDITOR` at the first match. + +**Tech Stack:** Rust 1.95, `ignore` 0.4, `grep-regex` 0.1, `grep-searcher` 0.1, `regex` 1, `ratatui` 0.29, `crossterm` 0.28, `syntect` 5. + +**Design spec:** `docs/superpowers/specs/2026-07-07-srchr-rust-port-design.md` + +--- + +## File Structure + +``` +srchr/ + srchr.fish, srchr.sh # kept until parity confirmed, then retired (Task 13) + rust/ + Cargo.toml + src/ + main.rs # arg parsing, TTY check, terminal setup/teardown, event loop wiring + search.rs # FileHit, smart-case, filename + content match, walk, aggregate + app.rs # TUI state: query, results, selection, status + ui.rs # ratatui rendering (input box, results list, preview pane) + preview.rs # line-range math + syntect styling into ratatui lines + editor.rs # $EDITOR resolution, path-safety guard, arg-vector construction + tests/ + search_tests.rs # integration tests over a fixture tree +``` + +Module responsibilities and boundaries: +- `search` — pure-ish search logic. Public: `is_case_sensitive`, `Query`, `FileHit`, `search()`. No UI knowledge. +- `editor` — pure functions: `resolve_editor`, `normalize_path`, `editor_args`, plus a `launch` that spawns the process. +- `preview` — pure `preview_start`, `PreviewData`, `build_preview`; styling helper used by `ui`. +- `app` — state machine, no rendering or blocking I/O. +- `ui` — pure rendering from `app` state. +- `main` — owns the terminal, the debounce timer, and the background search thread. + +--- + +## Task 1: Project scaffold + +**Files:** +- Create: `rust/Cargo.toml` +- Create: `rust/src/main.rs` + +- [ ] **Step 1: Create `rust/Cargo.toml`** + +```toml +[package] +name = "srchr" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "srchr" +path = "src/main.rs" + +[dependencies] +ignore = "0.4" +grep-regex = "0.1" +grep-searcher = "0.1" +regex = "1" +ratatui = "0.29" +crossterm = "0.28" +syntect = "5" + +[dev-dependencies] +tempfile = "3" +``` + +- [ ] **Step 2: Create minimal `rust/src/main.rs`** + +```rust +fn main() { + println!("srchr"); +} +``` + +- [ ] **Step 3: Build to verify the toolchain and deps resolve** + +Run: `cargo build --manifest-path rust/Cargo.toml` +Expected: compiles successfully (downloads crates on first run). + +- [ ] **Step 4: Commit** + +```bash +git add rust/Cargo.toml rust/src/main.rs +git commit -m "chore: scaffold rust srchr crate" +``` + +--- + +## Task 2: Smart-case detection + +**Files:** +- Create: `rust/src/search.rs` +- Modify: `rust/src/main.rs` (declare module) + +- [ ] **Step 1: Write the failing test** — append to `rust/src/search.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercase_query_is_case_insensitive() { + assert!(!is_case_sensitive("todo")); + } + + #[test] + fn uppercase_char_makes_it_case_sensitive() { + assert!(is_case_sensitive("Todo")); + } +} +``` + +- [ ] **Step 2: Add the module declaration** — at the top of `rust/src/main.rs`: + +```rust +mod search; + +fn main() { + println!("srchr"); +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml is_case` +Expected: FAIL — `is_case_sensitive` not found. + +- [ ] **Step 4: Implement** — add to the top of `rust/src/search.rs`: + +```rust +/// Smart-case: case-sensitive only when the query contains an uppercase char. +pub fn is_case_sensitive(query: &str) -> bool { + query.chars().any(|c| c.is_uppercase()) +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml is_case` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add rust/src/search.rs rust/src/main.rs +git commit -m "feat: smart-case detection" +``` + +--- + +## Task 3: FileHit type and Query compilation + +**Files:** +- Modify: `rust/src/search.rs` + +- [ ] **Step 1: Write the failing test** — add to the `tests` module in `rust/src/search.rs`: + +```rust + #[test] + fn query_compiles_valid_pattern() { + assert!(Query::compile("foo").is_ok()); + } + + #[test] + fn query_rejects_invalid_regex() { + assert!(Query::compile("foo(").is_err()); + } + + #[test] + fn query_uppercase_is_case_sensitive() { + let q = Query::compile("Foo").unwrap(); + assert!(q.case_sensitive); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml query_` +Expected: FAIL — `Query` not found. + +- [ ] **Step 3: Implement** — add near the top of `rust/src/search.rs`: + +```rust +use std::path::PathBuf; +use grep_regex::RegexMatcher; +use regex::RegexBuilder; + +/// One result row: a file that matched by name and/or content. +#[derive(Debug, Clone)] +pub struct FileHit { + pub path: PathBuf, + /// Number of content matches; 0 for name-only hits. + pub match_count: usize, + /// First matching line (1-based); None for name-only hits. + pub first_line: Option, +} + +/// A compiled query: a content matcher (grep) and a filename matcher (regex). +pub struct Query { + pub content: RegexMatcher, + pub name: regex::Regex, + pub case_sensitive: bool, +} + +impl Query { + pub fn compile(pattern: &str) -> Result { + let case_sensitive = is_case_sensitive(pattern); + let content = grep_regex::RegexMatcherBuilder::new() + .case_insensitive(!case_sensitive) + .build(pattern) + .map_err(|e| e.to_string())?; + let name = RegexBuilder::new(pattern) + .case_insensitive(!case_sensitive) + .build() + .map_err(|e| e.to_string())?; + Ok(Query { content, name, case_sensitive }) + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml query_` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/search.rs +git commit -m "feat: Query compilation and FileHit type" +``` + +--- + +## Task 4: Per-file content search + +**Files:** +- Modify: `rust/src/search.rs` + +- [ ] **Step 1: Write the failing test** — add to the `tests` module: + +```rust + use std::io::Write; + + fn write_file(dir: &std::path::Path, name: &str, body: &str) -> PathBuf { + let p = dir.join(name); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p + } + + #[test] + fn content_search_counts_and_first_line() { + let dir = tempfile::tempdir().unwrap(); + let p = write_file(dir.path(), "a.txt", "alpha\nbeta\nalpha\n"); + let q = Query::compile("alpha").unwrap(); + let (count, first) = search_file_content(&q, &p).unwrap(); + assert_eq!(count, 2); + assert_eq!(first, Some(1)); + } + + #[test] + fn content_search_no_match_is_zero() { + let dir = tempfile::tempdir().unwrap(); + let p = write_file(dir.path(), "a.txt", "nothing here\n"); + let q = Query::compile("zzz").unwrap(); + let (count, first) = search_file_content(&q, &p).unwrap(); + assert_eq!(count, 0); + assert_eq!(first, None); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml content_search` +Expected: FAIL — `search_file_content` not found. + +- [ ] **Step 3: Implement** — add to `rust/src/search.rs`: + +```rust +use std::path::Path; +use grep_searcher::Searcher; +use grep_searcher::sinks::UTF8; + +/// Returns (total match count, first matching line number 1-based). +pub fn search_file_content(query: &Query, path: &Path) -> std::io::Result<(usize, Option)> { + let mut count = 0usize; + let mut first: Option = None; + Searcher::new().search_path( + &query.content, + path, + UTF8(|lnum, _line| { + count += 1; + if first.is_none() { + first = Some(lnum as usize); + } + Ok(true) + }), + )?; + Ok((count, first)) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml content_search` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/search.rs +git commit -m "feat: per-file content search with count and first line" +``` + +--- + +## Task 5: Filename matching + +**Files:** +- Modify: `rust/src/search.rs` + +- [ ] **Step 1: Write the failing test** — add to the `tests` module: + +```rust + #[test] + fn filename_match_uses_basename() { + let q = Query::compile("config").unwrap(); + assert!(name_matches(&q, std::path::Path::new("src/config.rs"))); + assert!(!name_matches(&q, std::path::Path::new("src/main.rs"))); + } + + #[test] + fn filename_match_is_smart_case() { + let q = Query::compile("readme").unwrap(); + assert!(name_matches(&q, std::path::Path::new("README.md"))); + let q2 = Query::compile("README").unwrap(); + assert!(!name_matches(&q2, std::path::Path::new("readme.md"))); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml filename_match` +Expected: FAIL — `name_matches` not found. + +- [ ] **Step 3: Implement** — add to `rust/src/search.rs` (matches `fd`'s default of testing the basename): + +```rust +/// True if the file's basename matches the query (mirrors `fd` default). +pub fn name_matches(query: &Query, path: &Path) -> bool { + match path.file_name().and_then(|n| n.to_str()) { + Some(name) => query.name.is_match(name), + None => false, + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml filename_match` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/search.rs +git commit -m "feat: smart-case filename matching on basename" +``` + +--- + +## Task 6: Result ordering + +**Files:** +- Modify: `rust/src/search.rs` + +- [ ] **Step 1: Write the failing test** — add to the `tests` module: + +```rust + #[test] + fn ordering_content_before_name_only_then_by_count() { + let mut hits = vec![ + FileHit { path: "z_name.rs".into(), match_count: 0, first_line: None }, + FileHit { path: "b.rs".into(), match_count: 2, first_line: Some(1) }, + FileHit { path: "a.rs".into(), match_count: 5, first_line: Some(3) }, + ]; + sort_hits(&mut hits); + let paths: Vec<_> = hits.iter().map(|h| h.path.to_str().unwrap()).collect(); + assert_eq!(paths, vec!["a.rs", "b.rs", "z_name.rs"]); + } + + #[test] + fn ordering_breaks_count_ties_by_path() { + let mut hits = vec![ + FileHit { path: "b.rs".into(), match_count: 1, first_line: Some(1) }, + FileHit { path: "a.rs".into(), match_count: 1, first_line: Some(1) }, + ]; + sort_hits(&mut hits); + assert_eq!(hits[0].path.to_str().unwrap(), "a.rs"); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml ordering_` +Expected: FAIL — `sort_hits` not found. + +- [ ] **Step 3: Implement** — add to `rust/src/search.rs`: + +```rust +/// Content matches first (by descending count), then name-only; ties by path. +pub fn sort_hits(hits: &mut [FileHit]) { + hits.sort_by(|a, b| { + let a_name_only = a.first_line.is_none(); + let b_name_only = b.first_line.is_none(); + a_name_only + .cmp(&b_name_only) + .then_with(|| b.match_count.cmp(&a.match_count)) + .then_with(|| a.path.cmp(&b.path)) + }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml ordering_` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/search.rs +git commit -m "feat: result ordering (content-first, by count, path tiebreak)" +``` + +--- + +## Task 7: Tree walk + aggregation with cancellation + +**Files:** +- Modify: `rust/src/search.rs` +- Create: `rust/tests/search_tests.rs` + +- [ ] **Step 1: Write the failing integration test** — create `rust/tests/search_tests.rs`: + +```rust +use srchr::search::{search, Query}; +use std::io::Write; +use std::path::Path; + +fn write(dir: &Path, name: &str, body: &str) { + let p = dir.join(name); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); +} + +fn cancel_never() -> std::sync::Arc { + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) +} + +#[test] +fn merges_name_and_content_hits_deduped() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "alpha.rs", "alpha token\n"); // name AND content + write(dir.path(), "other.rs", "has alpha inside\n"); // content only + write(dir.path(), "alpha_only.txt", "nothing\n"); // name only + let q = Query::compile("alpha").unwrap(); + let hits = search(&q, dir.path(), &cancel_never()); + + let by_name: std::collections::HashMap<_, _> = hits + .iter() + .map(|h| (h.path.file_name().unwrap().to_str().unwrap().to_string(), h.clone())) + .collect(); + + // alpha.rs appears once, with content match info + let a = &by_name["alpha.rs"]; + assert_eq!(a.match_count, 1); + assert_eq!(a.first_line, Some(1)); + + // name-only hit present with 0 count / no line + let n = &by_name["alpha_only.txt"]; + assert_eq!(n.match_count, 0); + assert_eq!(n.first_line, None); + + // exactly three distinct files + assert_eq!(hits.len(), 3); +} + +#[test] +fn respects_gitignore() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".gitignore", "ignored/\n"); + write(dir.path(), "ignored/secret.rs", "alpha\n"); + write(dir.path(), "kept.rs", "alpha\n"); + let q = Query::compile("alpha").unwrap(); + let hits = search(&q, dir.path(), &cancel_never()); + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"kept.rs".to_string())); + assert!(!names.contains(&"secret.rs".to_string())); +} + +#[test] +fn cancel_flag_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.rs", "alpha\n"); + let q = Query::compile("alpha").unwrap(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let hits = search(&q, dir.path(), &cancel); + assert!(hits.is_empty()); +} +``` + +- [ ] **Step 2: Expose the crate library** — the integration test uses `srchr::search`, so add a library target. Create `rust/src/lib.rs`: + +```rust +pub mod search; +pub mod editor; +pub mod preview; +``` + +Then add to `rust/Cargo.toml` under `[[bin]]` (append a lib section): + +```toml +[lib] +name = "srchr" +path = "src/lib.rs" +``` + +And change `rust/src/main.rs`'s `mod search;` line to use the library crate: + +```rust +use srchr::search; +``` + +(Create `rust/src/editor.rs` and `rust/src/preview.rs` as empty files for now so `lib.rs` compiles: each may contain just `// filled in later`.) + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml --test search_tests` +Expected: FAIL — `search` function not found. + +- [ ] **Step 4: Implement** — add to `rust/src/search.rs`: + +```rust +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use ignore::WalkBuilder; + +/// Walk `root` (gitignore-aware), producing one FileHit per matching file. +/// Returns empty if `cancel` is set. Cancellation is checked per entry. +pub fn search(query: &Query, root: &Path, cancel: &Arc) -> Vec { + let mut hits: Vec = Vec::new(); + + for result in WalkBuilder::new(root).build() { + if cancel.load(Ordering::Relaxed) { + return Vec::new(); + } + let entry = match result { + Ok(e) => e, + Err(_) => continue, // skip unreadable entries silently + }; + if !entry.file_type().map_or(false, |ft| ft.is_file()) { + continue; + } + let path = entry.path(); + + let name_hit = name_matches(query, path); + let (count, first) = search_file_content(query, path).unwrap_or((0, None)); + + if count > 0 || name_hit { + hits.push(FileHit { + path: path.to_path_buf(), + match_count: count, + first_line: first, + }); + } + } + + sort_hits(&mut hits); + hits +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cargo test --manifest-path rust/Cargo.toml --test search_tests` +Expected: PASS (all three). + +- [ ] **Step 6: Run the full suite and clippy** + +Run: `cargo test --manifest-path rust/Cargo.toml && cargo clippy --manifest-path rust/Cargo.toml -- -D warnings` +Expected: all tests PASS, no clippy warnings. + +- [ ] **Step 7: Commit** + +```bash +git add rust/Cargo.toml rust/src/lib.rs rust/src/main.rs rust/src/search.rs rust/src/editor.rs rust/src/preview.rs rust/tests/search_tests.rs +git commit -m "feat: gitignore-aware walk merging name and content hits" +``` + +--- + +## Task 8: Editor resolution, path safety, and arg vector + +**Files:** +- Modify: `rust/src/editor.rs` + +- [ ] **Step 1: Write the failing tests** — replace the placeholder contents of `rust/src/editor.rs` with: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_leaves_plain_path() { + assert_eq!(normalize_path("src/main.rs"), "src/main.rs"); + } + + #[test] + fn normalize_guards_leading_plus() { + assert_eq!(normalize_path("+weird.rs"), "./+weird.rs"); + } + + #[test] + fn normalize_guards_leading_dash() { + assert_eq!(normalize_path("-weird.rs"), "./-weird.rs"); + } + + #[test] + fn args_with_line_prepend_plus_line() { + assert_eq!(editor_args("src/main.rs", Some(42)), vec!["+42", "src/main.rs"]); + } + + #[test] + fn args_without_line_just_path() { + assert_eq!(editor_args("src/main.rs", None), vec!["src/main.rs"]); + } + + #[test] + fn args_apply_path_guard() { + assert_eq!(editor_args("-weird.rs", Some(3)), vec!["+3", "./-weird.rs"]); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib editor` +Expected: FAIL — functions not found. + +- [ ] **Step 3: Implement** — add above the `tests` module in `rust/src/editor.rs`: + +```rust +/// Rewrite paths that begin with `+` or `-` to `./...` so editors don't treat +/// them as commands/options. +pub fn normalize_path(path: &str) -> String { + if path.starts_with('+') || path.starts_with('-') { + format!("./{path}") + } else { + path.to_string() + } +} + +/// Build the argument vector for the editor. Content hits jump to `+line`. +pub fn editor_args(path: &str, line: Option) -> Vec { + let safe = normalize_path(path); + match line { + Some(n) => vec![format!("+{n}"), safe], + None => vec![safe], + } +} + +/// Resolve `$EDITOR`; error (rather than guess) if unset or empty. +pub fn resolve_editor() -> Result { + match std::env::var("EDITOR") { + Ok(e) if !e.trim().is_empty() => Ok(e), + _ => Err("$EDITOR is not set".to_string()), + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib editor` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/editor.rs +git commit -m "feat: editor resolution, path-safety guard, arg vector" +``` + +--- + +## Task 9: Editor launch + +**Files:** +- Modify: `rust/src/editor.rs` + +- [ ] **Step 1: Write the failing test** — add to the `tests` module in `rust/src/editor.rs` (uses a fake editor that records its args): + +```rust + #[test] + fn launch_invokes_editor_with_args() { + use std::io::Read; + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("args.txt"); + // Fake editor: a shell script that writes its args to a file. + let script = dir.path().join("fakeed.sh"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\n", out.display()), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let status = launch(script.to_str().unwrap(), &editor_args("file.rs", Some(7))).unwrap(); + assert!(status.success()); + + let mut s = String::new(); + std::fs::File::open(&out).unwrap().read_to_string(&mut s).unwrap(); + assert_eq!(s, "+7\nfile.rs\n"); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib launch_invokes` +Expected: FAIL — `launch` not found. + +- [ ] **Step 3: Implement** — add to `rust/src/editor.rs`: + +```rust +use std::process::{Command, ExitStatus}; + +/// Spawn the editor as a foreground child inheriting stdio (it owns the tty), +/// and wait for it to exit. The terminal must already be restored by the caller. +pub fn launch(editor: &str, args: &[String]) -> std::io::Result { + Command::new(editor).args(args).status() +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib launch_invokes` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/editor.rs +git commit -m "feat: launch editor as inheriting foreground child" +``` + +--- + +## Task 10: Preview line-range math and data assembly + +**Files:** +- Modify: `rust/src/preview.rs` + +- [ ] **Step 1: Write the failing tests** — replace the placeholder contents of `rust/src/preview.rs` with: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::path::Path; + + fn write(dir: &Path, name: &str, body: &str) -> std::path::PathBuf { + let p = dir.join(name); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p + } + + #[test] + fn start_is_three_above_the_match() { + assert_eq!(preview_start(Some(10)), 7); + } + + #[test] + fn start_clamps_to_one_near_top() { + assert_eq!(preview_start(Some(2)), 1); + assert_eq!(preview_start(Some(1)), 1); + } + + #[test] + fn start_is_one_for_name_only() { + assert_eq!(preview_start(None), 1); + } + + #[test] + fn content_hit_starts_above_match_and_highlights() { + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.rs", "l1\nl2\nl3\nl4\nl5\nMATCH\nl7\n"); + let data = build_preview(&p, Some(6), 100).unwrap(); + assert_eq!(data.highlight, Some(6)); + // starts 3 above line 6 => line 3 + assert_eq!(data.lines.first().unwrap().0, 3); + assert!(data.lines.iter().any(|(n, t)| *n == 6 && t == "MATCH")); + } + + #[test] + fn name_only_hit_starts_at_top_no_highlight() { + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.rs", "first\nsecond\n"); + let data = build_preview(&p, None, 100).unwrap(); + assert_eq!(data.highlight, None); + assert_eq!(data.lines.first().unwrap().0, 1); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib preview` +Expected: FAIL — `preview_start` / `build_preview` not found. + +- [ ] **Step 3: Implement** — add above the `tests` module in `rust/src/preview.rs`: + +```rust +use std::io::{BufRead, BufReader}; +use std::path::Path; + +/// Mirrors the shell `start=$((line > 3 ? line - 3 : 1))`. +pub fn preview_start(first_line: Option) -> usize { + match first_line { + Some(n) if n > 3 => n - 3, + _ => 1, + } +} + +/// Lines selected for preview plus which line to highlight. +pub struct PreviewData { + /// (1-based line number, text) pairs, starting at `preview_start`. + pub lines: Vec<(usize, String)>, + pub highlight: Option, +} + +/// Read up to `max_lines` lines from the file starting at `preview_start`. +pub fn build_preview( + path: &Path, + first_line: Option, + max_lines: usize, +) -> std::io::Result { + let start = preview_start(first_line); + let reader = BufReader::new(std::fs::File::open(path)?); + let mut lines = Vec::new(); + for (idx, line) in reader.lines().enumerate() { + let lnum = idx + 1; + if lnum < start { + continue; + } + if lines.len() >= max_lines { + break; + } + lines.push((lnum, line.unwrap_or_default())); + } + Ok(PreviewData { lines, highlight: first_line }) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib preview` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/preview.rs +git commit -m "feat: preview line-range math and data assembly" +``` + +--- + +## Task 11: Syntect styling into ratatui lines + +**Files:** +- Modify: `rust/src/preview.rs` + +- [ ] **Step 1: Write the failing test** — add to the `tests` module in `rust/src/preview.rs`: + +```rust + #[test] + fn styled_lines_match_input_line_count_and_mark_highlight() { + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.rs", "fn main() {}\nlet x = 1;\nMATCH\n"); + let data = build_preview(&p, Some(3), 100).unwrap(); + let styled = style_preview(&data, "a.rs"); + // one styled line per source line + assert_eq!(styled.lines.len(), data.lines.len()); + // the highlighted row is recorded for the UI + assert_eq!(styled.highlight_index, Some(2)); // line 3 is the 3rd row (index 2) + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib styled_lines` +Expected: FAIL — `style_preview` / `StyledPreview` not found. + +- [ ] **Step 3: Implement** — add to `rust/src/preview.rs`. This converts syntect highlighting into `ratatui::text::Line`s and records which row (index into `lines`) is the highlighted match so the UI can paint its background. + +```rust +use once_cell::sync::Lazy; +use ratatui::style::{Color as TuiColor, Style}; +use ratatui::text::{Line, Span}; +use syntect::easy::HighlightLines; +use syntect::highlighting::{Style as SynStyle, ThemeSet}; +use syntect::parsing::SyntaxSet; + +static SYNTAXES: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); +static THEMES: Lazy = Lazy::new(ThemeSet::load_defaults); +const DEFAULT_THEME: &str = "base16-ocean.dark"; + +pub struct StyledPreview { + pub lines: Vec>, + /// Index into `lines` of the match row, if any. + pub highlight_index: Option, +} + +fn syn_to_tui(color: syntect::highlighting::Color) -> TuiColor { + TuiColor::Rgb(color.r, color.g, color.b) +} + +/// Highlight preview lines with syntect, choosing syntax by file name/extension. +pub fn style_preview(data: &PreviewData, file_name: &str) -> StyledPreview { + let syntax = SYNTAXES + .find_syntax_for_file(file_name) + .ok() + .flatten() + .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()); + let theme = &THEMES.themes[DEFAULT_THEME]; + let mut hl = HighlightLines::new(syntax, theme); + + let mut out_lines: Vec> = Vec::with_capacity(data.lines.len()); + let mut highlight_index: Option = None; + + for (row, (lnum, text)) in data.lines.iter().enumerate() { + if Some(*lnum) == data.highlight { + highlight_index = Some(row); + } + let ranges: Vec<(SynStyle, &str)> = hl + .highlight_line(text, &SYNTAXES) + .unwrap_or_else(|_| vec![(SynStyle::default(), text.as_str())]); + let spans: Vec> = ranges + .into_iter() + .map(|(style, piece)| { + Span::styled( + piece.to_string(), + Style::default().fg(syn_to_tui(style.foreground)), + ) + }) + .collect(); + out_lines.push(Line::from(spans)); + } + + StyledPreview { lines: out_lines, highlight_index } +} +``` + +- [ ] **Step 4: Add `once_cell` dependency** — in `rust/Cargo.toml` under `[dependencies]`: + +```toml +once_cell = "1" +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib styled_lines` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add rust/Cargo.toml rust/src/preview.rs +git commit -m "feat: syntect syntax highlighting into ratatui lines" +``` + +--- + +## Task 12: App state machine + +**Files:** +- Create: `rust/src/app.rs` +- Modify: `rust/src/lib.rs` (add `pub mod app;`) + +- [ ] **Step 1: Write the failing tests** — create `rust/src/app.rs`: + +```rust +use crate::search::FileHit; + +/// UI state, independent of rendering and I/O. +pub struct App { + pub query: String, + pub results: Vec, + pub selected: usize, + pub status: String, +} + +impl App { + pub fn new() -> Self { + App { query: String::new(), results: Vec::new(), selected: 0, status: String::new() } + } + + pub fn push_char(&mut self, c: char) { + self.query.push(c); + } + + pub fn backspace(&mut self) { + self.query.pop(); + } + + /// Replace results (from a completed search) and clamp the selection. + pub fn set_results(&mut self, results: Vec) { + self.results = results; + if self.selected >= self.results.len() { + self.selected = self.results.len().saturating_sub(1); + } + } + + pub fn move_down(&mut self) { + if self.selected + 1 < self.results.len() { + self.selected += 1; + } + } + + pub fn move_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + pub fn selected_hit(&self) -> Option<&FileHit> { + self.results.get(self.selected) + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn hit(name: &str) -> FileHit { + FileHit { path: PathBuf::from(name), match_count: 1, first_line: Some(1) } + } + + #[test] + fn typing_and_backspace_edit_query() { + let mut app = App::new(); + app.push_char('a'); + app.push_char('b'); + app.backspace(); + assert_eq!(app.query, "a"); + } + + #[test] + fn set_results_clamps_selection() { + let mut app = App::new(); + app.set_results(vec![hit("a"), hit("b"), hit("c")]); + app.selected = 2; + app.set_results(vec![hit("a")]); + assert_eq!(app.selected, 0); + } + + #[test] + fn selection_moves_within_bounds() { + let mut app = App::new(); + app.set_results(vec![hit("a"), hit("b")]); + app.move_up(); // stays at 0 + assert_eq!(app.selected, 0); + app.move_down(); + assert_eq!(app.selected, 1); + app.move_down(); // stays at 1 + assert_eq!(app.selected, 1); + } +} +``` + +- [ ] **Step 2: Register the module** — add to `rust/src/lib.rs`: + +```rust +pub mod app; +``` + +- [ ] **Step 3: Run the tests to verify they pass** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib app` +Expected: PASS (the implementation is included in Step 1 alongside the tests). + +- [ ] **Step 4: Commit** + +```bash +git add rust/src/app.rs rust/src/lib.rs +git commit -m "feat: app state machine (query, results, selection)" +``` + +--- + +## Task 13: UI rendering + +**Files:** +- Create: `rust/src/ui.rs` +- Modify: `rust/src/lib.rs` (add `pub mod ui;`) + +- [ ] **Step 1: Implement rendering** — create `rust/src/ui.rs`. Rendering is validated manually (TUI can't be auto-tested); keep it a pure function of `&App` plus a rendered preview. + +```rust +use crate::app::App; +use crate::preview::StyledPreview; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +/// Render the whole UI. `preview` is the styled preview of the selected row. +pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1), Constraint::Length(1)]) + .split(f.area()); + + // Query box + let query = Paragraph::new(format!("> {}", app.query)) + .block(Block::default().borders(Borders::ALL).title("query")); + f.render_widget(query, chunks[0]); + + // Middle: results | preview + let mid = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(chunks[1]); + + let items: Vec = app + .results + .iter() + .map(|h| { + let path = h.path.to_string_lossy(); + let tag = if h.first_line.is_none() { + "[name]".to_string() + } else { + format!("({})", h.match_count) + }; + ListItem::new(Line::from(vec![ + Span::raw(path.into_owned()), + Span::raw(" "), + Span::styled(tag, Style::default().fg(Color::DarkGray)), + ])) + }) + .collect(); + + let mut state = ListState::default(); + if !app.results.is_empty() { + state.select(Some(app.selected)); + } + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("results")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + f.render_stateful_widget(list, mid[0], &mut state); + + // Preview, with the match row background-highlighted. + let preview_lines: Vec = preview + .lines + .iter() + .enumerate() + .map(|(i, line)| { + if Some(i) == preview.highlight_index { + let mut l = line.clone(); + l = l.style(Style::default().bg(Color::Rgb(60, 60, 80))); + l + } else { + line.clone() + } + }) + .collect(); + let preview_widget = + Paragraph::new(preview_lines).block(Block::default().borders(Borders::ALL).title("preview")); + f.render_widget(preview_widget, mid[1]); + + // Status line + let status = Paragraph::new(app.status.clone()).style(Style::default().fg(Color::DarkGray)); + f.render_widget(status, chunks[2]); +} +``` + +- [ ] **Step 2: Register the module** — add to `rust/src/lib.rs`: + +```rust +pub mod ui; +``` + +- [ ] **Step 3: Build to verify it compiles** + +Run: `cargo build --manifest-path rust/Cargo.toml` +Expected: compiles (may need minor ratatui API adjustments for the pinned version — fix any signature mismatches, e.g. `f.area()` vs `f.size()`). + +- [ ] **Step 4: Commit** + +```bash +git add rust/src/ui.rs rust/src/lib.rs +git commit -m "feat: ratatui rendering of query, results, and preview" +``` + +--- + +## Task 14: Main event loop — TTY check, debounce, background search, editor handoff + +**Files:** +- Modify: `rust/src/main.rs` + +- [ ] **Step 1: Implement the event loop** — replace `rust/src/main.rs` with: + +```rust +use std::io::{self, IsTerminal}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::execute; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use srchr::app::App; +use srchr::editor; +use srchr::preview::{build_preview, style_preview, PreviewData, StyledPreview}; +use srchr::search::{search, FileHit, Query}; + +/// A completed search's results, tagged with the query that produced them. +struct SearchResult { + query: String, + hits: Vec, +} + +const DEBOUNCE: Duration = Duration::from_millis(60); +const PREVIEW_MAX_LINES: usize = 400; + +fn main() { + if !io::stdout().is_terminal() { + eprintln!("srchr: not a terminal (this is an interactive tool)"); + std::process::exit(2); + } + + let root = std::env::args().nth(1).map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")); + + if let Err(e) = run(root) { + eprintln!("srchr: {e}"); + std::process::exit(1); + } +} + +fn run(root: PathBuf) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + let (result_tx, result_rx): (Sender, Receiver) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + + let mut pending_query: Option = None; + let mut pending_at = Instant::now(); + let mut launch_target: Option<(String, Option)> = None; + + loop { + // Drain completed searches; keep only results for the current query. + while let Ok(res) = result_rx.try_recv() { + if res.query == app.query { + app.set_results(res.hits); + app.status = format!("{} files", app.results.len()); + } + } + + // Fire a debounced search if the query settled. + if let Some(q) = pending_query.clone() { + if pending_at.elapsed() >= DEBOUNCE { + pending_query = None; + spawn_search(&q, &root, &cancel, result_tx.clone()); + } + } + + // Render. + let styled = current_preview(&app); + terminal.draw(|f| srchr::ui::render(f, &app, &styled))?; + + // Poll input with a short timeout so the debounce/results loop keeps ticking. + if event::poll(Duration::from_millis(30))? { + if let Event::Key(key) = event::read()? { + match handle_key(key, &mut app) { + Action::Quit => break, + Action::Open => { + if let Some(hit) = app.selected_hit() { + launch_target = + Some((hit.path.to_string_lossy().into_owned(), hit.first_line)); + } + break; + } + Action::QueryChanged => { + cancel.store(true, Ordering::Relaxed); // cancel in-flight + let fresh = Arc::new(AtomicBool::new(false)); + // Reset the shared cancel by swapping in a fresh flag. + // (See note below: we clone a fresh Arc per search instead.) + let _ = fresh; + pending_query = Some(app.query.clone()); + pending_at = Instant::now(); + if app.query.is_empty() { + app.set_results(Vec::new()); + app.status.clear(); + pending_query = None; + } + } + Action::None => {} + } + } + } + } + + // Restore the terminal BEFORE launching the editor. + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + if let Some((path, line)) = launch_target { + let ed = editor::resolve_editor().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + editor::launch(&ed, &editor::editor_args(&path, line))?; + } + + Ok(()) +} + +enum Action { + None, + Quit, + Open, + QueryChanged, +} + +fn handle_key(key: KeyEvent, app: &mut App) -> Action { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => Action::Quit, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit, + (KeyCode::Enter, _) => Action::Open, + (KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + app.move_down(); + Action::None + } + (KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + app.move_up(); + Action::None + } + (KeyCode::Backspace, _) => { + app.backspace(); + Action::QueryChanged + } + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + app.push_char(c); + Action::QueryChanged + } + _ => Action::None, + } +} + +/// Build the styled preview for the currently selected row. +fn current_preview(app: &App) -> StyledPreview { + match app.selected_hit() { + Some(hit) => { + let name = hit + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + match build_preview(&hit.path, hit.first_line, PREVIEW_MAX_LINES) { + Ok(data) => style_preview(&data, &name), + Err(_) => empty_preview(""), + } + } + None => empty_preview(""), + } +} + +fn empty_preview(msg: &str) -> StyledPreview { + let data = PreviewData { lines: vec![(1, msg.to_string())], highlight: None }; + style_preview(&data, "") +} + +/// Spawn a background search with its own fresh cancel flag. +fn spawn_search(query: &str, root: &PathBuf, cancel: &Arc, tx: Sender) { + // Reset the shared flag for the new search. + cancel.store(false, Ordering::Relaxed); + let cancel = Arc::clone(cancel); + let query = query.to_string(); + let root = root.clone(); + thread::spawn(move || { + let hits = match Query::compile(&query) { + Ok(q) => search(&q, &root, &cancel), + Err(_) => Vec::new(), // invalid/partial regex: show nothing + }; + let _ = tx.send(SearchResult { query, hits }); + }); +} +``` + +> **Implementation note for the engineer:** the cancellation model above shares one `AtomicBool`. On `QueryChanged` we set it to `true` to signal any in-flight search to stop, and `spawn_search` resets it to `false` for the new run. Because searches are debounced (only the settled query spawns), at most one search is typically in flight. If you observe races (a stale search resetting the flag), switch to a per-search `Arc` stored in a small struct and compare a generation counter before applying results. Keep the `res.query == app.query` guard regardless — it is the correctness backstop that discards stale results. + +- [ ] **Step 2: Build** + +Run: `cargo build --manifest-path rust/Cargo.toml` +Expected: compiles. Fix any pinned-version API mismatches (e.g. `Frame` generics, `f.area()`). + +- [ ] **Step 3: Run the full test suite + clippy + fmt** + +Run: `cargo test --manifest-path rust/Cargo.toml && cargo clippy --manifest-path rust/Cargo.toml -- -D warnings && cargo fmt --manifest-path rust/Cargo.toml -- --check` +Expected: tests PASS, no clippy warnings, formatting clean. (If fmt fails, run `cargo fmt --manifest-path rust/Cargo.toml` and re-commit.) + +- [ ] **Step 4: Manual smoke test (requires a TTY — ask the user)** + +Ask the user to run: +```bash +cargo run --manifest-path rust/Cargo.toml -- . +``` +and confirm: typing filters live; results show counts / `[name]`; arrows move selection and update preview; the match line is highlighted; Enter opens `$EDITOR` at the right line; Esc quits cleanly with the terminal restored. + +- [ ] **Step 5: Commit** + +```bash +git add rust/src/main.rs +git commit -m "feat: live-grep TUI event loop with debounce and editor handoff" +``` + +--- + +## Task 15: CI + edge-case hardening + +**Files:** +- Modify: `.github/workflows/` (add or extend a workflow with a Rust job) +- Modify: `rust/src/main.rs` and `rust/src/preview.rs` (edge cases below) + +- [ ] **Step 1: Add binary-file preview placeholder test** — add to the `tests` module in `rust/src/preview.rs`: + +```rust + #[test] + fn binary_content_yields_placeholder() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("bin.dat"); + std::fs::write(&p, [0u8, 159, 146, 150, 0, 1, 2]).unwrap(); + let data = build_preview_safe(&p, None, 100); + assert!(data.lines.iter().any(|(_, t)| t.contains("binary"))); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib binary_content` +Expected: FAIL — `build_preview_safe` not found. + +- [ ] **Step 3: Implement `build_preview_safe`** — add to `rust/src/preview.rs`, and switch `main.rs::current_preview` to call it: + +```rust +/// Like `build_preview`, but detects binary/unreadable files and returns a +/// placeholder instead of garbage. +pub fn build_preview_safe(path: &Path, first_line: Option, max_lines: usize) -> PreviewData { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(_) => { + return PreviewData { lines: vec![(1, "".into())], highlight: None } + } + }; + if bytes.iter().take(8192).any(|b| *b == 0) { + return PreviewData { lines: vec![(1, "".into())], highlight: None }; + } + build_preview(path, first_line, max_lines).unwrap_or(PreviewData { + lines: vec![(1, "".into())], + highlight: None, + }) +} +``` + +Then in `rust/src/main.rs`, change `current_preview` to use it: + +```rust + let data = build_preview_safe(&hit.path, hit.first_line, PREVIEW_MAX_LINES); + style_preview(&data, &name) +``` + +(and update the `use srchr::preview::...` import to bring in `build_preview_safe` instead of `build_preview`.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml --lib binary_content` +Expected: PASS. + +- [ ] **Step 5: Add a Rust CI job** — create `.github/workflows/rust.yml`: + +```yaml +name: rust +on: + push: + pull_request: +jobs: + build-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - run: cargo fmt -- --check + - run: cargo clippy -- -D warnings + - run: cargo test +``` + +- [ ] **Step 6: Verify locally then commit** + +Run: `cargo fmt --manifest-path rust/Cargo.toml -- --check && cargo clippy --manifest-path rust/Cargo.toml -- -D warnings && cargo test --manifest-path rust/Cargo.toml` +Expected: all green. + +```bash +git add .github/workflows/rust.yml rust/src/preview.rs rust/src/main.rs +git commit -m "ci: rust job; feat: binary/unreadable preview placeholders" +``` + +--- + +## Task 16: Retire the shell ports + +> Only after the user confirms the binary reaches parity via the manual smoke test in Task 14. + +**Files:** +- Modify: `AGENTS.md` +- Modify: `README.md` (if present) +- Delete: `srchr.fish`, `srchr.sh`, `tests/smoke.sh` (shell-specific) + +- [ ] **Step 1: Confirm parity** — ask the user to confirm the manual smoke test passed and they are ready to remove the shell versions. Do not proceed without confirmation. + +- [ ] **Step 2: Update `AGENTS.md`** — remove the "keep the ports in sync" invariant and the shell-specific structure/verification sections; document the Rust crate layout, `cargo test`/`clippy`/`fmt` verification, and the manual TUI smoke-test caveat instead. (Write the new content to match the actual final module set.) + +- [ ] **Step 3: Remove shell files** + +```bash +git rm srchr.fish srchr.sh tests/smoke.sh +``` + +- [ ] **Step 4: Verify the Rust suite still passes** + +Run: `cargo test --manifest-path rust/Cargo.toml` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add AGENTS.md +git commit -m "chore: retire shell ports in favor of the rust binary" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Self-contained, only `$EDITOR` spawned → Tasks 4–7 (ignore/grep, no fd/rg), 11 (syntect, no bat), 9/14 (editor). ✔ +- Live-grep, debounced → Task 14 (`DEBOUNCE`, background thread, channel). ✔ +- Rows = files with match counts; `[name]` for name-only → Tasks 6, 12, 13. ✔ +- Merged name + content, deduped → Task 7. ✔ +- Smart-case regex (content + filename) → Tasks 2, 3, 5. ✔ +- Preview: first-match highlight, 3 lines above; name-only from top → Tasks 10, 11. ✔ +- Enter opens `+line file` / `file`; path-safety guard → Tasks 8, 9, 14. ✔ +- `$EDITOR` unset → error (no guess) → Task 8. ✔ +- Edge cases: invalid regex, empty query, no matches, unreadable/binary, no TTY → Tasks 14, 15. ✔ +- Testing: cargo test integration + unit, clippy/fmt, CI, manual TUI caveat → Tasks 7–15. ✔ +- Fixed default theme (bat-theme deferred) → Task 11 (`DEFAULT_THEME`). ✔ +- Retire shell ports after parity → Task 16. ✔ + +**Placeholder scan:** No "TBD"/"handle edge cases"-style gaps; every code step shows code. The two "adjust for pinned API" notes (Tasks 13, 14) are legitimate version-drift cautions, not missing content. + +**Type consistency:** `FileHit { path, match_count, first_line }`, `Query`, `PreviewData { lines, highlight }`, `StyledPreview { lines, highlight_index }`, and `App` methods are used consistently across search/preview/app/ui/main. `build_preview` (Task 10) is superseded by `build_preview_safe` (Task 15) in `main`, with the import change called out explicitly. + +**Known follow-ups (out of scope, per spec):** parallel walk optimization, `PgUp`/`PgDn` preview scrolling, `--theme`/`$BAT_THEME`, per-editor line-jump mappings. From ddf24a6335e0b90528fd5b6708740cfa44722549 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:30:48 +0200 Subject: [PATCH 05/21] chore: scaffold rust srchr crate --- rust/Cargo.toml | 20 ++++++++++++++++++++ rust/src/main.rs | 3 +++ 2 files changed, 23 insertions(+) create mode 100644 rust/Cargo.toml create mode 100644 rust/src/main.rs diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..dfab22f --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "srchr" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "srchr" +path = "src/main.rs" + +[dependencies] +ignore = "0.4" +grep-regex = "0.1" +grep-searcher = "0.1" +regex = "1" +ratatui = "0.29" +crossterm = "0.28" +syntect = "5" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..e881b4b --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("srchr"); +} From be4079f6ebad5d0f8ea934c513cdc7375201b5e3 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:31:21 +0200 Subject: [PATCH 06/21] chore: gitignore rust target, track Cargo.lock --- rust/.gitignore | 1 + rust/Cargo.lock | 1164 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1165 insertions(+) create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..169e2f7 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1164 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "srchr" +version = "0.1.0" +dependencies = [ + "crossterm", + "grep-regex", + "grep-searcher", + "ignore", + "ratatui", + "regex", + "syntect", + "tempfile", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" From 5324800963a5c7282dec3e8867aee38c082c5826 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:33:04 +0200 Subject: [PATCH 07/21] feat: smart-case detection --- rust/src/main.rs | 2 ++ rust/src/search.rs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 rust/src/search.rs diff --git a/rust/src/main.rs b/rust/src/main.rs index e881b4b..cce6db2 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,3 +1,5 @@ +mod search; + fn main() { println!("srchr"); } diff --git a/rust/src/search.rs b/rust/src/search.rs new file mode 100644 index 0000000..954aede --- /dev/null +++ b/rust/src/search.rs @@ -0,0 +1,19 @@ +/// Smart-case: case-sensitive only when the query contains an uppercase char. +pub fn is_case_sensitive(query: &str) -> bool { + query.chars().any(|c| c.is_uppercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercase_query_is_case_insensitive() { + assert!(!is_case_sensitive("todo")); + } + + #[test] + fn uppercase_char_makes_it_case_sensitive() { + assert!(is_case_sensitive("Todo")); + } +} From f8c2dc19d0acf504d96269be137f497624c081f0 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:33:27 +0200 Subject: [PATCH 08/21] feat: Query compilation and FileHit type --- rust/src/search.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/rust/src/search.rs b/rust/src/search.rs index 954aede..2a66c6a 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -1,8 +1,44 @@ +use std::path::PathBuf; +use grep_regex::RegexMatcher; +use regex::RegexBuilder; + /// Smart-case: case-sensitive only when the query contains an uppercase char. pub fn is_case_sensitive(query: &str) -> bool { query.chars().any(|c| c.is_uppercase()) } +/// One result row: a file that matched by name and/or content. +#[derive(Debug, Clone)] +pub struct FileHit { + pub path: PathBuf, + /// Number of content matches; 0 for name-only hits. + pub match_count: usize, + /// First matching line (1-based); None for name-only hits. + pub first_line: Option, +} + +/// A compiled query: a content matcher (grep) and a filename matcher (regex). +pub struct Query { + pub content: RegexMatcher, + pub name: regex::Regex, + pub case_sensitive: bool, +} + +impl Query { + pub fn compile(pattern: &str) -> Result { + let case_sensitive = is_case_sensitive(pattern); + let content = grep_regex::RegexMatcherBuilder::new() + .case_insensitive(!case_sensitive) + .build(pattern) + .map_err(|e| e.to_string())?; + let name = RegexBuilder::new(pattern) + .case_insensitive(!case_sensitive) + .build() + .map_err(|e| e.to_string())?; + Ok(Query { content, name, case_sensitive }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -16,4 +52,19 @@ mod tests { fn uppercase_char_makes_it_case_sensitive() { assert!(is_case_sensitive("Todo")); } + + #[test] + fn query_compiles_valid_pattern() { + assert!(Query::compile("foo").is_ok()); + } + + #[test] + fn query_rejects_invalid_regex() { + assert!(Query::compile("foo(").is_err()); + } + + #[test] + fn query_uppercase_is_case_sensitive() { + assert!(Query::compile("Foo").unwrap().case_sensitive); + } } From ccc25e5d65d948998d75e026c0068052c19c9886 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:34:01 +0200 Subject: [PATCH 09/21] feat: per-file content search with count and first line --- rust/src/search.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/rust/src/search.rs b/rust/src/search.rs index 2a66c6a..6528774 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -1,5 +1,8 @@ use std::path::PathBuf; +use std::path::Path; use grep_regex::RegexMatcher; +use grep_searcher::Searcher; +use grep_searcher::sinks::UTF8; use regex::RegexBuilder; /// Smart-case: case-sensitive only when the query contains an uppercase char. @@ -39,6 +42,24 @@ impl Query { } } +/// Returns (total match count, first matching line number 1-based). +pub fn search_file_content(query: &Query, path: &Path) -> std::io::Result<(usize, Option)> { + let mut count = 0usize; + let mut first: Option = None; + Searcher::new().search_path( + &query.content, + path, + UTF8(|lnum, _line| { + count += 1; + if first.is_none() { + first = Some(lnum as usize); + } + Ok(true) + }), + )?; + Ok((count, first)) +} + #[cfg(test)] mod tests { use super::*; @@ -67,4 +88,32 @@ mod tests { fn query_uppercase_is_case_sensitive() { assert!(Query::compile("Foo").unwrap().case_sensitive); } + + fn write_file(dir: &std::path::Path, name: &str, body: &str) -> PathBuf { + use std::io::Write; + let p = dir.join(name); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p + } + + #[test] + fn content_search_counts_and_first_line() { + let dir = tempfile::tempdir().unwrap(); + let p = write_file(dir.path(), "a.txt", "alpha\nbeta\nalpha\n"); + let q = Query::compile("alpha").unwrap(); + let (count, first) = search_file_content(&q, &p).unwrap(); + assert_eq!(count, 2); + assert_eq!(first, Some(1)); + } + + #[test] + fn content_search_no_match_is_zero() { + let dir = tempfile::tempdir().unwrap(); + let p = write_file(dir.path(), "a.txt", "nothing here\n"); + let q = Query::compile("zzz").unwrap(); + let (count, first) = search_file_content(&q, &p).unwrap(); + assert_eq!(count, 0); + assert_eq!(first, None); + } } From 7f919793be3e01b08122bdb3dfae54b116698701 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:34:20 +0200 Subject: [PATCH 10/21] feat: smart-case filename matching on basename --- rust/src/search.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/rust/src/search.rs b/rust/src/search.rs index 6528774..81d0204 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -60,6 +60,14 @@ pub fn search_file_content(query: &Query, path: &Path) -> std::io::Result<(usize Ok((count, first)) } +/// True if the file's basename matches the query (mirrors `fd` default). +pub fn name_matches(query: &Query, path: &Path) -> bool { + match path.file_name().and_then(|n| n.to_str()) { + Some(name) => query.name.is_match(name), + None => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -116,4 +124,19 @@ mod tests { assert_eq!(count, 0); assert_eq!(first, None); } + + #[test] + fn filename_match_uses_basename() { + let q = Query::compile("config").unwrap(); + assert!(name_matches(&q, Path::new("src/config.rs"))); + assert!(!name_matches(&q, Path::new("src/main.rs"))); + } + + #[test] + fn filename_match_is_smart_case() { + let q = Query::compile("readme").unwrap(); + assert!(name_matches(&q, Path::new("README.md"))); + let q2 = Query::compile("README").unwrap(); + assert!(!name_matches(&q2, Path::new("readme.md"))); + } } From b0adc39f4c516f2fe1d852f0fd9769ff4dbf0dce Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:34:52 +0200 Subject: [PATCH 11/21] feat: result ordering (content-first, by count, path tiebreak) --- rust/src/search.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/rust/src/search.rs b/rust/src/search.rs index 81d0204..f90f719 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -68,6 +68,18 @@ pub fn name_matches(query: &Query, path: &Path) -> bool { } } +/// Content matches first (by descending count), then name-only; ties by path. +pub fn sort_hits(hits: &mut [FileHit]) { + hits.sort_by(|a, b| { + let a_name_only = a.first_line.is_none(); + let b_name_only = b.first_line.is_none(); + a_name_only + .cmp(&b_name_only) + .then_with(|| b.match_count.cmp(&a.match_count)) + .then_with(|| a.path.cmp(&b.path)) + }); +} + #[cfg(test)] mod tests { use super::*; @@ -139,4 +151,27 @@ mod tests { let q2 = Query::compile("README").unwrap(); assert!(!name_matches(&q2, Path::new("readme.md"))); } + + #[test] + fn ordering_content_before_name_only_then_by_count() { + let mut hits = vec![ + FileHit { path: "z_name.rs".into(), match_count: 0, first_line: None }, + FileHit { path: "b.rs".into(), match_count: 2, first_line: Some(1) }, + FileHit { path: "a.rs".into(), match_count: 5, first_line: Some(3) }, + ]; + sort_hits(&mut hits); + let order: Vec<_> = hits.iter().map(|h| h.path.to_str().unwrap()).collect(); + assert_eq!(order, vec!["a.rs", "b.rs", "z_name.rs"]); + } + + #[test] + fn ordering_breaks_count_ties_by_path() { + let mut hits = vec![ + FileHit { path: "b.rs".into(), match_count: 1, first_line: Some(1) }, + FileHit { path: "a.rs".into(), match_count: 1, first_line: Some(1) }, + ]; + sort_hits(&mut hits); + let order: Vec<_> = hits.iter().map(|h| h.path.to_str().unwrap()).collect(); + assert_eq!(order, vec!["a.rs", "b.rs"]); + } } From e6e049b9ecfcdc3efee768b478338823cd3cb096 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:36:24 +0200 Subject: [PATCH 12/21] feat: gitignore-aware walk merging name and content hits --- rust/Cargo.toml | 4 +++ rust/src/editor.rs | 1 + rust/src/lib.rs | 3 ++ rust/src/main.rs | 3 +- rust/src/preview.rs | 1 + rust/src/search.rs | 37 +++++++++++++++++++++ rust/tests/search_tests.rs | 67 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 rust/src/editor.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/preview.rs create mode 100644 rust/tests/search_tests.rs diff --git a/rust/Cargo.toml b/rust/Cargo.toml index dfab22f..8cac4d5 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,6 +7,10 @@ edition = "2021" name = "srchr" path = "src/main.rs" +[lib] +name = "srchr" +path = "src/lib.rs" + [dependencies] ignore = "0.4" grep-regex = "0.1" diff --git a/rust/src/editor.rs b/rust/src/editor.rs new file mode 100644 index 0000000..7bb3afa --- /dev/null +++ b/rust/src/editor.rs @@ -0,0 +1 @@ +// filled in later diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..035da54 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,3 @@ +pub mod search; +pub mod editor; +pub mod preview; diff --git a/rust/src/main.rs b/rust/src/main.rs index cce6db2..0860444 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,4 +1,5 @@ -mod search; +#[allow(unused_imports)] +use srchr::search; fn main() { println!("srchr"); diff --git a/rust/src/preview.rs b/rust/src/preview.rs new file mode 100644 index 0000000..7bb3afa --- /dev/null +++ b/rust/src/preview.rs @@ -0,0 +1 @@ +// filled in later diff --git a/rust/src/search.rs b/rust/src/search.rs index f90f719..204ef1e 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -1,8 +1,11 @@ use std::path::PathBuf; use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use grep_regex::RegexMatcher; use grep_searcher::Searcher; use grep_searcher::sinks::UTF8; +use ignore::WalkBuilder; use regex::RegexBuilder; /// Smart-case: case-sensitive only when the query contains an uppercase char. @@ -80,6 +83,40 @@ pub fn sort_hits(hits: &mut [FileHit]) { }); } +/// Walk `root` (gitignore-aware), producing one FileHit per matching file. +/// Returns empty if `cancel` is set. Cancellation is checked per entry. +pub fn search(query: &Query, root: &Path, cancel: &Arc) -> Vec { + let mut hits: Vec = Vec::new(); + + for result in WalkBuilder::new(root).require_git(false).build() { + if cancel.load(Ordering::Relaxed) { + return Vec::new(); + } + let entry = match result { + Ok(e) => e, + Err(_) => continue, // skip unreadable entries silently + }; + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + let path = entry.path(); + + let name_hit = name_matches(query, path); + let (count, first) = search_file_content(query, path).unwrap_or((0, None)); + + if count > 0 || name_hit { + hits.push(FileHit { + path: path.to_path_buf(), + match_count: count, + first_line: first, + }); + } + } + + sort_hits(&mut hits); + hits +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/tests/search_tests.rs b/rust/tests/search_tests.rs new file mode 100644 index 0000000..65b4218 --- /dev/null +++ b/rust/tests/search_tests.rs @@ -0,0 +1,67 @@ +use srchr::search::{search, Query}; +use std::io::Write; +use std::path::Path; + +fn write(dir: &Path, name: &str, body: &str) { + let p = dir.join(name); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); +} + +fn cancel_never() -> std::sync::Arc { + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) +} + +#[test] +fn merges_name_and_content_hits_deduped() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "alpha.rs", "alpha token\n"); + write(dir.path(), "other.rs", "has alpha inside\n"); + write(dir.path(), "alpha_only.txt", "nothing\n"); + let q = Query::compile("alpha").unwrap(); + let hits = search(&q, dir.path(), &cancel_never()); + + let by_name: std::collections::HashMap<_, _> = hits + .iter() + .map(|h| (h.path.file_name().unwrap().to_str().unwrap().to_string(), h.clone())) + .collect(); + + let a = &by_name["alpha.rs"]; + assert_eq!(a.match_count, 1); + assert_eq!(a.first_line, Some(1)); + + let n = &by_name["alpha_only.txt"]; + assert_eq!(n.match_count, 0); + assert_eq!(n.first_line, None); + + assert_eq!(hits.len(), 3); +} + +#[test] +fn respects_gitignore() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".gitignore", "ignored/\n"); + write(dir.path(), "ignored/secret.rs", "alpha\n"); + write(dir.path(), "kept.rs", "alpha\n"); + let q = Query::compile("alpha").unwrap(); + let hits = search(&q, dir.path(), &cancel_never()); + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"kept.rs".to_string())); + assert!(!names.contains(&"secret.rs".to_string())); +} + +#[test] +fn cancel_flag_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.rs", "alpha\n"); + let q = Query::compile("alpha").unwrap(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let hits = search(&q, dir.path(), &cancel); + assert!(hits.is_empty()); +} From d8e1006acecf9ce071c4da81f9559d3eda5f4605 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:39:15 +0200 Subject: [PATCH 13/21] feat: editor resolution, path safety, args, launch --- rust/src/editor.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/rust/src/editor.rs b/rust/src/editor.rs index 7bb3afa..91579fa 100644 --- a/rust/src/editor.rs +++ b/rust/src/editor.rs @@ -1 +1,94 @@ -// filled in later +use std::process::{Command, ExitStatus}; + +/// Rewrite paths that begin with `+` or `-` to `./...` so editors don't treat +/// them as commands/options. +pub fn normalize_path(path: &str) -> String { + if path.starts_with('+') || path.starts_with('-') { + format!("./{path}") + } else { + path.to_string() + } +} + +/// Build the argument vector for the editor. Content hits jump to `+line`. +pub fn editor_args(path: &str, line: Option) -> Vec { + let safe = normalize_path(path); + match line { + Some(n) => vec![format!("+{n}"), safe], + None => vec![safe], + } +} + +/// Resolve `$EDITOR`; error (rather than guess) if unset or empty. +pub fn resolve_editor() -> Result { + match std::env::var("EDITOR") { + Ok(e) if !e.trim().is_empty() => Ok(e), + _ => Err("$EDITOR is not set".to_string()), + } +} + +/// Spawn the editor as a foreground child inheriting stdio (it owns the tty), +/// and wait for it to exit. The terminal must already be restored by the caller. +pub fn launch(editor: &str, args: &[String]) -> std::io::Result { + Command::new(editor).args(args).status() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_leaves_plain_path() { + assert_eq!(normalize_path("src/main.rs"), "src/main.rs"); + } + + #[test] + fn normalize_guards_leading_plus() { + assert_eq!(normalize_path("+weird.rs"), "./+weird.rs"); + } + + #[test] + fn normalize_guards_leading_dash() { + assert_eq!(normalize_path("-weird.rs"), "./-weird.rs"); + } + + #[test] + fn args_with_line_prepend_plus_line() { + assert_eq!(editor_args("src/main.rs", Some(42)), vec!["+42", "src/main.rs"]); + } + + #[test] + fn args_without_line_just_path() { + assert_eq!(editor_args("src/main.rs", None), vec!["src/main.rs"]); + } + + #[test] + fn args_apply_path_guard() { + assert_eq!(editor_args("-weird.rs", Some(3)), vec!["+3", "./-weird.rs"]); + } + + #[test] + fn launch_invokes_editor_with_args() { + use std::io::Read; + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("args.txt"); + let script = dir.path().join("fakeed.sh"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\n", out.display()), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let status = launch(script.to_str().unwrap(), &editor_args("file.rs", Some(7))).unwrap(); + assert!(status.success()); + + let mut s = String::new(); + std::fs::File::open(&out).unwrap().read_to_string(&mut s).unwrap(); + assert_eq!(s, "+7\nfile.rs\n"); + } +} From 78392581706bd46c35c67cd22129beec67847efd Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:40:43 +0200 Subject: [PATCH 14/21] feat: preview line window and syntect styling --- rust/Cargo.lock | 1 + rust/Cargo.toml | 1 + rust/src/preview.rs | 155 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 169e2f7..3e474a2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -843,6 +843,7 @@ dependencies = [ "grep-regex", "grep-searcher", "ignore", + "once_cell", "ratatui", "regex", "syntect", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 8cac4d5..3f9b133 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -19,6 +19,7 @@ regex = "1" ratatui = "0.29" crossterm = "0.28" syntect = "5" +once_cell = "1" [dev-dependencies] tempfile = "3" diff --git a/rust/src/preview.rs b/rust/src/preview.rs index 7bb3afa..3a95871 100644 --- a/rust/src/preview.rs +++ b/rust/src/preview.rs @@ -1 +1,154 @@ -// filled in later +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use once_cell::sync::Lazy; +use ratatui::style::{Color as TuiColor, Style}; +use ratatui::text::{Line, Span}; +use syntect::easy::HighlightLines; +use syntect::highlighting::{Style as SynStyle, ThemeSet}; +use syntect::parsing::SyntaxSet; + +static SYNTAXES: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); +static THEMES: Lazy = Lazy::new(ThemeSet::load_defaults); +const DEFAULT_THEME: &str = "base16-ocean.dark"; + +/// Mirrors the shell `start=$((line > 3 ? line - 3 : 1))`. +pub fn preview_start(first_line: Option) -> usize { + match first_line { + Some(n) if n > 3 => n - 3, + _ => 1, + } +} + +/// Lines selected for preview plus which line to highlight. +pub struct PreviewData { + /// (1-based line number, text) pairs, starting at `preview_start`. + pub lines: Vec<(usize, String)>, + pub highlight: Option, +} + +/// Read up to `max_lines` lines from the file starting at `preview_start`. +pub fn build_preview( + path: &Path, + first_line: Option, + max_lines: usize, +) -> std::io::Result { + let start = preview_start(first_line); + let reader = BufReader::new(std::fs::File::open(path)?); + let mut lines = Vec::new(); + for (idx, line) in reader.lines().enumerate() { + let lnum = idx + 1; + if lnum < start { + continue; + } + if lines.len() >= max_lines { + break; + } + lines.push((lnum, line.unwrap_or_default())); + } + Ok(PreviewData { lines, highlight: first_line }) +} + +pub struct StyledPreview { + pub lines: Vec>, + /// Index into `lines` of the match row, if any. + pub highlight_index: Option, +} + +fn syn_to_tui(color: syntect::highlighting::Color) -> TuiColor { + TuiColor::Rgb(color.r, color.g, color.b) +} + +/// Highlight preview lines with syntect, choosing syntax by file name/extension. +pub fn style_preview(data: &PreviewData, file_name: &str) -> StyledPreview { + let syntax = SYNTAXES + .find_syntax_for_file(file_name) + .ok() + .flatten() + .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()); + let theme = &THEMES.themes[DEFAULT_THEME]; + let mut hl = HighlightLines::new(syntax, theme); + + let mut out_lines: Vec> = Vec::with_capacity(data.lines.len()); + let mut highlight_index: Option = None; + + for (row, (lnum, text)) in data.lines.iter().enumerate() { + if Some(*lnum) == data.highlight { + highlight_index = Some(row); + } + let ranges: Vec<(SynStyle, &str)> = hl + .highlight_line(text, &SYNTAXES) + .unwrap_or_else(|_| vec![(SynStyle::default(), text.as_str())]); + let spans: Vec> = ranges + .into_iter() + .map(|(style, piece)| { + Span::styled( + piece.to_string(), + Style::default().fg(syn_to_tui(style.foreground)), + ) + }) + .collect(); + out_lines.push(Line::from(spans)); + } + + StyledPreview { lines: out_lines, highlight_index } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::path::Path; + + fn write(dir: &Path, name: &str, body: &str) -> std::path::PathBuf { + let p = dir.join(name); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p + } + + #[test] + fn start_is_three_above_the_match() { + assert_eq!(preview_start(Some(10)), 7); + } + + #[test] + fn start_clamps_to_one_near_top() { + assert_eq!(preview_start(Some(2)), 1); + assert_eq!(preview_start(Some(1)), 1); + } + + #[test] + fn start_is_one_for_name_only() { + assert_eq!(preview_start(None), 1); + } + + #[test] + fn content_hit_starts_above_match_and_highlights() { + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.rs", "l1\nl2\nl3\nl4\nl5\nMATCH\nl7\n"); + let data = build_preview(&p, Some(6), 100).unwrap(); + assert_eq!(data.highlight, Some(6)); + assert_eq!(data.lines.first().unwrap().0, 3); + assert!(data.lines.iter().any(|(n, t)| *n == 6 && t == "MATCH")); + } + + #[test] + fn name_only_hit_starts_at_top_no_highlight() { + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.rs", "first\nsecond\n"); + let data = build_preview(&p, None, 100).unwrap(); + assert_eq!(data.highlight, None); + assert_eq!(data.lines.first().unwrap().0, 1); + } + + #[test] + fn styled_lines_match_input_line_count_and_mark_highlight() { + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.rs", "fn main() {}\nlet x = 1;\nMATCH\n"); + let data = build_preview(&p, Some(3), 100).unwrap(); + let styled = style_preview(&data, "a.rs"); + assert_eq!(styled.lines.len(), data.lines.len()); + assert_eq!(styled.highlight_index, Some(2)); + } +} From 39e21e67248aef22f54715c3b59f6d6937f3fa35 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:41:19 +0200 Subject: [PATCH 15/21] feat: app state machine --- rust/src/app.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + 2 files changed, 92 insertions(+) create mode 100644 rust/src/app.rs diff --git a/rust/src/app.rs b/rust/src/app.rs new file mode 100644 index 0000000..54394e3 --- /dev/null +++ b/rust/src/app.rs @@ -0,0 +1,91 @@ +use crate::search::FileHit; + +/// UI state, independent of rendering and I/O. +pub struct App { + pub query: String, + pub results: Vec, + pub selected: usize, + pub status: String, +} + +impl App { + pub fn new() -> Self { + App { query: String::new(), results: Vec::new(), selected: 0, status: String::new() } + } + + pub fn push_char(&mut self, c: char) { + self.query.push(c); + } + + pub fn backspace(&mut self) { + self.query.pop(); + } + + /// Replace results (from a completed search) and clamp the selection. + pub fn set_results(&mut self, results: Vec) { + self.results = results; + if self.selected >= self.results.len() { + self.selected = self.results.len().saturating_sub(1); + } + } + + pub fn move_down(&mut self) { + if self.selected + 1 < self.results.len() { + self.selected += 1; + } + } + + pub fn move_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + pub fn selected_hit(&self) -> Option<&FileHit> { + self.results.get(self.selected) + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn hit(name: &str) -> FileHit { + FileHit { path: PathBuf::from(name), match_count: 1, first_line: Some(1) } + } + + #[test] + fn typing_and_backspace_edit_query() { + let mut app = App::new(); + app.push_char('a'); + app.push_char('b'); + app.backspace(); + assert_eq!(app.query, "a"); + } + + #[test] + fn set_results_clamps_selection() { + let mut app = App::new(); + app.set_results(vec![hit("a"), hit("b"), hit("c")]); + app.selected = 2; + app.set_results(vec![hit("a")]); + assert_eq!(app.selected, 0); + } + + #[test] + fn selection_moves_within_bounds() { + let mut app = App::new(); + app.set_results(vec![hit("a"), hit("b")]); + app.move_up(); + assert_eq!(app.selected, 0); + app.move_down(); + assert_eq!(app.selected, 1); + app.move_down(); + assert_eq!(app.selected, 1); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 035da54..4045eba 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,3 +1,4 @@ pub mod search; pub mod editor; pub mod preview; +pub mod app; From ede6e163cf031be0b5bd75aaea4ee7778948d995 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:42:08 +0200 Subject: [PATCH 16/21] feat: ratatui rendering of query, results, preview --- rust/src/lib.rs | 1 + rust/src/ui.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 rust/src/ui.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4045eba..877c0e6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2,3 +2,4 @@ pub mod search; pub mod editor; pub mod preview; pub mod app; +pub mod ui; diff --git a/rust/src/ui.rs b/rust/src/ui.rs new file mode 100644 index 0000000..be1ac6e --- /dev/null +++ b/rust/src/ui.rs @@ -0,0 +1,70 @@ +use crate::app::App; +use crate::preview::StyledPreview; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +/// Render the whole UI. `preview` is the styled preview of the selected row. +pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1), Constraint::Length(1)]) + .split(f.area()); + + let query = Paragraph::new(format!("> {}", app.query)) + .block(Block::default().borders(Borders::ALL).title("query")); + f.render_widget(query, chunks[0]); + + let mid = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(chunks[1]); + + let items: Vec = app + .results + .iter() + .map(|h| { + let path = h.path.to_string_lossy(); + let tag = if h.first_line.is_none() { + "[name]".to_string() + } else { + format!("({})", h.match_count) + }; + ListItem::new(Line::from(vec![ + Span::raw(path.into_owned()), + Span::raw(" "), + Span::styled(tag, Style::default().fg(Color::DarkGray)), + ])) + }) + .collect(); + + let mut state = ListState::default(); + if !app.results.is_empty() { + state.select(Some(app.selected)); + } + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("results")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + f.render_stateful_widget(list, mid[0], &mut state); + + let preview_lines: Vec = preview + .lines + .iter() + .enumerate() + .map(|(i, line)| { + if Some(i) == preview.highlight_index { + line.clone().style(Style::default().bg(Color::Rgb(60, 60, 80))) + } else { + line.clone() + } + }) + .collect(); + let preview_widget = + Paragraph::new(preview_lines).block(Block::default().borders(Borders::ALL).title("preview")); + f.render_widget(preview_widget, mid[1]); + + let status = Paragraph::new(app.status.clone()).style(Style::default().fg(Color::DarkGray)); + f.render_widget(status, chunks[2]); +} From c330001b8214b7b4f7ad7ee979e3f4f88450c1e1 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:43:56 +0200 Subject: [PATCH 17/21] feat: live-grep TUI event loop --- rust/src/app.rs | 13 +- rust/src/editor.rs | 10 +- rust/src/lib.rs | 4 +- rust/src/main.rs | 253 ++++++++++++++++++++++++++++++++++++- rust/src/preview.rs | 10 +- rust/src/search.rs | 46 +++++-- rust/src/ui.rs | 13 +- rust/tests/search_tests.rs | 7 +- 8 files changed, 329 insertions(+), 27 deletions(-) diff --git a/rust/src/app.rs b/rust/src/app.rs index 54394e3..63040bc 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -10,7 +10,12 @@ pub struct App { impl App { pub fn new() -> Self { - App { query: String::new(), results: Vec::new(), selected: 0, status: String::new() } + App { + query: String::new(), + results: Vec::new(), + selected: 0, + status: String::new(), + } } pub fn push_char(&mut self, c: char) { @@ -56,7 +61,11 @@ mod tests { use std::path::PathBuf; fn hit(name: &str) -> FileHit { - FileHit { path: PathBuf::from(name), match_count: 1, first_line: Some(1) } + FileHit { + path: PathBuf::from(name), + match_count: 1, + first_line: Some(1), + } } #[test] diff --git a/rust/src/editor.rs b/rust/src/editor.rs index 91579fa..bf10985 100644 --- a/rust/src/editor.rs +++ b/rust/src/editor.rs @@ -54,7 +54,10 @@ mod tests { #[test] fn args_with_line_prepend_plus_line() { - assert_eq!(editor_args("src/main.rs", Some(42)), vec!["+42", "src/main.rs"]); + assert_eq!( + editor_args("src/main.rs", Some(42)), + vec!["+42", "src/main.rs"] + ); } #[test] @@ -88,7 +91,10 @@ mod tests { assert!(status.success()); let mut s = String::new(); - std::fs::File::open(&out).unwrap().read_to_string(&mut s).unwrap(); + std::fs::File::open(&out) + .unwrap() + .read_to_string(&mut s) + .unwrap(); assert_eq!(s, "+7\nfile.rs\n"); } } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 877c0e6..bfa129d 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,5 +1,5 @@ -pub mod search; +pub mod app; pub mod editor; pub mod preview; -pub mod app; +pub mod search; pub mod ui; diff --git a/rust/src/main.rs b/rust/src/main.rs index 0860444..44fbd38 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,6 +1,253 @@ -#[allow(unused_imports)] -use srchr::search; +use std::io::{self, IsTerminal}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use srchr::app::App; +use srchr::editor; +use srchr::preview::{build_preview, style_preview, PreviewData, StyledPreview}; +use srchr::search::{search, FileHit, Query}; + +const DEBOUNCE: Duration = Duration::from_millis(60); +const POLL_INTERVAL: Duration = Duration::from_millis(30); +const PREVIEW_MAX_LINES: usize = 400; + +struct SearchResult { + query: String, + hits: Vec, + error: Option, +} fn main() { - println!("srchr"); + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + eprintln!("srchr: not a terminal (this is an interactive tool)"); + std::process::exit(2); + } + + let root = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + + if let Err(e) = run(root) { + eprintln!("srchr: {e}"); + std::process::exit(1); + } +} + +fn run(root: PathBuf) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + let (result_tx, result_rx): (Sender, Receiver) = mpsc::channel(); + let mut pending_query: Option = None; + let mut pending_at = Instant::now(); + let mut current_cancel: Option> = None; + let mut launch_target: Option<(String, Option)> = None; + + loop { + while let Ok(res) = result_rx.try_recv() { + if res.query == app.query { + app.set_results(res.hits); + app.status = match res.error { + Some(e) => format!("invalid pattern: {e}"), + None => format!("{} files", app.results.len()), + }; + } + } + + if let Some(q) = pending_query.clone() { + if pending_at.elapsed() >= DEBOUNCE { + pending_query = None; + if let Some(cancel) = current_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + current_cancel = Some(spawn_search(&q, &root, result_tx.clone())); + } + } + + let styled = current_preview(&app); + terminal.draw(|f| srchr::ui::render(f, &app, &styled))?; + + if event::poll(POLL_INTERVAL)? { + if let Event::Key(key) = event::read()? { + match handle_key(key, &mut app) { + Action::Quit => break, + Action::Open => { + if let Some(hit) = app.selected_hit() { + launch_target = + Some((hit.path.to_string_lossy().into_owned(), hit.first_line)); + } + break; + } + Action::QueryChanged => { + if let Some(cancel) = current_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + if app.query.is_empty() { + pending_query = None; + app.set_results(Vec::new()); + app.status.clear(); + } else { + pending_query = Some(app.query.clone()); + pending_at = Instant::now(); + app.status = "searching...".to_string(); + } + } + Action::None => {} + } + } + } + } + + if let Some(cancel) = current_cancel { + cancel.store(true, Ordering::Relaxed); + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + if let Some((path, line)) = launch_target { + let ed = editor::resolve_editor().map_err(io::Error::other)?; + editor::launch(&ed, &editor::editor_args(&path, line))?; + } + + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +enum Action { + None, + Quit, + Open, + QueryChanged, +} + +fn handle_key(key: KeyEvent, app: &mut App) -> Action { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => Action::Quit, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit, + (KeyCode::Enter, _) => Action::Open, + (KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + app.move_down(); + Action::None + } + (KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + app.move_up(); + Action::None + } + (KeyCode::Backspace, _) => { + app.backspace(); + Action::QueryChanged + } + (KeyCode::Char(c), modifiers) if !modifiers.contains(KeyModifiers::CONTROL) => { + app.push_char(c); + Action::QueryChanged + } + _ => Action::None, + } +} + +fn current_preview(app: &App) -> StyledPreview { + match app.selected_hit() { + Some(hit) => { + let name = hit + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + match build_preview(&hit.path, hit.first_line, PREVIEW_MAX_LINES) { + Ok(data) => style_preview(&data, &name), + Err(_) => empty_preview(""), + } + } + None => empty_preview(""), + } +} + +fn empty_preview(msg: &str) -> StyledPreview { + let data = PreviewData { + lines: vec![(1, msg.to_string())], + highlight: None, + }; + style_preview(&data, "") +} + +fn spawn_search(query: &str, root: &Path, tx: Sender) -> Arc { + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let query = query.to_string(); + let root = root.to_path_buf(); + thread::spawn(move || { + let (hits, error) = match Query::compile(&query) { + Ok(q) => (search(&q, &root, &worker_cancel), None), + Err(e) => (Vec::new(), Some(e)), + }; + let _ = tx.send(SearchResult { query, hits, error }); + }); + cancel +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handle_key_edits_query() { + let mut app = App::new(); + let action = handle_key( + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + &mut app, + ); + assert_eq!(action, Action::QueryChanged); + assert_eq!(app.query, "a"); + + let action = handle_key( + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + &mut app, + ); + assert_eq!(action, Action::QueryChanged); + assert_eq!(app.query, ""); + } + + #[test] + fn handle_key_moves_selection() { + let mut app = App::new(); + app.set_results(vec![ + FileHit { + path: "a.rs".into(), + match_count: 1, + first_line: Some(1), + }, + FileHit { + path: "b.rs".into(), + match_count: 1, + first_line: Some(1), + }, + ]); + + let action = handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut app); + assert_eq!(action, Action::None); + assert_eq!(app.selected, 1); + + let action = handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), &mut app); + assert_eq!(action, Action::None); + assert_eq!(app.selected, 0); + } } diff --git a/rust/src/preview.rs b/rust/src/preview.rs index 3a95871..374beb6 100644 --- a/rust/src/preview.rs +++ b/rust/src/preview.rs @@ -46,7 +46,10 @@ pub fn build_preview( } lines.push((lnum, line.unwrap_or_default())); } - Ok(PreviewData { lines, highlight: first_line }) + Ok(PreviewData { + lines, + highlight: first_line, + }) } pub struct StyledPreview { @@ -91,7 +94,10 @@ pub fn style_preview(data: &PreviewData, file_name: &str) -> StyledPreview { out_lines.push(Line::from(spans)); } - StyledPreview { lines: out_lines, highlight_index } + StyledPreview { + lines: out_lines, + highlight_index, + } } #[cfg(test)] diff --git a/rust/src/search.rs b/rust/src/search.rs index 204ef1e..03fe39d 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -1,12 +1,12 @@ -use std::path::PathBuf; -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use grep_regex::RegexMatcher; -use grep_searcher::Searcher; use grep_searcher::sinks::UTF8; +use grep_searcher::Searcher; use ignore::WalkBuilder; use regex::RegexBuilder; +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; /// Smart-case: case-sensitive only when the query contains an uppercase char. pub fn is_case_sensitive(query: &str) -> bool { @@ -41,7 +41,11 @@ impl Query { .case_insensitive(!case_sensitive) .build() .map_err(|e| e.to_string())?; - Ok(Query { content, name, case_sensitive }) + Ok(Query { + content, + name, + case_sensitive, + }) } } @@ -192,9 +196,21 @@ mod tests { #[test] fn ordering_content_before_name_only_then_by_count() { let mut hits = vec![ - FileHit { path: "z_name.rs".into(), match_count: 0, first_line: None }, - FileHit { path: "b.rs".into(), match_count: 2, first_line: Some(1) }, - FileHit { path: "a.rs".into(), match_count: 5, first_line: Some(3) }, + FileHit { + path: "z_name.rs".into(), + match_count: 0, + first_line: None, + }, + FileHit { + path: "b.rs".into(), + match_count: 2, + first_line: Some(1), + }, + FileHit { + path: "a.rs".into(), + match_count: 5, + first_line: Some(3), + }, ]; sort_hits(&mut hits); let order: Vec<_> = hits.iter().map(|h| h.path.to_str().unwrap()).collect(); @@ -204,8 +220,16 @@ mod tests { #[test] fn ordering_breaks_count_ties_by_path() { let mut hits = vec![ - FileHit { path: "b.rs".into(), match_count: 1, first_line: Some(1) }, - FileHit { path: "a.rs".into(), match_count: 1, first_line: Some(1) }, + FileHit { + path: "b.rs".into(), + match_count: 1, + first_line: Some(1), + }, + FileHit { + path: "a.rs".into(), + match_count: 1, + first_line: Some(1), + }, ]; sort_hits(&mut hits); let order: Vec<_> = hits.iter().map(|h| h.path.to_str().unwrap()).collect(); diff --git a/rust/src/ui.rs b/rust/src/ui.rs index be1ac6e..8b54fd3 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -10,7 +10,11 @@ use ratatui::Frame; pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(1), Constraint::Length(1)]) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + ]) .split(f.area()); let query = Paragraph::new(format!("> {}", app.query)) @@ -55,14 +59,15 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { .enumerate() .map(|(i, line)| { if Some(i) == preview.highlight_index { - line.clone().style(Style::default().bg(Color::Rgb(60, 60, 80))) + line.clone() + .style(Style::default().bg(Color::Rgb(60, 60, 80))) } else { line.clone() } }) .collect(); - let preview_widget = - Paragraph::new(preview_lines).block(Block::default().borders(Borders::ALL).title("preview")); + let preview_widget = Paragraph::new(preview_lines) + .block(Block::default().borders(Borders::ALL).title("preview")); f.render_widget(preview_widget, mid[1]); let status = Paragraph::new(app.status.clone()).style(Style::default().fg(Color::DarkGray)); diff --git a/rust/tests/search_tests.rs b/rust/tests/search_tests.rs index 65b4218..4f12eaf 100644 --- a/rust/tests/search_tests.rs +++ b/rust/tests/search_tests.rs @@ -26,7 +26,12 @@ fn merges_name_and_content_hits_deduped() { let by_name: std::collections::HashMap<_, _> = hits .iter() - .map(|h| (h.path.file_name().unwrap().to_str().unwrap().to_string(), h.clone())) + .map(|h| { + ( + h.path.file_name().unwrap().to_str().unwrap().to_string(), + h.clone(), + ) + }) .collect(); let a = &by_name["alpha.rs"]; From 3738c17ad1531dd3a9c550b42c5f5f5890347660 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:44:55 +0200 Subject: [PATCH 18/21] ci: rust checks; feat: safe preview placeholders --- .github/workflows/rust.yml | 18 ++++++++++++++++++ rust/src/main.rs | 8 +++----- rust/src/preview.rs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..f804506 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,18 @@ +name: rust +on: + push: + pull_request: +jobs: + build-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - run: cargo fmt -- --check + - run: cargo clippy -- -D warnings + - run: cargo test diff --git a/rust/src/main.rs b/rust/src/main.rs index 44fbd38..b5b8c00 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -16,7 +16,7 @@ use ratatui::Terminal; use srchr::app::App; use srchr::editor; -use srchr::preview::{build_preview, style_preview, PreviewData, StyledPreview}; +use srchr::preview::{build_preview_safe, style_preview, PreviewData, StyledPreview}; use srchr::search::{search, FileHit, Query}; const DEBOUNCE: Duration = Duration::from_millis(60); @@ -172,10 +172,8 @@ fn current_preview(app: &App) -> StyledPreview { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - match build_preview(&hit.path, hit.first_line, PREVIEW_MAX_LINES) { - Ok(data) => style_preview(&data, &name), - Err(_) => empty_preview(""), - } + let data = build_preview_safe(&hit.path, hit.first_line, PREVIEW_MAX_LINES); + style_preview(&data, &name) } None => empty_preview(""), } diff --git a/rust/src/preview.rs b/rust/src/preview.rs index 374beb6..fc1f9a2 100644 --- a/rust/src/preview.rs +++ b/rust/src/preview.rs @@ -52,6 +52,30 @@ pub fn build_preview( }) } +/// Like `build_preview`, but detects binary/unreadable files and returns a +/// placeholder instead of garbage. +pub fn build_preview_safe(path: &Path, first_line: Option, max_lines: usize) -> PreviewData { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(_) => { + return PreviewData { + lines: vec![(1, "".into())], + highlight: None, + } + } + }; + if bytes.iter().take(8192).any(|b| *b == 0) { + return PreviewData { + lines: vec![(1, "".into())], + highlight: None, + }; + } + build_preview(path, first_line, max_lines).unwrap_or(PreviewData { + lines: vec![(1, "".into())], + highlight: None, + }) +} + pub struct StyledPreview { pub lines: Vec>, /// Index into `lines` of the match row, if any. @@ -157,4 +181,13 @@ mod tests { assert_eq!(styled.lines.len(), data.lines.len()); assert_eq!(styled.highlight_index, Some(2)); } + + #[test] + fn binary_content_yields_placeholder() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("bin.dat"); + std::fs::write(&p, [0u8, 159, 146, 150, 0, 1, 2]).unwrap(); + let data = build_preview_safe(&p, None, 100); + assert!(data.lines.iter().any(|(_, t)| t.contains("binary"))); + } } From c41223a06d7e961700098a70e401505ce43a447e Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 17:49:40 +0200 Subject: [PATCH 19/21] fix: cache previews and harden terminal/editor handling --- rust/src/main.rs | 112 +++++++++++++++++++++++++------------------- rust/src/preview.rs | 31 ++++++++++-- 2 files changed, 92 insertions(+), 51 deletions(-) diff --git a/rust/src/main.rs b/rust/src/main.rs index b5b8c00..4e543a9 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -59,73 +59,91 @@ fn run(root: PathBuf) -> io::Result<()> { let mut pending_at = Instant::now(); let mut current_cancel: Option> = None; let mut launch_target: Option<(String, Option)> = None; + let mut preview_key: Option<(PathBuf, Option)> = None; + let mut styled_preview = empty_preview(""); - loop { - while let Ok(res) = result_rx.try_recv() { - if res.query == app.query { - app.set_results(res.hits); - app.status = match res.error { - Some(e) => format!("invalid pattern: {e}"), - None => format!("{} files", app.results.len()), - }; + let loop_result = (|| -> io::Result<()> { + loop { + while let Ok(res) = result_rx.try_recv() { + if res.query == app.query { + app.set_results(res.hits); + app.status = match res.error { + Some(e) => format!("invalid pattern: {e}"), + None => format!("{} files", app.results.len()), + }; + } } - } - if let Some(q) = pending_query.clone() { - if pending_at.elapsed() >= DEBOUNCE { - pending_query = None; - if let Some(cancel) = current_cancel.take() { - cancel.store(true, Ordering::Relaxed); + if let Some(q) = pending_query.clone() { + if pending_at.elapsed() >= DEBOUNCE { + pending_query = None; + if let Some(cancel) = current_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + current_cancel = Some(spawn_search(&q, &root, result_tx.clone())); } - current_cancel = Some(spawn_search(&q, &root, result_tx.clone())); } - } - let styled = current_preview(&app); - terminal.draw(|f| srchr::ui::render(f, &app, &styled))?; - - if event::poll(POLL_INTERVAL)? { - if let Event::Key(key) = event::read()? { - match handle_key(key, &mut app) { - Action::Quit => break, - Action::Open => { - if let Some(hit) = app.selected_hit() { - launch_target = - Some((hit.path.to_string_lossy().into_owned(), hit.first_line)); - } - break; - } - Action::QueryChanged => { - if let Some(cancel) = current_cancel.take() { - cancel.store(true, Ordering::Relaxed); + let selected_key = app + .selected_hit() + .map(|hit| (hit.path.clone(), hit.first_line)); + if selected_key != preview_key { + styled_preview = current_preview(&app); + preview_key = selected_key; + } + terminal.draw(|f| srchr::ui::render(f, &app, &styled_preview))?; + + if event::poll(POLL_INTERVAL)? { + if let Event::Key(key) = event::read()? { + match handle_key(key, &mut app) { + Action::Quit => return Ok(()), + Action::Open => { + if let Some(hit) = app.selected_hit() { + launch_target = + Some((hit.path.to_string_lossy().into_owned(), hit.first_line)); + } + return Ok(()); } - if app.query.is_empty() { - pending_query = None; - app.set_results(Vec::new()); - app.status.clear(); - } else { - pending_query = Some(app.query.clone()); - pending_at = Instant::now(); - app.status = "searching...".to_string(); + Action::QueryChanged => { + if let Some(cancel) = current_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + if app.query.is_empty() { + pending_query = None; + app.set_results(Vec::new()); + app.status.clear(); + } else { + pending_query = Some(app.query.clone()); + pending_at = Instant::now(); + app.status = "searching...".to_string(); + } } + Action::None => {} } - Action::None => {} } } } - } + })(); if let Some(cancel) = current_cancel { cancel.store(true, Ordering::Relaxed); } - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; + let raw_result = disable_raw_mode(); + let screen_result = execute!(terminal.backend_mut(), LeaveAlternateScreen); + let cursor_result = terminal.show_cursor(); + + loop_result?; + raw_result?; + screen_result?; + cursor_result?; if let Some((path, line)) = launch_target { let ed = editor::resolve_editor().map_err(io::Error::other)?; - editor::launch(&ed, &editor::editor_args(&path, line))?; + let status = editor::launch(&ed, &editor::editor_args(&path, line))?; + if !status.success() { + return Err(io::Error::other(format!("editor exited with {status}"))); + } } Ok(()) diff --git a/rust/src/preview.rs b/rust/src/preview.rs index fc1f9a2..c13f472 100644 --- a/rust/src/preview.rs +++ b/rust/src/preview.rs @@ -1,4 +1,4 @@ -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Read}; use std::path::Path; use once_cell::sync::Lazy; @@ -55,8 +55,8 @@ pub fn build_preview( /// Like `build_preview`, but detects binary/unreadable files and returns a /// placeholder instead of garbage. pub fn build_preview_safe(path: &Path, first_line: Option, max_lines: usize) -> PreviewData { - let bytes = match std::fs::read(path) { - Ok(b) => b, + let mut file = match std::fs::File::open(path) { + Ok(f) => f, Err(_) => { return PreviewData { lines: vec![(1, "".into())], @@ -64,7 +64,17 @@ pub fn build_preview_safe(path: &Path, first_line: Option, max_lines: usi } } }; - if bytes.iter().take(8192).any(|b| *b == 0) { + let mut prefix = [0u8; 8192]; + let bytes_read = match file.read(&mut prefix) { + Ok(n) => n, + Err(_) => { + return PreviewData { + lines: vec![(1, "".into())], + highlight: None, + } + } + }; + if prefix[..bytes_read].contains(&0) { return PreviewData { lines: vec![(1, "".into())], highlight: None, @@ -76,6 +86,7 @@ pub fn build_preview_safe(path: &Path, first_line: Option, max_lines: usi }) } +#[derive(Clone)] pub struct StyledPreview { pub lines: Vec>, /// Index into `lines` of the match row, if any. @@ -190,4 +201,16 @@ mod tests { let data = build_preview_safe(&p, None, 100); assert!(data.lines.iter().any(|(_, t)| t.contains("binary"))); } + + #[test] + fn safe_preview_respects_line_cap_for_text_files() { + let dir = tempfile::tempdir().unwrap(); + let body = (1..=100) + .map(|n| format!("line {n}")) + .collect::>() + .join("\n"); + let p = write(dir.path(), "large.txt", &body); + let data = build_preview_safe(&p, None, 5); + assert_eq!(data.lines.len(), 5); + } } From 381b3bfc2072748450a469c51f5cd1303c694e80 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 18:32:46 +0200 Subject: [PATCH 20/21] chore: retire shell ports --- AGENTS.md | 72 ++++++----- README.md | 83 ++++++------ srchr.fish | 35 ------ srchr.sh | 35 ------ tests/smoke.sh | 333 ------------------------------------------------- 5 files changed, 81 insertions(+), 477 deletions(-) delete mode 100644 srchr.fish delete mode 100644 srchr.sh delete mode 100755 tests/smoke.sh diff --git a/AGENTS.md b/AGENTS.md index 143da1b..7e8229b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,53 @@ # AGENTS.md -Shell functions for a unified fd + rg + fzf + bat file search (`srchr `). -No build system. Local smoke tests live in `tests/smoke.sh`; GitHub Actions runs the same script. +Rust implementation for a unified live-grep file search (`srchr`). The binary +embeds file walking, content search, fuzzy-style TUI selection, syntax preview, +and editor launch behavior. Runtime external dependency: `$EDITOR` only. ## Structure -- `srchr.fish` — fish implementation -- `srchr.sh` — bash **and** zsh implementation (one sourceable file; syntax is kept POSIX-compatible for both) +- `rust/` — Cargo crate for the `srchr` binary +- `rust/src/search.rs` — gitignore-aware name/content search and aggregation +- `rust/src/preview.rs` — preview windowing, binary/unreadable placeholders, syntect styling +- `rust/src/editor.rs` — `$EDITOR` resolution, path safety, launch args +- `rust/src/app.rs` — TUI state machine +- `rust/src/ui.rs` — ratatui rendering +- `rust/src/main.rs` — terminal setup, event loop, debounce, editor handoff +- `rust/tests/` — integration tests - `docs/superpowers/specs/` — design docs; update when behavior changes +- `docs/superpowers/plans/` — implementation plans + +## Behavior Invariants + +- The tool must not require `fd`, `rg`, `fzf`, or `bat` at runtime. +- `$EDITOR` is required. If unset or empty, exit with a clear error instead of + guessing an editor. +- Content and filename queries use smart-case regex semantics. +- Results are file-level rows: content hits show a match count; name-only hits + show `[name]`. +- Selected paths may begin with `+` or `-`. Keep the guard that rewrites those + relative paths to `./...` before calling `$EDITOR`; otherwise vim/nvim can + treat `+...` as editor commands or `-...` as options. +- The interactive TUI needs a TTY. Agents cannot fully test it; ask the user for + a manual smoke test after changes that affect interaction. -## Critical invariant: keep the ports in sync - -The two files implement the same function. The `sh -c` preview/enter snippets -passed to fzf must stay **byte-identical** between `srchr.fish` and `srchr.sh`. -Any behavior change goes into both files. +## Verification -Quoting differs by necessity, not choice: -- fish: snippets are inline, using `\'` to escape single quotes (valid in fish only) -- bash/zsh: snippets are assembled from local vars (`locate`/`preview`/`open`) - because those shells cannot escape `'` inside single quotes +Run the Rust checks from the workspace root: -The search term is never interpolated into the fzf command strings (injection -safety). It reaches the snippets via the `SRCHR_TERM` env var: `set -lx` in -fish, env prefix on the fzf call only (`| SRCHR_TERM=$term fzf`) in sh — -do not `export` it into the session. +```sh +cargo fmt --manifest-path rust/Cargo.toml -- --check +cargo clippy --manifest-path rust/Cargo.toml -- -D warnings +cargo test --manifest-path rust/Cargo.toml +``` -Snippets run via `sh -c '...' sh {}` (file arrives as `$1`) so they work no -matter which shell fzf's `$SHELL -c` uses. +GitHub Actions runs the same fmt/clippy/test gates in `.github/workflows/rust.yml`. -Selected paths may begin with `+` or `-` (raw output from `fd`/`rg`). Keep the -snippet guard that rewrites those relative paths to `./...` before calling -`rg`, `bat`, or `$EDITOR`; otherwise nvim/vim can treat `+...` as editor -commands and tools can treat `-...` as options. +Manual TUI smoke test: -## Verification +```sh +cargo run --manifest-path rust/Cargo.toml -- . +``` -- Run the automated smoke suite: `tests/smoke.sh` -- The script covers syntax checks, fzf preview/bind parity across shell ports, - direct snippet behavior, and security smoke checks for leading-option terms - and selected paths beginning with `+` or `-`. -- zsh is optional locally: the script uses direct `zsh` when installed, falls - back to Docker when available, and skips only when neither exists. -- The interactive fzf flow needs a TTY; the agent cannot test it — ask the - user for a manual smoke test. +Check live typing, result counts, `[name]` rows, preview highlight, arrow-key +selection, `Enter` opening `$EDITOR`, and `Esc` restoring the terminal. diff --git a/README.md b/README.md index fcd500a..e37639f 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,68 @@ # srchr -Unified file search for your shell: one command that matches **file names** -(via [fd](https://github.com/sharkdp/fd)) and **file contents** (via -[ripgrep](https://github.com/BurntSushi/ripgrep)), merges the results into -[fzf](https://github.com/junegunn/fzf), previews with -[bat](https://github.com/sharkdp/bat), and opens your selection in `$EDITOR`. +Self-contained live-grep file search in Rust. `srchr` searches file names and +file contents, merges the results into an interactive terminal UI, previews the +selected file with syntax highlighting, and opens your selection in `$EDITOR`. -``` -srchr +Runtime external dependency: `$EDITOR` only. + +```sh +srchr ``` -- Files whose **name** matches the term and files whose **contents** match - are combined and deduplicated into a single fzf picker. -- **Smart preview:** if the selected file contains the term, the bat preview - jumps to the first matching line and highlights it; otherwise it shows the - file from the top. -- **Smart open:** pressing enter opens `$EDITOR + ` when the file - contains the term (vim/nvim/helix-style line jump), or `$EDITOR ` - otherwise. -- Content matching is smart-case (`rg -S`), mirroring fd's default. +- Type to run a live smart-case regex search over file names and file contents. +- Results are deduplicated file rows. +- Content hits show a match count, sorted before name-only hits. +- Name-only hits show `[name]`. +- The preview highlights the first content match with context above it, or shows + the file from the top for name-only hits. +- Pressing `Enter` opens `$EDITOR + ` for content hits, or + `$EDITOR ` for name-only hits. ## Requirements -[fd](https://github.com/sharkdp/fd), -[ripgrep](https://github.com/BurntSushi/ripgrep), -[fzf](https://github.com/junegunn/fzf), and -[bat](https://github.com/sharkdp/bat) on your `PATH`, plus an `$EDITOR` that -understands `+` (vim, nvim, helix, kakoune, nano, ...). +- Rust toolchain to build from source. +- `$EDITOR` set to an editor that understands `+` for line jumps (vim, + nvim, helix, kakoune, nano, ...). -## Test +If `$EDITOR` is unset or empty, `srchr` exits with a clear error. -Run the local smoke checks: +## Build ```sh -tests/smoke.sh +cargo build --manifest-path rust/Cargo.toml --release ``` -This requires `fish` on your `PATH`. - -The interactive fzf flow still needs a manual TTY smoke test. +The binary is at `rust/target/release/srchr`. -## Install +## Test -### fish +```sh +cargo fmt --manifest-path rust/Cargo.toml -- --check +cargo clippy --manifest-path rust/Cargo.toml -- -D warnings +cargo test --manifest-path rust/Cargo.toml +``` -Copy (or symlink) `srchr.fish` into your functions directory: +The interactive TUI still needs a manual TTY smoke test: -```fish -ln -s (pwd)/srchr.fish ~/.config/fish/functions/srchr.fish +```sh +cargo run --manifest-path rust/Cargo.toml -- . ``` -### bash / zsh +## Install -Source `srchr.sh` from your `.bashrc` or `.zshrc`: +Build the release binary and place it somewhere on your `PATH`, for example: ```sh -source /path/to/srchr/srchr.sh +cargo build --manifest-path rust/Cargo.toml --release +install -Dm755 rust/target/release/srchr ~/.local/bin/srchr ``` ## Notes -- The search term is passed to the fzf preview/enter commands via the - `SRCHR_TERM` environment variable rather than string interpolation, so - terms containing quotes or shell metacharacters are safe. -- Search terms are passed to `fd`/`rg` after `--`, and selected relative - paths starting with `+` or `-` are normalized before invoking `bat` or - `$EDITOR`, so option/command-looking inputs are treated as data. -- `srchr.sh` is a single file that works in both bash and zsh. +- Search respects gitignore rules. +- Search terms are smart-case regexes for both content and filename matches. +- Selected relative paths starting with `+` or `-` are normalized before + invoking `$EDITOR`, so option/command-looking paths are treated as data. +- Preview uses an embedded default syntect theme; it does not read `bat` config + or require `bat` to be installed. diff --git a/srchr.fish b/srchr.fish deleted file mode 100644 index b021afa..0000000 --- a/srchr.fish +++ /dev/null @@ -1,35 +0,0 @@ -function srchr - set -l search_term $argv[1] - - if test -z "$search_term" - echo "Usage: srchr " - - return 1 - end - - set -lx SRCHR_TERM $search_term - - # locate: normalize the selected path, then find the first matching line. - set -l locate 'file=$1; ' - set locate $locate'case $file in [-+]* ) file=./$file;; esac; ' - set locate $locate'line=$(rg -nS -m1 -- "$SRCHR_TERM" "$file" | cut -d: -f1); ' - - # preview: highlight around the match, or show the whole file when none. - set -l preview $locate - set preview $preview'if [ -n "$line" ]; then ' - set preview $preview'start=$((line > 3 ? line - 3 : 1)); ' - set preview $preview'bat --color always --highlight-line "$line" --line-range "$start:" "$file"; ' - set preview $preview'else bat --color always "$file"; fi' - - # open: jump the editor to the match, or just open the file when none. - set -l open $locate - set open $open'if [ -n "$line" ]; then exec "$EDITOR" "+$line" "$file"; ' - set open $open'else exec "$EDITOR" "$file"; fi' - - begin - fd -tf -- $search_term - rg -lS -- $search_term - end | sort -u | fzf \ - --preview "sh -c '$preview' sh {}" \ - --bind "enter:become(sh -c '$open' sh {})" -end diff --git a/srchr.sh b/srchr.sh deleted file mode 100644 index d502680..0000000 --- a/srchr.sh +++ /dev/null @@ -1,35 +0,0 @@ -# Unified fd + rg search for bash and zsh. Source this file, then run: -# srchr -srchr() { - local search_term=$1 - - if [ -z "$search_term" ]; then - echo "Usage: srchr " - - return 1 - fi - - # locate: normalize the selected path, then find the first matching line. - local locate='file=$1; ' - locate=$locate'case $file in [-+]* ) file=./$file;; esac; ' - locate=$locate'line=$(rg -nS -m1 -- "$SRCHR_TERM" "$file" | cut -d: -f1); ' - - # preview: highlight around the match, or show the whole file when none. - local preview=$locate - preview=$preview'if [ -n "$line" ]; then ' - preview=$preview'start=$((line > 3 ? line - 3 : 1)); ' - preview=$preview'bat --color always --highlight-line "$line" --line-range "$start:" "$file"; ' - preview=$preview'else bat --color always "$file"; fi' - - # open: jump the editor to the match, or just open the file when none. - local open=$locate - open=$open'if [ -n "$line" ]; then exec "$EDITOR" "+$line" "$file"; ' - open=$open'else exec "$EDITOR" "$file"; fi' - - { - fd -tf -- "$search_term" - rg -lS -- "$search_term" - } | sort -u | SRCHR_TERM=$search_term fzf \ - --preview "sh -c '$preview' sh {}" \ - --bind "enter:become(sh -c '$open' sh {})" -} diff --git a/tests/smoke.sh b/tests/smoke.sh deleted file mode 100755 index 41d5b6d..0000000 --- a/tests/smoke.sh +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) -tmp_root=${TMPDIR:-/tmp} -work=$(mktemp -d "$tmp_root/srchr-smoke.XXXXXXXXXX") -trap 'rm -rf "$work"' EXIT - -fail() { - printf 'FAIL: %s\n' "$*" >&2 - exit 1 -} - -pass() { - printf 'PASS: %s\n' "$*" -} - -assert_eq() { - local actual=$1 - local expected=$2 - local context=$3 - - if [[ $actual != "$expected" ]]; then - printf 'FAIL: %s\n' "$context" >&2 - printf 'expected: [%s]\n' "$expected" >&2 - printf 'actual: [%s]\n' "$actual" >&2 - exit 1 - fi -} - -assert_file_contains_line() { - local file=$1 - local expected=$2 - local context=$3 - local line - - while IFS= read -r line; do - [[ $line == "$expected" ]] && return 0 - done < "$file" - - printf 'FAIL: %s\n' "$context" >&2 - printf 'missing line: [%s]\n' "$expected" >&2 - printf 'file contents:\n' >&2 - while IFS= read -r line; do - printf ' [%s]\n' "$line" >&2 - done < "$file" - exit 1 -} - -assert_file_lacks_text() { - local file=$1 - local text=$2 - local context=$3 - local line - - while IFS= read -r line; do - if [[ $line == *"$text"* ]]; then - printf 'FAIL: %s\n' "$context" >&2 - printf 'unexpected line: [%s]\n' "$line" >&2 - exit 1 - fi - done < "$file" -} - -assert_lacks_text() { - local actual=$1 - local text=$2 - local context=$3 - - if [[ $actual == *"$text"* ]]; then - printf 'FAIL: %s\n' "$context" >&2 - printf 'unexpected text: [%s]\n' "$text" >&2 - printf 'actual: [%s]\n' "$actual" >&2 - exit 1 - fi -} - -write_stubs() { - local bin=$1 - mkdir -p "$bin" - - cat > "$bin/fd" <<'STUB' -#!/bin/sh -{ - printf 'fd' - for arg do printf '\t%s' "$arg"; done - printf '\n' -} >> "$CALL_LOG" -printf '%s\n' 'match-file' -STUB - - cat > "$bin/rg" <<'STUB' -#!/bin/sh -case "$1" in - -lS) - { - printf 'rg_search' - for arg do printf '\t%s' "$arg"; done - printf '\n' - } >> "$CALL_LOG" - printf '%s\n' 'match-file' - ;; - -nS) - { - printf 'rg_snippet' - for arg do printf '\t%s' "$arg"; done - printf '\n' - } >> "$CALL_LOG" - file=$5 - case "$file" in - *nomatch*) exit 1 ;; - *) printf '7:matched\n' ;; - esac - ;; - *) - printf 'unexpected rg invocation:' >&2 - for arg do printf ' [%s]' "$arg" >&2; done - printf '\n' >&2 - exit 1 - ;; -esac -STUB - - cat > "$bin/fzf" <<'STUB' -#!/bin/sh -: > "$FZF_ARGS_FILE" -for arg do printf '%s\n' "$arg" >> "$FZF_ARGS_FILE"; done -cat >/dev/null -STUB - - cat > "$bin/bat" <<'STUB' -#!/bin/sh -{ - printf 'bat' - for arg do printf '\t%s' "$arg"; done - printf '\n' -} >> "$CALL_LOG" -STUB - - cat > "$bin/stub-editor" <<'STUB' -#!/bin/sh -{ - printf 'editor' - for arg do printf '\t%s' "$arg"; done - printf '\n' -} >> "$CALL_LOG" -STUB - - chmod +x "$bin/fd" "$bin/rg" "$bin/fzf" "$bin/bat" "$bin/stub-editor" -} - -capture_sh() { - local term=$1 - local out=$2 - mkdir -p "$out" - : > "$out/calls.log" - : > "$out/fzf.args" - - env -u SRCHR_TERM PATH="$STUB_BIN:$PATH" CALL_LOG="$out/calls.log" FZF_ARGS_FILE="$out/fzf.args" \ - bash -c '. ./srchr.sh; srchr "$1"; if [ "${SRCHR_TERM+x}" = x ]; then printf "%s\n" "SRCHR_TERM leaked after bash invocation" >&2; exit 1; fi' sh "$term" -} - -capture_fish() { - local term=$1 - local out=$2 - mkdir -p "$out" - : > "$out/calls.log" - : > "$out/fzf.args" - - env -u SRCHR_TERM PATH="$STUB_BIN:$PATH" CALL_LOG="$out/calls.log" FZF_ARGS_FILE="$out/fzf.args" \ - fish --no-config -c 'source srchr.fish; srchr $argv[1]; if set -q SRCHR_TERM; printf "%s\n" "SRCHR_TERM leaked after fish invocation" >&2; exit 1; end' -- "$term" -} - -arg_after() { - local file=$1 - local flag=$2 - local previous= - local line - - while IFS= read -r line; do - if [[ $previous == "$flag" ]]; then - printf '%s' "$line" - return 0 - fi - previous=$line - done < "$file" - - fail "missing $flag in $file" -} - -preview_snippet_from() { - local cmd=$1 - local prefix="sh -c '" - local suffix="' sh {}" - - [[ $cmd == "$prefix"*"$suffix" ]] || fail "unexpected preview command: $cmd" - cmd=${cmd#"$prefix"} - cmd=${cmd%"$suffix"} - printf '%s' "$cmd" -} - -open_snippet_from() { - local cmd=$1 - local prefix="enter:become(sh -c '" - local suffix="' sh {})" - - [[ $cmd == "$prefix"*"$suffix" ]] || fail "unexpected bind command: $cmd" - cmd=${cmd#"$prefix"} - cmd=${cmd%"$suffix"} - printf '%s' "$cmd" -} - -run_preview() { - local snippet=$1 - local selected=$2 - local log=$3 - - : > "$log" - env PATH="$STUB_BIN:$PATH" CALL_LOG="$log" SRCHR_TERM='needle' \ - sh -c "$snippet" sh "$selected" -} - -run_open() { - local snippet=$1 - local selected=$2 - local log=$3 - - : > "$log" - env PATH="$STUB_BIN:$PATH" CALL_LOG="$log" SRCHR_TERM='needle' EDITOR=stub-editor \ - sh -c "$snippet" sh "$selected" -} - -cd "$ROOT" - -command -v fish >/dev/null 2>&1 || fail 'fish is required for fish -n srchr.fish' -fish --no-config -n srchr.fish -pass 'fish syntax' - -bash -n srchr.sh -pass 'bash syntax' - -if command -v zsh >/dev/null 2>&1; then - zsh -n srchr.sh - pass 'zsh syntax' -elif command -v docker >/dev/null 2>&1; then - docker run --rm -v "$PWD:/w" -w /w zshusers/zsh zsh -n srchr.sh - pass 'zsh syntax via Docker' -else - printf 'SKIP: zsh syntax (zsh and docker unavailable)\n' -fi - -STUB_BIN="$work/bin" -write_stubs "$STUB_BIN" - -SH_OUT="$work/sh" -FISH_OUT="$work/fish" -capture_sh '--exec=rm' "$SH_OUT" -capture_fish '--exec=rm' "$FISH_OUT" -pass 'SRCHR_TERM remains local to each srchr invocation' - -assert_file_contains_line "$SH_OUT/calls.log" $'fd\t-tf\t--\t--exec=rm' 'bash fd search term must be passed after --' -assert_file_contains_line "$SH_OUT/calls.log" $'rg_search\t-lS\t--\t--exec=rm' 'bash rg search term must be passed after --' -assert_file_contains_line "$FISH_OUT/calls.log" $'fd\t-tf\t--\t--exec=rm' 'fish fd search term must be passed after --' -assert_file_contains_line "$FISH_OUT/calls.log" $'rg_search\t-lS\t--\t--exec=rm' 'fish rg search term must be passed after --' -pass 'search terms that look like options are passed after --' - -INJECTION_TERM=$'needle\'";touch pwned' -SH_INJECTION_OUT="$work/sh-injection" -FISH_INJECTION_OUT="$work/fish-injection" -capture_sh "$INJECTION_TERM" "$SH_INJECTION_OUT" -capture_fish "$INJECTION_TERM" "$FISH_INJECTION_OUT" - -sh_injection_preview_arg=$(arg_after "$SH_INJECTION_OUT/fzf.args" '--preview') -fish_injection_preview_arg=$(arg_after "$FISH_INJECTION_OUT/fzf.args" '--preview') -sh_injection_bind_arg=$(arg_after "$SH_INJECTION_OUT/fzf.args" '--bind') -fish_injection_bind_arg=$(arg_after "$FISH_INJECTION_OUT/fzf.args" '--bind') - -assert_lacks_text "$sh_injection_preview_arg" "$INJECTION_TERM" 'bash preview fzf arg must not interpolate the search term' -assert_lacks_text "$fish_injection_preview_arg" "$INJECTION_TERM" 'fish preview fzf arg must not interpolate the search term' -assert_lacks_text "$sh_injection_bind_arg" "$INJECTION_TERM" 'bash bind fzf arg must not interpolate the search term' -assert_lacks_text "$fish_injection_bind_arg" "$INJECTION_TERM" 'fish bind fzf arg must not interpolate the search term' -pass 'fzf preview/bind args receive search term only through SRCHR_TERM' - -sh_preview_arg=$(arg_after "$SH_OUT/fzf.args" '--preview') -fish_preview_arg=$(arg_after "$FISH_OUT/fzf.args" '--preview') -sh_bind_arg=$(arg_after "$SH_OUT/fzf.args" '--bind') -fish_bind_arg=$(arg_after "$FISH_OUT/fzf.args" '--bind') - -assert_eq "$fish_preview_arg" "$sh_preview_arg" 'fish and sh preview fzf args must match byte-for-byte' -assert_eq "$fish_bind_arg" "$sh_bind_arg" 'fish and sh bind fzf args must match byte-for-byte' -pass 'fish and sh fzf preview/bind args match' - -preview_snippet=$(preview_snippet_from "$sh_preview_arg") -open_snippet=$(open_snippet_from "$sh_bind_arg") -DIRECT_LOG="$work/direct.log" - -run_preview "$preview_snippet" 'match-file' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\tmatch-file' 'preview match must search selected file' -assert_file_contains_line "$DIRECT_LOG" $'bat\t--color\talways\t--highlight-line\t7\t--line-range\t4:\tmatch-file' 'preview match must highlight first matched line' - -run_preview "$preview_snippet" 'nomatch-file' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\tnomatch-file' 'preview no-match must search selected file' -assert_file_contains_line "$DIRECT_LOG" $'bat\t--color\talways\tnomatch-file' 'preview no-match must show the whole file' -assert_file_lacks_text "$DIRECT_LOG" '--highlight-line' 'preview no-match must not request a highlighted line' -pass 'preview snippet handles match and no-match cases' - -run_open "$open_snippet" 'match-file' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\tmatch-file' 'open match must search selected file' -assert_file_contains_line "$DIRECT_LOG" $'editor\t+7\tmatch-file' 'open match must pass +line to editor' - -run_open "$open_snippet" 'nomatch-file' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\tnomatch-file' 'open no-match must search selected file' -assert_file_contains_line "$DIRECT_LOG" $'editor\tnomatch-file' 'open no-match must pass only the file to editor' -pass 'open snippet handles match and no-match cases' - -run_preview "$preview_snippet" '+!touch pwned-match' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\t./+!touch pwned-match' 'preview must normalize leading + before rg while preserving selected path as one argument' -assert_file_contains_line "$DIRECT_LOG" $'bat\t--color\talways\t--highlight-line\t7\t--line-range\t4:\t./+!touch pwned-match' 'preview must normalize leading + before bat while preserving selected path as one argument' - -run_preview "$preview_snippet" '-dash match' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\t./-dash match' 'preview must normalize leading - before rg while preserving selected path as one argument' -assert_file_contains_line "$DIRECT_LOG" $'bat\t--color\talways\t--highlight-line\t7\t--line-range\t4:\t./-dash match' 'preview must normalize leading - before bat while preserving selected path as one argument' - -run_open "$open_snippet" '+!touch pwned-match' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\t./+!touch pwned-match' 'open must normalize leading + before rg while preserving selected path as one argument' -assert_file_contains_line "$DIRECT_LOG" $'editor\t+7\t./+!touch pwned-match' 'open must normalize leading + before editor while preserving selected path as one argument' - -run_open "$open_snippet" '-dash match' "$DIRECT_LOG" -assert_file_contains_line "$DIRECT_LOG" $'rg_snippet\t-nS\t-m1\t--\tneedle\t./-dash match' 'open must normalize leading - before rg while preserving selected path as one argument' -assert_file_contains_line "$DIRECT_LOG" $'editor\t+7\t./-dash match' 'open must normalize leading - before editor while preserving selected path as one argument' -pass 'selected paths beginning with + or - are normalized before tool invocation' - -printf 'All smoke tests passed.\n' From 9faa5ae6622bd7eafdda649cb704d7b0b6ffe331 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 18:37:12 +0200 Subject: [PATCH 21/21] ci: remove shell smoke workflow --- .github/workflows/test.yml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 4d8e301..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Test - -on: - push: - pull_request: - -jobs: - smoke: - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Install shell and search dependencies - run: | - sudo apt-get update - sudo apt-get install -y fish zsh fd-find ripgrep fzf bat - sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd - sudo ln -sf "$(command -v batcat)" /usr/local/bin/bat - - - name: Run smoke tests - run: tests/smoke.sh