import type { WorkspaceFolder } from 'vscode' import type { IndexHost, WorkspaceFile } from './workspaceIndex' import type { FolderReader } from './workspaceScope' import * as path from 'node:path' import { minimatch } from 'minimatch' import { RelativePattern, Uri, workspace } from 'vscode' import { folderScope } from './workspaceScope' /** 建索引时总不看的目录:虚拟环境、依赖、Python 字节码缓存。 */ const ALWAYS_EXCLUDED = ['**/.venv', '**/node_modules', '**/__pycache__'] /** 这个工作区文件夹里不看的:files.exclude、search.exclude 里开着的(带 when 条件的不算),加上总不看的目录。 */ function excludedPatterns(folder: WorkspaceFolder): string[] { const enabled = (section: string): string[] => Object.entries(workspace.getConfiguration(section, folder.uri).get>('exclude') ?? {}) .filter(([, on]) => on === true) .map(([pattern]) => pattern) return [...new Set([...enabled('files'), ...enabled('search'), ...ALWAYS_EXCLUDED])] } /** 排除规则写的可能是目录(`**\/.git`),目录里的文件也算排除。 */ function isExcluded(relative: string, patterns: readonly string[]): boolean { return patterns.some(pattern => minimatch(relative, pattern, { dot: true }) || minimatch(relative, `${pattern}/**`, { dot: true })) } /** * 交给 findFiles 的排除 glob:让它别去翻 .venv 这种大目录。带花括号、逗号的写法拼不进一个 `{…}`, * 留给找到之后再按 isExcluded 筛。 */ function excludeGlob(patterns: readonly string[]): string { return `{${patterns.filter(pattern => !/[{},]/.test(pattern)).flatMap(pattern => [pattern, `${pattern}/**`]).join(',')}}` } function workspaceFile(uri: Uri): { file: WorkspaceFile, folder: WorkspaceFolder } | undefined { const folder = workspace.getWorkspaceFolder(uri) if (!folder) return undefined return { folder, file: { uri: uri.toString(), folder: folder.uri.toString(), path: path.posix.relative(folder.uri.path, uri.path) } } } /** 读这个工作区文件夹里的文件(路径相对它、`/` 分隔)。 */ export function folderReader(folder: WorkspaceFolder): FolderReader { const at = (path: string): Uri => Uri.joinPath(folder.uri, path) return { exists: path => Promise.resolve(workspace.fs.stat(at(path))).then(() => true, () => false), read: path => Promise.resolve(workspace.fs.readFile(at(path))).then(bytes => new TextDecoder('utf-8').decode(bytes), () => undefined), } } /** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */ function scopeOf(folder: WorkspaceFolder): ReturnType { return folderScope(folderReader(folder), workspace.getConfiguration('search', folder.uri).get('useIgnoreFiles', true)) } /** * 库规则的工作区索引在 VS Code 里怎么找文件、读文件、盯改动;解析交给调用方(语言插件 + 解析运行时)。 * 不看的:files.exclude、search.exclude 排除的、总不看的目录,加上嵌套的 checkout 和忽略文件忽略的(见 folderScope)。 * 插件声明的引擎版本(^1.97)的正式 API 里 findFiles 不能按忽略文件筛,所以忽略文件自己读。 */ export function workspaceIndexHost(parse: IndexHost['parse'], report: IndexHost['report']): IndexHost { return { async list(include) { const found = await Promise.all((workspace.workspaceFolders ?? []).map(async (folder) => { const patterns = excludedPatterns(folder) const scope = scopeOf(folder) const uris = await workspace.findFiles(new RelativePattern(folder, include), excludeGlob(patterns)) const files = uris.flatMap((uri) => { const file = workspaceFile(uri)?.file return file && !isExcluded(file.path, patterns) ? [file] : [] }) const skipped = await Promise.all(files.map(file => scope.skips(file.path))) return files.filter((_, i) => !skipped[i]) })) return found.flat() }, async read(file) { return new TextDecoder('utf-8').decode(await workspace.fs.readFile(Uri.parse(file.uri))) }, watch(include, listener) { const forward = (kind: 'change' | 'create') => async (uri: Uri): Promise => { const found = workspaceFile(uri) if (!found || !minimatch(found.file.path, include, { dot: true }) || isExcluded(found.file.path, excludedPatterns(found.folder))) return if (!await scopeOf(found.folder).skips(found.file.path)) listener({ kind, file: found.file }) } // 删目录时 VS Code 只报目录本身,匹配不上 include,所以删除另用一个什么都盯的 const files = workspace.createFileSystemWatcher(include, false, false, true) const deletions = workspace.createFileSystemWatcher('**/*', true, true, false) const subscriptions = [ files, deletions, files.onDidChange(forward('change')), files.onDidCreate(forward('create')), deletions.onDidDelete((uri) => { const found = workspaceFile(uri) if (found) listener({ kind: 'delete', file: found.file }) }), ] return { dispose: () => subscriptions.forEach(subscription => subscription.dispose()) } }, parse, report, } }