vscode-delfiles-by-search 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +162 -0
- package/LICENSE +20 -0
- package/PUBLISHING.md +110 -0
- package/README.md +183 -0
- package/USAGE.md +47 -0
- package/bin/delfiles-cli.js +3 -0
- package/package.json +80 -0
- package/src/cli/index.js +258 -0
- package/src/core/file-manager.js +158 -0
- package/src/core/parser.js +95 -0
- package/src/core/searcher.js +172 -0
- package/src/extension/extension.js +268 -0
- package/vscode-delfiles-by-search-0.0.1.vsix +0 -0
- package/vscode-delfiles-by-search-0.0.2.tgz +0 -0
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const inquirer = require('inquirer');
|
|
7
|
+
const clipboardy = require('clipboardy');
|
|
8
|
+
const { parseFilePaths } = require('../core/parser');
|
|
9
|
+
const { deleteFiles } = require('../core/file-manager');
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('delfiles')
|
|
15
|
+
.description('从搜索结果字符串中提取文件路径并删除文件')
|
|
16
|
+
.version('0.0.1');
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.option('-s, --string <content>', '直接传入包含搜索结果的字符串')
|
|
20
|
+
.option('-f, --file <path>', '包含搜索结果的文件路径')
|
|
21
|
+
.option('-b, --base <path>', '基准目录路径', process.cwd())
|
|
22
|
+
.option('-i, --ignore <pattern>', '忽略的文件模式 (glob),多个模式用逗号分隔')
|
|
23
|
+
.option('--no-interaction', '跳过交互式确认', false)
|
|
24
|
+
.option('-d, --dry-run', '模拟运行,不实际删除文件', false)
|
|
25
|
+
.option('--no-trash', '永久删除文件,不移动到回收站')
|
|
26
|
+
.option('-v, --verbose', '显示详细日志', false)
|
|
27
|
+
.action(async (options) => {
|
|
28
|
+
try {
|
|
29
|
+
let inputString = '';
|
|
30
|
+
let basePath = options.base ? path.resolve(options.base) : process.cwd();
|
|
31
|
+
let ignorePatterns = options.ignore ? options.ignore.split(',') : [];
|
|
32
|
+
let isDryRun = options.dryRun || false;
|
|
33
|
+
let moveToTrash = options.trash !== false;
|
|
34
|
+
let isInteractive = false;
|
|
35
|
+
|
|
36
|
+
// 1. 获取输入内容
|
|
37
|
+
if (options.string) {
|
|
38
|
+
// 优先使用命令行直接传入的字符串
|
|
39
|
+
inputString = options.string;
|
|
40
|
+
} else if (options.file) {
|
|
41
|
+
// 从文件读取
|
|
42
|
+
const filePath = path.resolve(process.cwd(), options.file);
|
|
43
|
+
if (!fs.existsSync(filePath)) {
|
|
44
|
+
console.error(`错误: 文件不存在: ${filePath}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
inputString = fs.readFileSync(filePath, 'utf-8');
|
|
48
|
+
} else if (process.stdin.isTTY) {
|
|
49
|
+
// 如果是 TTY 且没有指定文件或字符串,进入交互模式
|
|
50
|
+
isInteractive = true;
|
|
51
|
+
console.log('--- 进入交互式模式 ---');
|
|
52
|
+
|
|
53
|
+
// 尝试从剪贴板读取内容
|
|
54
|
+
let clipboardContent = '';
|
|
55
|
+
try {
|
|
56
|
+
clipboardContent = await clipboardy.read();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// 忽略剪贴板读取错误
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const choices = [
|
|
62
|
+
{ name: '从文件读取搜索结果 (-f)', value: 'file' },
|
|
63
|
+
{ name: '直接粘贴/输入搜索结果文本', value: 'string' }
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
if (clipboardContent && clipboardContent.trim().length > 0) {
|
|
67
|
+
choices.unshift({
|
|
68
|
+
name: `使用剪贴板内容 (${clipboardContent.trim().substring(0, 30).replace(/\n/g, ' ')}...)`,
|
|
69
|
+
value: 'clipboard'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const answers = await inquirer.prompt([
|
|
74
|
+
{
|
|
75
|
+
type: 'list',
|
|
76
|
+
name: 'inputType',
|
|
77
|
+
message: '请选择输入方式:',
|
|
78
|
+
choices: choices
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: 'editor',
|
|
82
|
+
name: 'searchString',
|
|
83
|
+
message: '请输入需要搜索的字符串(-s),保存并关闭以继续:',
|
|
84
|
+
when: (answers) => answers.inputType === 'string',
|
|
85
|
+
validate: (input) => {
|
|
86
|
+
if (input && input.trim().length > 0) return true;
|
|
87
|
+
return '内容不能为空';
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'input',
|
|
92
|
+
name: 'filePath',
|
|
93
|
+
message: '请输入文件路径:',
|
|
94
|
+
when: (answers) => answers.inputType === 'file',
|
|
95
|
+
validate: (input) => {
|
|
96
|
+
if (fs.existsSync(input.trim())) return true;
|
|
97
|
+
return '文件不存在,请重新输入';
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'input',
|
|
102
|
+
name: 'basePath',
|
|
103
|
+
message: '请输入基准目录路径:',
|
|
104
|
+
default: process.cwd(),
|
|
105
|
+
validate: (input) => {
|
|
106
|
+
if (fs.existsSync(input.trim()) && fs.statSync(input.trim()).isDirectory()) return true;
|
|
107
|
+
return '目录不存在,请重新输入';
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'input',
|
|
112
|
+
name: 'ignore',
|
|
113
|
+
message: '请输入忽略规则 (Glob模式,多个用逗号分隔,可选):',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: 'confirm',
|
|
117
|
+
name: 'dryRun',
|
|
118
|
+
message: '是否仅模拟运行 (Dry Run)?',
|
|
119
|
+
default: true
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: 'confirm',
|
|
123
|
+
name: 'moveToTrash',
|
|
124
|
+
message: '是否移动到回收站 (否则永久删除)?',
|
|
125
|
+
default: true,
|
|
126
|
+
when: (answers) => !answers.dryRun
|
|
127
|
+
}
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
if (answers.inputType === 'file') {
|
|
131
|
+
inputString = fs.readFileSync(answers.filePath.trim(), 'utf-8');
|
|
132
|
+
} else if (answers.inputType === 'clipboard') {
|
|
133
|
+
inputString = clipboardContent;
|
|
134
|
+
console.log('已读取剪贴板内容。');
|
|
135
|
+
} else {
|
|
136
|
+
inputString = answers.searchString;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
basePath = path.resolve(answers.basePath.trim());
|
|
140
|
+
if (answers.ignore && answers.ignore.trim()) {
|
|
141
|
+
ignorePatterns = answers.ignore.split(',').map(p => p.trim());
|
|
142
|
+
}
|
|
143
|
+
isDryRun = answers.dryRun;
|
|
144
|
+
if (!isDryRun) {
|
|
145
|
+
moveToTrash = answers.moveToTrash;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
} else {
|
|
149
|
+
// 尝试从标准输入读取(支持管道)
|
|
150
|
+
// 这里不需要提示,因为非 TTY 意味着正在通过管道传输
|
|
151
|
+
inputString = await readStdin();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!inputString || inputString.trim() === '') {
|
|
155
|
+
console.error('错误: 输入内容为空');
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. 解析文件路径
|
|
160
|
+
let filePaths = parseFilePaths(inputString);
|
|
161
|
+
console.log(`解析到 ${filePaths.length} 个文件路径`);
|
|
162
|
+
|
|
163
|
+
if (filePaths.length === 0) {
|
|
164
|
+
console.log('未找到任何文件路径,退出。');
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (options.verbose) {
|
|
169
|
+
console.log('解析到的文件:', filePaths);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. 交互式文件过滤
|
|
173
|
+
if (isInteractive && options.interaction !== false) {
|
|
174
|
+
const { selectedFiles } = await inquirer.prompt([
|
|
175
|
+
{
|
|
176
|
+
type: 'checkbox',
|
|
177
|
+
name: 'selectedFiles',
|
|
178
|
+
message: '请选择要删除的文件 (空格键选择/取消,Enter 确认):',
|
|
179
|
+
choices: filePaths.map(file => ({ name: file, checked: true })),
|
|
180
|
+
pageSize: 20
|
|
181
|
+
}
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
if (selectedFiles.length !== filePaths.length) {
|
|
185
|
+
filePaths = selectedFiles;
|
|
186
|
+
console.log(`已过滤,将处理 ${filePaths.length} 个文件。`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 4. 确认删除(如果是交互式终端且不是 dry-run 且之前未经过交互式确认)
|
|
191
|
+
if (!isDryRun && process.stdout.isTTY && !isInteractive) {
|
|
192
|
+
const { confirm } = await inquirer.prompt([{
|
|
193
|
+
type: 'confirm',
|
|
194
|
+
name: 'confirm',
|
|
195
|
+
message: `即将对 ${filePaths.length} 个文件执行删除操作 (基准路径: ${basePath}),确定吗?`,
|
|
196
|
+
default: false
|
|
197
|
+
}]);
|
|
198
|
+
|
|
199
|
+
if (!confirm) {
|
|
200
|
+
console.log('操作已取消');
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 5. 执行删除
|
|
206
|
+
const result = await deleteFiles(filePaths, {
|
|
207
|
+
basePath,
|
|
208
|
+
dryRun: isDryRun,
|
|
209
|
+
moveToTrash,
|
|
210
|
+
logger: console.log,
|
|
211
|
+
ignorePatterns
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// 5. 输出结果摘要
|
|
215
|
+
if (result.errors.length > 0) {
|
|
216
|
+
console.error('\n错误列表:');
|
|
217
|
+
result.errors.forEach(err => console.error(`- ${err.file}: ${err.error}`));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error('发生未知错误:', error);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
function readStdin() {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
let data = '';
|
|
229
|
+
const stdin = process.stdin;
|
|
230
|
+
|
|
231
|
+
if (stdin.isTTY) {
|
|
232
|
+
// 如果是 TTY,且没有参数传入文件,则等待用户输入
|
|
233
|
+
// 这里只是简单的处理,对于交互式 CLI 应该有更好的提示
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
stdin.on('data', chunk => {
|
|
237
|
+
data += chunk;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
stdin.on('end', () => {
|
|
241
|
+
resolve(data);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
stdin.on('error', err => {
|
|
245
|
+
reject(err);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 导出以便测试或模块调用
|
|
251
|
+
module.exports = {
|
|
252
|
+
program
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// 如果直接执行
|
|
256
|
+
if (require.main === module) {
|
|
257
|
+
program.parse(process.argv);
|
|
258
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { promisify } = require('util');
|
|
5
|
+
const minimatch = require('minimatch');
|
|
6
|
+
|
|
7
|
+
// 使用动态导入加载 trash,因为它是纯 ESM 包
|
|
8
|
+
async function loadTrash() {
|
|
9
|
+
try {
|
|
10
|
+
const trashModule = await import('trash');
|
|
11
|
+
return trashModule.default;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error('无法加载 trash 模块:', error);
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 检查文件是否存在
|
|
20
|
+
* @param {string} filePath
|
|
21
|
+
* @returns {Promise<boolean>}
|
|
22
|
+
*/
|
|
23
|
+
async function fileExists(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
await fs.promises.access(filePath, fs.constants.F_OK);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 删除文件操作的结果
|
|
34
|
+
* @typedef {Object} DeleteResult
|
|
35
|
+
* @property {string[]} success - 成功删除的文件列表
|
|
36
|
+
* @property {string[]} failed - 删除失败的文件列表
|
|
37
|
+
* @property {string[]} skipped - 跳过的文件列表(例如不存在)
|
|
38
|
+
* @property {Object[]} errors - 错误详情列表 { file: string, error: string }
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 执行文件删除操作
|
|
43
|
+
* @param {string[]} relativePaths - 相对路径列表
|
|
44
|
+
* @param {Object} options - 配置选项
|
|
45
|
+
* @param {string} options.basePath - 基础路径
|
|
46
|
+
* @param {boolean} [options.dryRun=false] - 是否模拟运行
|
|
47
|
+
* @param {boolean} [options.moveToTrash=true] - 是否移动到回收站
|
|
48
|
+
* @param {Function} [options.logger=console.log] - 日志记录函数
|
|
49
|
+
* @param {string[]} [options.ignorePatterns=[]] - 忽略的文件模式列表
|
|
50
|
+
* @returns {Promise<DeleteResult>}
|
|
51
|
+
*/
|
|
52
|
+
async function deleteFiles(relativePaths, options) {
|
|
53
|
+
const {
|
|
54
|
+
basePath,
|
|
55
|
+
dryRun = false,
|
|
56
|
+
moveToTrash = true,
|
|
57
|
+
logger = console.log,
|
|
58
|
+
ignorePatterns = []
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
if (!basePath) {
|
|
62
|
+
throw new Error('必须指定 basePath');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 尝试加载 .gitignore
|
|
66
|
+
let gitignorePatterns = [];
|
|
67
|
+
try {
|
|
68
|
+
const gitignorePath = path.join(basePath, '.gitignore');
|
|
69
|
+
if (await fileExists(gitignorePath)) {
|
|
70
|
+
const content = await fs.promises.readFile(gitignorePath, 'utf-8');
|
|
71
|
+
gitignorePatterns = content
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map(line => line.trim())
|
|
74
|
+
.filter(line => line && !line.startsWith('#'));
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logger(`[WARN] 无法读取 .gitignore: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const allIgnorePatterns = [...ignorePatterns, ...gitignorePatterns];
|
|
81
|
+
|
|
82
|
+
const result = {
|
|
83
|
+
success: [],
|
|
84
|
+
failed: [],
|
|
85
|
+
skipped: [],
|
|
86
|
+
errors: []
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const trash = moveToTrash ? await loadTrash() : null;
|
|
90
|
+
|
|
91
|
+
logger(`开始处理 ${relativePaths.length} 个文件...`);
|
|
92
|
+
logger(`基准路径: ${basePath}`);
|
|
93
|
+
logger(`模式: ${dryRun ? '模拟运行 (Dry Run)' : '实际执行'}`);
|
|
94
|
+
logger(`删除方式: ${moveToTrash ? '移动到回收站' : '永久删除'}`);
|
|
95
|
+
if (allIgnorePatterns.length > 0) {
|
|
96
|
+
logger(`忽略规则: ${allIgnorePatterns.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const relativePath of relativePaths) {
|
|
100
|
+
// 检查忽略规则
|
|
101
|
+
if (allIgnorePatterns.length > 0) {
|
|
102
|
+
const isIgnored = allIgnorePatterns.some(pattern => minimatch(relativePath, pattern, { dot: true }));
|
|
103
|
+
if (isIgnored) {
|
|
104
|
+
logger(`[IGNORE] 已忽略: ${relativePath}`);
|
|
105
|
+
result.skipped.push(relativePath);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const absolutePath = path.resolve(basePath, relativePath);
|
|
111
|
+
|
|
112
|
+
// 安全检查:确保路径在 basePath 内,防止路径遍历攻击
|
|
113
|
+
if (!absolutePath.startsWith(path.resolve(basePath))) {
|
|
114
|
+
const errorMsg = `路径非法(超出基准路径范围): ${relativePath}`;
|
|
115
|
+
logger(`[ERROR] ${errorMsg}`);
|
|
116
|
+
result.failed.push(relativePath);
|
|
117
|
+
result.errors.push({ file: relativePath, error: errorMsg });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const exists = await fileExists(absolutePath);
|
|
123
|
+
if (!exists) {
|
|
124
|
+
logger(`[SKIP] 文件不存在: ${relativePath}`);
|
|
125
|
+
result.skipped.push(relativePath);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (dryRun) {
|
|
130
|
+
logger(`[DRY-RUN] 将删除: ${relativePath}`);
|
|
131
|
+
result.success.push(relativePath);
|
|
132
|
+
} else {
|
|
133
|
+
if (moveToTrash && trash) {
|
|
134
|
+
await trash(absolutePath);
|
|
135
|
+
logger(`[TRASH] 已移至回收站: ${relativePath}`);
|
|
136
|
+
} else {
|
|
137
|
+
await fs.promises.unlink(absolutePath);
|
|
138
|
+
logger(`[DELETE] 已永久删除: ${relativePath}`);
|
|
139
|
+
}
|
|
140
|
+
result.success.push(relativePath);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const errorMsg = error.message;
|
|
144
|
+
logger(`[FAIL] 删除失败: ${relativePath} - ${errorMsg}`);
|
|
145
|
+
result.failed.push(relativePath);
|
|
146
|
+
result.errors.push({ file: relativePath, error: errorMsg });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
logger('--- 处理完成 ---');
|
|
151
|
+
logger(`成功: ${result.success.length}, 失败: ${result.failed.length}, 跳过: ${result.skipped.length}`);
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
deleteFiles
|
|
158
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* 解析包含文件路径的搜索结果字符串
|
|
4
|
+
* @param {string} inputString - 输入的长字符串
|
|
5
|
+
* @returns {string[]} - 提取出的文件路径列表(去重)
|
|
6
|
+
*/
|
|
7
|
+
function parseFilePaths(inputString) {
|
|
8
|
+
if (!inputString || typeof inputString !== 'string') {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const lines = inputString.split('\n');
|
|
13
|
+
const filePaths = new Set();
|
|
14
|
+
|
|
15
|
+
// 正则表达式匹配文件路径
|
|
16
|
+
// 特征:
|
|
17
|
+
// 1. 行首可能有空白字符
|
|
18
|
+
// 2. 路径部分不包含空白字符(或者是为了支持带空格的路径,可以放宽)
|
|
19
|
+
// 3. 必须以冒号结尾
|
|
20
|
+
// 4. 通常包含文件扩展名(可选,但有助于过滤误报)
|
|
21
|
+
// 5. 排除常见的代码行特征(如 function, var, const, if 等,虽然正则匹配结尾冒号已经过滤了大部分)
|
|
22
|
+
|
|
23
|
+
// 针对用户提供的示例: __APP___uni/141.js:
|
|
24
|
+
// 我们可以放宽一点,允许路径中有除了冒号和换行符以外的字符
|
|
25
|
+
// 但是通常文件路径不会包含双引号、单引号、括号等代码符号
|
|
26
|
+
|
|
27
|
+
// 改进的正则:
|
|
28
|
+
// ^\s* : 行首空白
|
|
29
|
+
// ( : 捕获组开始
|
|
30
|
+
// .+? : 非贪婪匹配任意字符
|
|
31
|
+
// ) : 捕获组结束
|
|
32
|
+
// (:)? : 可选的冒号
|
|
33
|
+
// \s*$ : 行尾空白
|
|
34
|
+
|
|
35
|
+
// 我们不仅匹配以冒号结尾的行,也尝试匹配看起来像路径的行
|
|
36
|
+
// 但这会增加误报风险,所以后续的过滤条件需要更严格
|
|
37
|
+
// 注意:这里的正则比较宽泛,主要靠后续的逻辑过滤
|
|
38
|
+
const pathRegex = /^\s*(.+?)(:)?\s*$/;
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
// 忽略空行
|
|
42
|
+
if (!line || line.trim() === '') continue;
|
|
43
|
+
|
|
44
|
+
// 忽略明显是统计信息的行,如 "274 个结果 - 137 文件"
|
|
45
|
+
if (line.includes('个结果') && line.includes('文件')) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const match = line.match(pathRegex);
|
|
50
|
+
if (match) {
|
|
51
|
+
let pathCandidate = match[1].trim();
|
|
52
|
+
|
|
53
|
+
// 去除可能的行尾冒号
|
|
54
|
+
if (pathCandidate.endsWith(':')) {
|
|
55
|
+
pathCandidate = pathCandidate.slice(0, -1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 过滤掉明显不是路径的行
|
|
59
|
+
// 1. 必须包含路径分隔符 (/ 或 \) 或者包含点 (.) 且看起来像文件扩展名
|
|
60
|
+
// 2. 排除包含代码特征字符的行
|
|
61
|
+
const hasPathChars = pathCandidate.includes('/') || pathCandidate.includes('\\');
|
|
62
|
+
const hasExtension = pathCandidate.includes('.') && pathCandidate.lastIndexOf('.') > pathCandidate.lastIndexOf('/');
|
|
63
|
+
|
|
64
|
+
if (!hasPathChars && !hasExtension) continue;
|
|
65
|
+
|
|
66
|
+
// 排除常见的代码结构字符
|
|
67
|
+
const invalidChars = ['(', ')', '=', '{', '}', ';', '<', '>', '[', ']'];
|
|
68
|
+
if (invalidChars.some(char => pathCandidate.includes(char))) continue;
|
|
69
|
+
|
|
70
|
+
// 排除注释
|
|
71
|
+
if (pathCandidate.startsWith('//') || pathCandidate.startsWith('*') || pathCandidate.startsWith('/*')) continue;
|
|
72
|
+
|
|
73
|
+
// 排除纯数字(行号)
|
|
74
|
+
if (/^\d+$/.test(pathCandidate)) continue;
|
|
75
|
+
|
|
76
|
+
// 排除纯文本描述
|
|
77
|
+
// 1. 如果没有扩展名,且包含空格,视为无效
|
|
78
|
+
if (!hasExtension && pathCandidate.includes(' ')) continue;
|
|
79
|
+
// 2. 如果以大写字母开头且不包含斜杠(通常是句子),视为无效
|
|
80
|
+
if (/^[A-Z]/.test(pathCandidate) && !hasPathChars) continue;
|
|
81
|
+
|
|
82
|
+
// 排除常见的保留字开头的行(虽然上面符号过滤已经能过滤大部分,但双重保险)
|
|
83
|
+
const reservedWords = ['import ', 'export ', 'const ', 'let ', 'var ', 'function ', 'class ', 'if ', 'for ', 'while ', 'return '];
|
|
84
|
+
if (reservedWords.some(word => pathCandidate.startsWith(word))) continue;
|
|
85
|
+
|
|
86
|
+
filePaths.add(pathCandidate);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return Array.from(filePaths);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
parseFilePaths
|
|
95
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const ignore = require('ignore');
|
|
5
|
+
const { promisify } = require('util');
|
|
6
|
+
|
|
7
|
+
const readdirPromise = promisify(fs.readdir);
|
|
8
|
+
const statPromise = promisify(fs.stat);
|
|
9
|
+
const readFilePromise = promisify(fs.readFile);
|
|
10
|
+
|
|
11
|
+
class Searcher {
|
|
12
|
+
constructor(basePath, regex, options = {}) {
|
|
13
|
+
this.basePath = basePath;
|
|
14
|
+
this.regex = regex;
|
|
15
|
+
this.options = options;
|
|
16
|
+
this.ignoreManager = ignore();
|
|
17
|
+
this.debug = false;
|
|
18
|
+
this.followNestedGitignore = options.followNestedGitignore || false;
|
|
19
|
+
|
|
20
|
+
// Initialize with default ignores if provided
|
|
21
|
+
if (options.ignorePatterns && Array.isArray(options.ignorePatterns)) {
|
|
22
|
+
this.ignoreManager.add(options.ignorePatterns);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setDebug(enable) {
|
|
27
|
+
this.debug = enable;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
log(msg) {
|
|
31
|
+
if (this.debug) {
|
|
32
|
+
console.log(`[Searcher] ${msg}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
warn(msg) {
|
|
37
|
+
console.warn(`[Searcher Warn] ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 初始化:同步读取 .gitignore
|
|
42
|
+
* 按照要求,尝试读取两个位置:
|
|
43
|
+
* 1. 源码目录下的 .gitignore (Literal requirement from prompt)
|
|
44
|
+
* 2. basePath 下的 .gitignore (Logical requirement for functionality)
|
|
45
|
+
*/
|
|
46
|
+
init() {
|
|
47
|
+
// 1. 读取 basePath 下的 .gitignore (最常用场景)
|
|
48
|
+
this._loadGitignore(this.basePath);
|
|
49
|
+
|
|
50
|
+
// 2. 读取当前文件同目录下的 .gitignore (满足特定 Prompt 要求)
|
|
51
|
+
// 注意:这通常用于开发调试或特定规则注入
|
|
52
|
+
try {
|
|
53
|
+
const localGitignore = path.join(__dirname, '.gitignore');
|
|
54
|
+
if (fs.existsSync(localGitignore)) {
|
|
55
|
+
this._loadGitignore(__dirname, localGitignore);
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Ignore error for this specific path
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (this.debug) {
|
|
62
|
+
this.log('Ignore rules loaded.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_loadGitignore(targetDir, filePath = null) {
|
|
67
|
+
const gitignorePath = filePath || path.join(targetDir, '.gitignore');
|
|
68
|
+
|
|
69
|
+
if (fs.existsSync(gitignorePath)) {
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
72
|
+
// ignore 包会自动处理注释和空行
|
|
73
|
+
this.ignoreManager.add(content);
|
|
74
|
+
this.log(`Loaded .gitignore from ${gitignorePath}`);
|
|
75
|
+
if (this.debug) {
|
|
76
|
+
const rules = content.split('\n').filter(l => l.trim() && !l.startsWith('#')).length;
|
|
77
|
+
this.log(`Added ${rules} rules from ${gitignorePath}`);
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
this.warn(`Failed to read .gitignore at ${gitignorePath}: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
this.log(`.gitignore not found at ${gitignorePath}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async search() {
|
|
88
|
+
this.init(); // Ensure rules are loaded
|
|
89
|
+
const results = [];
|
|
90
|
+
await this._walk(this.basePath, '', results);
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 递归遍历目录
|
|
96
|
+
* @param {string} currentDir - 当前绝对路径
|
|
97
|
+
* @param {string} relativeDir - 相对于 basePath 的路径
|
|
98
|
+
* @param {string[]} results - 结果收集数组
|
|
99
|
+
*/
|
|
100
|
+
async _walk(currentDir, relativeDir, results) {
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = await readdirPromise(currentDir, { withFileTypes: true });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
this.warn(`Failed to read directory ${currentDir}: ${err.message}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const entryName = entry.name;
|
|
111
|
+
// 构造相对于 basePath 的路径,用于 ignore 检查
|
|
112
|
+
// 注意:ignore 库期望的是相对路径,如 "node_modules" 或 "src/file.js"
|
|
113
|
+
const entryRelativePath = relativeDir ? path.join(relativeDir, entryName) : entryName;
|
|
114
|
+
|
|
115
|
+
// 1. 检查是否被忽略
|
|
116
|
+
// ignore 库的 ignores 方法对目录需要特殊处理?
|
|
117
|
+
// 通常如果路径以 / 结尾,ignore 可能会视为目录,但在遍历中我们知道它是目录
|
|
118
|
+
// 这里我们直接传相对路径。ignore 库通常能处理 "dir" 和 "dir/"
|
|
119
|
+
|
|
120
|
+
// 检测目录命中
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
// 为了匹配 logs/ 这样的规则,尝试加上 /
|
|
123
|
+
// 但 ignore 库通常只需路径即可匹配
|
|
124
|
+
if (this.ignoreManager.ignores(entryRelativePath) || this.ignoreManager.ignores(entryRelativePath + '/')) {
|
|
125
|
+
if (this.debug) {
|
|
126
|
+
console.log(`IGNORED: ${entryRelativePath}/ by rule`);
|
|
127
|
+
}
|
|
128
|
+
continue; // 剪枝:整棵子树跳过
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 递归
|
|
132
|
+
// TODO: Handle followNestedGitignore if needed (complex, skipping for now based on 'default false')
|
|
133
|
+
await this._walk(path.join(currentDir, entryName), entryRelativePath, results);
|
|
134
|
+
|
|
135
|
+
} else if (entry.isFile()) {
|
|
136
|
+
// 检测文件命中
|
|
137
|
+
if (this.ignoreManager.ignores(entryRelativePath)) {
|
|
138
|
+
if (this.debug) {
|
|
139
|
+
console.log(`IGNORED: ${entryRelativePath} by rule`);
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. 读取内容并匹配正则
|
|
145
|
+
try {
|
|
146
|
+
const content = await readFilePromise(path.join(currentDir, entryName), 'utf-8');
|
|
147
|
+
if (this.regex.test(content)) {
|
|
148
|
+
results.push(entryRelativePath);
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
// 读取失败(如二进制文件),忽略并记录警告
|
|
152
|
+
// this.warn(`Failed to read file ${entryRelativePath}: ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 保持对外 API 兼容
|
|
161
|
+
*/
|
|
162
|
+
async function searchFiles(basePath, regex, ignorePatterns = []) {
|
|
163
|
+
const searcher = new Searcher(basePath, regex, { ignorePatterns });
|
|
164
|
+
// 可以通过环境变量或其他方式开启 debug,这里暂不开启
|
|
165
|
+
// searcher.setDebug(true);
|
|
166
|
+
return await searcher.search();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
searchFiles,
|
|
171
|
+
Searcher // 导出类以便测试
|
|
172
|
+
};
|