eslint-plugin-fsd-lint
Version:
ESLint plugin for enforcing Feature-Sliced Design (FSD) architecture
180 lines (155 loc) • 6.24 kB
JavaScript
/**
* @fileoverview Enforces ordered imports by Feature-Sliced Design (FSD) layers
*/
import { normalizePath, extractLayerFromImportPath } from '../utils/path-utils.js';
import { mergeConfig } from '../utils/config-utils.js';
export default {
meta: {
type: "suggestion",
docs: {
description: "Enforces ordered imports by Feature-Sliced Design (FSD) layers.",
recommended: true,
},
messages: {
incorrectGrouping:
"🚨 '{{ currentImport }}' import is not correctly grouped. Keep imports ordered by layer."
},
fixable: "code",
schema: [
{
type: "object",
properties: {
alias: {
oneOf: [
{ type: "string" },
{
type: "object",
properties: {
value: { type: "string" },
withSlash: { type: "boolean" }
},
required: ["value"],
additionalProperties: false
}
]
},
customOrder: {
type: "array",
items: { type: "string" },
description: "Custom layer order (default from top to bottom: app, processes, pages, widgets, features, entities, shared)"
}
},
additionalProperties: false
}
]
},
create(context) {
// Merge user config with default config
const options = context.options[0] || {};
const config = mergeConfig(options);
// Order of layers (from top to bottom)
const layerOrder = options.customOrder || [
'app',
'processes',
'pages',
'widgets',
'features',
'entities',
'shared'
];
return {
Program(node) {
const importNodes = node.body.filter(statement => statement.type === "ImportDeclaration");
if (importNodes.length === 0) {
return;
}
const sourceCode = context.sourceCode || context.getSourceCode();
const sourceText = sourceCode.getText();
const sourceLines = sourceText.split("\n");
// Group imports by FSD layer
const groupedImports = layerOrder.reduce((acc, layer) => {
acc[layer] = [];
return acc;
}, {});
// Imports that are NOT part of the FSD structure
const nonFSDImports = [];
// Store the start position of the first import and the end position of the last import
const firstImportStart = importNodes[0].range[0];
const lastImportEnd = importNodes[importNodes.length - 1].range[1];
// Process each import node along with preceding comments and empty lines
for (let i = 0; i < importNodes.length; i++) {
const importNode = importNodes[i];
const importPath = importNode.source.value;
// Get the end position of the previous node (or file start if it's the first node)
const prevNodeEnd = i > 0 ? importNodes[i-1].range[1] : 0;
// Retrieve all text between previous node and current node (including empty lines and comments)
let precedingText = "";
if (i === 0) {
// If it's the first import, get the text from the file start to the import start
const startLine = importNode.loc.start.line;
let relevantStart = 0;
// Identify relevant empty lines and comments before the first import
for (let line = startLine - 1; line >= 1; line--) {
const lineText = sourceLines[line - 1];
// Stop if encountering meaningful code (not an import, comment, or empty line)
if (lineText.trim() !== "" &&
!lineText.trim().startsWith("//") &&
!lineText.trim().startsWith("/*") &&
!lineText.trim().startsWith("*")) {
break;
}
relevantStart = sourceLines.slice(0, line - 1).join("\n").length;
if (line > 1) relevantStart += 1; // Consider newline character
}
if (relevantStart > 0) {
precedingText = sourceText.substring(relevantStart, importNode.range[0]);
}
} else {
// Otherwise, retrieve text between the previous import and the current import
precedingText = sourceText.substring(prevNodeEnd, importNode.range[0]);
}
// Import text
const importText = sourceCode.getText(importNode);
// Combined text (including empty lines, comments, and import statement)
const combinedText = precedingText + importText;
// Classify imports based on FSD layers
const layer = extractLayerFromImportPath(importPath, config);
if (layer && layerOrder.includes(layer)) {
groupedImports[layer].push({ node: importNode, text: combinedText });
} else {
nonFSDImports.push({ node: importNode, text: combinedText });
}
}
// Generate sorted imports by layer
const sortedImports = layerOrder.flatMap(layer => groupedImports[layer]);
// Place non-FSD imports at the top
const finalImportOrder = [...nonFSDImports, ...sortedImports];
// Construct the final sorted import block
let sortedImportText = "";
finalImportOrder.forEach((item, index) => {
if (index === 0) {
// Remove unnecessary leading newlines for the first import block
sortedImportText += item.text.trimStart();
} else {
sortedImportText += item.text;
}
});
// Extract original import block text
const originalText = sourceText.substring(firstImportStart, lastImportEnd);
// Check if a change is needed
if (originalText.trim() !== sortedImportText.trim()) {
context.report({
node: importNodes[0],
messageId: "incorrectGrouping",
data: {
currentImport: importNodes[0].source.value,
},
fix(fixer) {
return fixer.replaceTextRange([firstImportStart, lastImportEnd], sortedImportText);
},
});
}
}
};
}
};