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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/jp/tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ crossbeam-channel = { workspace = true, features = ["std"] }
crossterm = { workspace = true }
duct = { workspace = true }
fancy-regex = { workspace = true, features = ["perf", "std", "unicode"] }
grep-matcher = { workspace = true }
grep-printer = { workspace = true }
grep-regex = { workspace = true }
grep-searcher = { workspace = true }
htmd = { workspace = true }
ignore = { workspace = true }
Expand Down
6 changes: 4 additions & 2 deletions .config/jp/tools/src/fs/grep_files.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
use camino::{Utf8Path, Utf8PathBuf};
use grep_printer::StandardBuilder;
use grep_regex::RegexMatcher;
use grep_searcher::{BinaryDetection, SearcherBuilder};
use ignore::gitignore::Gitignore;
use jp_tool::AccessPolicy;
use matcher::FancyMatcher;

use super::fs_list_files;
use crate::{Error, util::OneOrMany};

mod matcher;

pub(crate) async fn fs_grep_files(
root: &Utf8Path,
access: Option<&AccessPolicy>,
Expand Down Expand Up @@ -45,7 +47,7 @@ pub(crate) async fn fs_grep_files(
pattern = format!("{pattern}|{pat}");
}

let matcher = RegexMatcher::new(&pattern)?;
let matcher = FancyMatcher::new(&pattern)?;

let mut printer = StandardBuilder::new()
.max_columns(Some(1000))
Expand Down
54 changes: 54 additions & 0 deletions .config/jp/tools/src/fs/grep_files/matcher.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use fancy_regex::{Regex, RegexBuilder};
use grep_matcher::{Match, Matcher, NoCaptures};

/// A search matcher backed by `fancy-regex`.
///
/// Patterns get the same dialect as the rest of the toolset, so lookaround
/// (`(?=...)`, `(?<!...)`) and backreferences (`\1`) are available in addition
/// to the usual syntax.
pub(crate) struct FancyMatcher {
regex: Regex,
}

impl FancyMatcher {
pub(crate) fn new(pattern: &str) -> Result<Self, fancy_regex::Error> {
// Line-oriented anchoring: `^` and `$` bind to line boundaries rather
// than to the ends of the haystack, because the searcher hands over
// whole buffers and callers write grep patterns, not whole-file ones.
let regex = RegexBuilder::new(pattern)
.multi_line(true)
.dot_matches_new_line(false)
.unicode_mode(true)
.build()?;

Ok(Self { regex })
}
}

impl Matcher for FancyMatcher {
type Captures = NoCaptures;
type Error = fancy_regex::Error;

fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error> {
// `find_from_pos` starts the search at `at` while leaving the preceding
// bytes visible, which is what lets lookbehind work across the offset.
// Slicing the haystack instead would hide them.
//
// Bytes that are not valid UTF-8 simply never match, so one latin-1
// line does not blind the search to the rest of the buffer.
let found = self
.regex
.find_from_pos(haystack, at)?
.map(|m| Match::new(m.start(), m.end()));

Ok(found)
}

fn new_captures(&self) -> Result<Self::Captures, Self::Error> {
Ok(NoCaptures::new())
}
}

#[cfg(test)]
#[path = "matcher_tests.rs"]
mod tests;
48 changes: 48 additions & 0 deletions .config/jp/tools/src/fs/grep_files/matcher_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use super::*;

#[test]
fn reports_absolute_offsets_when_searching_from_a_position() {
let matcher = FancyMatcher::new("needle").unwrap();

let found = matcher.find_at(b"needle and needle", 7).unwrap().unwrap();

assert_eq!((found.start(), found.end()), (11, 17));
}

#[test]
fn lookbehind_reaches_behind_the_start_position() {
let matcher = FancyMatcher::new("(?<=let )needle").unwrap();

// The match begins at 4, so a search from 4 only succeeds if the bytes
// before it are still visible to the lookbehind.
let found = matcher.find_at(b"let needle", 4).unwrap().unwrap();

assert_eq!((found.start(), found.end()), (4, 10));
}

#[test]
fn caret_anchors_to_a_line_start_within_the_buffer() {
let matcher = FancyMatcher::new("^beta").unwrap();

let found = matcher.find_at(b"alpha\nbeta\n", 0).unwrap().unwrap();

assert_eq!((found.start(), found.end()), (6, 10));
}

#[test]
fn offsets_stay_absolute_in_a_haystack_holding_invalid_utf8() {
let matcher = FancyMatcher::new("needle").unwrap();

// The latin-1 byte occupies one byte, so the match starts at 5. Reporting
// it anywhere else would make the searcher slice the wrong line.
let found = matcher.find_at(b"caf\xe9 needle", 0).unwrap().unwrap();

assert_eq!((found.start(), found.end()), (5, 11));
}

#[test]
fn a_pattern_that_cannot_match_returns_none_rather_than_an_error() {
let matcher = FancyMatcher::new("needle").unwrap();

assert!(matcher.find_at(b"nothing here", 0).unwrap().is_none());
}
91 changes: 91 additions & 0 deletions .config/jp/tools/src/fs/grep_files_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,97 @@ async fn suppressed_path_is_reported_so_the_caller_can_ask_the_user() {
);
}

/// Search a single-file workspace, returning the tool's rendered output.
///
/// The pattern semantics below are the contract the tool exposes to callers;
/// they must hold for whichever regex engine backs the search.
async fn grep_one_file(content: &[u8], pattern: &str) -> String {
let tmp = tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("a.txt"), content).unwrap();

fs_grep_files(
root,
None,
pattern.to_owned(),
None,
None,
None,
&Gitignore::empty(),
)
.await
.unwrap()
}

#[tokio::test]
async fn caret_and_dollar_anchor_per_line() {
let matches = grep_one_file(b"alpha\nbeta\nalphabet\n", "^alpha$").await;

assert_eq!(matches, "a.txt:1:alpha\n");
}

#[tokio::test]
async fn dot_does_not_match_across_lines() {
let matches = grep_one_file(b"a\nb\n", "a.b").await;

assert_eq!(
matches,
"No matches found. Broaden your search to see more."
);
}

#[tokio::test]
async fn word_boundaries_hold_and_matching_is_case_sensitive() {
let matches = grep_one_file(b"foo\nfoobar\nFOO\n", r"\bfoo\b").await;

assert_eq!(matches, "a.txt:1:foo\n");
}

#[tokio::test]
async fn repeated_match_on_one_line_prints_the_line_once() {
let matches = grep_one_file(b"foo bar foo\n", "foo").await;

assert_eq!(matches, "a.txt:1:foo bar foo\n");
}

#[tokio::test]
async fn a_zero_width_pattern_matches_an_empty_line() {
let matches = grep_one_file(b"alpha\n\nbeta\n", "^$").await;

assert_eq!(matches, "a.txt:2:\n");
}

#[tokio::test]
async fn a_line_that_is_not_utf8_does_not_abort_the_search() {
// Latin-1 bytes in a text file are not binary (no NUL), so the search runs
// on. Only the matched line reaches the output, which keeps the final
// UTF-8 decode of the printer buffer intact.
let matches = grep_one_file(b"caf\xe9 latte\nneedle here\n", "needle").await;

assert_eq!(matches, "a.txt:2:needle here\n");
}

#[tokio::test]
async fn negative_lookahead_excludes_a_match() {
let matches = grep_one_file(b"foo bar\nfoo baz\n", "foo (?!bar)").await;

assert_eq!(matches, "a.txt:2:foo baz\n");
}

#[tokio::test]
async fn lookbehind_sees_text_before_the_match() {
let matches = grep_one_file(b"let needle\nfn needle\n", "(?<=let )needle").await;

assert_eq!(matches, "a.txt:1:let needle\n");
}

#[tokio::test]
async fn backreference_matches_a_repeated_group() {
let matches = grep_one_file(b"hello hello\nhello world\n", r"(\w+) \1").await;

assert_eq!(matches, "a.txt:1:hello hello\n");
}

#[tokio::test]
#[test_log::test]
async fn test_grep_files() {
Expand Down
5 changes: 5 additions & 0 deletions .config/supply-chain/audits.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ who = "Jean Mertz <git@jeanmertz.com>"
criteria = "safe-to-deploy"
delta = "0.16.2 -> 0.17.0"

[[audits.fancy-regex]]
who = "Jean Mertz <git@jeanmertz.com>"
criteria = "safe-to-deploy"
delta = "0.17.0 -> 0.19.0"

[[audits.finl_unicode]]
who = "Jean Mertz <git@jeanmertz.com>"
criteria = "safe-to-deploy"
Expand Down
11 changes: 2 additions & 9 deletions .config/supply-chain/imports.lock
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,6 @@ user-id = 189
user-login = "BurntSushi"
user-name = "Andrew Gallant"

[[publisher.grep-regex]]
version = "0.1.13"
when = "2024-09-09"
user-id = 189
user-login = "BurntSushi"
user-name = "Andrew Gallant"

[[publisher.grep-searcher]]
version = "0.1.16"
when = "2025-10-22"
Expand Down Expand Up @@ -581,8 +574,8 @@ user-login = "BurntSushi"
user-name = "Andrew Gallant"

[[publisher.regex-automata]]
version = "0.4.10"
when = "2025-08-24"
version = "0.4.18"
when = "2026-08-04"
user-id = 189
user-login = "BurntSushi"
user-name = "Andrew Gallant"
Expand Down
2 changes: 1 addition & 1 deletion .jp/mcp/tools/fs/grep_files.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ parameters = "function_call"
[conversation.tools.fs_grep_files.parameters.pattern]
type = "string"
required = true
summary = "Regular expression to filter the results by."
summary = "Regular expression to filter the results by. Lookaround and backreferences are supported."

[conversation.tools.fs_grep_files.parameters.context]
type = "integer"
Expand Down
2 changes: 1 addition & 1 deletion .jp/mcp/tools/fs/grep_user_docs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ parameters = "function_call"
[conversation.tools.fs_grep_user_docs.parameters.pattern]
type = "string"
required = true
summary = "Regular expression to filter the results by."
summary = "Regular expression to filter the results by. Lookaround and backreferences are supported."

[conversation.tools.fs_grep_user_docs.parameters.context]
type = "integer"
Expand Down
5 changes: 4 additions & 1 deletion .jp/mcp/tools/fs/modify_file.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ properties.regex = { type = "boolean", description = "Whether to treat this patt
required = true
default = false
type = "boolean"
summary = "Whether to treat `old` as a regular expression."
summary = "Whether to treat `old` as a regular expression. Lookaround and backreferences are supported."
description = """
If `true`, `old` is treated as a regular expression and the `new` is treated \
as a replacement string, which may contain capture groups.
Expand All @@ -112,6 +112,9 @@ a literal string.

Individual patterns can override this via their own `regex` field.

Patterns are compiled with `fancy-regex`, so lookahead (`(?=...)`, `(?!...)`), \
lookbehind (`(?<=...)`, `(?<!...)`) and backreferences (`\\1`) all work.

IMPORTANT: in regex mode, `(` and `)` are capture groups, not literal \
parentheses. A pattern copied verbatim from source code (e.g. `foo(bar)`) \
will NOT match that source; escape metacharacters (`foo\\(bar\\)`) or use \
Expand Down
2 changes: 1 addition & 1 deletion .jp/mcp/tools/git/diff_commit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ summary = "Files to show the diff for. Required to prevent accidental full-commi

[conversation.tools.git_diff_commit.parameters.pattern]
type = "string"
summary = "Regex pattern to search within the diff output. When set, only matching lines with context are returned instead of the full diff."
summary = "Regex pattern to search within the diff output. When set, only matching lines with context are returned instead of the full diff. Lookaround and backreferences are supported."

[conversation.tools.git_diff_commit.parameters.context]
type = "integer"
Expand Down
2 changes: 1 addition & 1 deletion .jp/mcp/tools/git/diff_file.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ summary = "Files to show the diff for. Required to prevent accidental full-tree

[conversation.tools.git_diff_file.parameters.pattern]
type = "string"
summary = "Regex pattern to search within the diff output. When set, only matching lines with context are returned instead of the full diff."
summary = "Regex pattern to search within the diff output. When set, only matching lines with context are returned instead of the full diff. Lookaround and backreferences are supported."

[conversation.tools.git_diff_file.parameters.context]
type = "integer"
Expand Down
27 changes: 7 additions & 20 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading