From b52ad6466055ee6ed8067b7d02249a2aa4f8e729 Mon Sep 17 00:00:00 2001 From: Samantha Zhan Date: Wed, 6 Nov 2024 14:22:59 -0800 Subject: [PATCH 1/4] feat: add no-unused eslint rule --- apps/docs/.eslintrc.js | 1 + apps/docs/package.json | 1 + package-lock.json | 1 + package.json | 1 + .../__tests__/stylex-no-unused-test.js | 148 +++++++++++++ packages/eslint-plugin/src/index.js | 3 + .../eslint-plugin/src/stylex-no-unused.js | 200 ++++++++++++++++++ 7 files changed, 355 insertions(+) create mode 100644 packages/eslint-plugin/__tests__/stylex-no-unused-test.js create mode 100644 packages/eslint-plugin/src/stylex-no-unused.js diff --git a/apps/docs/.eslintrc.js b/apps/docs/.eslintrc.js index d5753ae0..e9a9eb16 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..e4924197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", + "@stylexjs/eslint-plugin": "^0.8.0", "@types/estree": "^1.0.6", "@types/jest": "^29.5.13", "babel-plugin-syntax-hermes-parser": "^0.25.0", diff --git a/package.json b/package.json index 572ecdd7..4412f6a2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", + "@stylexjs/eslint-plugin": "^0.8.0", "@types/estree": "^1.0.6", "@types/jest": "^29.5.13", "babel-plugin-syntax-hermes-parser": "^0.25.0", 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..edd94258 --- /dev/null +++ b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js @@ -0,0 +1,148 @@ +/** + * 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( +
+
+ ) + } + `, + }, + { // 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 anonymous default 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', + }, + ], + }, + ], +}); 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..033fdf41 --- /dev/null +++ b/packages/eslint-plugin/src/stylex-no-unused.js @@ -0,0 +1,200 @@ +/** + * 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 +} from 'estree'; + +type PropertyValue = Property | SpreadElement | AssignmentProperty | RestElement; +function isStylexCallee(node: Node) { + return ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'stylex' && + node.property.type === 'Identifier' && + node.property.name === 'create' + ); +} + +function isStylexDeclaration(node: Node) { + return ( + node && + node.type === 'CallExpression' && + isStylexCallee(node.callee) && + node.arguments.length === 1 && + node.arguments[0].type === 'ObjectExpression' + ); +} + +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>(); + + 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); + } + } + }; + } + + + return { + Program(node: Program) { + // 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); + }, + + // detect stylex usage "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)); + }, + + ExportDefaultDeclaration(node: ExportDefaultDeclaration) { + const exportName = node.declaration.name; + if (exportName == null || !stylexProperties.has(exportName)) { + return; + } + stylexProperties.delete(exportName); + }, + + '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; \ No newline at end of file From 3de1e30df3de330d8173eaf730ddd808e9b8f873 Mon Sep 17 00:00:00 2001 From: Samantha Zhan Date: Wed, 6 Nov 2024 14:44:16 -0800 Subject: [PATCH 2/4] fix prettier fix --- .../__tests__/stylex-no-unused-test.js | 18 +++--- .../eslint-plugin/src/stylex-no-unused.js | 60 ++++++++++++------- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/packages/eslint-plugin/__tests__/stylex-no-unused-test.js b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js index edd94258..1c8bb8d5 100644 --- a/packages/eslint-plugin/__tests__/stylex-no-unused-test.js +++ b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js @@ -24,8 +24,9 @@ const eslintTester = new ESLintTester({ eslintTester.run('stylex-no-unused', rule.default, { valid: [ - { // all style used; identifier and literal - code: ` + { + // all style used; identifier and literal + code: ` import stylex from 'stylex'; const styles = stylex.create({ main: { @@ -62,8 +63,9 @@ eslintTester.run('stylex-no-unused', rule.default, { ) } `, - }, - { // styles default export + }, + { + // styles default export code: ` import stylex from 'stylex'; const styles = stylex.create({ @@ -83,7 +85,8 @@ eslintTester.run('stylex-no-unused', rule.default, { export default styles; `, }, - { // styles anonymous default export + { + // styles anonymous default export code: ` import stylex from 'stylex'; export default stylex.create({ @@ -96,7 +99,7 @@ eslintTester.run('stylex-no-unused', rule.default, { }, }) `, - } + }, ], invalid: [ { @@ -139,8 +142,7 @@ eslintTester.run('stylex-no-unused', rule.default, { `, errors: [ { - message: - 'Unused style detected: styles.main', + message: 'Unused style detected: styles.main', }, ], }, diff --git a/packages/eslint-plugin/src/stylex-no-unused.js b/packages/eslint-plugin/src/stylex-no-unused.js index 033fdf41..e983d99c 100644 --- a/packages/eslint-plugin/src/stylex-no-unused.js +++ b/packages/eslint-plugin/src/stylex-no-unused.js @@ -20,10 +20,14 @@ import type { RestElement, MemberExpression, AssignmentProperty, - ExportDefaultDeclaration + ExportDefaultDeclaration, } from 'estree'; -type PropertyValue = Property | SpreadElement | AssignmentProperty | RestElement; +type PropertyValue = + | Property + | SpreadElement + | AssignmentProperty + | RestElement; function isStylexCallee(node: Node) { return ( node.type === 'MemberExpression' && @@ -46,13 +50,13 @@ function isStylexDeclaration(node: Node) { function getPropertiesByName(node: Node | null) { const properties = new Map(); - if(node == null) { + if (node == null) { return properties; } node.properties - ?.filter(property => !property.computed && !property.method) - .forEach(property => { - const {key} = property; + ?.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') { @@ -75,27 +79,33 @@ const stylexNoUnused = { if (id && id.type === 'Identifier' && init && isStylexDeclaration(init)) { stylexProperties.set( id.name, - getPropertiesByName((init.arguments && init.arguments?.length > 0) ? init.arguments[0] : null), + getPropertiesByName( + init.arguments && init.arguments?.length > 0 + ? init.arguments[0] + : null, + ), ); } } - function checkArguments(namespaces: Map): (argument: Expression | SpreadElement | null) => void { + 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 => { + argument.properties.forEach((property) => { if (property.key) { namespaces.delete(property.key.name); } }); } else if (argument.type === 'ArrayExpression') { - argument.elements.forEach(element => { + argument.elements.forEach((element) => { namespaces.delete(element?.value); }); } else if (argument.type === 'ConditionalExpression') { - const {consequent, alternate} = argument; + const { consequent, alternate } = argument; // check for nested expressions checkArguments(namespaces)(consequent); checkArguments(namespaces)(alternate); @@ -110,16 +120,17 @@ const stylexNoUnused = { }; } - return { Program(node: Program) { // 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(({ type }) => type === 'VariableDeclaration') + .map(({ declarations }) => + declarations && declarations.length === 1 ? declarations[0] : null, + ) .filter(Boolean) - .filter(({init}) => init && isStylexDeclaration(init)) + .filter(({ init }) => init && isStylexDeclaration(init)) .forEach(saveStylexCalls); }, @@ -145,7 +156,7 @@ const stylexNoUnused = { return; } const namespaces = stylexProperties.get(functionName); - if(namespaces == null) { + if (namespaces == null) { return; } node.arguments?.forEach(checkArguments(namespaces)); @@ -161,10 +172,11 @@ const stylexNoUnused = { 'Program:exit'() { // Fallback to legacy `getSourceCode()` for compatibility with older ESLint versions - const sourceCode = context.sourceCode || - (typeof context.getSourceCode === 'function' - ? context.getSourceCode() - : null); + const sourceCode = + context.sourceCode || + (typeof context.getSourceCode === 'function' + ? context.getSourceCode() + : null); stylexProperties.forEach((namespaces, varName) => { namespaces.forEach((node, namespaceName) => { @@ -175,11 +187,13 @@ const stylexNoUnused = { const commaOffset = sourceCode.getTokenAfter(node, { includeComments: false, - })?.value === ',' ? 1 : 0; + })?.value === ',' + ? 1 + : 0; const left = sourceCode.getTokenBefore(node, { includeComments: false, }); - if(node.range == null || left?.range == null) { + if (node.range == null || left?.range == null) { return null; } return fixer.removeRange([ @@ -197,4 +211,4 @@ const stylexNoUnused = { }, }; -export default stylexNoUnused as typeof stylexNoUnused; \ No newline at end of file +export default stylexNoUnused as typeof stylexNoUnused; From ea6aef4e70871e49c2cd1ac5800d9218a6a124b5 Mon Sep 17 00:00:00 2001 From: Samantha Zhan Date: Thu, 7 Nov 2024 14:31:58 -0800 Subject: [PATCH 3/4] Addressed comments; added tests: named exports, indirect style invoke; added features: non-default export, used style as return, full import pattern support --- apps/docs/.eslintrc.js | 2 +- .../__tests__/stylex-no-unused-test.js | 223 +++++++++++++++++- .../eslint-plugin/src/stylex-no-unused.js | 113 +++++++-- 3 files changed, 313 insertions(+), 25 deletions(-) diff --git a/apps/docs/.eslintrc.js b/apps/docs/.eslintrc.js index e9a9eb16..33193539 100644 --- a/apps/docs/.eslintrc.js +++ b/apps/docs/.eslintrc.js @@ -13,7 +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', + // '@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/packages/eslint-plugin/__tests__/stylex-no-unused-test.js b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js index 1c8bb8d5..ea12998c 100644 --- a/packages/eslint-plugin/__tests__/stylex-no-unused-test.js +++ b/packages/eslint-plugin/__tests__/stylex-no-unused-test.js @@ -58,7 +58,96 @@ eslintTester.run('stylex-no-unused', rule.default, { }); 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( +
) } @@ -86,7 +175,22 @@ eslintTester.run('stylex-no-unused', rule.default, { `, }, { - // styles anonymous default export + // 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({ @@ -121,7 +225,7 @@ eslintTester.run('stylex-no-unused', rule.default, { }); export default function TestComponent() { return( -
+
) } @@ -135,7 +239,7 @@ eslintTester.run('stylex-no-unused', rule.default, { }); export default function TestComponent() { return( -
+
) } @@ -146,5 +250,116 @@ eslintTester.run('stylex-no-unused', rule.default, { }, ], }, + { + // 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/stylex-no-unused.js b/packages/eslint-plugin/src/stylex-no-unused.js index e983d99c..4885ea2a 100644 --- a/packages/eslint-plugin/src/stylex-no-unused.js +++ b/packages/eslint-plugin/src/stylex-no-unused.js @@ -21,6 +21,8 @@ import type { MemberExpression, AssignmentProperty, ExportDefaultDeclaration, + ExportNamedDeclaration, + ReturnStatement, } from 'estree'; type PropertyValue = @@ -28,25 +30,6 @@ type PropertyValue = | SpreadElement | AssignmentProperty | RestElement; -function isStylexCallee(node: Node) { - return ( - node.type === 'MemberExpression' && - node.object.type === 'Identifier' && - node.object.name === 'stylex' && - node.property.type === 'Identifier' && - node.property.name === 'create' - ); -} - -function isStylexDeclaration(node: Node) { - return ( - node && - node.type === 'CallExpression' && - isStylexCallee(node.callee) && - node.arguments.length === 1 && - node.arguments[0].type === 'ObjectExpression' - ); -} function getPropertiesByName(node: Node | null) { const properties = new Map(); @@ -72,6 +55,34 @@ const stylexNoUnused = { }, 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; @@ -120,8 +131,46 @@ const stylexNoUnused = { }; } + 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 @@ -134,7 +183,7 @@ const stylexNoUnused = { .forEach(saveStylexCalls); }, - // detect stylex usage "stylex.__" or "styles[__]" + // Exempt used styles: "stylex.__" or "styles[__]" MemberExpression(node: MemberExpression) { if ( node.object.type === 'Identifier' && @@ -162,6 +211,20 @@ const stylexNoUnused = { 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)) { @@ -170,6 +233,16 @@ const stylexNoUnused = { 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 = From cd1152183f9d449f9d6e1131fd365418a361d0b5 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Mon, 11 Nov 2024 11:57:21 -0800 Subject: [PATCH 4/4] remove extra dep --- package-lock.json | 24 +++++++++++++++++++++++- package.json | 1 - 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e4924197..a49a0dd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", - "@stylexjs/eslint-plugin": "^0.8.0", "@types/estree": "^1.0.6", "@types/jest": "^29.5.13", "babel-plugin-syntax-hermes-parser": "^0.25.0", @@ -117,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", @@ -141,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", @@ -34941,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", @@ -34968,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/package.json b/package.json index 4412f6a2..572ecdd7 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", - "@stylexjs/eslint-plugin": "^0.8.0", "@types/estree": "^1.0.6", "@types/jest": "^29.5.13", "babel-plugin-syntax-hermes-parser": "^0.25.0",