UNPKG

eslint-plugin-react-naming-convention

Version:

ESLint React's ESLint plugin for naming convention related rules.

472 lines (463 loc) • 14.3 kB
import * as AST from '@eslint-react/ast'; import { useComponentCollector, useComponentCollectorLegacy, isCreateContextCall, getInstanceId } from '@eslint-react/core'; import { createRuleForPlugin, RE_PASCAL_CASE, RE_CONSTANT_CASE, RE_CAMEL_CASE, RE_KEBAB_CASE, RE_SNAKE_CASE } from '@eslint-react/shared'; import { identity, _, isObject } from '@eslint-react/eff'; import { AST_NODE_TYPES } from '@typescript-eslint/types'; import { match, P } from 'ts-pattern'; import path from 'node:path'; import { snakeCase, pascalCase, camelCase, kebabCase } from 'string-ts'; var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name3 in all) __defProp(target, name3, { get: all[name3], enumerable: true }); }; // src/configs/recommended.ts var recommended_exports = {}; __export(recommended_exports, { name: () => name, rules: () => rules }); var name = "react-naming-convention/recommended"; var rules = { "react-naming-convention/context-name": "warn", "react-naming-convention/use-state": "warn" }; // package.json var name2 = "eslint-plugin-react-naming-convention"; var version = "1.31.0"; var createRule = createRuleForPlugin("naming-convention"); // src/utils/regexp.ts var RE_REGEXP_STR = /^\/(.+)\/([A-Za-z]*)$/u; function toRegExp(string) { const [, pattern, flags = "u"] = RE_REGEXP_STR.exec(string) ?? []; if (pattern) return new RegExp(pattern, flags); return { test: (s) => s === string }; } // src/rules/component-name.ts var defaultOptions = [ { allowAllCaps: false, excepts: [], rule: "PascalCase" } ]; var schema = [ { anyOf: [ { type: "string", enum: ["PascalCase", "CONSTANT_CASE"] }, { type: "object", additionalProperties: false, properties: { allowAllCaps: { type: "boolean" }, /** * @todo Remove in the next major version * @deprecated */ allowLeadingUnderscore: { type: "boolean" }, /** * @todo Remove in the next major version * @deprecated */ allowNamespace: { type: "boolean" }, excepts: { type: "array", items: { type: "string", format: "regex" } }, rule: { type: "string", enum: ["PascalCase", "CONSTANT_CASE"] } } } ] } ]; var RULE_NAME = "component-name"; var component_name_default = createRule({ meta: { type: "problem", defaultOptions: [...defaultOptions], docs: { description: "enforce naming convention for components" }, messages: { invalid: "A component name '{{name}}' does not match {{rule}}." }, schema }, name: RULE_NAME, create(context) { const options = normalizeOptions(context.options); const { rule } = options; const collector = useComponentCollector(context); const collectorLegacy = useComponentCollectorLegacy(); return { ...collector.listeners, ...collectorLegacy.listeners, "Program:exit"(node) { const functionComponents = collector.ctx.getAllComponents(node); const classComponents = collectorLegacy.ctx.getAllComponents(node); for (const { node: component } of functionComponents.values()) { const id = AST.getFunctionIdentifier(component); if (id?.name == null) continue; const name3 = id.name; if (isValidName(name3, options)) return; context.report({ messageId: "invalid", node: id, data: { name: name3, rule } }); } for (const { node: component } of classComponents.values()) { const id = AST.getClassIdentifier(component); if (id?.name == null) continue; const name3 = id.name; if (isValidName(name3, options)) continue; context.report({ messageId: "invalid", node: id, data: { name: name3, rule } }); } } }; }, defaultOptions }); function normalizeOptions(options) { const opts = options[0]; const defaultOpts = defaultOptions[0]; if (opts == null) return defaultOpts; return { ...defaultOpts, ...typeof opts === "string" ? { rule: opts } : { ...opts, excepts: opts.excepts?.map(toRegExp) ?? [] } }; } function isValidName(name3, options) { if (name3 == null) return true; if (options.excepts.some((regex) => regex.test(name3))) return true; const normalized = name3.split(".").at(-1) ?? name3; switch (options.rule) { case "CONSTANT_CASE": return RE_CONSTANT_CASE.test(normalized); case "PascalCase": if (normalized.length > 3 && /^[A-Z]+$/u.test(normalized)) { return options.allowAllCaps; } return RE_PASCAL_CASE.test(normalized); } } var RULE_NAME2 = "context-name"; var context_name_default = createRule({ meta: { type: "problem", docs: { description: "enforce context name to be a valid component name with the suffix 'Context'" }, messages: { invalid: "A context name must be a valid component name with the suffix 'Context'." }, schema: [] }, name: RULE_NAME2, create(context) { if (!context.sourceCode.text.includes("createContext")) return {}; return { CallExpression(node) { if (!isCreateContextCall(context, node)) return; const id = getInstanceId(node); if (id == null) return; const name3 = match(id).with({ type: AST_NODE_TYPES.Identifier, name: P.select() }, identity).with({ type: AST_NODE_TYPES.MemberExpression, property: { name: P.select(P.string) } }, identity).otherwise(() => _); if (name3 != null && /^[A-Z]/u.test(name3) && name3.endsWith("Context")) return; context.report({ messageId: "invalid", node: id }); } }; }, defaultOptions: [] }); var RULE_NAME3 = "filename"; var defaultOptions2 = [ { excepts: ["^index$"], extensions: [".js", ".jsx", ".ts", ".tsx"], rule: "PascalCase" } ]; var schema2 = [ { anyOf: [ { type: "string", enum: ["PascalCase", "camelCase", "kebab-case", "snake_case"] }, { type: "object", additionalProperties: false, properties: { excepts: { type: "array", items: { type: "string", format: "regex" } }, extensions: { type: "array", items: { type: "string" }, uniqueItems: true }, rule: { type: "string", enum: ["PascalCase", "camelCase", "kebab-case", "snake_case"] } } } ] } ]; var filename_default = createRule({ meta: { type: "problem", defaultOptions: [...defaultOptions2], docs: { description: "enforce naming convention for JSX filenames" }, messages: { filenameEmpty: "A file must have non-empty name.", filenameInvalid: "A file with name '{{name}}' does not match {{rule}}. Rename it to '{{suggestion}}'." }, schema: schema2 }, name: RULE_NAME3, create(context) { const options = context.options[0] ?? defaultOptions2[0]; const rule = typeof options === "string" ? options : options.rule ?? "PascalCase"; const excepts = typeof options === "string" ? [] : options.excepts ?? []; function validate(name3, casing = rule, ignores = excepts) { const shouldIgnore = ignores.map(toRegExp).some((pattern) => pattern.test(name3)); if (shouldIgnore) return true; return match(casing).with("PascalCase", () => RE_PASCAL_CASE.test(name3)).with("camelCase", () => RE_CAMEL_CASE.test(name3)).with("kebab-case", () => RE_KEBAB_CASE.test(name3)).with("snake_case", () => RE_SNAKE_CASE.test(name3)).exhaustive(); } function getSuggestion(name3, casing = rule) { return match(casing).with("PascalCase", () => pascalCase(name3)).with("camelCase", () => camelCase(name3)).with("kebab-case", () => kebabCase(name3)).with("snake_case", () => snakeCase(name3)).exhaustive(); } return { Program(node) { const [basename = "", ...rest] = path.basename(context.filename).split("."); if (basename.length === 0) { context.report({ messageId: "filenameEmpty", node }); return; } if (validate(basename)) { return; } context.report({ messageId: "filenameInvalid", node, data: { name: context.filename, rule, suggestion: [getSuggestion(basename), ...rest].join(".") } }); } }; }, defaultOptions: defaultOptions2 }); var RULE_NAME4 = "filename-extension"; var defaultOptions3 = [{ allow: "as-needed", extensions: [".jsx", ".tsx"], ignoreFilesWithoutCode: false }]; var schema3 = [ { anyOf: [ { type: "string", enum: ["always", "as-needed"] }, { type: "object", additionalProperties: false, properties: { allow: { type: "string", enum: ["always", "as-needed"] }, extensions: { type: "array", items: { type: "string" }, uniqueItems: true }, ignoreFilesWithoutCode: { type: "boolean" } } } ] } ]; var filename_extension_default = createRule({ meta: { type: "problem", defaultOptions: [...defaultOptions3], docs: { description: "enforce naming convention for JSX file extensions" }, messages: { useJsxFileExtension: "Use {{extensions}} file extension for JSX files.", useNonJsxFileExtension: "Do not use {{extensions}} file extension for files without JSX." }, schema: schema3 }, name: RULE_NAME4, create(context) { const options = context.options[0] ?? defaultOptions3[0]; const allow = isObject(options) ? options.allow : options; const extensions = isObject(options) && "extensions" in options ? options.extensions : defaultOptions3[0].extensions; const extensionsString = extensions.map((ext) => `'${ext}'`).join(", "); const filename = context.filename; let hasJSXNode = false; return { JSXElement() { hasJSXNode = true; }, JSXFragment() { hasJSXNode = true; }, "Program:exit"(node) { const fileNameExt = filename.slice(filename.lastIndexOf(".")); const isJSXExt = extensions.includes(fileNameExt); if (hasJSXNode && !isJSXExt) { context.report({ messageId: "useJsxFileExtension", node, data: { extensions: extensionsString } }); return; } const hasCode = node.body.length > 0; const ignoreFilesWithoutCode = isObject(options) && options.ignoreFilesWithoutCode === true; if (!hasCode && ignoreFilesWithoutCode) { return; } if (!hasJSXNode && isJSXExt && allow === "as-needed") { context.report({ messageId: "useNonJsxFileExtension", node, data: { extensions: extensionsString } }); } } }; }, defaultOptions: defaultOptions3 }); var RULE_NAME5 = "use-state"; var RULE_FEATURES = [ "CHK" ]; var use_state_default = createRule({ meta: { type: "problem", docs: { description: "enforce destructuring and symmetric naming of 'useState' hook value and setter", [Symbol.for("rule_features")]: RULE_FEATURES }, messages: { invalid: "An useState call is not destructured into value + setter pair." }, schema: [] }, name: RULE_NAME5, create(context) { return { "CallExpression[callee.name='useState']"(node) { if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) { context.report({ messageId: "invalid", node }); } const id = getInstanceId(node); if (id?.type !== AST_NODE_TYPES.ArrayPattern) { context.report({ messageId: "invalid", node }); return; } const [value, setter] = id.elements; if (value == null || setter == null) { context.report({ messageId: "invalid", node }); return; } const setterName = match(setter).with({ type: AST_NODE_TYPES.Identifier }, (id2) => id2.name).otherwise(() => _); if (setterName == null || !setterName.startsWith("set")) { context.report({ messageId: "invalid", node }); return; } const valueName = match(value).with({ type: AST_NODE_TYPES.Identifier }, ({ name: name3 }) => snakeCase(name3)).with({ type: AST_NODE_TYPES.ObjectPattern }, ({ properties }) => { const values = properties.reduce((acc, prop) => { if (prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier) { return [...acc, prop.key.name]; } return acc; }, []); return values.join("_"); }).otherwise(() => _); if (valueName == null || `set_${valueName}` !== snakeCase(setterName)) { context.report({ messageId: "invalid", node }); return; } } }; }, defaultOptions: [] }); // src/plugin.ts var plugin = { meta: { name: name2, version }, rules: { ["component-name"]: component_name_default, ["context-name"]: context_name_default, ["filename"]: filename_default, ["filename-extension"]: filename_extension_default, ["use-state"]: use_state_default } }; // src/index.ts function makeConfig(config) { return { ...config, plugins: { "react-naming-convention": plugin } }; } function makeLegacyConfig({ rules: rules2 }) { return { plugins: ["react-naming-convention"], rules: rules2 }; } var index_default = { ...plugin, configs: { ["recommended"]: makeConfig(recommended_exports), ["recommended-legacy"]: makeLegacyConfig(recommended_exports) } }; export { index_default as default };