UNPKG

eslint-plugin-unicorn

Version:
247 lines (209 loc) 7.52 kB
import {hasSideEffect} from '@eslint-community/eslint-utils'; import {isMemberExpression, isMethodCall} from './ast/index.js'; import { getParenthesizedText, isParenthesized, isSameReference, needsSemicolon, shouldAddParenthesesToMemberExpressionObject, unwrapTypeScriptExpression, } from './utils/index.js'; import {createTypeCheckers} from './utils/type-helpers.js'; const MESSAGE_ID = 'prefer-temporal-conversion'; const MESSAGE_ID_SUGGESTION = 'prefer-temporal-conversion/suggestion'; const messages = { [MESSAGE_ID]: 'Prefer `.{{method}}()` when converting to `Temporal.{{target}}`.', [MESSAGE_ID_SUGGESTION]: 'Replace with `.{{method}}()`.', }; const dateFields = ['year', 'month', 'day']; const timeFields = ['hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond']; const isProperty = (node, property) => isMemberExpression(unwrapTypeScriptExpression(node), {property, computed: false, optional: false}); const isTemporalType = (node, name) => isMemberExpression(node, {object: 'Temporal', property: name, optional: false}); function isTemporalSource(node, name) { if (node.type === 'NewExpression') { return isTemporalType(node.callee, name); } if (isMethodCall(node, { method: 'from', minimumArguments: 1, maximumArguments: 2, optionalCall: false, optionalMember: false, })) { return isTemporalType(node.callee.object, name); } return isMethodCall(node, { method: name === 'ZonedDateTime' ? 'zonedDateTimeISO' : 'plainDateTimeISO', maximumArguments: 1, optionalCall: false, optionalMember: false, }) && isTemporalType(node.callee.object, 'Now'); } const conversions = new Map([ ['Instant', {sources: ['ZonedDateTime'], fields: []}], ['PlainDateTime', {sources: ['ZonedDateTime'], fields: [...dateFields, ...timeFields]}], ['PlainDate', {sources: ['ZonedDateTime', 'PlainDateTime'], fields: dateFields}], ['PlainTime', {sources: ['ZonedDateTime', 'PlainDateTime'], fields: timeFields}], ].map(([name, {sources, fields}]) => { const {isTarget: isSource} = createTypeCheckers({ targetTypeNames: new Set(sources.map(name => `Temporal.${name}`)), checkClassHeritage: false, isTargetNode: node => sources.some(name => isTemporalSource(node, name)), isTargetType(type, checker) { const symbol = type.getSymbol(); if (!symbol) { return false; } const qualifiedName = checker.getFullyQualifiedName(symbol); return sources.some(name => qualifiedName === `Temporal.${name}` || qualifiedName.endsWith(`.Temporal.${name}`)); }, }); return [name, {fields, isSource}]; })); function getFieldEntries(node, fields) { if (node.type === 'NewExpression') { return node.arguments.map((value, index) => [fields[index] ?? 'calendar', value]); } const argument = unwrapTypeScriptExpression(node.arguments[0]); if (argument.type !== 'ObjectExpression') { return; } const entries = []; for (const property of argument.properties) { if (property.type !== 'Property' || property.computed || property.method || property.kind !== 'init') { return; } entries.push([property.key.name ?? property.key.value, property.value]); } return entries; } function getFieldReconstruction(node, fields, context) { const entries = getFieldEntries(node, fields); if (!entries) { return; } const hasDate = fields.includes('year'); const isConstructor = node.type === 'NewExpression'; const hasCalendar = entries.some(([name]) => name === 'calendar'); const remainingFields = new Set(fields); if (hasDate && hasCalendar) { remainingFields.add('calendar'); } let source; for (const [name, value] of entries) { if ( !remainingFields.delete(name === 'monthCode' ? 'month' : name) || !isProperty(value, name === 'calendar' ? 'calendarId' : name) ) { return; } const receiver = unwrapTypeScriptExpression(value).object; if (hasSideEffect(receiver, context.sourceCode) || (source && !isSameReference(source, receiver))) { return; } source ??= receiver; } if (remainingFields.size > 0) { return; } return {source, canAutofix: !hasDate || (!isConstructor && hasCalendar)}; } function getConversionMatch(node, target, fields, context) { const [argument] = node.arguments; const unwrappedArgument = unwrapTypeScriptExpression(argument); const isFromCall = node.type === 'CallExpression' && node.callee.property.name === 'from'; if (isFromCall && isMethodCall(unwrappedArgument, { methods: ['toString', 'toJSON'], argumentsLength: 0, optionalCall: false, optionalMember: false, })) { return {source: unwrappedArgument.callee.object, canAutofix: target !== 'Instant'}; } if (isFromCall && unwrappedArgument.type !== 'ObjectExpression') { return {source: argument, canAutofix: true}; } if (target !== 'Instant') { return getFieldReconstruction(node, fields, context); } const isMilliseconds = node.type === 'CallExpression' && node.callee.property.name === 'fromEpochMilliseconds'; if (!isProperty(argument, isMilliseconds ? 'epochMilliseconds' : 'epochNanoseconds')) { return; } return {source: unwrapTypeScriptExpression(argument).object, canAutofix: !isMilliseconds}; } /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { const {sourceCode} = context; const getProblem = node => { let constructor; if (node.type === 'NewExpression') { constructor = node.callee; } else if (isMethodCall(node, { methods: ['from', 'fromEpochMilliseconds', 'fromEpochNanoseconds'], argumentsLength: 1, optionalCall: false, optionalMember: false, })) { constructor = node.callee.object; } else { return; } if (!isMemberExpression(constructor, {object: 'Temporal', computed: false, optional: false})) { return; } const target = constructor.property.name; const conversion = conversions.get(target); if ( !conversion || node.arguments.length === 0 || node.arguments.some(argument => argument.type === 'SpreadElement') || (target === 'Instant' && node.arguments.length !== 1) || (target !== 'Instant' && node.type === 'CallExpression' && node.callee.property.name !== 'from') ) { return; } const conversionMatch = getConversionMatch(node, target, conversion.fields, context); if (!conversionMatch || !conversion.isSource(conversionMatch.source, context)) { return; } const {source, canAutofix} = conversionMatch; const method = `to${target}`; const problem = {node, messageId: MESSAGE_ID, data: {method, target}}; if (sourceCode.getCommentsInside(node).length > 0) { return problem; } const fix = fixer => { let text = getParenthesizedText(source, context); if (shouldAddParenthesesToMemberExpressionObject(source, context) && !isParenthesized(source, context)) { text = `(${text})`; } text += `.${method}()`; if (needsSemicolon(sourceCode.getTokenBefore(node), context, text)) { text = `;${text}`; } return fixer.replaceText(node, text); }; if (canAutofix) { problem.fix = fix; } else { problem.suggest = [{messageId: MESSAGE_ID_SUGGESTION, data: {method}, fix}]; } return problem; }; context.on('CallExpression', getProblem); context.on('NewExpression', getProblem); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer direct Temporal conversion methods.', recommended: 'unopinionated', }, fixable: 'code', hasSuggestions: true, schema: [], messages, languages: [ 'js/js', ], }, }; export default config;