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
52 changes: 50 additions & 2 deletions src/services/SearchService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import { FileSearchResult, SearchMatch, SearchOptions } from '../types';
import { BINARY_EXTENSIONS, DEFAULT_SEARCH_OPTIONS } from '../constants';
import { escapeRegExp, matchGlob } from '../util';
import { escapeRegExp, GitIgnoreRule, isPathIgnoredByGitIgnore, matchGlob, parseGitIgnore } from '../util';

export class SearchService {
private options: SearchOptions;
Expand All @@ -18,6 +18,7 @@ export class SearchService {
// Get search exclude patterns from VSCode settings
const searchConfig = vscode.workspace.getConfiguration('search');
const searchExclude = searchConfig.get<Record<string, boolean>>('exclude', {});
const useIgnoreFiles = searchConfig.get<boolean>('useIgnoreFiles', true);
const filesConfig = vscode.workspace.getConfiguration('files');
const filesExclude = filesConfig.get<Record<string, boolean>>('exclude', {});

Expand Down Expand Up @@ -50,10 +51,15 @@ export class SearchService {
cancellationTokenSource.dispose();
}, 1000);

const files = await vscode.workspace.findFiles('**/*', excludeGlob, this.options.maxFilesToSearch, cancellationTokenSource.token);;
let files = await vscode.workspace.findFiles('**/*', excludeGlob, this.options.maxFilesToSearch, cancellationTokenSource.token);
clearTimeout(timer);
cancellationTokenSource.dispose();

if (useIgnoreFiles) {
const gitIgnoreRulesByWorkspace = await this.getGitIgnoreRulesByWorkspace();
files = this.filterGitIgnoredFiles(files, gitIgnoreRulesByWorkspace);
}

const collator = new Intl.Collator('en', { sensitivity: 'base' });
files.sort((a, b) => {
const pathA = a.path.split('/');
Expand Down Expand Up @@ -83,6 +89,48 @@ export class SearchService {
return files;
}

private async getGitIgnoreRulesByWorkspace(): Promise<Map<string, GitIgnoreRule[]>> {
const rulesByWorkspace = new Map<string, GitIgnoreRule[]>();
const workspaceFolders = vscode.workspace.workspaceFolders ?? [];

await Promise.all(workspaceFolders.map(async (workspaceFolder) => {
try {
const gitIgnoreUri = vscode.Uri.joinPath(workspaceFolder.uri, '.gitignore');
const bytes = await vscode.workspace.fs.readFile(gitIgnoreUri);
const rules = parseGitIgnore(new TextDecoder('utf-8', { fatal: false }).decode(bytes));

if (rules.length > 0) {
rulesByWorkspace.set(workspaceFolder.uri.toString(), rules);
}
} catch (error) {
// Workspaces without a root .gitignore should search normally.
}
}));

return rulesByWorkspace;
}

private filterGitIgnoredFiles(files: vscode.Uri[], rulesByWorkspace: Map<string, GitIgnoreRule[]>): vscode.Uri[] {
if (rulesByWorkspace.size === 0) {
return files;
}

return files.filter((file) => {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(file);
if (!workspaceFolder) {
return true;
}

const rules = rulesByWorkspace.get(workspaceFolder.uri.toString());
if (!rules) {
return true;
}

const relativePath = vscode.workspace.asRelativePath(file, false);
return !isPathIgnoredByGitIgnore(relativePath, rules);
});
}

async search(files: vscode.Uri[], query: string, includePattern?: string, excludePattern?: string): Promise<FileSearchResult[]> {
const fileMatchMap = new Map<string, SearchMatch[]>();
if (!query) {
Expand Down
116 changes: 115 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,118 @@ export function matchGlob(pattern: string, path: string): boolean {

const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
}
}

export interface GitIgnoreRule {
pattern: string;
negated: boolean;
directoryOnly: boolean;
anchored: boolean;
hasSlash: boolean;
}

export function parseGitIgnore(content: string): GitIgnoreRule[] {
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => {
let pattern = line;
const negated = pattern.startsWith('!');
if (negated) {
pattern = pattern.slice(1).trim();
}

if (pattern.startsWith('\\#') || pattern.startsWith('\\!')) {
pattern = pattern.slice(1);
}

const anchored = pattern.startsWith('/');
const directoryOnly = pattern.endsWith('/');
pattern = pattern.replace(/^\/+/, '').replace(/\/+$/, '');

return {
pattern,
negated,
directoryOnly,
anchored,
hasSlash: pattern.includes('/')
};
})
.filter((rule) => rule.pattern.length > 0);
}

export function isPathIgnoredByGitIgnore(path: string, rules: GitIgnoreRule[]): boolean {
const normalizedPath = normalizePath(path);
let ignored = false;

for (const rule of rules) {
if (matchesGitIgnoreRule(normalizedPath, rule)) {
ignored = !rule.negated;
}
}

return ignored;
}

function matchesGitIgnoreRule(path: string, rule: GitIgnoreRule): boolean {
if (rule.anchored || rule.hasSlash) {
const candidates = rule.directoryOnly
? getDirectoryPrefixes(path)
: [path, ...getDirectoryPrefixes(path)];

return candidates.some((candidate) => matchGitIgnoreGlob(rule.pattern, candidate));
}

if (!rule.directoryOnly) {
return path
.split('/')
.some((segment) => matchGitIgnoreGlob(rule.pattern, segment));
}

return getDirectoryPrefixes(path)
.flatMap((directory) => directory.split('/'))
.some((segment) => matchGitIgnoreGlob(rule.pattern, segment));
}

function getDirectoryPrefixes(path: string): string[] {
const segments = path.split('/');
const directories: string[] = [];

for (let index = 1; index < segments.length; index++) {
directories.push(segments.slice(0, index).join('/'));
}

return directories;
}

function normalizePath(path: string): string {
return path.replace(/\\/g, '/').replace(/^\/+/, '');
}

function matchGitIgnoreGlob(pattern: string, path: string): boolean {
const regex = new RegExp(`^${globToRegex(pattern)}$`);
return regex.test(path);
}

function globToRegex(pattern: string): string {
let regex = '';

for (let index = 0; index < pattern.length; index++) {
const char = pattern[index];
const nextChar = pattern[index + 1];

if (char === '*' && nextChar === '*') {
regex += '.*';
index++;
} else if (char === '*') {
regex += '[^/]*';
} else if (char === '?') {
regex += '[^/]';
} else {
regex += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
}

return regex;
}