UNPKG

eslint-plugin-preact-signals

Version:

TypeScript/ESLint plugin for validating use of @preact/signals

145 lines (138 loc) 5.56 kB
'use strict'; var node_fs = require('node:fs'); var utils = require('@typescript-eslint/utils'); var typescript = require('typescript'); var node_path = require('node:path'); const createRule = utils.ESLintUtils.RuleCreator((name) => `https://github.com/bensaufley/eslint-plugin-preact-signals/tree/main/docs/${name}.md`); const isBooleanCoercion = (node) => { if (!node.parent) return false; switch (node.parent.type) { case utils.TSESTree.AST_NODE_TYPES.UnaryExpression: return node.parent.operator === '!'; case utils.TSESTree.AST_NODE_TYPES.IfStatement: return containsNode(node.parent.test, node); case utils.TSESTree.AST_NODE_TYPES.ConditionalExpression: return containsNode(node.parent.test, node); case utils.TSESTree.AST_NODE_TYPES.LogicalExpression: if (containsNode(node.parent, node)) { return node.parent.operator === '??' ? 'nullishCoalesce' : true; } return false; default: return false; } }; const containsNode = (parent, node) => { if (parent === node) return true; if ('left' in parent && containsNode(parent.left, node)) return true; if ('right' in parent && containsNode(parent.right, node)) return true; return false; }; const getTypes = (type) => (type.isUnionOrIntersection() ? type.types.flatMap(getTypes) : [type]); const typeIsSignal = (type) => { if (type === undefined) return undefined; const types = getTypes(type); const symbolTypeIndex = types.findIndex((t) => ['Signal', 'ReadonlySignal'].includes(t.symbol?.getName())); if (symbolTypeIndex === -1) return undefined; return [types[symbolTypeIndex], ...types.toSpliced(symbolTypeIndex)]; }; const rule = createRule({ create(context) { const services = utils.ESLintUtils.getParserServices(context); return { Identifier(node) { const typeChecker = services.program.getTypeChecker(); const tsNode = services.esTreeNodeToTSNodeMap.get(node); const type = typeChecker.getTypeAtLocation(tsNode); const types = typeIsSignal(type); if (!types) return; const [signalType, ...otherTypes] = types; const boolCoercion = isBooleanCoercion(node); if (!boolCoercion) return; const isNullable = otherTypes.some((t) => t.flags & typescript.TypeFlags.Null || t.flags & typescript.TypeFlags.Undefined); // Allow nullish coalescing with null or undefined if (boolCoercion === 'nullishCoalesce') { if (context.options[0]?.allowNullishCoalesce === 'always') return; if (context.options[0]?.allowNullishCoalesce === 'nullish' && isNullable) return; } const fromPreactPackages = signalType.symbol.getDeclarations()?.some((declaration) => { const importPath = declaration.getSourceFile().fileName; return importPath.includes('/@preact/signals'); }); // Includes signals-core, signals, signals-react if (!fromPreactPackages) return; if (boolCoercion === 'nullishCoalesce') { context.report({ node, messageId: 'implicitNullishCheck', data: { name: node.name }, }); return; } context.report({ node, messageId: 'implicitBooleanSignal', }); }, }; }, meta: { schema: [ { type: 'object', properties: { allowNullishCoalesce: { oneOf: [ { type: 'string', enum: ['always', 'nullish'], }, { type: 'boolean', enum: [false], }, ], default: 'nullish', }, }, }, ], type: 'problem', messages: { implicitBooleanSignal: 'Signal is implicitly converted to a boolean, which will always be true.', implicitNullishCheck: 'Signal is implicitly checked for nullishness. Because this may be confused with a boolean check, consider using ${name} === null instead.', }, docs: { description: 'Disallow implicit conversion of Signals to boolean', recommended: 'error', requiresTypeChecking: true, }, }, name: 'no-implicit-boolean-signal', defaultOptions: [ { allowNullishCoalesce: 'nullish', }, ], }); const rules = { 'no-implicit-boolean-signal': rule, }; const packagePath = typeof require === 'undefined' ? node_path.resolve(undefined, '../package.json') : require.resolve('../package.json'); const { name, version } = JSON.parse(node_fs.readFileSync(packagePath, 'utf8')); const plugin = { meta: { name, version }, rules, }; module.exports = plugin;