UNPKG

eslint-plugin-fsd-lint

Version:

ESLint plugin for enforcing Feature-Sliced Design (FSD) architecture

644 lines (532 loc) 17.5 kB
/** * @fileoverview Path processing utility functions */ import fs from "fs"; import path from "path"; // Path cache const pathCache = new Map(); const tsConfigCache = new Map(); const importResolveCache = new Map(); const DEFAULT_RESOLVE_EXTENSIONS = [ ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json", ]; /** * Normalize file path (compatible with all operating systems) * @param {string} filePath - File path to normalize * @return {string} - Normalized path */ export function normalizePath(filePath) { if (pathCache.has(filePath)) { return pathCache.get(filePath); } // Convert Windows backslashes to forward slashes let normalized = filePath.replace(/\\/g, "/"); // Remove duplicate slashes normalized = normalized.replace(/\/+/g, "/"); // Remove trailing slash if (normalized.length > 1 && normalized.endsWith("/")) { normalized = normalized.slice(0, -1); } // Cache result pathCache.set(filePath, normalized); return normalized; } /** * Extract path segments * @param {string} filePath - Path to analyze * @return {string[]} - Array of path segments */ export function getPathSegments(filePath) { const normalized = normalizePath(filePath); return normalized.split("/").filter((segment) => segment.length > 0); } /** * Check if path is relative * @param {string} filePath - Path to check * @return {boolean} - Whether the path is relative */ export function isRelativePath(filePath) { return ( filePath.startsWith("./") || filePath.startsWith("../") || filePath === "." || filePath === ".." ); } function toAbsolutePath(filePath) { const normalizedPath = normalizePath(filePath); if (path.isAbsolute(normalizedPath) || /^[A-Za-z]:\//.test(normalizedPath)) { return normalizedPath; } return normalizePath(path.resolve(normalizedPath)); } /** * Extract relative path from source root * @param {string} filePath - File path * @param {string} rootPattern - Root directory pattern (default: '/src/') * @return {string|null} - Extracted relative path or null */ export function getRelativePathFromRoot(filePath, rootPattern = "/src/") { const normalizedPath = normalizePath(filePath); const rootIndex = normalizedPath.indexOf(rootPattern); if (rootIndex === -1) return null; return normalizedPath.substring(rootIndex + rootPattern.length); } function getSourceRootFromPath(filePath, rootPattern = "/src/") { const normalizedPath = normalizePath(filePath); const normalizedRootPattern = normalizePath(rootPattern); const rootIndex = normalizedPath.indexOf(normalizedRootPattern); if (rootIndex !== -1) { return normalizePath( normalizedPath.substring(0, rootIndex + normalizedRootPattern.length), ); } return null; } /** * Get the configured folder name for a layer. * @param {string} layer - Layer key * @param {Object} config - Configuration object * @return {string} - Configured layer folder pattern */ export function getLayerPattern(layer, config) { return config.layers[layer]?.pattern || layer; } /** * Find a configured layer by a path segment. * @param {string} segment - Path segment to match * @param {Object} config - Configuration object * @return {string|null} - Matching layer key or null */ export function findLayerBySegment(segment, config) { return ( Object.keys(config.layers).find( (layer) => layer === segment || getLayerPattern(layer, config) === segment, ) || null ); } /** * Remove the configured import alias from an import path. * @param {string} importPath - Path from import statement * @param {Object} config - Configuration object * @return {string|null} - Path after alias or null when alias does not match */ export function getImportPathWithoutAlias(importPath, config) { if (isRelativePath(importPath)) { return null; } const aliasConfig = config.alias; const aliasValue = normalizePath(aliasConfig.value); const withSlash = aliasConfig.withSlash; const normalizedPath = normalizePath(importPath); const aliasPatterns = withSlash ? [`${aliasValue}/`] : [aliasValue, `${aliasValue}/`]; const matchingPattern = aliasPatterns.find((pattern) => normalizedPath.startsWith(pattern), ); if (!matchingPattern) { return null; } let pathWithoutAlias = normalizedPath.substring(matchingPattern.length); if (pathWithoutAlias.startsWith("/")) { pathWithoutAlias = pathWithoutAlias.substring(1); } return pathWithoutAlias; } /** * Extract layer from import path considering alias * @param {string} importPath - Path from import statement * @param {Object} config - Configuration object * @return {string|null} - Extracted layer or null */ export function extractLayerFromImportPath(importPath, config) { const pathWithoutAlias = getImportPathWithoutAlias(importPath, config); if (!pathWithoutAlias) return null; // First path segment is the layer const firstSegment = pathWithoutAlias.split("/")[0]; // Check if it matches any layer return findLayerBySegment(firstSegment, config); } /** * Extract slice info from an aliased import path. * @param {string} importPath - Path from import statement * @param {Object} config - Configuration object * @return {string|null} - Extracted slice name or null */ export function extractSliceFromImportPath(importPath, config) { const pathWithoutAlias = getImportPathWithoutAlias(importPath, config); if (!pathWithoutAlias) return null; const segments = pathWithoutAlias.split("/"); if (segments.length < 2) return null; const layer = findLayerBySegment(segments[0], config); if (!layer) return null; return segments[1]; } /** * Extract FSD layer info from file path * @param {string} filePath - File path to analyze * @param {Object} config - Layer configuration and folder pattern options * @return {string|null} - Extracted layer name or null */ export function extractLayerFromPath(filePath, config) { const relativePath = getRelativePathFromRoot(filePath, config.rootPath); if (!relativePath) return null; const firstDir = relativePath.split("/")[0]; // Handle folder pattern if enabled if (config.folderPattern?.enabled) { const regex = new RegExp(config.folderPattern.regex); const match = firstDir.match(regex); if (match && match[config.folderPattern.extractionGroup]) { const extracted = match[config.folderPattern.extractionGroup]; // Check if extracted name matches any layer return Object.keys(config.layers).find( (layer) => config.layers[layer].pattern === extracted || layer === extracted, ); } } // Default layer matching if no folder pattern or no match return findLayerBySegment(firstDir, config); } /** * Extract slice info from file path * @param {string} filePath - File path to analyze * @param {Object} config - Configuration options * @return {string|null} - Extracted slice name or null */ export function extractSliceFromPath(filePath, config) { const relativePath = getRelativePathFromRoot(filePath, config?.rootPath); if (!relativePath) return null; const segments = relativePath.split("/"); if (segments.length < 2) return null; // Second segment is typically the slice return segments[1]; } function fileExists(filePath) { try { return fs.statSync(filePath).isFile(); } catch { return false; } } function directoryExists(filePath) { try { return fs.statSync(filePath).isDirectory(); } catch { return false; } } function resolveFileCandidate(candidatePath) { const normalizedCandidate = normalizePath(candidatePath); if (fileExists(normalizedCandidate)) { return normalizedCandidate; } if (!path.extname(normalizedCandidate)) { for (const extension of DEFAULT_RESOLVE_EXTENSIONS) { const withExtension = `${normalizedCandidate}${extension}`; if (fileExists(withExtension)) { return normalizePath(withExtension); } } } if (directoryExists(normalizedCandidate)) { for (const extension of DEFAULT_RESOLVE_EXTENSIONS) { const indexPath = path.join(normalizedCandidate, `index${extension}`); if (fileExists(indexPath)) { return normalizePath(indexPath); } } } for (const extension of DEFAULT_RESOLVE_EXTENSIONS) { const indexPath = path.join(normalizedCandidate, `index${extension}`); if (fileExists(indexPath)) { return normalizePath(indexPath); } } return null; } function stripJsonComments(json) { let result = ""; let inString = false; let escaped = false; for (let index = 0; index < json.length; index++) { const char = json[index]; const next = json[index + 1]; if (inString) { result += char; if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; result += char; continue; } if (char === "/" && next === "/") { while (index < json.length && json[index] !== "\n") { index++; } result += "\n"; continue; } if (char === "/" && next === "*") { index += 2; while ( index < json.length && !(json[index] === "*" && json[index + 1] === "/") ) { index++; } index++; continue; } result += char; } return result.replace(/,\s*([}\]])/g, "$1"); } function parseTsConfig(tsConfigPath) { const normalizedPath = normalizePath(tsConfigPath); if (tsConfigCache.has(normalizedPath)) { return tsConfigCache.get(normalizedPath); } let parsedConfig = null; try { const rawConfig = fs.readFileSync(normalizedPath, "utf8"); const config = JSON.parse(stripJsonComments(rawConfig)); const compilerOptions = config.compilerOptions || {}; const configDir = normalizePath(path.dirname(normalizedPath)); const baseUrl = compilerOptions.baseUrl ? normalizePath(path.resolve(configDir, compilerOptions.baseUrl)) : configDir; parsedConfig = { path: normalizedPath, dir: configDir, baseUrl, paths: compilerOptions.paths || {}, }; } catch { parsedConfig = null; } tsConfigCache.set(normalizedPath, parsedConfig); return parsedConfig; } function findNearestTsConfig(currentFilePath, config) { if (config.tsconfigPath) { const explicitPath = normalizePath(path.resolve(config.tsconfigPath)); return fileExists(explicitPath) ? explicitPath : null; } let currentDir = path.dirname(toAbsolutePath(currentFilePath)); while (currentDir && currentDir !== path.dirname(currentDir)) { for (const configFileName of ["tsconfig.json", "jsconfig.json"]) { const candidatePath = path.join(currentDir, configFileName); if (fileExists(candidatePath)) { return normalizePath(candidatePath); } } currentDir = path.dirname(currentDir); } return null; } function matchTsConfigPathPattern(importPath, pattern) { if (!pattern.includes("*")) { return importPath === pattern ? "" : null; } const [prefix, suffix] = pattern.split("*"); if (!importPath.startsWith(prefix) || !importPath.endsWith(suffix)) { return null; } return importPath.slice(prefix.length, importPath.length - suffix.length); } function resolveWithTsConfigPaths(importPath, currentFilePath, config) { const tsConfigPath = findNearestTsConfig(currentFilePath, config); if (!tsConfigPath) { return null; } const tsConfig = parseTsConfig(tsConfigPath); if (!tsConfig) { return null; } const pathEntries = Object.entries(tsConfig.paths).sort( ([leftPattern], [rightPattern]) => rightPattern.replace("*", "").length - leftPattern.replace("*", "").length, ); for (const [pattern, targets] of pathEntries) { const wildcardMatch = matchTsConfigPathPattern(importPath, pattern); if (wildcardMatch === null) { continue; } for (const target of targets) { const mappedTarget = target.includes("*") ? target.replace("*", wildcardMatch) : target; const candidatePath = path.resolve(tsConfig.baseUrl, mappedTarget); const resolvedPath = resolveFileCandidate(candidatePath); if (resolvedPath) { return resolvedPath; } } } const baseUrlCandidate = path.resolve(tsConfig.baseUrl, importPath); return resolveFileCandidate(baseUrlCandidate); } function resolveWithConfiguredAlias(importPath, currentFilePath, config) { const pathWithoutAlias = getImportPathWithoutAlias(importPath, config); if (!pathWithoutAlias) { return null; } const sourceRoot = getSourceRootFromPath( toAbsolutePath(currentFilePath), config.rootPath, ); if (!sourceRoot) { return null; } return resolveFileCandidate(path.join(sourceRoot, pathWithoutAlias)); } /** * Resolve an import path to a real file when possible. * Falls back to null for external packages or unresolved virtual paths. * @param {string} importPath - Path from import statement * @param {string} currentFilePath - File that contains the import * @param {Object} config - Configuration options * @return {string|null} - Resolved filesystem path or null */ export function resolveImportPath(importPath, currentFilePath, config) { if (typeof importPath !== "string") { return null; } const cacheKey = [ importPath, currentFilePath, config.rootPath, config.tsconfigPath || "", config.alias?.value || "", config.alias?.withSlash ? "1" : "0", ].join("\0"); if (importResolveCache.has(cacheKey)) { return importResolveCache.get(cacheKey); } let resolvedPath = null; if (isRelativePath(importPath)) { const currentDir = path.dirname(toAbsolutePath(currentFilePath)); resolvedPath = resolveFileCandidate(path.resolve(currentDir, importPath)); } else { resolvedPath = resolveWithTsConfigPaths(importPath, currentFilePath, config) || resolveWithConfiguredAlias(importPath, currentFilePath, config); } importResolveCache.set(cacheKey, resolvedPath); return resolvedPath; } /** * Get layer and slice information for an import target. * Resolved filesystem paths are preferred, with legacy string parsing as fallback. * @param {string} importPath - Path from import statement * @param {string} currentFilePath - File that contains the import * @param {Object} config - Configuration options * @return {{ resolvedPath: string|null, layer: string|null, slice: string|null }} */ export function getImportTargetInfo(importPath, currentFilePath, config) { const resolvedPath = resolveImportPath(importPath, currentFilePath, config); if (resolvedPath) { return { resolvedPath, layer: extractLayerFromPath(resolvedPath, config), slice: extractSliceFromPath(resolvedPath, config), }; } if (isRelativePath(importPath)) { const unresolvedPath = normalizePath( path.resolve(path.dirname(toAbsolutePath(currentFilePath)), importPath), ); return { resolvedPath: null, layer: extractLayerFromPath(unresolvedPath, config), slice: extractSliceFromPath(unresolvedPath, config), }; } return { resolvedPath: null, layer: extractLayerFromImportPath(importPath, config), slice: extractSliceFromImportPath(importPath, config), }; } function getEntityCrossImportInfoFromSegments(segments, config) { if (segments.length < 4 || segments[2] !== "@x") { return null; } const layer = findLayerBySegment(segments[0], config); if (layer !== "entities") { return null; } return { layer, targetSlice: segments[1], consumerSlice: segments[3].replace(/\.[^.]+$/, ""), }; } /** * Extract FSD Entities @x public API information from an import target. * @param {string} importPath - Path from import statement * @param {string} currentFilePath - File that contains the import * @param {Object} config - Configuration options * @return {{ layer: string, targetSlice: string, consumerSlice: string }|null} */ export function getEntityCrossImportPublicApiInfo( importPath, currentFilePath, config, ) { const resolvedPath = resolveImportPath(importPath, currentFilePath, config); if (resolvedPath) { const relativePath = getRelativePathFromRoot(resolvedPath, config.rootPath); if (relativePath) { const resolvedInfo = getEntityCrossImportInfoFromSegments( relativePath.split("/").filter(Boolean), config, ); if (resolvedInfo) { return resolvedInfo; } } } const pathWithoutAlias = getImportPathWithoutAlias(importPath, config); if (!pathWithoutAlias) { return null; } return getEntityCrossImportInfoFromSegments( pathWithoutAlias.split("/").filter(Boolean), config, ); } /** * Check if file is a test file * @param {string} filePath - File path to check * @param {string[]} patterns - Array of test file patterns * @return {boolean} - Whether it's a test file */ export function isTestFile(filePath, patterns) { return patterns.some((pattern) => { // Support simple wildcard patterns const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*"); const regex = new RegExp(regexPattern); return regex.test(filePath); }); }