UNPKG

vue-unused

Version:

A highly accurate CLI tool to find and remove unused Vue components and files.

386 lines (349 loc) 10.8 kB
/** * @fileoverview This is the core analyzer for `vue-unused`. * It scans the project filesystem, builds a dependency graph of all files, * and identifies any files that are not part of this graph (i.e., are unused). * It leverages AST parsing for deep analysis of imports and Vue template usage. */ const fg = require("fast-glob"); const fs = require("fs"); const path = require("path"); const babelParser = require("@babel/parser"); const traverse = require("@babel/traverse").default; const ignore = require("ignore").default; const { parseVueFile, getTemplateTags, getImportedComponents, getUsedImportSources, } = require("./vue-parser"); function getVueVersion(rootDir) { try { // Use require.resolve for robust lookup of vue package from the target project const vuePkgPath = require.resolve("vue/package.json", { paths: [rootDir], }); const vuePkg = require(vuePkgPath); return vuePkg.version.startsWith("2") ? 2 : 3; } catch { // Fallback to Vue 2 for legacy projects if detection fails return 2; } } const getCompiler = (rootDir) => { const vueVersion = getVueVersion(rootDir); if (vueVersion === 2) { return { version: 2, compiler: require("vue-template-compiler") }; } else { return { version: 3, compiler: require("@vue/compiler-sfc") }; } }; const memoize = (fn) => { const cache = new Map(); return (...args) => { const key = JSON.stringify(args); if (cache.has(key)) { return cache.get(key); } const result = fn(...args); cache.set(key, result); return result; }; }; const extractImports = (code, filePath) => { const imports = new Set(); try { const ast = babelParser.parse(code, { sourceType: "module", plugins: ["typescript", "jsx", "importAssertions"], }); traverse(ast, { ImportDeclaration({ node }) { imports.add(node.source.value); }, CallExpression({ node }) { // import('...') or require('...') if ( node.callee.type === "Import" && node.arguments.length && node.arguments[0].type === "StringLiteral" ) { imports.add(node.arguments[0].value); } // require('...') if ( node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments.length && node.arguments[0].type === "StringLiteral" ) { imports.add(node.arguments[0].value); } }, }); } catch (error) { if (process.env.VUE_UNUSED_VERBOSE) { console.error(`Failed to parse ${filePath}:`, error.message); } } return Array.from(imports); }; const debugLogs = []; const getIgnorer = (rootDir) => { const gitignorePath = path.join(rootDir, ".gitignore"); const ig = ignore(); if (fs.existsSync(gitignorePath)) { const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8"); ig.add(gitignoreContent); } return ig; }; const isPackageImport = (imp, aliases) => { // An import is a package if it's not relative/absolute and not a configured alias. const isAlias = aliases.some( ([alias]) => imp === alias || imp.startsWith(alias + "/") ); return !imp.startsWith(".") && !path.isAbsolute(imp) && !isAlias; }; exports.analyzeProject = async (config, spinner) => { debugLogs.length = 0; const pLimit = (await import("p-limit")).default; const limit = pLimit(20); // Limit concurrency to 20 const { version: vueVersion, compiler } = getCompiler(config.rootDir); // Merge ignore from config. .gitignore is handled by fast-glob directly. const ignorePatterns = [ ...new Set([ ...(config.ignore || []), "**/node_modules/**", "**/dist/**", "**/build/**", "**/.output/**", "**/.nuxt/**", "**/.vite/**", "**/.git/**", ]), ]; const aliases = Object.entries(config.alias || {}).sort( (a, b) => b[0].length - a[0].length ); const memoizedNormalize = memoize(normalizeImportPath); const ignorer = getIgnorer(config.rootDir); const globPattern = config.extensions === "ALL" || (Array.isArray(config.extensions) && config.extensions.length === 0) ? "**/*" : `**/*.{${config.extensions .map((ext) => ext.replace(/^\./, "")) .join(",")}}`; const allFilesRaw = await fg([globPattern], { cwd: config.rootDir, ignore: ignorePatterns, absolute: true, }); const allFiles = allFilesRaw.filter( (file) => !ignorer.ignores(path.relative(config.rootDir, file)) ); // Helper to get real, normalized, case-sensitive path function normalizeFilePath(file) { try { return fs.realpathSync(file); } catch { return path.resolve(file); } } // Use normalized paths for allFilesSet const allFilesSet = new Set(allFiles.map((f) => normalizeFilePath(f))); const usedFiles = new Set( (config.entry || []) .map((f) => path.resolve(config.rootDir, f)) .map(normalizeFilePath) ); // Dependency graph: file -> Set of dependencies (normalized absolute paths) const dependencyGraph = {}; // Read all files in parallel, updating spinner with progress let processed = 0; const total = allFiles.length; if (config.verbose) { console.log(`Total files to analyze: ${total}`); } const fileContents = await Promise.all( allFiles.map((file) => limit(async () => { const code = await fs.promises.readFile(file, "utf-8"); processed++; const relPath = path.relative(config.rootDir, file); if (config.verbose) { console.log(`[Analyzing] ${relPath} (${processed}/${total})`); } else if (spinner && processed % 5 === 0) { spinner.text = `Analyzing: ${relPath} (${processed}/${total})`; } return { file, code }; }) ) ); // Extract imports and template tags in parallel await Promise.all( fileContents.map(async ({ file, code }) => { const allImports = new Set(); let contentToParse = code; if (file.endsWith(".vue")) { const { scriptContent, templateContent } = parseVueFile(code, { version: vueVersion, compiler, }); contentToParse = scriptContent; const templateTags = getTemplateTags(templateContent); const importedComponents = getImportedComponents(scriptContent); const usedInTemplate = getUsedImportSources( templateTags, importedComponents ); usedInTemplate.forEach((imp) => { if (!isPackageImport(imp, aliases)) { allImports.add(imp); } }); } const imports = extractImports(contentToParse, file); imports.forEach((imp) => { if (!isPackageImport(imp, aliases)) { allImports.add(imp); } }); if (config.verbose && allImports.size > 0) { console.log( `[Verbose] Found imports in ${path.relative(config.rootDir, file)}:`, allImports ); } const fileNorm = normalizeFilePath(file); if (!dependencyGraph[fileNorm]) dependencyGraph[fileNorm] = new Set(); for (const imp of allImports) { const normalized = await memoizedNormalize( imp, file, config.rootDir, aliases, config.extensions ); if (normalized) { const normPath = normalizeFilePath(normalized); if (config.verbose) { console.log( `[DEBUG] Import '${imp}' in '${file}' resolved to '${normPath}'` ); } dependencyGraph[fileNorm].add(normPath); usedFiles.add(normPath); } } }) ); if (config.verbose) { for (const unused of [...allFilesSet].filter((f) => !usedFiles.has(f))) { console.log(`[DEBUG] Unused file candidate: '${unused}'`); } } const unusedFiles = [...allFilesSet].filter((f) => !usedFiles.has(f)); // Convert Set values to arrays for JSON serialisation const graphOut = Object.fromEntries( Object.entries(dependencyGraph).map(([k, v]) => [k, [...v]]) ); // At the end, if debug is enabled, write debugLogs to a file if (process.env.VUE_UNUSED_DEBUG) { fs.writeFileSync( path.join(config.rootDir, "vue-unused-debug.log"), debugLogs.join("\n"), "utf-8" ); } return { allFiles, usedFiles: [...usedFiles], unusedFiles, dependencyGraph: graphOut, }; }; const existsCache = new Map(); async function cachedExists(filePath) { if (existsCache.has(filePath)) return existsCache.get(filePath); const exists = await fs.promises .access(filePath) .then(() => true) .catch(() => false); existsCache.set(filePath, exists); return exists; } const normalizeImportPath = async ( imp, basePath, rootDir, aliases, extensions ) => { let resolved = imp; let aliasMatched = false; // 1. Handle aliases. for (const [alias, target] of aliases) { if (imp === alias || imp.startsWith(alias + "/")) { resolved = path.resolve( rootDir, target, imp.slice(alias.length).replace(/^\//, "") ); aliasMatched = true; break; } } // 2. Handle relative or absolute paths. if (!aliasMatched) { resolved = path.resolve(path.dirname(basePath), imp); } if (extensions === "ALL" || extensions.length === 0) { if (await cachedExists(resolved)) { return resolved; } } for (const ext of extensions) { if (resolved.endsWith(ext) && (await cachedExists(resolved))) { if (process.env.VUE_UNUSED_DEBUG) { debugLogs.push( `[DEBUG] normalizeImportPath: '${imp}' resolved directly to '${resolved}'` ); } return resolved; } } // Try appending extensions for (const ext of extensions) { const candidate = `${resolved}${ext}`; if (await cachedExists(candidate)) { if (process.env.VUE_UNUSED_DEBUG) { debugLogs.push( `[DEBUG] normalizeImportPath: '${imp}' resolved to '${candidate}' by extension` ); } return candidate; } } // Try index files (e.g., ./foo/index.js) for (const ext of extensions) { const idxPath = path.join(resolved, `index${ext}`); if (await cachedExists(idxPath)) { if (process.env.VUE_UNUSED_DEBUG) { debugLogs.push( `[DEBUG] normalizeImportPath: '${imp}' resolved to '${idxPath}' as index file` ); } return idxPath; } } if (await cachedExists(resolved)) { if (process.env.VUE_UNUSED_DEBUG) { debugLogs.push( `[DEBUG] normalizeImportPath: '${imp}' resolved to '${resolved}' as fallback` ); } return resolved; } return null; };