Add code_search: bounded literal and regex search over the workspace

04472dce58d6 · AtlantisPleb · · parent 74a10e0d9713

Add code_search: bounded literal and regex search over the workspace

Implements OpenAgentsInc/openagents#50: a dedicated search capability
beside repo_tree and repo_map, so the coder no longer has to shell out
to grep for "where is X handled?" questions. Sealing the search behind
the plugin boundary makes it replayable — the same tree and the same
query always produce the same output — and makes its bounds visible in
the manifest instead of buried in a shell flag.

The guest crate (plugins/code-search, built on the owned PDK like every
other plugin) searches the ${workspace} mount line by line with one of
two matchers: a literal substring, or a documented regex subset —
classes with ranges and negation, \d/\w\/\s escapes, single-atom
*/+/? quantifiers, line anchors, and top-level alternation, with
groups and counted repetition refused with a reason rather than
silently misread. Traversal honors the same documented gitignore
subset as repo_tree, reimplemented here because each guest is a sealed
artifact.

Matches come back grouped per file with 1-based line numbers and a
bounded context window. Every ceiling is named in the output: files
considered, scanned, and unscanned; per-file match totals beside what
the ceilings returned; matches dropped; and binary, oversized, and
unreadable files counted, never hidden — truncated is true exactly
when something was dropped.

The logic is unit-tested against a fake host in src/tests.rs (19
tests) and through the real WASM sandbox in
coder-plugin-code-search.test.ts (8 tests), including a check that the
capability catalog surfaces code_search for a "where is X handled?"
prompt. Built with rustc 1.94.1 targeting wasm32-unknown-unknown, the
workspace's release profile, digest pinned in the manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added packages/openagents-cli/test/coder-plugin-code-search.test.ts
  • modified plugins/Cargo.lock
  • modified plugins/Cargo.toml
  • added plugins/code-search/Cargo.toml
  • added plugins/code-search/code_search.wasm
  • added plugins/code-search/manifest.json
  • added plugins/code-search/src/lib.rs
  • added plugins/code-search/src/tests.rs

Diff

8 files changed, +1813 -0

packages/openagents-cli/test/coder-plugin-code-search.test.ts added +186

@@ -0,0 +1,186 @@

1
/**
2
 * The workspace search through the real boundary: the checked-in
3
 * `code_search` plugin against a staged fixture workspace. The search
4
 * logic is unit-tested against a fake host in
5
 * `plugins/code-search/src/tests.rs`; this file proves the same behavior
6
 * holds through the WASM sandbox — literal and regex matching with
7
 * per-file grouping, the gitignore subset, the bound ceilings with their
8
 * honest truncation record, and the clean refusal of patterns outside the
9
 * regex subset. The manifest's `${workspace}` mount is rewritten to the
10
 * fixture directory, as every sandbox test does; the host-side
11
 * `${workspace}` resolution is proven elsewhere.
12
 */
13
14
import { mkdirSync, mkdtempSync, copyFileSync, readFileSync, writeFileSync } from "node:fs";
15
import { tmpdir } from "node:os";
16
import { join } from "node:path";
17
import { fileURLToPath } from "node:url";
18
import { describe, expect, it } from "vitest";
19
20
import {
21
  invokePlugin,
22
  isRefusal,
23
  loadPluginFromManifest,
24
  type LoadedPlugin,
25
} from "../src/coder-plugins.js";
26
import { discoverPluginCatalog, matchCapabilities } from "../src/coder-capability.js";
27
28
const MANIFEST = fileURLToPath(
29
  new URL("../../../plugins/code-search/manifest.json", import.meta.url),
30
);
31
const WASM = fileURLToPath(
32
  new URL("../../../plugins/code-search/code_search.wasm", import.meta.url),
33
);
34
35
const stage = (): { plugin: LoadedPlugin } => {
36
  const dir = mkdtempSync(join(tmpdir(), "code-search-"));
37
  const workspace = join(dir, "workspace");
38
39
  mkdirSync(join(workspace, "src"), { recursive: true });
40
  mkdirSync(join(workspace, "node_modules", "pkg"), { recursive: true });
41
42
  writeFileSync(join(workspace, ".gitignore"), "node_modules/\n*.log\n");
43
  writeFileSync(
44
    join(workspace, "src", "auth.ex"),
45
    "defmodule Auth do\n  def handle_login(conn) do\n    conn\n  end\n\n  def handle_logout(conn) do\n    conn\n  end\nend\n",
46
  );
47
  writeFileSync(
48
    join(workspace, "src", "router.ex"),
49
    "defmodule Router do\n  # login is handled by Auth.handle_login\nend\n",
50
  );
51
  writeFileSync(join(workspace, "node_modules", "pkg", "index.js"), "function login() {}\n");
52
  writeFileSync(join(workspace, "debug.log"), "login attempt failed\n");
53
54
  const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
55
    capabilities: { mounts: Array<{ path: string; readonly: true }> };
56
  };
57
  manifest.capabilities.mounts = [{ path: workspace, readonly: true }];
58
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
59
  copyFileSync(WASM, join(dir, "code_search.wasm"));
60
61
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
62
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
63
  return { plugin: outcome };
64
};
65
66
type Match = { line: number; text: string; before: string[]; after: string[] };
67
type Output = {
68
  files: Array<{ path: string; matches: Match[]; matches_total: number }>;
69
  files_considered: number;
70
  files_scanned: number;
71
  files_unscanned: number;
72
  files_matched: number;
73
  matches_returned: number;
74
  matches_dropped: number;
75
  skipped_gitignored: number;
76
  ignored_negations: number;
77
  skipped_binary: number;
78
  skipped_oversized: number;
79
  skipped_unreadable: number;
80
  truncated: boolean;
81
};
82
83
/** The guest envelope: `ok` on success, `refusal` as a value otherwise. */
84
type Envelope = { ok?: Output; refusal?: { code: string; reason: string } };
85
86
const invoke = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Envelope> => {
87
  const packet = new TextEncoder().encode(JSON.stringify(input));
88
  const outcome = await invokePlugin(plugin, packet);
89
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
90
  return JSON.parse(new TextDecoder().decode(outcome)) as Envelope;
91
};
92
93
const call = async (plugin: LoadedPlugin, input: Record<string, unknown>): Promise<Output> => {
94
  const envelope = await invoke(plugin, input);
95
  if (envelope.ok === undefined) throw new Error(JSON.stringify(envelope.refusal));
96
  return envelope.ok;
97
};
98
99
describe("the code_search plugin through the sandbox", () => {
100
  it("finds a literal pattern, grouped per file, honoring gitignore", async () => {
101
    const { plugin } = stage();
102
    const out = await call(plugin, { pattern: "login" });
103
104
    const paths = out.files.map((file) => file.path);
105
    expect(paths).toEqual(["src/auth.ex", "src/router.ex"]);
106
    expect(paths.some((path) => path.includes("node_modules"))).toBe(false);
107
    expect(paths.some((path) => path.endsWith(".log"))).toBe(false);
108
    // The ignored directory counts once and the log file once.
109
    expect(out.skipped_gitignored).toBe(2);
110
    expect(out.files_matched).toBe(2);
111
    expect(out.truncated).toBe(false);
112
113
    const auth = out.files[0];
114
    expect(auth?.matches.map((match) => match.line)).toEqual([2]);
115
    expect(auth?.matches[0]?.text).toBe("  def handle_login(conn) do");
116
    expect(auth?.matches[0]?.before).toEqual(["defmodule Auth do"]);
117
    expect(auth?.matches[0]?.after).toEqual(["    conn", "  end"]);
118
  });
119
120
  it("matches a regex from the documented subset", async () => {
121
    const { plugin } = stage();
122
    const out = await call(plugin, { pattern: "def handle_\\w+\\(", regex: true });
123
124
    expect(out.files.map((file) => file.path)).toEqual(["src/auth.ex"]);
125
    expect(out.files[0]?.matches.map((match) => match.line)).toEqual([2, 6]);
126
    expect(out.matches_returned).toBe(2);
127
  });
128
129
  it("enforces the match ceiling and reports what was dropped", async () => {
130
    const { plugin } = stage();
131
    const out = await call(plugin, { pattern: "conn", max_matches: 2, context_lines: 0 });
132
133
    // auth.ex holds four `conn` lines; the ceiling returns two of them,
134
    // counts the other two dropped, and leaves router.ex unscanned.
135
    expect(out.matches_returned).toBe(2);
136
    expect(out.files[0]?.matches_total).toBe(4);
137
    expect(out.matches_dropped).toBe(2);
138
    expect(out.files_unscanned).toBeGreaterThan(0);
139
    expect(out.truncated).toBe(true);
140
  });
141
142
  it("returns an empty, untruncated result when nothing matches", async () => {
143
    const { plugin } = stage();
144
    const out = await call(plugin, { pattern: "no_such_token_anywhere" });
145
146
    expect(out.files).toEqual([]);
147
    expect(out.matches_returned).toBe(0);
148
    expect(out.files_scanned).toBeGreaterThan(0);
149
    expect(out.truncated).toBe(false);
150
  });
151
152
  it("refuses a pattern outside the regex subset with a reason, as a value", async () => {
153
    const { plugin } = stage();
154
    const envelope = await invoke(plugin, { pattern: "handle_(login|logout)", regex: true });
155
156
    expect(envelope.ok).toBeUndefined();
157
    expect(envelope.refusal?.code).toBe("unsupported");
158
    expect(envelope.refusal?.reason).toContain("groups");
159
  });
160
161
  it("refuses an empty pattern cleanly", async () => {
162
    const { plugin } = stage();
163
    const envelope = await invoke(plugin, { pattern: "   " });
164
165
    expect(envelope.ok).toBeUndefined();
166
    expect(envelope.refusal?.code).toBe("unsupported");
167
  });
168
169
  it("is deterministic: the same tree and query produce identical output", async () => {
170
    const { plugin } = stage();
171
    const first = await call(plugin, { pattern: "handle", context_lines: 1 });
172
    const second = await call(plugin, { pattern: "handle", context_lines: 1 });
173
174
    expect(second).toEqual(first);
175
  });
176
});
177
178
describe("code_search in the capability catalog", () => {
179
  it("is discovered and surfaces for a `where is X handled?` prompt", () => {
180
    const catalog = discoverPluginCatalog(fileURLToPath(import.meta.url));
181
    expect(catalog.map((entry) => entry.name)).toContain("code_search");
182
183
    const matches = matchCapabilities(catalog, "where is the login flow handled?");
184
    expect(matches[0]?.entry.name).toBe("code_search");
185
  });
186
});
plugins/Cargo.lock modified +9

@@ -8,6 +8,15 @@ version = "2.0.1"

8 8
source = "registry+https://github.com/rust-lang/crates.io-index"
9 9
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 10
11
[[package]]
12
name = "code-search"
13
version = "0.1.0"
14
dependencies = [
15
 "openagents-pdk",
16
 "serde",
17
 "serde_json",
18
]
19
11 20
[[package]]
12 21
name = "dir-stats"
13 22
version = "0.1.0"
plugins/Cargo.toml modified +1

@@ -25,6 +25,7 @@ members = [

25 25
  "repo-map",
26 26
  "git-facts",
27 27
  "session-search",
28
  "code-search",
28 29
  "patch-check",
29 30
  "knowledge-base",
30 31
]
plugins/code-search/Cargo.toml added +14

@@ -0,0 +1,14 @@

1
[package]
2
name = "code-search"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin: bounded literal and regex search over the workspace mount, gitignore-aware, with per-file match grouping and honest truncation."
7
8
[lib]
9
crate-type = ["cdylib", "rlib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
14
serde_json = { workspace = true }
plugins/code-search/code_search.wasm added

Binary file. Nothing to show as text.

plugins/code-search/manifest.json added +135

@@ -0,0 +1,135 @@

1
{
2
  "manifest_version": 1,
3
  "name": "code_search",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Search the code in this repository or workspace for a literal string or a regex pattern — grep for where a function, symbol, error message, config key, or route is handled, defined, used, or mentioned, and answer questions like `where is X handled?`. Matches are grouped per file with line numbers and bounded context lines. Traversal honors gitignore through the same documented subset as repo_tree (no negation; `!` lines are counted), and `.git` is always skipped. Regex support is a documented subset: `.`, character classes with ranges and negation, `\\d`/`\\w`/`\\s`, `*`/`+`/`?` on single atoms, `^`/`$` line anchors, and `|` alternation; groups and counted repetition are refused with a reason. Read-only, deterministic, and bounded: ceilings on files scanned, matches returned per file and in total, and context lines, with truncation reported honestly — files considered, scanned, and unscanned, matches dropped, and binary, oversized, and unreadable files counted, never hidden.",
7
  "artifact": {
8
    "path": "code_search.wasm",
9
    "digest": "sha256:c562bca39422833cfe39f0f46eac882d1849b2f6db8a4f630c7bde39b3ffcf42"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "pattern": {
21
          "type": "string",
22
          "description": "What to search for. Required and must be non-empty. A literal substring unless `regex` is true."
23
        },
24
        "regex": {
25
          "type": "boolean",
26
          "description": "Treat `pattern` as a regex in the documented subset. Literal when omitted or false. A pattern outside the subset is refused with a reason, never silently misread."
27
        },
28
        "path": {
29
          "type": "string",
30
          "description": "Subtree to search, relative to the workspace root. The root when omitted. Ancestor gitignores still apply."
31
        },
32
        "case_sensitive": {
33
          "type": "boolean",
34
          "description": "Match case exactly. Default true; false folds ASCII case on both sides."
35
        },
36
        "max_files": {
37
          "type": "integer",
38
          "description": "Most files whose bytes are searched. Default 300, capped at 2000."
39
        },
40
        "max_matches": {
41
          "type": "integer",
42
          "description": "Most matches returned across all files. Default 20, capped at 100."
43
        },
44
        "max_matches_per_file": {
45
          "type": "integer",
46
          "description": "Most matches returned per file. Default 5, capped at 20."
47
        },
48
        "context_lines": {
49
          "type": "integer",
50
          "description": "Context lines kept on each side of a match. Default 2, capped at 5."
51
        }
52
      },
53
      "required": ["pattern"]
54
    },
55
    "output": {
56
      "type": "object",
57
      "properties": {
58
        "files": {
59
          "type": "array",
60
          "description": "Matching files in walk order, each with its matches grouped.",
61
          "items": {
62
            "type": "object",
63
            "properties": {
64
              "path": { "type": "string" },
65
              "matches": {
66
                "type": "array",
67
                "items": {
68
                  "type": "object",
69
                  "properties": {
70
                    "line": { "type": "integer", "description": "1-based line number." },
71
                    "text": { "type": "string" },
72
                    "before": { "type": "array", "items": { "type": "string" } },
73
                    "after": { "type": "array", "items": { "type": "string" } }
74
                  }
75
                }
76
              },
77
              "matches_total": {
78
                "type": "integer",
79
                "description": "Every matching line in the file, counted even when the match ceilings returned fewer."
80
              }
81
            }
82
          }
83
        },
84
        "files_considered": {
85
          "type": "integer",
86
          "description": "Candidate files the walk produced (gitignored files excluded)."
87
        },
88
        "files_scanned": {
89
          "type": "integer",
90
          "description": "Files whose bytes were searched."
91
        },
92
        "files_unscanned": {
93
          "type": "integer",
94
          "description": "Considered files never searched because the file or match budget stopped the scan first."
95
        },
96
        "files_matched": { "type": "integer" },
97
        "matches_returned": { "type": "integer" },
98
        "matches_dropped": {
99
          "type": "integer",
100
          "description": "Matching lines found in scanned files but dropped by the per-file or total match ceiling."
101
        },
102
        "skipped_gitignored": {
103
          "type": "integer",
104
          "description": "Entries the gitignore rules dropped; a skipped directory counts once and is never walked."
105
        },
106
        "ignored_negations": {
107
          "type": "integer",
108
          "description": "`!` gitignore lines the subset ignored rather than honored."
109
        },
110
        "skipped_binary": {
111
          "type": "integer",
112
          "description": "Files skipped because a NUL byte marked them binary."
113
        },
114
        "skipped_oversized": {
115
          "type": "integer",
116
          "description": "Files past the per-file byte bound, never read."
117
        },
118
        "skipped_unreadable": {
119
          "type": "integer",
120
          "description": "Files the host refused to read for any reason but size."
121
        },
122
        "truncated": {
123
          "type": "boolean",
124
          "description": "True exactly when something was dropped: files unscanned, matches dropped, or the walk cut short."
125
        }
126
      }
127
    }
128
  },
129
  "capabilities": {
130
    "mounts": [{ "path": "${workspace}", "readonly": true }],
131
    "hosts": [],
132
    "timeout_ms": 10000,
133
    "memory_max_mib": 128
134
  }
135
}
plugins/code-search/src/lib.rs added +1024

@@ -0,0 +1,1024 @@

1
//! `code_search`: bounded literal and regex search over the workspace, as a
2
//! `packet-v0` guest plugin (OpenAgentsInc/openagents#50).
3
//!
4
//! Mount 0 is the workspace — the manifest declares it as the literal
5
//! `${workspace}`, which the host resolves to its working directory at load
6
//! time, exactly as `repo_tree` and `repo_map` do. The coder shells out to
7
//! grep or rg for this today; sealing the search behind the plugin boundary
8
//! makes it replayable — the same tree and the same query always produce the
9
//! same output — and makes its bounds visible in the manifest instead of
10
//! buried in a shell flag.
11
//!
12
//! One tool, two matchers:
13
//!
14
//! - **Literal** (the default): a substring match of `pattern` against each
15
//!   line.
16
//! - **Regex** (`regex: true`): a documented subset of regular expressions,
17
//!   matched per line. The subset is: literal characters; `.` for any
18
//!   character; character classes `[...]` with ranges and leading-`^`
19
//!   negation; the escapes `\d`, `\w`, `\s` (and their `\D`/`\W`/`\S`
20
//!   negations outside classes), `\t`, `\n`, and escaped punctuation; the
21
//!   postfix quantifiers `*`, `+`, `?` on a single atom; `^` and `$`
22
//!   anchored to the line; and top-level alternation with `|`. Groups
23
//!   `(...)`, counted repetition `{n,m}`, and backreferences are refused
24
//!   with a reason, never silently misread.
25
//!
26
//! Both matchers are line-based: a line either matches or it does not, and
27
//! each matching line is one match, however many times the pattern occurs
28
//! on it. `case_sensitive: false` folds ASCII case on both sides, the same
29
//! folding `repo_tree`'s query mode uses.
30
//!
31
//! ## The walk
32
//!
33
//! Traversal honors the same documented gitignore subset as `repo_tree` —
34
//! comments and blanks skipped, trailing-slash directory patterns,
35
//! leading-slash anchoring, `*` within a segment, `**` across segments, and
36
//! no negation (`!` lines are counted as `ignored_negations`) — because a
37
//! search that reads what git ignores answers questions nobody asked.
38
//! `.git` is skipped unconditionally. The rules are reimplemented here
39
//! rather than imported because each guest is a sealed, self-contained
40
//! artifact; `repo_tree` owns the canonical statement of the subset.
41
//!
42
//! ## Bounds, and what truncation reports
43
//!
44
//! Every ceiling that fires is named in the output rather than swallowed:
45
//! `files_considered` counts the candidate files the walk produced,
46
//! `files_scanned` the ones whose bytes were actually searched, and
47
//! `files_unscanned` the considered files a budget left unread. Per file,
48
//! `matches_total` counts every matching line even when the per-file or
49
//! total match ceiling returned fewer, and `matches_dropped` sums what was
50
//! found in scanned files but not returned. Oversized, binary, and
51
//! unreadable files are counted, not hidden. `truncated` is true exactly
52
//! when any of that happened — silent truncation is a defect here.
53
54
use openagents_pdk::{
55
    list_mounted_dir, plugin_entry, read_mounted_file, MountDirListing, Refusal, RefusalCode,
56
};
57
use serde::{Deserialize, Serialize};
58
59
/// The workspace is the manifest's one mount.
60
const WORKSPACE_MOUNT: u32 = 0;
61
const DEFAULT_MAX_FILES: usize = 300;
62
const FILE_CAP: usize = 2_000;
63
const DEFAULT_MAX_MATCHES: usize = 20;
64
const MATCH_CAP: usize = 100;
65
const DEFAULT_PER_FILE: usize = 5;
66
const PER_FILE_CAP: usize = 20;
67
const DEFAULT_CONTEXT: usize = 2;
68
const CONTEXT_CAP: usize = 5;
69
/// Per-file byte bound; a larger file is counted oversized rather than read.
70
const MAX_FILE_BYTES: u64 = 524_288;
71
/// Characters kept of each returned line, match and context alike, so one
72
/// minified file cannot flood the output.
73
const LINE_CHAR_BOUND: usize = 200;
74
/// Most directory listings one invocation may ask the host for.
75
pub const LISTING_BOUND: usize = 2_000;
76
/// Most candidate files one invocation holds before the walk stops.
77
pub const HELD_BOUND: usize = 10_000;
78
/// Directory depth ceiling for the walk; deeper entries are cut, truncated.
79
const DEPTH_BOUND: usize = 16;
80
/// Backtracking steps the regex matcher may spend on one line before the
81
/// whole search is refused as too expensive. Quantifiers apply only to
82
/// single atoms in this subset, so real patterns never come near it.
83
const STEP_BUDGET: usize = 200_000;
84
85
#[derive(Debug, Deserialize)]
86
pub struct Input {
87
    /// What to search for. Required; must be non-empty after trimming.
88
    pub pattern: String,
89
    /// Treat `pattern` as a regex in the documented subset. Literal when
90
    /// absent or false.
91
    #[serde(default)]
92
    pub regex: Option<bool>,
93
    /// Subtree to search, relative to the workspace root. The root when absent.
94
    #[serde(default)]
95
    pub path: Option<String>,
96
    /// Match case exactly. Default true; false folds ASCII case.
97
    #[serde(default)]
98
    pub case_sensitive: Option<bool>,
99
    /// Most files whose bytes are searched. Default 300, capped at 2000.
100
    #[serde(default)]
101
    pub max_files: Option<usize>,
102
    /// Most matches returned across all files. Default 20, capped at 100.
103
    #[serde(default)]
104
    pub max_matches: Option<usize>,
105
    /// Most matches returned per file. Default 5, capped at 20.
106
    #[serde(default)]
107
    pub max_matches_per_file: Option<usize>,
108
    /// Context lines kept on each side of a match. Default 2, capped at 5.
109
    #[serde(default)]
110
    pub context_lines: Option<usize>,
111
}
112
113
/// One matching line, with its bounded context window.
114
#[derive(Debug, Serialize, PartialEq, Eq)]
115
pub struct Match {
116
    /// 1-based line number.
117
    pub line: usize,
118
    pub text: String,
119
    /// Up to `context_lines` lines immediately before the match.
120
    pub before: Vec<String>,
121
    /// Up to `context_lines` lines immediately after the match.
122
    pub after: Vec<String>,
123
}
124
125
/// All of one file's returned matches, grouped.
126
#[derive(Debug, Serialize)]
127
pub struct FileMatches {
128
    /// Path relative to the workspace root.
129
    pub path: String,
130
    pub matches: Vec<Match>,
131
    /// Every matching line in the file, counted even when the match
132
    /// ceilings returned fewer than this.
133
    pub matches_total: usize,
134
}
135
136
#[derive(Debug, Serialize)]
137
pub struct Output {
138
    /// Matching files in walk order, each with its matches grouped.
139
    pub files: Vec<FileMatches>,
140
    /// Candidate files the walk produced (gitignored files excluded).
141
    pub files_considered: usize,
142
    /// Files whose bytes were searched.
143
    pub files_scanned: usize,
144
    /// Considered files never searched because the file or match budget
145
    /// stopped the scan first.
146
    pub files_unscanned: usize,
147
    /// Scanned files with at least one match.
148
    pub files_matched: usize,
149
    /// Matches returned across all files.
150
    pub matches_returned: usize,
151
    /// Matching lines found in scanned files but dropped by the per-file
152
    /// or total match ceiling.
153
    pub matches_dropped: usize,
154
    /// Entries the gitignore rules dropped (each skipped directory counts
155
    /// once; nothing under it is walked or counted).
156
    pub skipped_gitignored: usize,
157
    /// `!` gitignore lines this subset ignored rather than honored.
158
    pub ignored_negations: usize,
159
    /// Files skipped because a NUL byte marked them binary.
160
    pub skipped_binary: usize,
161
    /// Files past the per-file byte bound, never read.
162
    pub skipped_oversized: usize,
163
    /// Files the host refused to read for any reason but size.
164
    pub skipped_unreadable: usize,
165
    /// True exactly when something was dropped: files unscanned, matches
166
    /// dropped, or the walk itself cut short by a listing or held bound.
167
    pub truncated: bool,
168
}
169
170
/// The host capabilities this plugin uses, as a seam the tests fake.
171
pub trait Host {
172
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal>;
173
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
174
}
175
176
struct RealHost;
177
178
impl Host for RealHost {
179
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
180
        list_mounted_dir(mount_index, path)
181
    }
182
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
183
        read_mounted_file(path)
184
    }
185
}
186
187
/// The whole tool, over any [`Host`].
188
pub fn code_search(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
189
    let pattern = input.pattern.trim();
190
    if pattern.is_empty() {
191
        return Err(Refusal::unsupported(
192
            "the pattern is empty; pass a non-empty literal or regex in `pattern`",
193
        ));
194
    }
195
    let case_sensitive = input.case_sensitive.unwrap_or(true);
196
    let matcher = if input.regex.unwrap_or(false) {
197
        Matcher::Regex(parse_pattern(pattern, case_sensitive).map_err(|why| {
198
            Refusal::unsupported(format!(
199
                "the pattern is not a valid regex in this subset: {why}"
200
            ))
201
        })?)
202
    } else {
203
        Matcher::Literal {
204
            needle: if case_sensitive {
205
                pattern.to_string()
206
            } else {
207
                pattern.to_ascii_lowercase()
208
            },
209
            case_sensitive,
210
        }
211
    };
212
    let max_files = input
213
        .max_files
214
        .unwrap_or(DEFAULT_MAX_FILES)
215
        .clamp(1, FILE_CAP);
216
    let max_matches = input
217
        .max_matches
218
        .unwrap_or(DEFAULT_MAX_MATCHES)
219
        .clamp(1, MATCH_CAP);
220
    let per_file = input
221
        .max_matches_per_file
222
        .unwrap_or(DEFAULT_PER_FILE)
223
        .clamp(1, PER_FILE_CAP);
224
    let context = input
225
        .context_lines
226
        .unwrap_or(DEFAULT_CONTEXT)
227
        .min(CONTEXT_CAP);
228
229
    // Phase 1: the gitignore-aware walk collects candidate files in a fixed
230
    // depth-first order, so the scan below is deterministic and the
231
    // unscanned remainder is countable instead of unknown.
232
    let start = normalize_start(input.path.as_deref());
233
    let mut walk = Walk::new(host);
234
    walk.load_ancestors(&start);
235
    let mut candidates: Vec<(String, u64)> = Vec::new();
236
    walk.walk(&start, 0, &mut |path, kind, size| {
237
        if kind != "file" {
238
            return true;
239
        }
240
        if candidates.len() >= HELD_BOUND {
241
            return false;
242
        }
243
        candidates.push((path, size));
244
        true
245
    })?;
246
247
    // Phase 2: scan candidates in walk order until a budget stops it.
248
    let mut files: Vec<FileMatches> = Vec::new();
249
    let mut files_scanned = 0usize;
250
    let mut files_unscanned = 0usize;
251
    let mut files_matched = 0usize;
252
    let mut matches_returned = 0usize;
253
    let mut matches_dropped = 0usize;
254
    let mut skipped_binary = 0usize;
255
    let mut skipped_oversized = 0usize;
256
    let mut skipped_unreadable = 0usize;
257
    for (at, (path, size)) in candidates.iter().enumerate() {
258
        if files_scanned >= max_files || matches_returned >= max_matches {
259
            files_unscanned = candidates.len() - at;
260
            break;
261
        }
262
        if *size > MAX_FILE_BYTES {
263
            skipped_oversized += 1;
264
            continue;
265
        }
266
        let bytes = match host.read(path) {
267
            Ok(bytes) => bytes,
268
            Err(refusal) if refusal.code == RefusalCode::FileTooLarge => {
269
                skipped_oversized += 1;
270
                continue;
271
            }
272
            Err(_) => {
273
                skipped_unreadable += 1;
274
                continue;
275
            }
276
        };
277
        if bytes.contains(&0) {
278
            skipped_binary += 1;
279
            continue;
280
        }
281
        files_scanned += 1;
282
        let text = String::from_utf8_lossy(&bytes);
283
        // `lines()` leaves the `\r` of CRLF endings on the line; strip it so
284
        // Windows-authored files match and render the same as Unix ones.
285
        let lines: Vec<&str> = text
286
            .lines()
287
            .map(|line| line.trim_end_matches('\r'))
288
            .collect();
289
        let mut hit_lines: Vec<usize> = Vec::new();
290
        for (index, line) in lines.iter().enumerate() {
291
            let matched = matcher.matches(line).map_err(|_| {
292
                Refusal::unsupported(format!(
293
                    "the regex exceeded the matching budget on {path}:{}; simplify the pattern",
294
                    index + 1
295
                ))
296
            })?;
297
            if matched {
298
                hit_lines.push(index);
299
            }
300
        }
301
        if hit_lines.is_empty() {
302
            continue;
303
        }
304
        files_matched += 1;
305
        let take = hit_lines
306
            .len()
307
            .min(per_file)
308
            .min(max_matches - matches_returned);
309
        let matches: Vec<Match> = hit_lines
310
            .iter()
311
            .take(take)
312
            .map(|&index| Match {
313
                line: index + 1,
314
                text: bound_line(lines[index]),
315
                before: lines[index.saturating_sub(context)..index]
316
                    .iter()
317
                    .map(|line| bound_line(line))
318
                    .collect(),
319
                after: lines[(index + 1).min(lines.len())..(index + 1 + context).min(lines.len())]
320
                    .iter()
321
                    .map(|line| bound_line(line))
322
                    .collect(),
323
            })
324
            .collect();
325
        matches_returned += take;
326
        matches_dropped += hit_lines.len() - take;
327
        files.push(FileMatches {
328
            path: path.clone(),
329
            matches,
330
            matches_total: hit_lines.len(),
331
        });
332
    }
333
334
    Ok(Output {
335
        files,
336
        files_considered: candidates.len(),
337
        files_scanned,
338
        files_unscanned,
339
        files_matched,
340
        matches_returned,
341
        matches_dropped,
342
        skipped_gitignored: walk.skipped_gitignored,
343
        ignored_negations: walk.ignored_negations,
344
        skipped_binary,
345
        skipped_oversized,
346
        skipped_unreadable,
347
        truncated: files_unscanned > 0 || matches_dropped > 0 || walk.truncated,
348
    })
349
}
350
351
/// Keep at most [`LINE_CHAR_BOUND`] characters of a line.
352
fn bound_line(line: &str) -> String {
353
    line.chars().take(LINE_CHAR_BOUND).collect()
354
}
355
356
// ---------------------------------------------------------------------------
357
// The matchers
358
359
enum Matcher {
360
    Literal {
361
        needle: String,
362
        case_sensitive: bool,
363
    },
364
    Regex(Pattern),
365
}
366
367
impl Matcher {
368
    /// Does the pattern match this line? `Err` means the regex step budget
369
    /// ran out — reported, never silently folded into "no match".
370
    fn matches(&self, line: &str) -> Result<bool, ()> {
371
        match self {
372
            Matcher::Literal {
373
                needle,
374
                case_sensitive,
375
            } => {
376
                if *case_sensitive {
377
                    Ok(line.contains(needle.as_str()))
378
                } else {
379
                    Ok(line.to_ascii_lowercase().contains(needle.as_str()))
380
                }
381
            }
382
            Matcher::Regex(pattern) => pattern.matches_line(line),
383
        }
384
    }
385
}
386
387
// ---------------------------------------------------------------------------
388
// The regex subset
389
390
/// A parsed pattern: top-level alternation over branches.
391
pub struct Pattern {
392
    branches: Vec<Branch>,
393
    /// ASCII-fold the line before matching; branch atoms were folded at
394
    /// parse time.
395
    fold_case: bool,
396
}
397
398
struct Branch {
399
    start_anchor: bool,
400
    end_anchor: bool,
401
    pieces: Vec<Piece>,
402
}
403
404
struct Piece {
405
    atom: Atom,
406
    quant: Quant,
407
}
408
409
enum Atom {
410
    /// `.`: any character on the line.
411
    Any,
412
    Char(char),
413
    Class {
414
        negated: bool,
415
        items: Vec<ClassItem>,
416
    },
417
}
418
419
enum ClassItem {
420
    Char(char),
421
    Range(char, char),
422
    Digit,
423
    Word,
424
    Space,
425
}
426
427
enum Quant {
428
    One,
429
    Star,
430
    Plus,
431
    Opt,
432
}
433
434
/// Parse the subset, folding case at parse time when asked. Errors are
435
/// prose for the refusal reason.
436
pub fn parse_pattern(text: &str, case_sensitive: bool) -> Result<Pattern, String> {
437
    let chars: Vec<char> = text.chars().collect();
438
    let mut branches = Vec::new();
439
    let mut at = 0usize;
440
    loop {
441
        let (branch, next) = parse_branch(&chars, at, case_sensitive)?;
442
        branches.push(branch);
443
        if next >= chars.len() {
444
            break;
445
        }
446
        // `parse_branch` stops only at `|` or the end.
447
        at = next + 1;
448
        if at >= chars.len() {
449
            return Err("a trailing `|` leaves an empty branch".to_string());
450
        }
451
    }
452
    Ok(Pattern {
453
        branches,
454
        fold_case: !case_sensitive,
455
    })
456
}
457
458
fn parse_branch(
459
    chars: &[char],
460
    mut at: usize,
461
    case_sensitive: bool,
462
) -> Result<(Branch, usize), String> {
463
    let mut start_anchor = false;
464
    if chars.get(at) == Some(&'^') {
465
        start_anchor = true;
466
        at += 1;
467
    }
468
    let mut pieces = Vec::new();
469
    let mut end_anchor = false;
470
    while at < chars.len() {
471
        let c = chars[at];
472
        if c == '|' {
473
            break;
474
        }
475
        if c == '$' {
476
            if at + 1 == chars.len() || chars[at + 1] == '|' {
477
                end_anchor = true;
478
                at += 1;
479
                break;
480
            }
481
            return Err("`$` is only supported at the end of a pattern or branch".to_string());
482
        }
483
        if c == '(' || c == ')' {
484
            return Err("groups `(...)` are not supported by the regex subset".to_string());
485
        }
486
        if c == '{' || c == '}' {
487
            return Err(
488
                "counted repetition `{n,m}` is not supported by the regex subset".to_string(),
489
            );
490
        }
491
        if c == '*' || c == '+' || c == '?' {
492
            return Err(format!("`{c}` has nothing to repeat"));
493
        }
494
        let (atom, next) = parse_atom(chars, at, case_sensitive)?;
495
        at = next;
496
        let quant = match chars.get(at) {
497
            Some('*') => {
498
                at += 1;
499
                Quant::Star
500
            }
501
            Some('+') => {
502
                at += 1;
503
                Quant::Plus
504
            }
505
            Some('?') => {
506
                at += 1;
507
                Quant::Opt
508
            }
509
            _ => Quant::One,
510
        };
511
        pieces.push(Piece { atom, quant });
512
    }
513
    Ok((
514
        Branch {
515
            start_anchor,
516
            end_anchor,
517
            pieces,
518
        },
519
        at,
520
    ))
521
}
522
523
fn parse_atom(chars: &[char], at: usize, case_sensitive: bool) -> Result<(Atom, usize), String> {
524
    let fold = |c: char| {
525
        if case_sensitive {
526
            c
527
        } else {
528
            c.to_ascii_lowercase()
529
        }
530
    };
531
    match chars[at] {
532
        '.' => Ok((Atom::Any, at + 1)),
533
        '[' => parse_class(chars, at, case_sensitive),
534
        '^' => Err("`^` is only supported at the start of a pattern or branch".to_string()),
535
        '\\' => {
536
            let escaped = *chars
537
                .get(at + 1)
538
                .ok_or_else(|| "a trailing `\\` escapes nothing".to_string())?;
539
            let atom = match escaped {
540
                'd' => class_of(false, ClassItem::Digit),
541
                'D' => class_of(true, ClassItem::Digit),
542
                'w' => class_of(false, ClassItem::Word),
543
                'W' => class_of(true, ClassItem::Word),
544
                's' => class_of(false, ClassItem::Space),
545
                'S' => class_of(true, ClassItem::Space),
546
                't' => Atom::Char('\t'),
547
                'n' => Atom::Char('\n'),
548
                other if other.is_ascii_alphanumeric() => {
549
                    return Err(format!("the escape `\\{other}` is not in the subset"))
550
                }
551
                other => Atom::Char(fold(other)),
552
            };
553
            Ok((atom, at + 2))
554
        }
555
        c => Ok((Atom::Char(fold(c)), at + 1)),
556
    }
557
}
558
559
fn class_of(negated: bool, item: ClassItem) -> Atom {
560
    Atom::Class {
561
        negated,
562
        items: vec![item],
563
    }
564
}
565
566
fn parse_class(chars: &[char], at: usize, case_sensitive: bool) -> Result<(Atom, usize), String> {
567
    let fold = |c: char| {
568
        if case_sensitive {
569
            c
570
        } else {
571
            c.to_ascii_lowercase()
572
        }
573
    };
574
    let mut cursor = at + 1;
575
    let negated = chars.get(cursor) == Some(&'^');
576
    if negated {
577
        cursor += 1;
578
    }
579
    let mut items: Vec<ClassItem> = Vec::new();
580
    loop {
581
        let c = *chars
582
            .get(cursor)
583
            .ok_or_else(|| "the character class `[` is never closed".to_string())?;
584
        if c == ']' {
585
            if items.is_empty() {
586
                return Err("the character class `[]` is empty".to_string());
587
            }
588
            return Ok((Atom::Class { negated, items }, cursor + 1));
589
        }
590
        let low = if c == '\\' {
591
            let escaped = *chars
592
                .get(cursor + 1)
593
                .ok_or_else(|| "a trailing `\\` escapes nothing".to_string())?;
594
            cursor += 2;
595
            match escaped {
596
                'd' => {
597
                    items.push(ClassItem::Digit);
598
                    continue;
599
                }
600
                'w' => {
601
                    items.push(ClassItem::Word);
602
                    continue;
603
                }
604
                's' => {
605
                    items.push(ClassItem::Space);
606
                    continue;
607
                }
608
                't' => '\t',
609
                'n' => '\n',
610
                other if other.is_ascii_alphanumeric() => {
611
                    return Err(format!("the class escape `\\{other}` is not in the subset"))
612
                }
613
                other => other,
614
            }
615
        } else {
616
            cursor += 1;
617
            c
618
        };
619
        // A `-` between two characters is a range; anywhere else it is the
620
        // literal dash, matching what grep users expect of `[a-z-]`.
621
        if chars.get(cursor) == Some(&'-') && chars.get(cursor + 1).is_some_and(|&next| next != ']')
622
        {
623
            let high = chars[cursor + 1];
624
            if high == '\\' {
625
                return Err("an escape cannot end a class range".to_string());
626
            }
627
            if (low as u32) > (high as u32) {
628
                return Err(format!("the class range `{low}-{high}` runs backwards"));
629
            }
630
            items.push(ClassItem::Range(fold(low), fold(high)));
631
            cursor += 2;
632
        } else {
633
            items.push(ClassItem::Char(fold(low)));
634
        }
635
    }
636
}
637
638
impl Pattern {
639
    /// Does any branch match anywhere on the line? `Err` means the step
640
    /// budget ran out.
641
    pub fn matches_line(&self, line: &str) -> Result<bool, ()> {
642
        let chars: Vec<char> = if self.fold_case {
643
            line.chars().map(|c| c.to_ascii_lowercase()).collect()
644
        } else {
645
            line.chars().collect()
646
        };
647
        let mut steps = STEP_BUDGET;
648
        for branch in &self.branches {
649
            let starts: Vec<usize> = if branch.start_anchor {
650
                vec![0]
651
            } else {
652
                (0..=chars.len()).collect()
653
            };
654
            for start in starts {
655
                if match_here(&branch.pieces, &chars, start, branch.end_anchor, &mut steps)? {
656
                    return Ok(true);
657
                }
658
            }
659
        }
660
        Ok(false)
661
    }
662
}
663
664
/// Match the remaining pieces at `pos`, backtracking over quantifiers.
665
/// Quantifiers apply to single atoms only, so the recursion depth is the
666
/// piece count and the budget bounds total work.
667
fn match_here(
668
    pieces: &[Piece],
669
    line: &[char],
670
    pos: usize,
671
    end_anchor: bool,
672
    steps: &mut usize,
673
) -> Result<bool, ()> {
674
    if *steps == 0 {
675
        return Err(());
676
    }
677
    *steps -= 1;
678
    let Some((piece, rest)) = pieces.split_first() else {
679
        return Ok(!end_anchor || pos == line.len());
680
    };
681
    let hits = |at: usize| line.get(at).is_some_and(|&c| atom_matches(&piece.atom, c));
682
    match piece.quant {
683
        Quant::One => {
684
            if hits(pos) {
685
                match_here(rest, line, pos + 1, end_anchor, steps)
686
            } else {
687
                Ok(false)
688
            }
689
        }
690
        Quant::Opt => {
691
            if hits(pos) && match_here(rest, line, pos + 1, end_anchor, steps)? {
692
                return Ok(true);
693
            }
694
            match_here(rest, line, pos, end_anchor, steps)
695
        }
696
        Quant::Star | Quant::Plus => {
697
            let least = if matches!(piece.quant, Quant::Plus) {
698
                1
699
            } else {
700
                0
701
            };
702
            let mut most = pos;
703
            while hits(most) {
704
                most += 1;
705
            }
706
            // Greedy: the longest run first, giving back one character at a
707
            // time, exactly the order a reader expects of `*`.
708
            let mut take = most;
709
            loop {
710
                if take < pos + least {
711
                    return Ok(false);
712
                }
713
                if match_here(rest, line, take, end_anchor, steps)? {
714
                    return Ok(true);
715
                }
716
                if take == pos {
717
                    return Ok(false);
718
                }
719
                take -= 1;
720
            }
721
        }
722
    }
723
}
724
725
fn atom_matches(atom: &Atom, c: char) -> bool {
726
    match atom {
727
        Atom::Any => true,
728
        Atom::Char(wanted) => c == *wanted,
729
        Atom::Class { negated, items } => {
730
            let inside = items.iter().any(|item| class_item_matches(item, c));
731
            inside != *negated
732
        }
733
    }
734
}
735
736
fn class_item_matches(item: &ClassItem, c: char) -> bool {
737
    match item {
738
        ClassItem::Char(wanted) => c == *wanted,
739
        ClassItem::Range(low, high) => (*low..=*high).contains(&c),
740
        ClassItem::Digit => c.is_ascii_digit(),
741
        ClassItem::Word => c.is_ascii_alphanumeric() || c == '_',
742
        ClassItem::Space => c.is_whitespace(),
743
    }
744
}
745
746
// ---------------------------------------------------------------------------
747
// The walk — the same documented gitignore subset as `repo_tree`, which owns
748
// its canonical statement; reimplemented because each guest is sealed.
749
750
/// A visitor: entry path, kind, size; `false` stops the whole walk.
751
type Sink<'s> = dyn FnMut(String, &str, u64) -> bool + 's;
752
753
struct Walk<'a> {
754
    host: &'a dyn Host,
755
    listings: usize,
756
    truncated: bool,
757
    skipped_gitignored: usize,
758
    ignored_negations: usize,
759
    rule_sets: Vec<RuleSet>,
760
}
761
762
impl<'a> Walk<'a> {
763
    fn new(host: &'a dyn Host) -> Self {
764
        Walk {
765
            host,
766
            listings: 0,
767
            truncated: false,
768
            skipped_gitignored: 0,
769
            ignored_negations: 0,
770
            rule_sets: Vec::new(),
771
        }
772
    }
773
774
    /// Rules from `.gitignore` files above the walk's start, so a subtree
775
    /// search still honors the root's ignores. The start's own `.gitignore`
776
    /// is loaded by the walk itself.
777
    fn load_ancestors(&mut self, start: &str) {
778
        if start.is_empty() {
779
            return;
780
        }
781
        self.push_gitignore("");
782
        let mut base = String::new();
783
        let components: Vec<&str> = start.split('/').collect();
784
        for component in &components[..components.len() - 1] {
785
            base = join(&base, component);
786
            self.push_gitignore(&base);
787
        }
788
    }
789
790
    /// Read and push this directory's `.gitignore`, if it has one. Returns
791
    /// whether a rule set was pushed, so the caller can pop symmetrically.
792
    fn push_gitignore(&mut self, dir: &str) -> bool {
793
        match self.host.read(&join(dir, ".gitignore")) {
794
            Ok(bytes) => {
795
                let (rules, negations) = parse_gitignore(&bytes);
796
                self.ignored_negations += negations;
797
                self.rule_sets.push(RuleSet {
798
                    base: dir.to_string(),
799
                    rules,
800
                });
801
                true
802
            }
803
            // Absent or unreadable is the same answer: no rules here.
804
            Err(_) => false,
805
        }
806
    }
807
808
    fn ignored(&self, path: &str, is_dir: bool) -> bool {
809
        self.rule_sets.iter().any(|set| {
810
            let rel = if set.base.is_empty() {
811
                Some(path)
812
            } else {
813
                path.strip_prefix(set.base.as_str())
814
                    .and_then(|rest| rest.strip_prefix('/'))
815
            };
816
            rel.is_some_and(|rel| set.rules.iter().any(|rule| rule.matches(rel, is_dir)))
817
        })
818
    }
819
820
    /// Depth-first over `dir` (whose own depth is `depth`; its children are
821
    /// `depth + 1`). Returns whether the walk should keep going.
822
    fn walk(&mut self, dir: &str, depth: usize, sink: &mut Sink) -> Result<bool, Refusal> {
823
        if self.listings >= LISTING_BOUND {
824
            self.truncated = true;
825
            return Ok(true);
826
        }
827
        self.listings += 1;
828
        let listing = match self.host.list(WORKSPACE_MOUNT, dir) {
829
            Ok(listing) => listing,
830
            // The walk's own root must exist; a nested directory that
831
            // refuses to list is skipped, fail-soft.
832
            Err(refusal) if depth == 0 => return Err(refusal),
833
            Err(_) => return Ok(true),
834
        };
835
        if listing.truncated {
836
            self.truncated = true;
837
        }
838
        let pushed = self.push_gitignore(dir);
839
        let mut keep_going = true;
840
        for entry in &listing.entries {
841
            // `.git` is skipped unconditionally, never walked, never counted.
842
            if entry.name == ".git" {
843
                continue;
844
            }
845
            // Symlinks are reported but never followed; neither shape can
846
            // be searched.
847
            if entry.kind != "file" && entry.kind != "dir" {
848
                continue;
849
            }
850
            let path = join(dir, &entry.name);
851
            if self.ignored(&path, entry.kind == "dir") {
852
                self.skipped_gitignored += 1;
853
                continue;
854
            }
855
            if !sink(path.clone(), &entry.kind, entry.size) {
856
                self.truncated = true;
857
                keep_going = false;
858
                break;
859
            }
860
            if entry.kind == "dir" {
861
                if depth + 1 >= DEPTH_BOUND {
862
                    self.truncated = true;
863
                } else if !self.walk(&path, depth + 1, sink)? {
864
                    keep_going = false;
865
                    break;
866
                }
867
            }
868
        }
869
        if pushed {
870
            self.rule_sets.pop();
871
        }
872
        Ok(keep_going)
873
    }
874
}
875
876
fn normalize_start(path: Option<&str>) -> String {
877
    let mut trimmed = path.unwrap_or("").trim();
878
    while let Some(rest) = trimmed.strip_prefix("./") {
879
        trimmed = rest;
880
    }
881
    let trimmed = trimmed.trim_matches('/');
882
    if trimmed == "." {
883
        String::new()
884
    } else {
885
        trimmed.to_string()
886
    }
887
}
888
889
fn join(dir: &str, name: &str) -> String {
890
    if dir.is_empty() {
891
        name.to_string()
892
    } else {
893
        format!("{dir}/{name}")
894
    }
895
}
896
897
// ---------------------------------------------------------------------------
898
// The gitignore subset
899
900
struct RuleSet {
901
    /// Directory holding the `.gitignore`, relative to the workspace root.
902
    base: String,
903
    rules: Vec<Rule>,
904
}
905
906
struct Rule {
907
    /// Pattern split on `/`; a lone `**` segment crosses segments.
908
    segments: Vec<String>,
909
    /// Trailing `/`: the pattern matches directories only.
910
    dir_only: bool,
911
    /// The pattern contained a `/`, so it matches relative to its
912
    /// `.gitignore`'s directory; otherwise it matches basenames anywhere
913
    /// below it.
914
    anchored: bool,
915
}
916
917
impl Rule {
918
    /// Does this rule match `rel` (relative to the rule's base)?
919
    fn matches(&self, rel: &str, is_dir: bool) -> bool {
920
        if self.dir_only && !is_dir {
921
            return false;
922
        }
923
        let path: Vec<&str> = rel.split('/').collect();
924
        if self.anchored {
925
            glob_path(&self.segments, &path)
926
        } else {
927
            path.last()
928
                .is_some_and(|name| glob_segment(&self.segments[0], name))
929
        }
930
    }
931
}
932
933
/// Parse one `.gitignore`'s bytes into the subset's rules, plus how many
934
/// `!` negation lines were ignored.
935
fn parse_gitignore(bytes: &[u8]) -> (Vec<Rule>, usize) {
936
    let text = String::from_utf8_lossy(bytes);
937
    let mut rules = Vec::new();
938
    let mut negations = 0usize;
939
    for raw in text.lines() {
940
        let line = raw.trim();
941
        if line.is_empty() || line.starts_with('#') {
942
            continue;
943
        }
944
        if line.starts_with('!') {
945
            negations += 1;
946
            continue;
947
        }
948
        let (body, dir_only) = match line.strip_suffix('/') {
949
            Some(body) => (body, true),
950
            None => (line, false),
951
        };
952
        let (body, leading_slash) = match body.strip_prefix('/') {
953
            Some(body) => (body, true),
954
            None => (body, false),
955
        };
956
        if body.is_empty() {
957
            continue;
958
        }
959
        let anchored = leading_slash || body.contains('/');
960
        let segments: Vec<String> = body
961
            .split('/')
962
            .filter(|s| !s.is_empty())
963
            .map(str::to_string)
964
            .collect();
965
        if segments.is_empty() {
966
            continue;
967
        }
968
        rules.push(Rule {
969
            segments,
970
            dir_only,
971
            anchored,
972
        });
973
    }
974
    (rules, negations)
975
}
976
977
/// Match pattern segments against path segments; a `**` pattern segment
978
/// matches zero or more whole path segments.
979
fn glob_path(pattern: &[String], path: &[&str]) -> bool {
980
    match pattern.split_first() {
981
        None => path.is_empty(),
982
        Some((first, rest)) if first == "**" => {
983
            (0..=path.len()).any(|skip| glob_path(rest, &path[skip..]))
984
        }
985
        Some((first, rest)) => path
986
            .split_first()
987
            .is_some_and(|(segment, more)| glob_segment(first, segment) && glob_path(rest, more)),
988
    }
989
}
990
991
/// Match one pattern segment against one name; `*` matches any run of
992
/// characters within the segment, everything else is literal.
993
fn glob_segment(pattern: &str, name: &str) -> bool {
994
    let pattern: Vec<char> = pattern.chars().collect();
995
    let name: Vec<char> = name.chars().collect();
996
    segment_match(&pattern, &name)
997
}
998
999
fn segment_match(pattern: &[char], name: &[char]) -> bool {
1000
    match pattern.split_first() {
1001
        None => name.is_empty(),
1002
        Some(('*', rest)) => {
1003
            // Collapse star runs so `**` inside a segment is one `*`.
1004
            let rest = if rest.first() == Some(&'*') {
1005
                &rest[1..]
1006
            } else {
1007
                rest
1008
            };
1009
            (0..=name.len()).any(|skip| segment_match(rest, &name[skip..]))
1010
        }
1011
        Some((ch, rest)) => name
1012
            .split_first()
1013
            .is_some_and(|(first, more)| first == ch && segment_match(rest, more)),
1014
    }
1015
}
1016
1017
fn handle(input: Input) -> Result<Output, Refusal> {
1018
    code_search(&RealHost, &input)
1019
}
1020
1021
plugin_entry!(handle);
1022
1023
#[cfg(test)]
1024
mod tests;
plugins/code-search/src/tests.rs added +444

@@ -0,0 +1,444 @@

1
//! The search against a fake host: literal and regex matching, the
2
//! gitignore subset, every bound and its truncation record, context
3
//! windows, and the clean refusal of patterns outside the regex subset —
4
//! all without a WASM runtime, the same pattern as `repo_tree`'s tests.
5
6
use super::*;
7
use openagents_pdk::{MountDirEntry, RefusalCode};
8
use std::collections::BTreeMap;
9
10
#[derive(Default)]
11
struct FakeHost {
12
    dirs: BTreeMap<(u32, String), MountDirListing>,
13
    files: BTreeMap<String, Vec<u8>>,
14
}
15
16
impl Host for FakeHost {
17
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
18
        self.dirs
19
            .get(&(mount_index, path.to_string()))
20
            .cloned()
21
            .ok_or_else(|| {
22
                Refusal::new(
23
                    RefusalCode::FileUnreadable,
24
                    "the mount has no such directory",
25
                )
26
            })
27
    }
28
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
29
        self.files
30
            .get(path)
31
            .cloned()
32
            .ok_or_else(|| Refusal::new(RefusalCode::FileUnreadable, "no such file"))
33
    }
34
}
35
36
/// Build a workspace host from `(path, contents)` pairs; directories are
37
/// derived from the paths, listings sorted by name like the real host's.
38
fn workspace(files: &[(&str, &str)]) -> FakeHost {
39
    let mut children: BTreeMap<String, BTreeMap<String, (String, u64)>> = BTreeMap::new();
40
    children.insert(String::new(), BTreeMap::new());
41
    let mut host = FakeHost::default();
42
    for (path, body) in files {
43
        host.files
44
            .insert((*path).to_string(), body.as_bytes().to_vec());
45
        let parts: Vec<&str> = path.split('/').collect();
46
        let mut dir = String::new();
47
        for (at, part) in parts.iter().enumerate() {
48
            if at == parts.len() - 1 {
49
                children
50
                    .entry(dir.clone())
51
                    .or_default()
52
                    .insert((*part).to_string(), ("file".to_string(), body.len() as u64));
53
            } else {
54
                children
55
                    .entry(dir.clone())
56
                    .or_default()
57
                    .entry((*part).to_string())
58
                    .or_insert(("dir".to_string(), 0));
59
                dir = join(&dir, part);
60
                children.entry(dir.clone()).or_default();
61
            }
62
        }
63
    }
64
    for (dir, kids) in children {
65
        let entries = kids
66
            .into_iter()
67
            .map(|(name, (kind, size))| MountDirEntry {
68
                name,
69
                kind,
70
                size,
71
                mtime_ms: 0,
72
            })
73
            .collect();
74
        host.dirs.insert(
75
            (0, dir),
76
            MountDirListing {
77
                entries,
78
                truncated: false,
79
            },
80
        );
81
    }
82
    host
83
}
84
85
fn input(pattern: &str) -> Input {
86
    Input {
87
        pattern: pattern.to_string(),
88
        regex: None,
89
        path: None,
90
        case_sensitive: None,
91
        max_files: None,
92
        max_matches: None,
93
        max_matches_per_file: None,
94
        context_lines: None,
95
    }
96
}
97
98
fn search(host: &FakeHost, input: &Input) -> Output {
99
    code_search(host, input).unwrap()
100
}
101
102
#[test]
103
fn a_literal_search_groups_matches_per_file_with_line_numbers() {
104
    let host = workspace(&[
105
        (
106
            "src/auth.ex",
107
            "defmodule Auth do\n  def login do\n    :ok\n  end\nend\n",
108
        ),
109
        (
110
            "src/user.ex",
111
            "defmodule User do\n  # login goes through Auth.login\nend\n",
112
        ),
113
        ("README.md", "No such word here.\n"),
114
    ]);
115
    let out = search(&host, &input("login"));
116
    assert_eq!(out.files_considered, 3);
117
    assert_eq!(out.files_scanned, 3);
118
    assert_eq!(out.files_matched, 2);
119
    assert_eq!(out.matches_returned, 2);
120
    assert!(!out.truncated);
121
    let paths: Vec<&str> = out.files.iter().map(|f| f.path.as_str()).collect();
122
    assert_eq!(paths, vec!["src/auth.ex", "src/user.ex"]);
123
    assert_eq!(out.files[0].matches[0].line, 2);
124
    assert_eq!(out.files[0].matches[0].text, "  def login do");
125
    assert_eq!(out.files[1].matches[0].line, 2);
126
}
127
128
#[test]
129
fn a_matching_line_is_one_match_however_often_the_pattern_occurs_on_it() {
130
    let host = workspace(&[("a.txt", "one two two two\nclean\ntwo\n")]);
131
    let out = search(&host, &input("two"));
132
    assert_eq!(out.matches_returned, 2);
133
    assert_eq!(out.files[0].matches[0].line, 1);
134
    assert_eq!(out.files[0].matches[1].line, 3);
135
}
136
137
#[test]
138
fn context_lines_surround_each_match_and_stop_at_the_file_edges() {
139
    let host = workspace(&[("a.txt", "first\nsecond\nhit here\nfourth\nfifth\n")]);
140
    let out = search(
141
        &host,
142
        &Input {
143
            context_lines: Some(2),
144
            ..input("hit")
145
        },
146
    );
147
    let matched = &out.files[0].matches[0];
148
    assert_eq!(matched.before, vec!["first", "second"]);
149
    assert_eq!(matched.after, vec!["fourth", "fifth"]);
150
151
    let edge = search(
152
        &host,
153
        &Input {
154
            context_lines: Some(2),
155
            ..input("first")
156
        },
157
    );
158
    assert_eq!(edge.files[0].matches[0].before, Vec::<String>::new());
159
    assert_eq!(edge.files[0].matches[0].after, vec!["second", "hit here"]);
160
}
161
162
#[test]
163
fn a_regex_search_matches_the_documented_subset() {
164
    let host = workspace(&[(
165
        "src/handlers.rs",
166
        "fn handle_login() {}\nfn handle_logout() {}\nfn ignore_me() {}\n",
167
    )]);
168
    let out = search(
169
        &host,
170
        &Input {
171
            regex: Some(true),
172
            ..input(r"fn handle_\w+\(")
173
        },
174
    );
175
    assert_eq!(out.matches_returned, 2);
176
    assert_eq!(out.files[0].matches[0].line, 1);
177
    assert_eq!(out.files[0].matches[1].line, 2);
178
}
179
180
#[test]
181
fn regex_anchors_classes_quantifiers_and_alternation_hold() {
182
    let host = workspace(&[("a.txt", "abc\nabbbc\nac\nxabc\nabcx\ndone\nfin\n")]);
183
    let anchored = search(
184
        &host,
185
        &Input {
186
            regex: Some(true),
187
            ..input("^ab*c$")
188
        },
189
    );
190
    let lines: Vec<usize> = anchored.files[0].matches.iter().map(|m| m.line).collect();
191
    assert_eq!(lines, vec![1, 2, 3]);
192
193
    let class = search(
194
        &host,
195
        &Input {
196
            regex: Some(true),
197
            ..input("^[a-n]+$")
198
        },
199
    );
200
    // `xabc` starts outside the class, `done` holds an `o` past `n`, and
201
    // the anchors reject both; `fin` sits inside the range.
202
    let lines: Vec<usize> = class.files[0].matches.iter().map(|m| m.line).collect();
203
    assert_eq!(lines, vec![1, 2, 3, 7]);
204
205
    let either = search(
206
        &host,
207
        &Input {
208
            regex: Some(true),
209
            ..input("^done$|^fin$")
210
        },
211
    );
212
    let lines: Vec<usize> = either.files[0].matches.iter().map(|m| m.line).collect();
213
    assert_eq!(lines, vec![6, 7]);
214
}
215
216
#[test]
217
fn case_folding_is_off_by_default_and_ascii_when_asked_for() {
218
    let host = workspace(&[("a.txt", "Login\nlogin\nLOGIN\n")]);
219
    let exact = search(&host, &input("login"));
220
    assert_eq!(exact.matches_returned, 1);
221
    let folded = search(
222
        &host,
223
        &Input {
224
            case_sensitive: Some(false),
225
            ..input("login")
226
        },
227
    );
228
    assert_eq!(folded.matches_returned, 3);
229
    let folded_regex = search(
230
        &host,
231
        &Input {
232
            regex: Some(true),
233
            case_sensitive: Some(false),
234
            ..input("^log[a-z]n$")
235
        },
236
    );
237
    assert_eq!(folded_regex.matches_returned, 3);
238
}
239
240
#[test]
241
fn gitignored_files_and_directories_are_never_searched() {
242
    let host = workspace(&[
243
        (".gitignore", "node_modules/\n*.log\n!keep.log\n"),
244
        ("src/app.ts", "needle in app\n"),
245
        ("node_modules/pkg/index.js", "needle in a dependency\n"),
246
        ("debug.log", "needle in a log\n"),
247
    ]);
248
    let out = search(&host, &input("needle"));
249
    let paths: Vec<&str> = out.files.iter().map(|f| f.path.as_str()).collect();
250
    assert_eq!(paths, vec!["src/app.ts"]);
251
    // The skipped directory counts once and the log file once; the `!` line
252
    // is counted as unhonored rather than silently applied.
253
    assert_eq!(out.skipped_gitignored, 2);
254
    assert_eq!(out.ignored_negations, 1);
255
}
256
257
#[test]
258
fn a_subtree_scope_still_honors_ancestor_gitignores() {
259
    let host = workspace(&[
260
        (".gitignore", "*.log\n"),
261
        ("src/app.ts", "needle\n"),
262
        ("src/trace.log", "needle\n"),
263
        ("other/away.ts", "needle\n"),
264
    ]);
265
    let out = search(
266
        &host,
267
        &Input {
268
            path: Some("src".to_string()),
269
            ..input("needle")
270
        },
271
    );
272
    let paths: Vec<&str> = out.files.iter().map(|f| f.path.as_str()).collect();
273
    assert_eq!(paths, vec!["src/app.ts"]);
274
    assert_eq!(out.files_considered, 1);
275
}
276
277
#[test]
278
fn the_total_match_ceiling_stops_the_scan_and_reports_the_remainder() {
279
    let host = workspace(&[
280
        ("a.txt", "hit\nhit\nhit\n"),
281
        ("b.txt", "hit\nhit\n"),
282
        ("c.txt", "hit\n"),
283
    ]);
284
    let out = search(
285
        &host,
286
        &Input {
287
            max_matches: Some(4),
288
            ..input("hit")
289
        },
290
    );
291
    assert_eq!(out.matches_returned, 4);
292
    // The ceiling landed inside b.txt: its third… second match was cut, and
293
    // c.txt was never opened. Both facts are stated, not implied.
294
    assert_eq!(out.matches_dropped, 1);
295
    assert_eq!(out.files_scanned, 2);
296
    assert_eq!(out.files_unscanned, 1);
297
    assert!(out.truncated);
298
    assert_eq!(out.files[1].matches.len(), 1);
299
    assert_eq!(out.files[1].matches_total, 2);
300
}
301
302
#[test]
303
fn the_per_file_ceiling_returns_the_first_matches_and_counts_the_rest() {
304
    let host = workspace(&[("a.txt", "hit\nhit\nhit\nhit\n")]);
305
    let out = search(
306
        &host,
307
        &Input {
308
            max_matches_per_file: Some(2),
309
            ..input("hit")
310
        },
311
    );
312
    assert_eq!(out.files[0].matches.len(), 2);
313
    assert_eq!(out.files[0].matches_total, 4);
314
    assert_eq!(out.matches_dropped, 2);
315
    assert!(out.truncated);
316
}
317
318
#[test]
319
fn the_file_ceiling_stops_the_scan_and_counts_the_unscanned() {
320
    let host = workspace(&[("a.txt", "hit\n"), ("b.txt", "hit\n"), ("c.txt", "hit\n")]);
321
    let out = search(
322
        &host,
323
        &Input {
324
            max_files: Some(2),
325
            ..input("hit")
326
        },
327
    );
328
    assert_eq!(out.files_scanned, 2);
329
    assert_eq!(out.files_unscanned, 1);
330
    assert_eq!(out.matches_returned, 2);
331
    assert!(out.truncated);
332
}
333
334
#[test]
335
fn binary_and_oversized_files_are_counted_not_searched() {
336
    let mut host = workspace(&[("a.txt", "hit\n"), ("blob.bin", "placeholder")]);
337
    host.files
338
        .insert("blob.bin".to_string(), vec![b'h', b'i', b't', 0, 1, 2]);
339
    let out = search(&host, &input("hit"));
340
    assert_eq!(out.skipped_binary, 1);
341
    assert_eq!(out.files_matched, 1);
342
343
    let mut big = workspace(&[("a.txt", "hit\n"), ("huge.txt", "hit\n")]);
344
    let listing = big.dirs.get_mut(&(0, String::new())).unwrap();
345
    for entry in &mut listing.entries {
346
        if entry.name == "huge.txt" {
347
            entry.size = MAX_FILE_BYTES + 1;
348
        }
349
    }
350
    let out = search(&big, &input("hit"));
351
    assert_eq!(out.skipped_oversized, 1);
352
    assert_eq!(out.files_matched, 1);
353
}
354
355
#[test]
356
fn an_empty_result_is_clean_and_untruncated() {
357
    let host = workspace(&[("a.txt", "nothing to see\n")]);
358
    let out = search(&host, &input("absent"));
359
    assert!(out.files.is_empty());
360
    assert_eq!(out.files_scanned, 1);
361
    assert_eq!(out.matches_returned, 0);
362
    assert!(!out.truncated);
363
}
364
365
#[test]
366
fn an_empty_pattern_is_refused() {
367
    let host = workspace(&[("a.txt", "text\n")]);
368
    let refusal = code_search(&host, &input("   ")).unwrap_err();
369
    assert_eq!(refusal.code, RefusalCode::Unsupported);
370
}
371
372
#[test]
373
fn patterns_outside_the_regex_subset_are_refused_with_a_reason() {
374
    let host = workspace(&[("a.txt", "text\n")]);
375
    for (pattern, expect) in [
376
        ("(group)", "groups"),
377
        ("a{2,3}", "counted repetition"),
378
        ("*dangling", "nothing to repeat"),
379
        ("[unclosed", "never closed"),
380
        ("[]", "empty"),
381
        ("[z-a]", "backwards"),
382
        ("trailing\\", "escapes nothing"),
383
        ("mid^anchor", "start"),
384
        ("a$b", "end"),
385
        ("a|", "empty branch"),
386
    ] {
387
        let refusal = code_search(
388
            &host,
389
            &Input {
390
                regex: Some(true),
391
                ..input(pattern)
392
            },
393
        )
394
        .unwrap_err();
395
        assert_eq!(refusal.code, RefusalCode::Unsupported, "{pattern}");
396
        assert!(
397
            refusal.reason.contains(expect),
398
            "{pattern}: {}",
399
            refusal.reason
400
        );
401
    }
402
}
403
404
#[test]
405
fn regex_metacharacters_are_plain_text_in_literal_mode() {
406
    let host = workspace(&[("a.txt", "value[0].method()\nvalue\n")]);
407
    let out = search(&host, &input("value[0].method()"));
408
    assert_eq!(out.matches_returned, 1);
409
    assert_eq!(out.files[0].matches[0].line, 1);
410
}
411
412
#[test]
413
fn long_lines_are_bounded_in_the_output() {
414
    let long = format!("{}needle{}\n", "x".repeat(300), "y".repeat(300));
415
    let host = workspace(&[("a.txt", long.as_str())]);
416
    let out = search(&host, &input("x"));
417
    assert_eq!(out.files[0].matches[0].text.chars().count(), 200);
418
}
419
420
#[test]
421
fn crlf_line_endings_match_and_render_without_the_carriage_return() {
422
    let host = workspace(&[("a.txt", "one\r\nhit here\r\nthree\r\n")]);
423
    let out = search(
424
        &host,
425
        &Input {
426
            regex: Some(true),
427
            ..input("here$")
428
        },
429
    );
430
    assert_eq!(out.matches_returned, 1);
431
    assert_eq!(out.files[0].matches[0].text, "hit here");
432
}
433
434
#[test]
435
fn the_same_tree_and_query_always_produce_the_same_output() {
436
    let host = workspace(&[
437
        ("src/a.rs", "needle one\n"),
438
        ("src/b.rs", "needle two\nneedle three\n"),
439
        ("lib/c.rs", "needle four\n"),
440
    ]);
441
    let first = serde_json::to_string(&search(&host, &input("needle"))).unwrap();
442
    let second = serde_json::to_string(&search(&host, &input("needle"))).unwrap();
443
    assert_eq!(first, second);
444
}

This page updates live while a promote is in flight · changelog