diff --git a/apps/docs/.eslintrc.js b/apps/docs/.eslintrc.js index d5753ae0..33193539 100644 --- a/apps/docs/.eslintrc.js +++ b/apps/docs/.eslintrc.js @@ -13,6 +13,7 @@ module.exports = { // The Eslint rule still needs work, but you can // enable it to test things out. '@stylexjs/valid-styles': 'error', + // '@stylexjs/no-unused': 'warn', // 'ft-flow/space-after-type-colon': 0, // 'ft-flow/no-types-missing-file-annotation': 0, // 'ft-flow/generic-spacing': 0, diff --git a/apps/docs/package.json b/apps/docs/package.json index d1b0ccd4..d02499cf 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@babel/eslint-parser": "^7.25.8", + "@stylexjs/eslint-plugin": "0.8.0", "@stylexjs/babel-plugin": "0.9.3", "clean-css": "^5.3.2", "eslint": "^8.57.1", diff --git a/package-lock.json b/package-lock.json index 5c98b847..a49a0dd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "devDependencies": { "@babel/eslint-parser": "^7.25.8", "@stylexjs/babel-plugin": "0.9.3", + "@stylexjs/eslint-plugin": "0.8.0", "clean-css": "^5.3.2", "eslint": "^8.57.1", "eslint-config-airbnb": "^19.0.4", @@ -140,6 +141,17 @@ "node": ">=6.9.0" } }, + "apps/docs/node_modules/@stylexjs/eslint-plugin": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@stylexjs/eslint-plugin/-/eslint-plugin-0.8.0.tgz", + "integrity": "sha512-1o626c96axO8nWpsY5PYV9CI+12Hr+cD4R4iYGlPRCHqbT0G4lgwBQorrJvb/ChK9Y/u5c7VZq2q2O5/G9C1AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-shorthand-expand": "^1.2.0", + "micromatch": "^4.0.5" + } + }, "apps/docs/node_modules/array.prototype.findlastindex": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", @@ -34940,6 +34952,7 @@ "@docusaurus/preset-classic": "2.4.1", "@mdx-js/react": "^1.6.22", "@stylexjs/babel-plugin": "0.9.3", + "@stylexjs/eslint-plugin": "0.8.0", "@stylexjs/stylex": "0.9.3", "@vercel/analytics": "^1.1.1", "clean-css": "^5.3.2", @@ -34967,6 +34980,16 @@ "regenerator-runtime": "^0.14.0" } }, + "@stylexjs/eslint-plugin": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@stylexjs/eslint-plugin/-/eslint-plugin-0.8.0.tgz", + "integrity": "sha512-1o626c96axO8nWpsY5PYV9CI+12Hr+cD4R4iYGlPRCHqbT0G4lgwBQorrJvb/ChK9Y/u5c7VZq2q2O5/G9C1AA==", + "dev": true, + "requires": { + "css-shorthand-expand": "^1.2.0", + "micromatch": "^4.0.5" + } + }, "array.prototype.findlastindex": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", diff --git a/packages/eslint-plugin/__tests__/stylex-no-unused-test.js b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js new file mode 100644 index 00000000..ea12998c --- /dev/null +++ b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js @@ -0,0 +1,365 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +'use strict'; + +jest.disableAutomock(); + +const { RuleTester: ESLintTester } = require('eslint'); +const rule = require('../src/stylex-no-unused'); + +const eslintTester = new ESLintTester({ + parser: require.resolve('hermes-eslint'), + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, +}); + +eslintTester.run('stylex-no-unused', rule.default, { + valid: [ + { + // all style used; identifier and literal + code: ` + import stylex from 'stylex'; + const styles = stylex.create({ + main: { + borderColor: { + default: 'green', + ':hover': 'red', + '@media (min-width: 1540px)': 1366, + }, + borderRadius: 10, + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + const sizeStyles = stylex.create({ + [8]: { + height: 8, + width: 8, + }, + [10]: { + height: 10, + width: 10, + }, + [12]: { + height: 12, + width: 12, + }, + }); + export default function TestComponent() { + return( +
+
+ ) + } + `, + }, + { + // stylex not default export + code: ` + import stylex from 'stylex'; + const styles = stylex.create({ + main: { + borderColor: { + default: 'green', + ':hover': 'red', + '@media (min-width: 1540px)': 1366, + }, + borderRadius: 10, + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export const sizeStyles = stylex.create({ + [8]: { + height: 8, + width: 8, + }, + [10]: { + height: 10, + width: 10, + }, + [12]: { + height: 12, + width: 12, + }, + }); + export default function TestComponent() { + return( +
+
+ ) + } + `, + }, + { + // indirect usage of style + code: ` + import stylex from 'stylex'; + const styles = stylex.create({ + main: { + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + const sizeStyles = stylex.create({ + [8]: { + height: 8, + width: 8, + }, + [10]: { + height: 10, + width: 10, + }, + [12]: { + height: 12, + width: 12, + }, + }); + const widthStyles = stylex.create({ + widthModeConstrained: { + width: 'auto', + }, + widthModeFlexible: { + width: '100%', + }, + }) + // style used as export + function getWidthStyles() { + return widthStyles; + } + export default function TestComponent({ width: number}) { + // style used as variable + const red = styles.dynamic('red'); + const display = width > 10 ? sizeStyles[12] : sizeStyles[8] + return( +
+
+ ) + } + `, + }, + { + // styles default export + code: ` + import stylex from 'stylex'; + const styles = stylex.create({ + main: { + borderColor: { + default: 'green', + ':hover': 'red', + '@media (min-width: 1540px)': 1366, + }, + borderRadius: 10, + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default styles; + `, + }, + { + // styles named default inline export + code: ` + import stylex from 'stylex'; + export default styles = stylex.create({ + maxDimensionsModal: { + maxWidth: '90%', + maxHeight: '90%', + }, + halfWindowWidth: { + width: '50vw', + }, + }) + `, + }, + { + // styles anonymous default inline export + code: ` + import stylex from 'stylex'; + export default stylex.create({ + maxDimensionsModal: { + maxWidth: '90%', + maxHeight: '90%', + }, + halfWindowWidth: { + width: '50vw', + }, + }) + `, + }, + ], + invalid: [ + { + code: ` + import stylex from 'stylex'; + const styles = stylex.create({ + main: { + borderColor: { + default: 'green', + ':hover': 'red', + '@media (min-width: 1540px)': 1366, + }, + borderRadius: 10, + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + } + `, + output: ` + import stylex from 'stylex'; + const styles = stylex.create({ + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + } + `, + errors: [ + { + message: 'Unused style detected: styles.main', + }, + ], + }, + { + // Import form: import * as stylex from '@stylexjs/stylex'; + code: ` + import * as customStylex from '@stylexjs/stylex'; + const styles = customStylex.create({ + main: { + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + }`, + output: ` + import * as customStylex from '@stylexjs/stylex'; + const styles = customStylex.create({ + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + }`, + errors: [ + { + message: 'Unused style detected: styles.main', + }, + ], + }, + { + // Import form: import {create} from '@stylexjs/stylex'; + code: ` + import {create, attrs} from '@stylexjs/stylex'; + const styles = create({ + main: { + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + }`, + output: ` + import {create, attrs} from '@stylexjs/stylex'; + const styles = create({ + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + }`, + errors: [ + { + message: 'Unused style detected: styles.main', + }, + ], + }, + { + // Import form: import {create as c} from '@stylexjs/stylex'; + code: ` + import {create as c} from '@stylexjs/stylex'; + const styles = c({ + main: { + display: 'flex', + }, + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + }`, + output: ` + import {create as c} from '@stylexjs/stylex'; + const styles = c({ + dynamic: (color) => ({ + backgroundColor: color, + }) + }); + export default function TestComponent() { + return( +
+
+ ) + }`, + errors: [ + { + message: 'Unused style detected: styles.main', + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/src/index.js b/packages/eslint-plugin/src/index.js index 07b55fc2..d96e7182 100644 --- a/packages/eslint-plugin/src/index.js +++ b/packages/eslint-plugin/src/index.js @@ -10,15 +10,18 @@ import validStyles from './stylex-valid-styles'; import sortKeys from './stylex-sort-keys'; import validShorthands from './stylex-valid-shorthands'; +import noUnused from './stylex-no-unused'; const rules: { 'valid-styles': typeof validStyles, 'sort-keys': typeof sortKeys, 'valid-shorthands': typeof validShorthands, + 'no-unused': typeof noUnused, } = { 'valid-styles': validStyles, 'sort-keys': sortKeys, 'valid-shorthands': validShorthands, + 'no-unused': noUnused, }; export { rules }; diff --git a/packages/eslint-plugin/src/stylex-no-unused.js b/packages/eslint-plugin/src/stylex-no-unused.js new file mode 100644 index 00000000..4885ea2a --- /dev/null +++ b/packages/eslint-plugin/src/stylex-no-unused.js @@ -0,0 +1,287 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +/*:: import { Rule } from 'eslint'; */ +import type { + CallExpression, + Expression, + Node, + Program, + Property, + SpreadElement, + RestElement, + MemberExpression, + AssignmentProperty, + ExportDefaultDeclaration, + ExportNamedDeclaration, + ReturnStatement, +} from 'estree'; + +type PropertyValue = + | Property + | SpreadElement + | AssignmentProperty + | RestElement; + +function getPropertiesByName(node: Node | null) { + const properties = new Map(); + if (node == null) { + return properties; + } + node.properties + ?.filter((property) => !property.computed && !property.method) + .forEach((property) => { + const { key } = property; + if (key?.type === 'Identifier') { + properties.set(key.name, property); + } else if (key?.type === 'Literal') { + properties.set(key.value, property); + } + }); + return properties; +} + +const stylexNoUnused = { + meta: { + fixable: 'code', + }, + create(context: Rule.RuleContext): { ... } { + const stylexProperties = new Map>(); + let stylexImportObject = 'stylex'; + let stylexImportProperty = 'create'; + + function isStylexCreate(node: Node) { + return ( + // const styles = s.create({...}) OR const styles = stylex.create({...}) + (stylexImportObject !== '' && + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === stylexImportObject && + node.property.type === 'Identifier' && + node.property.name === stylexImportProperty) || + // const styles = c({...}) OR const styles = create({...}) + (stylexImportObject === '' && + node.type === 'Identifier' && + node.name === stylexImportProperty) + ); + } + + function isStylexDeclaration(node: Node) { + return ( + node && + node.type === 'CallExpression' && + isStylexCreate(node.callee) && + node.arguments.length === 1 && + node.arguments[0].type === 'ObjectExpression' + ); + } + + function saveStylexCalls(node: Node) { + const id = node.id; + const init = node.init; + if (id && id.type === 'Identifier' && init && isStylexDeclaration(init)) { + stylexProperties.set( + id.name, + getPropertiesByName( + init.arguments && init.arguments?.length > 0 + ? init.arguments[0] + : null, + ), + ); + } + } + function checkArguments( + namespaces: Map, + ): (argument: Expression | SpreadElement | null) => void { + return function (argument: Expression | SpreadElement | null): void { + if (argument) { + if (argument.type === 'Literal') { + namespaces.delete(argument.value); + } else if (argument.type === 'ObjectExpression') { + argument.properties.forEach((property) => { + if (property.key) { + namespaces.delete(property.key.name); + } + }); + } else if (argument.type === 'ArrayExpression') { + argument.elements.forEach((element) => { + namespaces.delete(element?.value); + }); + } else if (argument.type === 'ConditionalExpression') { + const { consequent, alternate } = argument; + // check for nested expressions + checkArguments(namespaces)(consequent); + checkArguments(namespaces)(alternate); + } else if ( + argument.type === 'LogicalExpression' && + argument.operator === '&&' + ) { + // check for nested expressions but only on the right + checkArguments(namespaces)(argument.right); + } + } + }; + } + + function parseStylexImportStyle(node: Node) { + // identify stylex import + if ( + node.source?.value === '@stylexjs/stylex' && + // $FlowFixMe[prop-missing] + node.importKind === 'value' && + node.specifiers && + node.specifiers.length > 0 + ) { + // extract stylex import pattern + node.specifiers.forEach((specifier) => { + const specifierType = specifier.type; + if (specifierType === 'ImportNamespaceSpecifier') { + // import * as stylex from '@stylexjs/stylex'; + stylexImportObject = specifier.local.name; + stylexImportProperty = 'create'; + } else if (specifierType === 'ImportDefaultSpecifier') { + if (specifier.local.name === 'stylex') { + // import stylex from '@stylexjs/stylex'; + stylexImportObject = 'stylex'; + stylexImportProperty = 'create'; + } + } else if (specifierType === 'ImportSpecifier') { + if (specifier.imported?.name === 'create') { + // import {create} from '@stylexjs/stylex' OR import {create as c} from '@stylexjs/stylex' + stylexImportObject = ''; + stylexImportProperty = specifier.local.name; + } + } + }); + } + } + + return { + Program(node: Program) { + // detect stylex import style, which then decides which variables are stylex styles + node.body + .map((node) => (node.type === 'ImportDeclaration' ? node : null)) + .filter(Boolean) + .forEach(parseStylexImportStyle); + // stylex.create can only be singular variable declarations at the root + // of the file so we can look directly on Program and populate our set. + node.body + .filter(({ type }) => type === 'VariableDeclaration') + .map(({ declarations }) => + declarations && declarations.length === 1 ? declarations[0] : null, + ) + .filter(Boolean) + .filter(({ init }) => init && isStylexDeclaration(init)) + .forEach(saveStylexCalls); + }, + + // Exempt used styles: "stylex.__" or "styles[__]" + MemberExpression(node: MemberExpression) { + if ( + node.object.type === 'Identifier' && + stylexProperties.has(node.object.name) + ) { + if (node.computed && node.property.type !== 'Literal') { + stylexProperties.delete(node.object.name); + } else if (node.property.type === 'Identifier') { + stylexProperties.get(node.object.name)?.delete(node.property.name); + } else if (node.property.type === 'Literal') { + stylexProperties.get(node.object.name)?.delete(node.property.value); + } + } + }, + // catch function call "functionName(param)" + CallExpression(node: CallExpression) { + const functionName = node.callee?.name; + if (functionName == null || !stylexProperties.has(functionName)) { + return; + } + const namespaces = stylexProperties.get(functionName); + if (namespaces == null) { + return; + } + node.arguments?.forEach(checkArguments(namespaces)); + }, + + // Exempt used styles: export const exportStyles = stylex.create({}); + ExportNamedDeclaration(node: ExportNamedDeclaration) { + const declarations = node.declaration?.declarations; + if (declarations?.length !== 1) { + return; + } + const exportName = declarations[0].id.name; + if (exportName == null || !stylexProperties.has(exportName)) { + return; + } + stylexProperties.delete(exportName); + }, + + // Exempt used styles: export default exportStyles; + ExportDefaultDeclaration(node: ExportDefaultDeclaration) { + const exportName = node.declaration.name; + if (exportName == null || !stylexProperties.has(exportName)) { + return; + } + stylexProperties.delete(exportName); + }, + + // Exempt used styles: used as return + ReturnStatement(node: ReturnStatement) { + if (node.argument?.type === 'Identifier') { + const returnName = node.argument.name; + if (stylexProperties.has(node.argument.name)) { + stylexProperties.delete(returnName); + } + } + }, + + 'Program:exit'() { + // Fallback to legacy `getSourceCode()` for compatibility with older ESLint versions + const sourceCode = + context.sourceCode || + (typeof context.getSourceCode === 'function' + ? context.getSourceCode() + : null); + + stylexProperties.forEach((namespaces, varName) => { + namespaces.forEach((node, namespaceName) => { + context.report({ + node, + message: `Unused style detected: ${varName}.${namespaceName}`, + fix(fixer) { + const commaOffset = + sourceCode.getTokenAfter(node, { + includeComments: false, + })?.value === ',' + ? 1 + : 0; + const left = sourceCode.getTokenBefore(node, { + includeComments: false, + }); + if (node.range == null || left?.range == null) { + return null; + } + return fixer.removeRange([ + left.range[1], + node.range[1] + commaOffset, + ]); + }, + }); + }); + }); + + stylexProperties.clear(); + }, + }; + }, +}; + +export default stylexNoUnused as typeof stylexNoUnused;