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
60 changes: 46 additions & 14 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -8751,25 +8751,57 @@ static int search_result_cmp(const void *a, const void *b) {
return rb->score - ra->score; /* descending */
}

/* Moving an arbitrary file_pattern ahead of Select-String is not generally results-preserving:
* the current Windows path applies PowerShell -like to the full MatchInfo.Path, while POSIX
* delegates glob semantics to grep --include. Restrict the Windows optimization to plain suffix
* globs whose meaning cannot depend on path separator normalization or directory components. The
* original post-scan filter remains in place as a second guard. */
bool cbm_search_code_file_pattern_can_prefilter(const char *file_pattern) {
if (!file_pattern || file_pattern[0] != '*' || file_pattern[1] != '.' ||
file_pattern[2] == '\0') {
return false;
}
for (const unsigned char *p = (const unsigned char *)file_pattern + 2; *p; p++) {
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
*p == '.' || *p == '_' || *p == '-')) {
return false;
}
}
return true;
}

/* Build the grep/search command string based on scoped vs recursive mode.
* On Windows, uses PowerShell Select-String with tab-delimited output.
* On POSIX, uses grep with colon-delimited output. */
static void build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
const char *file_pattern, const char *tmpfile, const char *filelist,
const char *root_path) {
void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
const char *file_pattern, const char *tmpfile,
const char *filelist, const char *root_path) {
#ifdef _WIN32
const char *sm = use_regex ? "" : " -SimpleMatch";
if (scoped) {
if (file_pattern) {
snprintf(
cmd, cmd_sz,
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String "
"-LiteralPath $_ -Pattern $pat%s "
"-ErrorAction SilentlyContinue }"
" | Where-Object { $_.Path -like '*%s' }"
" | ForEach-Object { $_.Path + [char]9 + $_.LineNumber + [char]9 + $_.Line }\"",
tmpfile, filelist, sm, file_pattern);
if (cbm_search_code_file_pattern_can_prefilter(file_pattern)) {
snprintf(
cmd, cmd_sz,
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"Get-Content -Encoding UTF8 -LiteralPath '%s'"
" | Where-Object { $_ -like '%s' }"
" | ForEach-Object { Select-String -LiteralPath $_ -Pattern $pat%s "
"-ErrorAction SilentlyContinue }"
" | Where-Object { $_.Path -like '*%s' }"
" | ForEach-Object { $_.Path + [char]9 + $_.LineNumber + [char]9 + $_.Line }\"",
tmpfile, filelist, file_pattern, sm, file_pattern);
} else {
snprintf(
cmd, cmd_sz,
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String "
"-LiteralPath $_ -Pattern $pat%s "
"-ErrorAction SilentlyContinue }"
" | Where-Object { $_.Path -like '*%s' }"
" | ForEach-Object { $_.Path + [char]9 + $_.LineNumber + [char]9 + $_.Line }\"",
tmpfile, filelist, sm, file_pattern);
}
} else {
snprintf(
cmd, cmd_sz,
Expand Down Expand Up @@ -9807,8 +9839,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
search_scratch_close(&scratch);
} else {
char cmd[CBM_SZ_4K];
build_grep_cmd(cmd, sizeof(cmd), use_regex, scoped, file_pattern, tmpfile, filelist,
root_path);
cbm_search_code_build_grep_cmd(cmd, sizeof(cmd), use_regex, scoped, file_pattern, tmpfile,
filelist, root_path);

FILE *fp = cbm_popen(cmd, "r");
if (!fp) {
Expand Down
14 changes: 14 additions & 0 deletions src/mcp/mcp_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,18 @@ bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit,
bool cbm_detect_node_in_hunks(const cbm_node_t *node, const cbm_changed_hunk_t *hunks,
int hunk_count, const char *file);

/* search_code Windows pre-scan optimization: only simple suffix globs can be
* moved ahead of
* Select-String without changing the existing full-path
* PowerShell -like contract. Exposed for
* direct boundary tests only. */
bool cbm_search_code_file_pattern_can_prefilter(const char *file_pattern);

/* Internal command builder exposed so tests can pin the PowerShell pipeline
* ordering without
* starting an external shell. */
void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
const char *file_pattern, const char *tmpfile,
const char *filelist, const char *root_path);

#endif
44 changes: 44 additions & 0 deletions tests/test_mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -4501,6 +4501,48 @@ TEST(search_code_path_filter_matches_nothing) {
PASS();
}

TEST(search_code_file_pattern_prefilter_boundaries) {
ASSERT_TRUE(cbm_search_code_file_pattern_can_prefilter("*.pas"));
ASSERT_TRUE(cbm_search_code_file_pattern_can_prefilter("*.PAS"));
ASSERT_TRUE(cbm_search_code_file_pattern_can_prefilter("*.d.ts"));
ASSERT_TRUE(cbm_search_code_file_pattern_can_prefilter("*.foo-bar_1"));

ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter(NULL));
ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter(""));
ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter(".pas"));
ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("*.*"));
ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("src/*.pas"));
ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("src\\*.pas"));
ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("*.c++"));
ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("*R&D*.go"));
PASS();
}

TEST(search_code_windows_prefilter_precedes_content_scan) {
#ifdef _WIN32
char command[CBM_SZ_4K];
cbm_search_code_build_grep_cmd(command, sizeof(command), false, true, "*.go", "C:/tmp/pattern",
"C:/tmp/filelist", "C:/tmp/root");

const char *prefilter = strstr(command, "Where-Object { $_ -like '*.go' }");
const char *content_scan = strstr(command, "ForEach-Object { Select-String");
const char *postfilter = strstr(command, "Where-Object { $_.Path -like '**.go' }");
ASSERT_NOT_NULL(prefilter);
ASSERT_NOT_NULL(content_scan);
ASSERT_NOT_NULL(postfilter);
ASSERT_TRUE(prefilter < content_scan);
ASSERT_TRUE(content_scan < postfilter);

cbm_search_code_build_grep_cmd(command, sizeof(command), false, true, "*handler*.go",
"C:/tmp/pattern", "C:/tmp/filelist", "C:/tmp/root");
ASSERT_NULL(strstr(command, "Where-Object { $_ -like '*handler*.go' }"));
ASSERT_NOT_NULL(strstr(command, "Where-Object { $_.Path -like '**handler*.go' }"));
PASS();
#else
SKIP_PLATFORM("PowerShell prefilter runs on Windows");
#endif
}

/* issue #283: search_code with regex=true and a syntactically invalid pattern
* must return an explicit error, not an empty result indistinguishable from a
* legitimate no-match. */
Expand Down Expand Up @@ -10722,6 +10764,8 @@ SUITE(mcp) {
#endif
RUN_TEST(search_code_path_filter_prefilter_keeps_matches);
RUN_TEST(search_code_path_filter_matches_nothing);
RUN_TEST(search_code_file_pattern_prefilter_boundaries);
RUN_TEST(search_code_windows_prefilter_precedes_content_scan);
RUN_TEST(search_code_invalid_regex_errors_issue283);
RUN_TEST(search_code_literal_pipe_warns_issue282);
RUN_TEST(search_code_ampersand_accepted_issue272);
Expand Down
Loading