Skip to content

Commit b745297

Browse files
ozgesolidkeyclaude
andcommitted
Add wildcard search mode and fix regex match length in ripgrep search
Adds a Wildcard checkbox (mutually exclusive with Regex) where * matches any characters and ? matches any single character, with all other special chars treated as literals. Also fixes a bug where ripgrep search used pattern length instead of actual match length for regex matches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d294cf8 commit b745297

7 files changed

Lines changed: 75 additions & 4 deletions

File tree

src/main/fileHandler.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import * as readline from 'readline';
33
import { spawn } from 'child_process';
44
import { FileInfo, LineData, SearchMatch, SearchOptions } from '../shared/types';
55

6+
// Convert wildcard pattern to regex string: * = .*, ? = ., rest escaped
7+
function wildcardToRegex(pattern: string): string {
8+
return pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
9+
.replace(/\*/g, '.*')
10+
.replace(/\?/g, '.');
11+
}
12+
613
// Shared column filter utility — filters a line to only visible columns
714
export interface ColumnConfig {
815
delimiter: string;
@@ -334,6 +341,25 @@ export class FileHandler {
334341
const matches: SearchMatch[] = [];
335342
const MAX_MATCHES = 50000;
336343

344+
// Determine the effective regex pattern and whether ripgrep should use regex mode
345+
const useRegexMode = options.isRegex || options.isWildcard;
346+
let rgPattern = options.pattern;
347+
if (options.isWildcard) {
348+
rgPattern = wildcardToRegex(options.pattern);
349+
}
350+
351+
// Pre-compile regex for determining actual match length (ripgrep doesn't report it)
352+
let searchRegex: RegExp | null = null;
353+
if (useRegexMode) {
354+
try {
355+
const flags = options.matchCase ? '' : 'i';
356+
searchRegex = new RegExp(rgPattern, flags);
357+
} catch {
358+
// Invalid regex - ripgrep will also fail, return empty
359+
return [];
360+
}
361+
}
362+
337363
// Build ripgrep arguments
338364
const args: string[] = [
339365
'--line-number',
@@ -350,15 +376,15 @@ export class FileHandler {
350376
args.push('--word-regexp');
351377
}
352378

353-
if (!options.isRegex) {
379+
if (!useRegexMode) {
354380
args.push('--fixed-strings');
355381
}
356382

357383
// Limit matches
358384
args.push('--max-count', String(MAX_MATCHES));
359385

360386
// Add pattern and file
361-
args.push('--', options.pattern, this.filePath);
387+
args.push('--', rgPattern, this.filePath);
362388

363389
return new Promise((resolve) => {
364390
const proc = spawn('rg', args);
@@ -396,10 +422,20 @@ export class FileHandler {
396422
const adjustedLineNum = lineNum - 1 - this.headerLineCount;
397423
if (adjustedLineNum < 0) continue;
398424

425+
// For regex patterns, determine actual match length from the line text
426+
let matchLength = options.pattern.length;
427+
if (searchRegex) {
428+
const textFromMatch = lineText.substring(column - 1);
429+
const reMatch = searchRegex.exec(textFromMatch);
430+
if (reMatch && reMatch.index === 0) {
431+
matchLength = reMatch[0].length;
432+
}
433+
}
434+
399435
matches.push({
400436
lineNumber: adjustedLineNum,
401437
column: column - 1, // ripgrep uses 1-based columns
402-
length: options.pattern.length,
438+
length: matchLength,
403439
lineText,
404440
});
405441

@@ -464,6 +500,12 @@ export class FileHandler {
464500
const flags = options.matchCase ? 'g' : 'gi';
465501
if (options.isRegex) {
466502
regex = new RegExp(options.pattern, flags);
503+
} else if (options.isWildcard) {
504+
let converted = wildcardToRegex(options.pattern);
505+
if (options.wholeWord) {
506+
converted = `\\b${converted}\\b`;
507+
}
508+
regex = new RegExp(converted, flags);
467509
} else {
468510
let escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
469511
if (options.wholeWord) {

src/preload/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const api = {
6060
ipcRenderer.invoke('check-search-engine'),
6161

6262
// Search
63-
search: (options: { pattern: string; isRegex: boolean; matchCase: boolean; wholeWord: boolean; columnConfig?: { delimiter: string; columns: Array<{ index: number; visible: boolean }> } }): Promise<{ success: boolean; matches?: any[]; error?: string }> =>
63+
search: (options: { pattern: string; isRegex: boolean; isWildcard: boolean; matchCase: boolean; wholeWord: boolean; columnConfig?: { delimiter: string; columns: Array<{ index: number; visible: boolean }> } }): Promise<{ success: boolean; matches?: any[]; error?: string }> =>
6464
ipcRenderer.invoke(IPC.SEARCH, options),
6565

6666
cancelSearch: (): Promise<{ success: boolean }> =>

src/renderer/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
<label class="checkbox-label">
3737
<input type="checkbox" id="search-regex"> Regex
3838
</label>
39+
<label class="checkbox-label">
40+
<input type="checkbox" id="search-wildcard"> Wildcard
41+
</label>
3942
<label class="checkbox-label">
4043
<input type="checkbox" id="search-case"> Match Case
4144
</label>

src/renderer/renderer.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@ const elements = {
606606
btnToggleSidebar: document.getElementById('btn-toggle-sidebar') as HTMLButtonElement,
607607
searchInput: document.getElementById('search-input') as HTMLInputElement,
608608
searchRegex: document.getElementById('search-regex') as HTMLInputElement,
609+
searchWildcard: document.getElementById('search-wildcard') as HTMLInputElement,
609610
searchCase: document.getElementById('search-case') as HTMLInputElement,
610611
searchWholeWord: document.getElementById('search-whole-word') as HTMLInputElement,
611612
searchResultCount: document.getElementById('search-result-count') as HTMLSpanElement,
@@ -1580,6 +1581,7 @@ function applySearchHighlightsRaw(text: string, lineNumber: number): { searchRan
15801581
// Get the search pattern
15811582
const pattern = elements.searchInput.value;
15821583
const isRegex = elements.searchRegex.checked;
1584+
const isWildcard = elements.searchWildcard.checked;
15831585
const matchCase = elements.searchCase.checked;
15841586

15851587
const searchRanges: SearchRange[] = [];
@@ -1588,6 +1590,12 @@ function applySearchHighlightsRaw(text: string, lineNumber: number): { searchRan
15881590
let searchRegex: RegExp;
15891591
if (isRegex) {
15901592
searchRegex = new RegExp(pattern, matchCase ? 'g' : 'gi');
1593+
} else if (isWildcard) {
1594+
// Wildcard mode: * = any chars, ? = any single char, rest literal
1595+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
1596+
.replace(/\*/g, '.*')
1597+
.replace(/\?/g, '.');
1598+
searchRegex = new RegExp(escaped, matchCase ? 'g' : 'gi');
15911599
} else {
15921600
// Escape special regex chars for literal search
15931601
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -3472,6 +3480,7 @@ async function performSearch(): Promise<void> {
34723480
const searchOptions: SearchOptions = {
34733481
pattern,
34743482
isRegex: elements.searchRegex.checked,
3483+
isWildcard: elements.searchWildcard.checked,
34753484
matchCase: elements.searchCase.checked,
34763485
wholeWord: elements.searchWholeWord.checked,
34773486
};
@@ -4664,6 +4673,7 @@ async function navigateHighlight(highlightId: string, direction: 'prev' | 'next'
46644673
const result = await window.api.search({
46654674
pattern: highlight.pattern,
46664675
isRegex: highlight.isRegex,
4676+
isWildcard: false,
46674677
matchCase: highlight.matchCase,
46684678
wholeWord: highlight.wholeWord,
46694679
});
@@ -5816,6 +5826,18 @@ function init(): void {
58165826
elements.btnPrevResult.addEventListener('click', () => navigateSearchPrev());
58175827
elements.btnNextResult.addEventListener('click', () => navigateSearchNext());
58185828

5829+
// Regex and Wildcard are mutually exclusive
5830+
elements.searchRegex.addEventListener('change', () => {
5831+
if (elements.searchRegex.checked) {
5832+
elements.searchWildcard.checked = false;
5833+
}
5834+
});
5835+
elements.searchWildcard.addEventListener('change', () => {
5836+
if (elements.searchWildcard.checked) {
5837+
elements.searchRegex.checked = false;
5838+
}
5839+
});
5840+
58195841
// Search options popup
58205842
elements.btnSearchOptions.addEventListener('click', (e) => {
58215843
e.stopPropagation();

src/renderer/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface SearchColumnConfig {
3636
interface SearchOptions {
3737
pattern: string;
3838
isRegex: boolean;
39+
isWildcard: boolean;
3940
matchCase: boolean;
4041
wholeWord: boolean;
4142
columnConfig?: SearchColumnConfig;

src/shared/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface SearchColumnConfig {
2727
export interface SearchOptions {
2828
pattern: string;
2929
isRegex: boolean;
30+
isWildcard: boolean;
3031
matchCase: boolean;
3132
wholeWord: boolean;
3233
columnConfig?: SearchColumnConfig;

src/tests/benchmark.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ describe('Logan Benchmark', () => {
264264
const matches = await handler.search({
265265
pattern: 'Timeout waiting',
266266
isRegex: false,
267+
isWildcard: false,
267268
matchCase: false,
268269
wholeWord: false,
269270
});
@@ -297,6 +298,7 @@ describe('Logan Benchmark', () => {
297298
const matches = await handler.search({
298299
pattern: 'user_\\d{1,3}\\s+authenticated',
299300
isRegex: true,
301+
isWildcard: false,
300302
matchCase: false,
301303
wholeWord: false,
302304
});

0 commit comments

Comments
 (0)