UNPKG

grunt-ps-sass

Version:
345 lines (293 loc) 9.51 kB
const fs = require('fs'); const path = require('path'); /** * Service for resolving SCSS file dependencies by parsing import statements * This service is focused solely on dependency resolution - no change detection * For change detection, use the FileChangeDetector service separately */ class ScssDependencyResolver { constructor(options = {}) { this.includeExtensions = options.includeExtensions || ['.scss', '.sass']; this.verbose = options.verbose || false; this.resolveNodeModules = options.resolveNodeModules !== false; // default true } /** * Get all dependencies (imported files) for a Sass file recursively * @param {string} filePath - Path to the main SCSS file * @param {Object} options - Optional configuration * @returns {Array<string>} Array of absolute paths to all dependencies */ getDependencies(filePath, options = {}) { const context = this.#createDependencyContext(); this.#processSassFileRecursively(filePath, context); return this.#formatDependencyResult(context, options); } /** * Create initial context for dependency resolution * @private */ #createDependencyContext() { return { dependencies: new Set(), visited: new Set(), errors: [] }; } /** * Process a Sass file recursively to find all dependencies * @private */ #processSassFileRecursively(sassPath, context, depth = 0) { const normalizedPath = path.resolve(sassPath); if (context.visited.has(normalizedPath)) { return; } context.visited.add(normalizedPath); this.#logProcessing(sassPath, depth); try { const content = fs.readFileSync(normalizedPath, 'utf8'); const dir = path.dirname(normalizedPath); this.#extractAndProcessImports(content, dir, context, sassPath, depth); } catch(err) { this.#handleFileReadError(err, sassPath, context, depth); } } /** * Extract import statements and process each dependency * @private */ #extractAndProcessImports(content, dir, context, sassPath, depth) { const importRegex = /@(?:import|use|forward)\s+(?:url\()?['"]([^'"]+)['"](?:\))?(?:\s+as\s+\w+)?(?:\s+with\s*\([^)]*\))?;?/g; let match; while ((match = importRegex.exec(content)) !== null) { const importPath = match[1].trim(); this.#processImportStatement(importPath, dir, context, sassPath, depth); } } /** * Process a single import statement * @private */ #processImportStatement(importPath, dir, context, sassPath, depth) { // Skip CSS imports and URLs if (this.#isCssImport(importPath)) { return; } const resolvedPath = this.#resolveImportPath(importPath, dir); if (resolvedPath) { context.dependencies.add(resolvedPath); this.#processSassFileRecursively(resolvedPath, context, depth + 1); } else { this.#handleUnresolvedImport(importPath, sassPath, context, depth); } } /** * Handle unresolved import errors * @private */ #handleUnresolvedImport(importPath, sassPath, context, depth) { const error = `Could not resolve import: ${importPath} from ${sassPath}`; context.errors.push(error); if (this.verbose) { console.warn(`${' '.repeat(depth)}Warning: ${error}`); } } /** * Handle file reading errors * @private */ #handleFileReadError(err, sassPath, context, depth) { const error = `Error reading file ${sassPath}: ${err.message}`; context.errors.push(error); if (this.verbose) { console.error(`${' '.repeat(depth)}Error: ${error}`); } } /** * Log processing information if verbose mode is enabled * @private */ #logProcessing(sassPath, depth) { if (this.verbose) { console.log(`${' '.repeat(depth)}Processing: ${sassPath}`); } } /** * Format the final dependency result based on options * @private */ #formatDependencyResult(context, options) { // Return just the array of dependencies for simple use cases if (options.returnOnlyDependencies) { return Array.from(context.dependencies); } // Return detailed result with metadata const result = { dependencies: Array.from(context.dependencies), errors: context.errors, visited: Array.from(context.visited) }; if (options.includeStats) { result.stats = this.#getStats(result.dependencies); } return result; } /** * Get only the direct dependencies (first level imports) of a file * @param {string} filePath - Path to the SCSS file * @returns {Array<string>} Array of direct dependency paths */ getDirectDependencies(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const dir = path.dirname(filePath); return this.#extractDirectImports(content, dir); } catch(err) { this.#logDirectDependencyError(filePath, err); return []; } } /** * Extract direct import paths from file content * @private */ #extractDirectImports(content, dir) { const dependencies = []; const importRegex = /@(?:import|use|forward)\s+(?:url\()?['"]([^'"]+)['"](?:\))?(?:\s+as\s+\w+)?(?:\s+with\s*\([^)]*\))?;?/g; let match; while ((match = importRegex.exec(content)) !== null) { const importPath = match[1].trim(); if (!this.#isCssImport(importPath)) { const resolvedPath = this.#resolveImportPath(importPath, dir); if (resolvedPath) { dependencies.push(resolvedPath); } } } return dependencies; } /** * Log error for direct dependency extraction * @private */ #logDirectDependencyError(filePath, err) { if (this.verbose) { console.error(`Error reading file ${filePath}: ${err.message}`); } } /** * Resolve an import path to an absolute file path * @private */ #resolveImportPath(importPath, baseDir) { // Try different resolution strategies const candidates = this.#generateCandidatePaths(importPath, baseDir); for (const candidate of candidates) { if (fs.existsSync(candidate)) { return path.resolve(candidate); } } return null; } /** * Generate possible file paths for an import * @private */ #generateCandidatePaths(importPath, baseDir) { const candidates = []; // Handle node_modules imports (starting with ~) if (importPath.startsWith('~') && this.resolveNodeModules) { const nodeModulesPath = importPath.substring(1); const nodeModulesDir = this.#findNodeModules(baseDir); if (nodeModulesDir) { const basePath = path.join(nodeModulesDir, nodeModulesPath); candidates.push(...this.#addExtensionVariants(basePath)); } } else { // Regular relative/absolute imports const basePath = path.resolve(baseDir, importPath); candidates.push(...this.#addExtensionVariants(basePath)); } return candidates; } /** * Add different extension and partial variants for a base path * @private */ #addExtensionVariants(basePath) { const variants = []; const dir = path.dirname(basePath); const name = path.basename(basePath); // Original path variants.push(basePath); // Add extensions if not present for (const ext of this.includeExtensions) { if (!basePath.endsWith(ext)) { variants.push(basePath + ext); } } // Add partial variants (with underscore prefix) if (!name.startsWith('_')) { const partialName = '_' + name; const partialPath = path.join(dir, partialName); variants.push(partialPath); // Add extensions to partial variants for (const ext of this.includeExtensions) { if (!partialPath.endsWith(ext)) { variants.push(partialPath + ext); } } } return variants; } /** * Find the nearest node_modules directory * @private */ #findNodeModules(startDir) { let currentDir = startDir; while (currentDir !== path.dirname(currentDir)) { const nodeModulesPath = path.join(currentDir, 'node_modules'); if (fs.existsSync(nodeModulesPath)) { return nodeModulesPath; } currentDir = path.dirname(currentDir); } return null; } /** * Check if an import is a CSS import (should be skipped) * @private */ #isCssImport(importPath) { return ( importPath.endsWith('.css') || importPath.startsWith('http://') || importPath.startsWith('https://') || importPath.startsWith('//') ); } /** * Get statistics about dependencies * @private */ #getStats(dependencies) { const stats = { totalFiles: dependencies.length, byExtension: {}, totalSize: 0 }; dependencies.forEach(filePath => { const ext = path.extname(filePath); stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1; try { const stat = fs.statSync(filePath); stats.totalSize += stat.size; } catch(err) { // Ignore size calculation errors } }); return stats; } } module.exports = ScssDependencyResolver;