diff --git a/src/services/SearchService.ts b/src/services/SearchService.ts index 71ad1b1..45acb99 100644 --- a/src/services/SearchService.ts +++ b/src/services/SearchService.ts @@ -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; @@ -18,6 +18,7 @@ export class SearchService { // Get search exclude patterns from VSCode settings const searchConfig = vscode.workspace.getConfiguration('search'); const searchExclude = searchConfig.get>('exclude', {}); + const useIgnoreFiles = searchConfig.get('useIgnoreFiles', true); const filesConfig = vscode.workspace.getConfiguration('files'); const filesExclude = filesConfig.get>('exclude', {}); @@ -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('/'); @@ -83,6 +89,48 @@ export class SearchService { return files; } + private async getGitIgnoreRulesByWorkspace(): Promise> { + const rulesByWorkspace = new Map(); + 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): 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 { const fileMatchMap = new Map(); if (!query) { diff --git a/src/util.ts b/src/util.ts index 31d68f0..6b1bb6a 100644 --- a/src/util.ts +++ b/src/util.ts @@ -36,4 +36,118 @@ export function matchGlob(pattern: string, path: string): boolean { const regex = new RegExp(`^${regexPattern}$`); return regex.test(path); -} \ No newline at end of file +} + +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; +}