use std::sync::Arc;
use async_trait::async_trait;
use locode_host::{Host, rg_program};
use locode_tools::{Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const CONTENT_LINE_LIMIT: usize = 2_000;
const CONTENT_LINE_DEFAULT: usize = 200;
const FILE_COUNT_LIMIT: usize = 10_000;
const FILE_COUNT_DEFAULT: usize = 500;
pub(crate) struct GrokGrep {
host: Arc<Host>,
}
impl GrokGrep {
pub(crate) fn new(host: Arc<Host>) -> Self {
Self { host }
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum OutputMode {
#[default]
Content,
FilesWithMatches,
Count,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct GrepArgs {
#[schemars(
description = "The regular expression pattern to search for in file contents (rg --regexp)"
)]
pattern: String,
#[serde(default)]
#[schemars(
description = "File or directory to search in (rg pattern -- PATH). Defaults to workspace path."
)]
path: Option<String>,
#[serde(default)]
#[schemars(
description = "Glob pattern (rg --glob GLOB -- PATH) to filter files (e.g. \"*.js\", \"*.{ts,tsx}\")."
)]
glob: Option<String>,
#[serde(default)]
#[schemars(skip)]
output_mode: Option<OutputMode>,
#[serde(default, rename = "-B")]
#[schemars(description = "Number of lines to show before each match (rg -B).")]
before_context: Option<usize>,
#[serde(default, rename = "-A")]
#[schemars(description = "Number of lines to show after each match (rg -A).")]
after_context: Option<usize>,
#[serde(default, rename = "-C")]
#[schemars(description = "Number of lines to show before and after each match (rg -C).")]
context: Option<usize>,
#[serde(default, rename = "-i")]
#[schemars(description = "Case insensitive search (rg -i). Defaults to false.")]
case_insensitive: Option<bool>,
#[serde(default)]
#[schemars(
description = "File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than glob for standard file types."
)]
r#type: Option<String>,
#[serde(default)]
#[schemars(
description = "Limit output to first N lines/entries, equivalent to \"| head -N\". Defaults to 200 lines or 500 entries."
)]
head_limit: Option<usize>,
#[serde(default)]
#[schemars(
description = "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false."
)]
multiline: Option<bool>,
}
fn resolve_effective_head_limit(head_limit: Option<usize>, output_mode: &OutputMode) -> usize {
let (default, cap) = match output_mode {
OutputMode::Content => (CONTENT_LINE_DEFAULT, CONTENT_LINE_LIMIT),
OutputMode::FilesWithMatches | OutputMode::Count => (FILE_COUNT_DEFAULT, FILE_COUNT_LIMIT),
};
head_limit.unwrap_or(default).min(cap)
}
#[derive(Debug, Serialize)]
pub(crate) struct GrepOutput {
matched: bool,
truncated: bool,
#[serde(skip)]
text: String,
}
impl ToolOutput for GrepOutput {
fn to_prompt_text(&self) -> String {
if self.matched {
self.text.clone()
} else {
"No matches found.".to_owned()
}
}
}
#[async_trait]
impl Tool for GrokGrep {
type Args = GrepArgs;
type Output = GrepOutput;
fn kind(&self) -> ToolKind {
ToolKind::Grep
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
r#"Search file contents with regular expressions (ripgrep).
- Full regex syntax, so escape literal special characters: `functionCall\(`, or `interface\{\}` to find interface{} in Go.
- Pass the pattern as a raw regex string — no surrounding quotes.
- Respects .gitignore unless you pass a broad glob like '--glob *'.
- Only filter by 'type' or 'glob' when you are sure of the file type; import paths may not match source file types (.js vs .ts).
- Output is ripgrep-style: ':' marks match lines, '-' marks context lines, grouped by file. Large results are capped and report "at least" counts."#
}
async fn run(&self, ctx: &ToolCtx, args: GrepArgs) -> Result<Self::Output, ToolError> {
let output_mode = args.output_mode.clone().unwrap_or_default();
let effective_head_limit = resolve_effective_head_limit(args.head_limit, &output_mode);
let mut rg_args: Vec<String> = vec![
"--heading".into(),
"--with-filename".into(),
"--line-number".into(),
"--color=never".into(),
"--max-columns".into(),
"1000".into(),
"--max-columns-preview".into(),
];
if args.case_insensitive.unwrap_or(false) {
rg_args.push("--ignore-case".into());
}
if let Some(glob) = &args.glob
&& !glob.is_empty()
{
rg_args.push("--glob".into());
rg_args.push(glob.clone());
}
if let Some(t) = &args.r#type
&& !t.is_empty()
{
rg_args.push("--type".into());
rg_args.push(t.clone());
}
if args.multiline.unwrap_or(false) {
rg_args.push("-U".into());
rg_args.push("--multiline-dotall".into());
}
if let Some(c) = args.context
&& c > 0
{
rg_args.push("-C".into());
rg_args.push(c.to_string());
}
if let Some(b) = args.before_context
&& b > 0
{
rg_args.push("-B".into());
rg_args.push(b.to_string());
}
if let Some(a) = args.after_context
&& a > 0
{
rg_args.push("-A".into());
rg_args.push(a.to_string());
}
match output_mode {
OutputMode::FilesWithMatches => rg_args.push("-l".into()),
OutputMode::Count => rg_args.push("-c".into()),
OutputMode::Content => {}
}
rg_args.push("-e".into());
rg_args.push(args.pattern.clone());
rg_args.push(args.path.clone().unwrap_or_else(|| ".".into()));
rg_args.push("--max-filesize".into());
rg_args.push("5M".into());
let out = self
.host
.run_capture(&rg_program(), &rg_args, &ctx.cwd, None, &ctx.cancel)
.await
.map_err(|e| {
ToolError::Respond(format!(
"ripgrep (rg) could not be run ({e}); install rg or set LOCODE_RG_PATH."
))
})?;
match out.exit_code {
Some(0) => {
let (text, line_capped) = apply_head_limit(&out.stdout, effective_head_limit);
Ok(GrepOutput {
matched: true,
truncated: out.truncated || line_capped,
text,
})
}
Some(1) => Ok(GrepOutput {
matched: false,
truncated: false,
text: String::new(),
}),
_ => Err(ToolError::Respond(format!(
"grep failed: {}",
if out.stderr.is_empty() {
"ripgrep error".to_owned()
} else {
out.stderr
}
))),
}
}
}
fn apply_head_limit(text: &str, limit: usize) -> (String, bool) {
let mut end = 0usize;
let mut lines = 0usize;
for (idx, _) in text.match_indices('\n') {
lines += 1;
if lines == limit {
end = idx + 1;
break;
}
}
if lines < limit || end >= text.len() {
(text.to_owned(), false)
} else {
(text[..end].to_owned(), true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn head_limit_exact_fit_is_not_truncated() {
let text = "a\nb\nc\n";
let (out, truncated) = apply_head_limit(text, 3);
assert_eq!(out, text);
assert!(!truncated);
}
#[test]
fn head_limit_cuts_and_flags() {
let (out, truncated) = apply_head_limit("a\nb\nc\nd\n", 2);
assert_eq!(out, "a\nb\n");
assert!(truncated);
}
#[test]
fn effective_limit_defaults_and_caps() {
assert_eq!(
resolve_effective_head_limit(None, &OutputMode::Content),
200
);
assert_eq!(
resolve_effective_head_limit(None, &OutputMode::FilesWithMatches),
500
);
assert_eq!(
resolve_effective_head_limit(Some(9_999_999), &OutputMode::Content),
2_000
);
assert_eq!(
resolve_effective_head_limit(Some(9_999_999), &OutputMode::Count),
10_000
);
assert_eq!(
resolve_effective_head_limit(Some(50), &OutputMode::Content),
50
);
}
#[test]
fn schema_uses_grok_wire_names_and_hides_output_mode() {
let schema = serde_json::to_value(schemars::schema_for!(GrepArgs)).unwrap();
let props = schema["properties"].as_object().unwrap();
for key in [
"pattern",
"path",
"glob",
"-B",
"-A",
"-C",
"-i",
"type",
"head_limit",
"multiline",
] {
assert!(props.contains_key(key), "missing schema field {key}");
}
assert!(!props.contains_key("output_mode"));
let args: GrepArgs =
serde_json::from_value(serde_json::json!({"pattern": "x", "output_mode": "count"}))
.unwrap();
assert_eq!(args.output_mode, Some(OutputMode::Count));
}
#[test]
fn flag_fields_deserialize_from_wire_names() {
let args: GrepArgs = serde_json::from_value(serde_json::json!({
"pattern": "x", "-B": 2, "-A": 3, "-C": 1, "-i": true
}))
.unwrap();
assert_eq!(args.before_context, Some(2));
assert_eq!(args.after_context, Some(3));
assert_eq!(args.context, Some(1));
assert_eq!(args.case_insensitive, Some(true));
}
}