knip
Version:
Find unused files, dependencies and exports in your TypeScript and JavaScript projects
204 lines (203 loc) • 8.31 kB
JavaScript
import { readFileSync } from 'node:fs';
import { promisify } from 'node:util';
import { walk as _walk } from '@nodelib/fs.walk';
import fg, {} from 'fast-glob';
import picomatch from 'picomatch';
import { GLOBAL_IGNORE_PATTERNS, ROOT_WORKSPACE_NAME } from '../constants.js';
import { timerify } from './Performance.js';
import { compact } from './array.js';
import { debugLogObject } from './debug.js';
import { isFile } from './fs.js';
import { dirname, join, relative, toPosix } from './path.js';
const walk = promisify(_walk);
const _picomatch = timerify(picomatch);
const cachedGitIgnores = new Map();
const cachedGlobIgnores = new Map();
export const convertGitignoreToPicomatchIgnorePatterns = (pattern) => {
const negated = pattern[0] === '!';
if (negated)
pattern = pattern.slice(1);
let extPattern;
if (pattern.endsWith('/'))
pattern = pattern.slice(0, -1);
if (pattern.startsWith('*/**/'))
pattern = pattern.slice(5);
if (pattern.startsWith('/'))
pattern = pattern.slice(1);
else if (!pattern.startsWith('**/'))
pattern = `**/${pattern}`;
if (pattern.endsWith('/*'))
extPattern = pattern;
else
extPattern = `${pattern}/**`;
return { negated, patterns: [pattern, extPattern] };
};
export const parseAndConvertGitignorePatterns = (patterns, ancestor) => {
const matchFrom = ancestor ? new RegExp(`^(!?/?)(${ancestor})`) : undefined;
return patterns
.split(/\r?\n/)
.filter(line => line.trim() && !line.startsWith('#'))
.flatMap(line => {
const pattern = line.replace(/^\\(?=#)/, '').trim();
if (ancestor && matchFrom) {
if (pattern.match(matchFrom))
return [pattern.replace(matchFrom, '$1')];
if (pattern.startsWith('/**/'))
return [pattern.slice(1)];
if (pattern.startsWith('!/**/'))
return [`!${pattern.slice(2)}`];
if (pattern.startsWith('/') || pattern.startsWith('!/'))
return [];
}
return [pattern];
})
.map(pattern => convertGitignoreToPicomatchIgnorePatterns(pattern));
};
const findAncestorGitignoreFiles = (cwd) => {
const gitignorePaths = [];
let dir = dirname(cwd);
let prev;
while (dir) {
const filePath = join(dir, '.gitignore');
if (isFile(filePath))
gitignorePaths.push(filePath);
dir = dirname((prev = dir));
if (prev === dir || dir === '.')
break;
}
return gitignorePaths;
};
export const findAndParseGitignores = async (cwd) => {
const init = ['.git', ...GLOBAL_IGNORE_PATTERNS];
const ignores = new Set(init);
const unignores = [];
const gitignoreFiles = [];
const pmOptions = { ignore: unignores };
const matchers = new Set(init.map(pattern => _picomatch(pattern, pmOptions)));
const matcher = (str) => {
for (const isMatch of matchers) {
const state = isMatch(str);
if (state)
return state;
}
return false;
};
const addFile = (filePath) => {
gitignoreFiles.push(relative(cwd, filePath));
const dir = dirname(toPosix(filePath));
const base = relative(cwd, dir);
const ancestor = base.startsWith('..') ? `${relative(dir, cwd)}/` : undefined;
const dirIgnores = new Set(base === '' ? init : []);
const dirUnignores = new Set();
const patterns = readFileSync(filePath, 'utf8');
for (const rule of parseAndConvertGitignorePatterns(patterns, ancestor)) {
const [pattern1, pattern2] = rule.patterns;
if (rule.negated) {
if (base === '' || base.startsWith('..')) {
if (!unignores.includes(pattern2)) {
unignores.push(...rule.patterns);
dirUnignores.add(pattern1);
dirUnignores.add(pattern2);
}
}
else {
if (!unignores.includes(pattern2.startsWith('**/') ? pattern2 : `**/${pattern2}`)) {
const unignore = join(base, pattern1);
const extraUnignore = join(base, pattern2);
unignores.push(unignore, extraUnignore);
dirUnignores.add(unignore);
dirUnignores.add(extraUnignore);
}
}
}
else {
if (base === '' || base.startsWith('..')) {
ignores.add(pattern1);
ignores.add(pattern2);
dirIgnores.add(pattern1);
dirIgnores.add(pattern2);
}
else {
const ignore = join(base, pattern1);
const extraIgnore = join(base, pattern2);
ignores.add(ignore);
ignores.add(extraIgnore);
dirIgnores.add(ignore);
dirIgnores.add(extraIgnore);
}
}
}
const cacheDir = ancestor ? cwd : dir;
const cacheForDir = cachedGitIgnores.get(cwd);
if (ancestor && cacheForDir) {
for (const pattern of dirIgnores)
cacheForDir?.ignores.add(pattern);
cacheForDir.unignores = Array.from(new Set([...cacheForDir.unignores, ...dirUnignores]));
}
else {
cachedGitIgnores.set(cacheDir, { ignores: dirIgnores, unignores: Array.from(dirUnignores) });
}
for (const pattern of dirIgnores)
matchers.add(_picomatch(pattern, pmOptions));
};
findAncestorGitignoreFiles(cwd).forEach(addFile);
if (isFile('.git/info/exclude'))
addFile('.git/info/exclude');
const entryFilter = (entry) => {
if (entry.dirent.isFile() && entry.name === '.gitignore') {
addFile(entry.path);
return true;
}
return false;
};
const deepFilter = (entry) => !matcher(relative(cwd, entry.path));
await walk(cwd, {
entryFilter: timerify(entryFilter),
deepFilter: timerify(deepFilter),
});
debugLogObject('*', 'Parsed gitignore files', { gitignoreFiles });
return { gitignoreFiles, ignores, unignores };
};
const _parseFindGitignores = timerify(findAndParseGitignores);
export async function glob(patterns, options) {
if (Array.isArray(patterns) && patterns.length === 0)
return [];
const canCache = options.label && options.gitignore;
const willCache = canCache && !cachedGlobIgnores.has(options.dir);
const cachedIgnores = canCache && cachedGlobIgnores.get(options.dir);
const _ignore = options.gitignore && Array.isArray(options.ignore) ? [...options.ignore] : [];
if (options.gitignore) {
if (willCache) {
let dir = options.dir;
let prev;
while (dir) {
const cacheForDir = cachedGitIgnores.get(dir);
if (cacheForDir) {
_ignore.push(...cacheForDir.ignores);
}
dir = dirname((prev = dir));
if (prev === dir || dir === '.')
break;
}
}
}
else {
_ignore.push(...GLOBAL_IGNORE_PATTERNS);
}
const ignore = cachedIgnores || compact(_ignore);
const { dir, label, ...fgOptions } = { ...options, ignore };
const paths = await fg.glob(patterns, fgOptions);
debugLogObject(relative(options.cwd, options.dir) || ROOT_WORKSPACE_NAME, label ? `Finding ${label} paths` : 'Finding paths', () => ({ patterns, ...fgOptions, ignore: cachedIgnores ? `identical to ${dir} ↑` : ignore, paths }));
if (willCache)
cachedGlobIgnores.set(options.dir, ignore);
return paths;
}
export async function getGitIgnoredHandler(options) {
cachedGitIgnores.clear();
if (options.gitignore === false)
return () => false;
const gitignore = await _parseFindGitignores(options.cwd);
const matcher = _picomatch(Array.from(gitignore.ignores), { ignore: gitignore.unignores });
const isGitIgnored = (filePath) => matcher(relative(options.cwd, filePath));
return timerify(isGitIgnored);
}