/** * 文件夹对比忽略规则的纯逻辑模块(无 electron/fs 依赖,vitest 直接测试)。 * 位于 src/shared:主进程 folderScan 与渲染端 FolderView(无效规则提示)打包各自引用同一源文件, * 两进程无法共享运行时模块但可共享源码(与 preload 类型反向引用同理)。 * * gitignore 简化子集(0.10.0 高级 glob): * - `name`:名字匹配(任意层级命中文件与目录) * - `name/`:目录名匹配(任意层级,仅目录) * - `*.ext` / `a*b`:段内 `*` 通配(匹配任意字符,位置不限;段即 / 切分的路径片段) * - `src/temp`:多段路径(从根锚定匹配) * - `/build`:前导 `/` 锚定(仅根级匹配;无前导 `/` 的单段名为任意层级) * - `**`:整段跨段通配(匹配零或多段,如 build、**、cache 三段模式命中 build 下任意深度的 * cache;尾部 `**` 需至少一段——`a/**` 不匹配 a 自身,与 gitignore 一致) * - `!pattern`:否定规则(同一路径以最后一条匹配的规则为准,与 gitignore 一致) * * 语义约定: * - 大小写不敏感(Windows 文件系统语义) * - `#` 开头注释与空白行静默跳过(不计入无效规则) * - 父目录被剪枝后其子项无法被否定规则救回(BFS 剪枝语义,与 gitignore 一致) * - 非法规则(段内 `**`、空段、`!` 后为空、仅分隔符等)跳过并记入 invalid 清单供 UI 提示 */ /** 规则条数上限(UI 与主进程 IPC 双重防御) */ export const IGNORE_RULES_MAX = 50 /** 单条规则长度上限 */ export const IGNORE_RULE_MAX_LEN = 500 /** 编译后的匹配器:对完整相对路径判定(含否定规则仲裁) */ export interface IgnoreMatcher { /** 相对路径(统一 / 分隔,含文件/目录自身)是否被忽略;isDir 缺省按文件判定 */ matchesPath: (rel: string, isDir?: boolean) => boolean /** 被跳过的无效规则原文(供 UI 提示;注释与空白行不计入) */ invalid: readonly string[] } /** 编译后的单段形态:字面量(=== 快路径)/ 段内通配(正则)/ 跨段通配 */ interface SegPattern { kind: 'literal' | 'wild' | 'globstar' /** literal 的字面值(已小写) */ lit?: string /** wild 的整段正则(已锚定 ^$) */ re?: RegExp } interface CompiledRule { /** 段序列(已编译;已小写) */ segs: SegPattern[] /** 目录专用(尾部 /) */ dirOnly: boolean /** 否定规则(! 前缀) */ negate: boolean /** 从根锚定(多段或前导 /);否则任意层级起匹配 */ anchored: boolean } /** 编译单条规则文本(调用方已做 trim);返回 null 表示无效或为注释/空白行 */ function compileOne(rule: string): CompiledRule | null { if (rule.length === 0 || rule.length > IGNORE_RULE_MAX_LEN) return null if (rule.startsWith('#')) return null let body = rule let negate = false if (body.startsWith('!')) { negate = true body = body.slice(1) } let dirOnly = false if (body.endsWith('/')) { dirOnly = true body = body.slice(0, -1) } let anchored = false if (body.startsWith('/')) { anchored = true body = body.slice(1) } if (body.length === 0) return null const rawSegs = body.split('/') if (rawSegs.some((s) => s.length === 0)) return null if (rawSegs.some((s) => s.includes('**') && s !== '**')) return null if (rawSegs.length > 1) anchored = true const lower = rawSegs.map((s) => s.toLowerCase()) const segs: SegPattern[] = lower.map((s) => { if (s === '**') return { kind: 'globstar' } if (!s.includes('*')) return { kind: 'literal', lit: s } // 段内 * 通配:* → .*(段内无分隔符),其余字符转义后整段锚定匹配 const re = new RegExp('^' + s.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$') return { kind: 'wild', re } }) return { segs, dirOnly, negate, anchored } } /** 模式段序列是否匹配路径段序列(自后向前 DP)。 * `**` 跨零或多段;尾段 `**` 需至少一段(`a/**` 不含 a 自身,对齐 gitignore) */ function matchSegs(pat: SegPattern[], path: string[]): boolean { const P = pat.length const L = path.length // dp[i][j] = pat[i..] 匹配 path[j..] const dp: boolean[][] = Array.from({ length: P + 1 }, () => new Array(L + 1).fill(false)) dp[P][L] = true for (let i = P - 1; i >= 0; i--) { for (let j = L; j >= 0; j--) { const seg = pat[i] if (seg.kind === 'globstar') { dp[i][j] = i === P - 1 ? j < L // 尾部 **:吞掉剩余全部段(至少一段) : dp[i + 1][j] || (j < L && dp[i][j + 1]) // 非尾部:跨零段或多段 } else if (seg.kind === 'literal') { dp[i][j] = j < L && path[j] === seg.lit && dp[i + 1][j + 1] } else { dp[i][j] = j < L && seg.re!.test(path[j]) && dp[i + 1][j + 1] } } } return dp[0][0] } /** 单条规则是否匹配路径(dirOnly 仅匹配目录;非锚定规则任意起点起匹配) */ function ruleMatches(rule: CompiledRule, pathSegs: string[], isDir: boolean): boolean { if (rule.dirOnly && !isDir) return false if (rule.anchored) return matchSegs(rule.segs, pathSegs) for (let k = 0; k < pathSegs.length; k++) { if (matchSegs(rule.segs, pathSegs.slice(k))) return true } return false } /** 规则数组是否为可接受的 IPC 形态(folder:scan 防御性校验共用) */ export function isIgnoreRulesArray(v: unknown): v is string[] { return ( Array.isArray(v) && v.length <= IGNORE_RULES_MAX && v.every((r) => typeof r === 'string' && r.length <= IGNORE_RULE_MAX_LEN) ) } /** 编译规则串数组为匹配器(空数组 / 全部非法 → 永不匹配的空匹配器) */ export function compileIgnoreRules(rules: readonly string[]): IgnoreMatcher { const compiled: CompiledRule[] = [] const invalid: string[] = [] for (const r of rules) { if (compiled.length >= IGNORE_RULES_MAX) break if (typeof r !== 'string') continue const trimmed = r.trim() const c = compileOne(trimmed) if (c) compiled.push(c) else if (trimmed !== '' && !trimmed.startsWith('#')) invalid.push(trimmed) } const matchesPath = (rel: string, isDir?: boolean): boolean => { if (rel === '') return false const dir = isDir === true const segs = rel.toLowerCase().split('/') // 否定仲裁:从后往前找第一条匹配的规则,其否定性决定结果(gitignore 语义) for (let i = compiled.length - 1; i >= 0; i--) { if (ruleMatches(compiled[i], segs, dir)) return !compiled[i].negate } return false } return { matchesPath, invalid } }