knip
Version:
Find and fix unused dependencies, exports and files in your TypeScript and JavaScript projects
268 lines (267 loc) • 10.7 kB
JavaScript
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import { fdir } from 'fdir';
import { glob as tinyGlob } from 'tinyglobby';
import picomatch from 'picomatch';
import { GLOBAL_IGNORE_PATTERNS } from '../constants.js';
import { compact, partition } from './array.js';
import { debugLogObject } from './debug.js';
import { isDirectory, isFile } from './fs.js';
import { getCachedGitignore, isGitignoreCacheEnabled, setCachedGitignore } from './gitignore-cache.js';
import { timerify } from './Performance.js';
import { expandIgnorePatterns, parseAndConvertGitignorePatterns } from './parse-and-convert-gitignores.js';
import { dirname, join, relative, toPosix } from './path.js';
const cachedGitIgnores = new Map();
const cachedGlobIgnores = new Map();
const isGitRoot = (dir) => isDirectory(dir, '.git') || isFile(dir, '.git');
const getGitDir = (cwd) => {
const dotGit = join(cwd, '.git');
if (isDirectory(dotGit))
return dotGit;
if (isFile(dotGit)) {
const content = readFileSync(dotGit, 'utf8').trim();
const match = content.match(/^gitdir:\s*(.+)$/);
if (match)
return join(cwd, match[1]);
}
return undefined;
};
const findAncestorGitignoreFiles = (cwd) => {
const gitignorePaths = [];
if (isGitRoot(cwd))
return gitignorePaths;
let dir = dirname(cwd);
let prev;
while (dir) {
const filePath = join(dir, '.gitignore');
if (isFile(filePath))
gitignorePaths.push(filePath);
if (isGitRoot(dir))
break;
dir = dirname((prev = dir));
if (prev === dir || dir === '.')
break;
}
return gitignorePaths;
};
export const findAndParseGitignores = async (cwd, workspaceDirs) => {
if (isGitignoreCacheEnabled()) {
const cached = getCachedGitignore(cwd, workspaceDirs);
if (cached) {
for (const [dir, data] of cached.perDirIgnores)
cachedGitIgnores.set(dir, data);
debugLogObject('*', 'Parsed gitignore files (cached)', { gitignoreFiles: cached.gitignoreFiles });
return { gitignoreFiles: cached.gitignoreFiles, ignores: cached.ignores, unignores: cached.unignores };
}
}
const ignores = new Set(GLOBAL_IGNORE_PATTERNS);
const unignores = new Set();
const gitignoreFiles = [];
let deepFilterMatcher;
let prevUnignoreSize = unignores.size;
let unignoresArray = [];
const pendingIgnores = [];
const getMatcher = () => {
if (!deepFilterMatcher) {
unignoresArray = Array.from(unignores);
deepFilterMatcher = picomatch(Array.from(ignores), { ignore: unignoresArray });
pendingIgnores.length = 0;
}
else if (pendingIgnores.length > 0) {
const prev = deepFilterMatcher;
const incr = picomatch(pendingIgnores.splice(0), { ignore: unignoresArray });
deepFilterMatcher = (path) => prev(path) || incr(path);
}
return deepFilterMatcher;
};
const addFile = (filePath, baseDir) => {
gitignoreFiles.push(relative(cwd, filePath));
const dir = baseDir ?? dirname(toPosix(filePath));
const base = relative(cwd, dir);
const ancestor = base.startsWith('..') ? `${relative(dir, cwd)}/` : undefined;
const ignoresForDir = new Set(base === '' ? GLOBAL_IGNORE_PATTERNS : []);
const unignoresForDir = new Set();
const prevIgnoreSize = ignores.size;
const patterns = readFileSync(filePath, 'utf8');
const isRoot = base === '' || base.startsWith('..');
for (const { negated, pattern } of parseAndConvertGitignorePatterns(patterns, ancestor)) {
if (negated) {
if (isRoot) {
if (!unignores.has(pattern)) {
unignores.add(pattern);
unignoresForDir.add(pattern);
}
}
else if (!unignores.has(pattern)) {
const unignore = join(base, pattern);
unignores.add(unignore);
unignoresForDir.add(unignore);
}
}
else if (isRoot) {
ignores.add(pattern);
ignoresForDir.add(pattern);
}
else if (!unignores.has(pattern)) {
const ignore = join(base, pattern);
ignores.add(ignore);
ignoresForDir.add(ignore);
}
}
const cacheDir = ancestor ? cwd : dir;
const cacheForDir = cachedGitIgnores.get(cacheDir);
if (cacheForDir) {
for (const pattern of ignoresForDir)
cacheForDir.ignores.add(pattern);
for (const pattern of unignoresForDir)
cacheForDir.unignores.add(pattern);
}
else {
cachedGitIgnores.set(cacheDir, { ignores: ignoresForDir, unignores: unignoresForDir });
}
if (unignores.size !== prevUnignoreSize) {
deepFilterMatcher = undefined;
prevUnignoreSize = unignores.size;
}
else if (ignores.size !== prevIgnoreSize) {
for (const p of ignoresForDir)
if (!GLOBAL_IGNORE_PATTERNS.includes(p))
pendingIgnores.push(p);
}
};
for (const filePath of findAncestorGitignoreFiles(cwd))
addFile(filePath);
const gitDir = getGitDir(cwd);
if (gitDir) {
const excludePath = join(gitDir, 'info/exclude');
if (isFile(excludePath))
addFile(excludePath, cwd);
}
let isRelevantDir;
if (workspaceDirs && workspaceDirs.size > 0) {
const relevantAncestors = new Set();
const nonRootDirs = new Set();
for (const wsDir of workspaceDirs) {
if (wsDir !== cwd)
nonRootDirs.add(wsDir);
let dir = wsDir;
while (dir.length >= cwd.length) {
relevantAncestors.add(dir);
const parent = dirname(dir);
if (parent === dir)
break;
dir = parent;
}
}
if (nonRootDirs.size > 0) {
isRelevantDir = (absPath) => {
if (relevantAncestors.has(absPath))
return true;
for (const wsDir of nonRootDirs)
if (absPath.startsWith(`${wsDir}/`))
return true;
return false;
};
}
}
const cwdPrefixLen = cwd.length + 1;
const walkGitignores = async () => {
await new fdir()
.withFullPaths()
.exclude((_dirName, dirPath) => {
const absPath = toPosix(dirPath.slice(0, -1));
return (isRelevantDir && !isRelevantDir(absPath)) || getMatcher()(absPath.slice(cwdPrefixLen));
})
.filter((filePath, isDir) => {
if (isDir || basename(filePath) !== '.gitignore')
return false;
addFile(filePath);
return true;
})
.crawl(cwd)
.withPromise();
};
await walkGitignores();
if (unignores.size > 0) {
const unignorePaths = new Set();
for (const u of unignores) {
let p = u.replace(/^\*\*\//, '');
while (p && p !== '.' && p !== '/') {
unignorePaths.add(p);
const parent = dirname(p);
if (parent === p)
break;
p = parent;
}
}
for (const cacheForDir of cachedGitIgnores.values()) {
for (const pattern of cacheForDir.ignores) {
const match = picomatch(pattern);
for (const p of unignorePaths) {
if (match(p)) {
cacheForDir.ignores.delete(pattern);
break;
}
}
}
}
}
debugLogObject('*', 'Parsed gitignore files', { gitignoreFiles });
if (isGitignoreCacheEnabled()) {
setCachedGitignore(cwd, workspaceDirs, gitignoreFiles, ignores, unignores, cachedGitIgnores);
}
return { gitignoreFiles, ignores, unignores };
};
const _parseFindGitignores = timerify(findAndParseGitignores);
export async function glob(_patterns, options) {
if (Array.isArray(_patterns) && _patterns.length === 0)
return [];
const hasCache = cachedGlobIgnores.has(options.dir);
const willCache = !hasCache && options.gitignore && options.label;
const cachedIgnores = options.gitignore ? cachedGlobIgnores.get(options.dir) : undefined;
const _ignore = [...GLOBAL_IGNORE_PATTERNS];
const [negatedPatterns, patterns] = partition(_patterns, pattern => pattern.startsWith('!'));
if (options.gitignore && 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;
}
}
if (willCache)
cachedGlobIgnores.set(options.dir, compact(_ignore));
const ignorePatterns = (cachedIgnores || _ignore).concat(negatedPatterns.map(pattern => pattern.slice(1)));
const { dir, label, ...fgOptions } = { ...options, ignore: ignorePatterns, expandDirectories: false };
const paths = await tinyGlob(patterns, fgOptions);
debugLogObject(relative(options.cwd, dir), label ? `Finding ${label}` : 'Finding paths', () => ({
patterns,
...fgOptions,
ignore: hasCache && ignorePatterns.length === (cachedIgnores || _ignore).length
? `// using cache from previous glob cwd: ${fgOptions.cwd}`
: ignorePatterns,
paths,
}));
return paths;
}
export async function getGitIgnoredHandler(options, workspaceDirs) {
cachedGitIgnores.clear();
if (options.gitignore === false)
return () => false;
const { ignores, unignores } = await _parseFindGitignores(options.cwd, workspaceDirs);
const matcher = picomatch(expandIgnorePatterns(ignores), { ignore: expandIgnorePatterns(unignores) });
const cache = new Map();
const isGitIgnored = (filePath) => {
let result = cache.get(filePath);
if (result === undefined) {
result = matcher(relative(options.cwd, filePath));
cache.set(filePath, result);
}
return result;
};
return isGitIgnored;
}