diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 622a72f8b..df11d1553 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -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, @@ -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) { diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 313a4d1ce..7f08f6deb 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -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 diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 8e48e06e8..3f69bdfca 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -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. */ @@ -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);