import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; import { isIdentifier } from '@typescript-eslint/utils/ast-utils'; import { RuleFix, RuleFixer, SourceCode, } from '@typescript-eslint/utils/ts-eslint'; import postcss from 'postcss'; // NOTE: The rule will be available in ESLint configs as "@nx/workspace-sort-css-properties-alphabetically" export const RULE_NAME = 'sort-css-properties-alphabetically'; interface Loc { start: { line: number; column: number; }; end: { line: number; column: number; }; } const isMemberExpression = ( node: TSESTree.Node, ): node is TSESTree.MemberExpression => node.type === TSESTree.AST_NODE_TYPES.MemberExpression; const isCallExpression = ( node: TSESTree.Node, ): node is TSESTree.CallExpression => node.type === TSESTree.AST_NODE_TYPES.CallExpression; const isStyledTagname = (node: TSESTree.TaggedTemplateExpression): boolean => { if (isIdentifier(node.tag)) { return node.tag.name === 'css'; } if (isMemberExpression(node.tag) && isIdentifier(node.tag.object)) { return node.tag.object.name === 'styled'; } if (isCallExpression(node.tag) && isIdentifier(node.tag.callee)) { return node.tag.callee.name === 'styled'; } if ( isCallExpression(node.tag) && isMemberExpression(node.tag.callee) && isIdentifier(node.tag.callee.object) ) { return node.tag.callee.object.name === 'styled'; } if ( isCallExpression(node.tag) && isMemberExpression(node.tag.callee) && isMemberExpression(node.tag.callee.object) && isIdentifier(node.tag.callee.object.object) ) { return node.tag.callee.object.object.name === 'styled'; } return false; }; /** * An atomic rule is a rule without nested rules. */ const isValidAtomicRule = ( rule: postcss.Rule, ): { isValid: boolean; loc?: Loc } => { const decls = rule.nodes.filter( (node) => node.type === 'decl', ) as unknown as postcss.Declaration[]; const invalidDeclIndex = decls.findIndex((decl, index) => { if (index === 0) return false; const current = decl.prop; const prev = decls[index - 1].prop; return current < prev; }); return invalidDeclIndex > 0 ? { isValid: false, loc: { start: { line: decls[invalidDeclIndex - 1].source!.start!.line, column: decls[invalidDeclIndex - 1].source!.start!.column - 1, }, end: { line: decls[invalidDeclIndex].source!.end!.line, column: decls[invalidDeclIndex].source!.end!.column - 1, }, }, } : { isValid: true }; }; const isValidRule = (rule: postcss.Rule): { isValid: boolean; loc?: Loc } => { // check each rule recursively const { isValid, loc } = rule.nodes.reduce<{ isValid: boolean; loc?: Loc }>( (map, node) => { return node.type === 'rule' ? isValidRule(node) : map; }, { isValid: true }, ); // if there is any invalid rule, return result if (!isValid) { return { isValid, loc }; } // check declarations return isValidAtomicRule(rule); }; const getNodeStyles = (node: TSESTree.TaggedTemplateExpression): string => { const [firstQuasi, ...quasis] = node.quasi.quasis; // remove line break added to the first quasi const lineBreakCount = node.quasi.loc.start.line - 1; let styles = `${'\n'.repeat(lineBreakCount)}${' '.repeat( node.quasi.loc.start.column + 1, )}${firstQuasi.value.raw}`; // replace expression by spaces and line breaks quasis.forEach(({ value, loc }, idx) => { const prevLoc = idx === 0 ? firstQuasi.loc : quasis[idx - 1].loc; const lineBreaksCount = loc.start.line - prevLoc.end.line; const spacesCount = loc.start.line === prevLoc.end.line ? loc.start.column - prevLoc.end.column + 2 : loc.start.column + 1; styles = `${styles}${' '}${'\n'.repeat(lineBreaksCount)}${' '.repeat( spacesCount, )}${value.raw}`; }); return styles; }; const fix = ({ rule, fixer, src, }: { rule: postcss.Rule; fixer: RuleFixer; src: SourceCode; }): RuleFix[] => { // concat fixings recursively const fixings = rule.nodes .filter((node): node is postcss.Rule => node.type === 'rule') .flatMap((node) => fix({ rule: node, fixer, src })); const declarations = rule.nodes.filter( (node): node is postcss.Declaration => node.type === 'decl', ); const sortedDeclarations = sortDeclarations(declarations); return [ ...fixings, ...declarations.flatMap((decl, index) => { if (!areSameDeclarations(decl, sortedDeclarations[index])) { try { const range = getDeclRange({ decl, src }); const sortedDeclText = getDeclText({ decl: sortedDeclarations[index], src, }); return [ fixer.removeRange([range.startIdx, range.endIdx + 1]), fixer.insertTextAfterRange( [range.startIdx, range.startIdx], sortedDeclText, ), ]; } catch (e) { console.log(e); } } }), ]; }; const areSameDeclarations = ( a: postcss.ChildNode, b: postcss.ChildNode, ): boolean => a.source!.start!.line === b.source!.start!.line && a.source!.start!.column === b.source!.start!.column; const getDeclRange = ({ decl, src, }: { decl: postcss.ChildNode; src: SourceCode; }): { startIdx: number; endIdx: number } => { const loc = { start: { line: decl.source!.start!.line, column: decl.source!.start!.column - 1, }, end: { line: decl.source!.end!.line, column: decl.source!.end!.column - 1, }, }; const startIdx = src.getIndexFromLoc(loc.start); const endIdx = src.getIndexFromLoc(loc.end); return { startIdx, endIdx }; }; const getDeclText = ({ decl, src, }: { decl: postcss.ChildNode; src: SourceCode; }) => { const { startIdx, endIdx } = getDeclRange({ decl, src }); return src.getText().substring(startIdx, endIdx + 1); }; const sortDeclarations = (declarations: postcss.Declaration[]) => declarations .slice() .sort((declA, declB) => (declA.prop > declB.prop ? 1 : -1)); export const rule = ESLintUtils.RuleCreator(() => __filename)({ name: RULE_NAME, meta: { docs: { description: 'Styles are sorted alphabetically.', recommended: 'recommended', }, messages: { sortCssPropertiesAlphabetically: 'Declarations should be sorted alphabetically.', }, type: 'suggestion', schema: [], fixable: 'code', }, defaultOptions: [], create: (context) => { return { TaggedTemplateExpression: (node) => { if (!isStyledTagname(node)) return; try { const root = postcss.parse( getNodeStyles(node), ) as unknown as postcss.Rule; const { isValid } = isValidRule(root); if (!isValid) { return context.report({ node, messageId: 'sortCssPropertiesAlphabetically', fix: (fixer) => fix({ rule: root, fixer, src: context.sourceCode, }), }); } } catch (e) { return true; } }, }; }, });