diff --git a/eslint/babel-eslint-plugin/README.md b/eslint/babel-eslint-plugin/README.md index 7d1aebcbbd..fba35aa4dc 100644 --- a/eslint/babel-eslint-plugin/README.md +++ b/eslint/babel-eslint-plugin/README.md @@ -29,6 +29,7 @@ original ones as well!). { "rules": { "babel/new-cap": 1, + "babel/camelcase": 1, "babel/no-invalid-this": 1, "babel/object-curly-spacing": 1, "babel/quotes": 1, @@ -45,6 +46,7 @@ Each rule corresponds to a core `eslint` rule, and has the same options. 🛠: means it's autofixable with `--fix`. - `babel/new-cap`: Ignores capitalized decorators (`@Decorator`) +- `babel/camelcase: doesn't complain about optional chaining (`var foo = bar?.a_b;`) - `babel/no-invalid-this`: doesn't fail when inside class properties (`class A { a = this.b; }`) - `babel/object-curly-spacing`: doesn't complain about `export x from "mod";` or `export * as x from "mod";` (🛠) - `babel/quotes`: doesn't complain about JSX fragment shorthand syntax (`<>foo;`) diff --git a/eslint/babel-eslint-plugin/index.js b/eslint/babel-eslint-plugin/index.js index 5ef9660ce4..6cd30daefb 100644 --- a/eslint/babel-eslint-plugin/index.js +++ b/eslint/babel-eslint-plugin/index.js @@ -8,6 +8,7 @@ module.exports = { 'func-params-comma-dangle': require('./rules/func-params-comma-dangle'), 'generator-star-spacing': require('./rules/generator-star-spacing'), 'new-cap': require('./rules/new-cap'), + 'camelcase': require('./rules/camelcase'), 'no-await-in-loop': require('./rules/no-await-in-loop'), 'no-invalid-this': require('./rules/no-invalid-this'), 'no-unused-expressions': require('./rules/no-unused-expressions'), @@ -20,6 +21,7 @@ module.exports = { rulesConfig: { 'array-bracket-spacing': 0, 'arrow-parens': 0, + 'camelcase': 0, 'flow-object-type': 0, 'func-params-comma-dangle': 0, 'generator-star-spacing': 0, @@ -28,7 +30,7 @@ module.exports = { 'no-invalid-this': 0, 'no-unused-expressions': 0, 'object-curly-spacing': 0, - 'object-shorthand': 0, + 'object-shorthand': 0, 'quotes': 0, 'semi': 0, 'valid-typeof': 0, diff --git a/eslint/babel-eslint-plugin/rules/camelcase.js b/eslint/babel-eslint-plugin/rules/camelcase.js new file mode 100644 index 0000000000..65e8164cd7 --- /dev/null +++ b/eslint/babel-eslint-plugin/rules/camelcase.js @@ -0,0 +1,194 @@ +/** + * @fileoverview Rule to flag non-camelcased identifiers + * @author Nicholas C. Zakas + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +module.exports = { + meta: { + docs: { + description: "enforce camelcase naming convention", + category: "Stylistic Issues", + recommended: false, + url: "https://eslint.org/docs/rules/camelcase" + }, + + schema: [ + { + type: "object", + properties: { + ignoreDestructuring: { + type: "boolean" + }, + properties: { + enum: ["always", "never"] + } + }, + additionalProperties: false + } + ], + + messages: { + notCamelCase: "Identifier '{{name}}' is not in camel case." + } + }, + + create(context) { + + //-------------------------------------------------------------------------- + // Helpers + //-------------------------------------------------------------------------- + + // contains reported nodes to avoid reporting twice on destructuring with shorthand notation + const reported = []; + const ALLOWED_PARENT_TYPES = new Set(["CallExpression", "NewExpression"]); + const MEMBER_EXPRESSIONS = ["MemberExpression", "OptionalMemberExpression"]; + + /** + * Checks if expression is supported member expression. + * + * @param {string} expression - An expression to check. + * @returns {boolean} `true` if the expression type is supported + */ + function isMemberExpression(expression) { + return MEMBER_EXPRESSIONS.indexOf(expression) >= 0; + } + + /** + * Checks if a string contains an underscore and isn't all upper-case + * @param {string} name The string to check. + * @returns {boolean} if the string is underscored + * @private + */ + function isUnderscored(name) { + + // if there's an underscore, it might be A_CONSTANT, which is okay + return name.indexOf("_") > -1 && name !== name.toUpperCase(); + } + + /** + * Checks if a parent of a node is an ObjectPattern. + * @param {ASTNode} node The node to check. + * @returns {boolean} if the node is inside an ObjectPattern + * @private + */ + function isInsideObjectPattern(node) { + let { parent } = node; + + while (parent) { + if (parent.type === "ObjectPattern") { + return true; + } + + parent = parent.parent; + } + + return false; + } + + /** + * Reports an AST node as a rule violation. + * @param {ASTNode} node The node to report. + * @returns {void} + * @private + */ + function report(node) { + if (reported.indexOf(node.parent) < 0) { + reported.push(node.parent); + context.report({ node, messageId: "notCamelCase", data: { name: node.name } }); + } + } + + const options = context.options[0] || {}; + let properties = options.properties || ""; + const ignoreDestructuring = options.ignoreDestructuring || false; + + if (properties !== "always" && properties !== "never") { + properties = "always"; + } + + return { + + Identifier(node) { + + /* + * Leading and trailing underscores are commonly used to flag + * private/protected identifiers, strip them + */ + const name = node.name.replace(/^_+|_+$/g, ""), + effectiveParent = isMemberExpression(node.parent.type) ? node.parent.parent : node.parent; + + // MemberExpressions get special rules + if (isMemberExpression(node.parent.type)) { + + // "never" check properties + if (properties === "never") { + return; + } + + // Always report underscored object names + if (node.parent.object.type === "Identifier" && node.parent.object.name === node.name && isUnderscored(name)) { + report(node); + + // Report AssignmentExpressions only if they are the left side of the assignment + } else if (effectiveParent.type === "AssignmentExpression" && isUnderscored(name) && (!isMemberExpression(effectiveParent.right.type) || isMemberExpression(effectiveParent.left.type) && effectiveParent.left.property.name === node.name)) { + report(node); + } + + /* + * Properties have their own rules, and + * AssignmentPattern nodes can be treated like Properties: + * e.g.: const { no_camelcased = false } = bar; + */ + } else if (node.parent.type === "Property" || node.parent.type === "AssignmentPattern") { + + if (node.parent.parent && node.parent.parent.type === "ObjectPattern") { + + const assignmentKeyEqualsValue = node.parent.key.name === node.parent.value.name; + + // prevent checking righthand side of destructured object + if (node.parent.key === node && node.parent.value !== node) { + return; + } + + const valueIsUnderscored = node.parent.value.name && isUnderscored(name); + + // ignore destructuring if the option is set, unless a new identifier is created + if (valueIsUnderscored && !(assignmentKeyEqualsValue && ignoreDestructuring)) { + report(node); + } + } + + // "never" check properties or always ignore destructuring + if (properties === "never" || (ignoreDestructuring && isInsideObjectPattern(node))) { + return; + } + + // don't check right hand side of AssignmentExpression to prevent duplicate warnings + if (isUnderscored(name) && !ALLOWED_PARENT_TYPES.has(effectiveParent.type) && !(node.parent.right === node)) { + report(node); + } + + // Check if it's an import specifier + } else if (["ImportSpecifier", "ImportNamespaceSpecifier", "ImportDefaultSpecifier"].indexOf(node.parent.type) >= 0) { + + // Report only if the local imported identifier is underscored + if (node.parent.local && node.parent.local.name === node.name && isUnderscored(name)) { + report(node); + } + + // Report anything that is underscored that isn't a CallExpression + } else if (isUnderscored(name) && !ALLOWED_PARENT_TYPES.has(effectiveParent.type)) { + report(node); + } + } + + }; + + } +}; diff --git a/eslint/babel-eslint-plugin/tests/rules/camelcase.js b/eslint/babel-eslint-plugin/tests/rules/camelcase.js new file mode 100644 index 0000000000..0344018515 --- /dev/null +++ b/eslint/babel-eslint-plugin/tests/rules/camelcase.js @@ -0,0 +1,568 @@ +/** + * @fileoverview Tests for camelcase rule. + * @author Nicholas C. Zakas + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require("../../rules/camelcase"), + RuleTester = require("../RuleTester"); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester(); + +ruleTester.run("camelcase", rule, { + valid: [ + // Original test cases. + "firstName = \"Nicholas\"", + "FIRST_NAME = \"Nicholas\"", + "__myPrivateVariable = \"Patrick\"", + "myPrivateVariable_ = \"Patrick\"", + "function doSomething(){}", + "do_something()", + "new do_something", + "new do_something()", + "foo.do_something()", + "var foo = bar.baz_boom;", + "var foo = bar.baz_boom.something;", + "foo.boom_pow.qux = bar.baz_boom.something;", + "if (bar.baz_boom) {}", + "var obj = { key: foo.bar_baz };", + "var arr = [foo.bar_baz];", + "[foo.bar_baz]", + "var arr = [foo.bar_baz.qux];", + "[foo.bar_baz.nesting]", + "if (foo.bar_baz === boom.bam_pow) { [foo.baz_boom] }", + { + code: "var o = {key: 1}", + options: [{ properties: "always" }] + }, + { + code: "var o = {_leading: 1}", + options: [{ properties: "always" }] + }, + { + code: "var o = {trailing_: 1}", + options: [{ properties: "always" }] + }, + { + code: "var o = {bar_baz: 1}", + options: [{ properties: "never" }] + }, + { + code: "var o = {_leading: 1}", + options: [{ properties: "never" }] + }, + { + code: "var o = {trailing_: 1}", + options: [{ properties: "never" }] + }, + { + code: "obj.a_b = 2;", + options: [{ properties: "never" }] + }, + { + code: "obj._a = 2;", + options: [{ properties: "always" }] + }, + { + code: "obj.a_ = 2;", + options: [{ properties: "always" }] + }, + { + code: "obj._a = 2;", + options: [{ properties: "never" }] + }, + { + code: "obj.a_ = 2;", + options: [{ properties: "never" }] + }, + { + code: "var obj = {\n a_a: 1 \n};\n obj.a_b = 2;", + options: [{ properties: "never" }] + }, + { + code: "obj.foo_bar = function(){};", + options: [{ properties: "never" }] + }, + { + code: "var { category_id } = query;", + options: [{ ignoreDestructuring: true }], + parserOptions: { ecmaVersion: 6 } + }, + { + code: "var { category_id: category_id } = query;", + options: [{ ignoreDestructuring: true }], + parserOptions: { ecmaVersion: 6 } + }, + { + code: "var { category_id = 1 } = query;", + options: [{ ignoreDestructuring: true }], + parserOptions: { ecmaVersion: 6 }, + + }, + { + code: "var { category_id: category } = query;", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "var { _leading } = query;", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "var { trailing_ } = query;", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "import { camelCased } from \"external module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" } + }, + { + code: "import { _leading } from \"external module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" } + }, + { + code: "import { trailing_ } from \"external module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" } + }, + { + code: "import { no_camelcased as camelCased } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" } + }, + { + code: "import { no_camelcased as _leading } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" } + }, + { + code: "import { no_camelcased as trailing_ } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" } + }, + { + code: "import { no_camelcased as camelCased, anoterCamelCased } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" } + }, + { + code: "function foo({ no_camelcased: camelCased }) {};", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ no_camelcased: _leading }) {};", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ no_camelcased: trailing_ }) {};", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ camelCased = 'default value' }) {};", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ _leading = 'default value' }) {};", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ trailing_ = 'default value' }) {};", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ camelCased }) {};", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ _leading }) {}", + parserOptions: { ecmaVersion: 6 } + }, + { + code: "function foo({ trailing_ }) {}", + parserOptions: { ecmaVersion: 6 } + }, + + // Babel-specific test cases + { + code: "var foo = bar?.a_b;", + options: [{ properties: "never" }] + }, + ], + invalid: [ + { + code: "first_name = \"Nicholas\"", + errors: [ + { + messageId: "notCamelCase", + data: { name: "first_name" }, + type: "Identifier" + } + ] + }, + { + code: "__private_first_name = \"Patrick\"", + errors: [ + { + messageId: "notCamelCase", + data: { name: "__private_first_name" }, + type: "Identifier" + } + ] + }, + { + code: "function foo_bar(){}", + errors: [ + { + messageId: "notCamelCase", + data: { name: "foo_bar" }, + type: "Identifier" + } + ] + }, + { + code: "obj.foo_bar = function(){};", + errors: [ + { + messageId: "notCamelCase", + data: { name: "foo_bar" }, + type: "Identifier" + } + ] + }, + { + code: "bar_baz.foo = function(){};", + errors: [ + { + messageId: "notCamelCase", + data: { name: "bar_baz" }, + type: "Identifier" + } + ] + }, + { + code: "[foo_bar.baz]", + errors: [ + { + messageId: "notCamelCase", + data: { name: "foo_bar" }, + type: "Identifier" + } + ] + }, + { + code: "if (foo.bar_baz === boom.bam_pow) { [foo_bar.baz] }", + errors: [ + { + messageId: "notCamelCase", + data: { name: "foo_bar" }, + type: "Identifier" + } + ] + }, + { + code: "foo.bar_baz = boom.bam_pow", + errors: [ + { + messageId: "notCamelCase", + data: { name: "bar_baz" }, + type: "Identifier" + } + ] + }, + { + code: "var foo = { bar_baz: boom.bam_pow }", + errors: [ + { + messageId: "notCamelCase", + data: { name: "bar_baz" }, + type: "Identifier" + } + ] + }, + { + code: "foo.qux.boom_pow = { bar: boom.bam_pow }", + errors: [ + { + messageId: "notCamelCase", + data: { name: "boom_pow" }, + type: "Identifier" + } + ] + }, + { + code: "var o = {bar_baz: 1}", + options: [{ properties: "always" }], + errors: [ + { + messageId: "notCamelCase", + data: { name: "bar_baz" }, + type: "Identifier" + } + ] + }, + { + code: "obj.a_b = 2;", + options: [{ properties: "always" }], + errors: [ + { + messageId: "notCamelCase", + data: { name: "a_b" }, + type: "Identifier" + } + ] + }, + { + code: "var { category_id: category_alias } = query;", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "category_alias" }, + type: "Identifier" + } + ] + }, + { + code: "var { category_id: category_alias } = query;", + options: [{ ignoreDestructuring: true }], + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "category_alias" }, + type: "Identifier" + } + ] + }, + { + code: "var { category_id: categoryId, ...other_props } = query;", + options: [{ ignoreDestructuring: true }], + parserOptions: { ecmaVersion: 2018 }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "other_props" }, + type: "Identifier" + } + ] + }, + { + code: "var { category_id } = query;", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "category_id" }, + type: "Identifier" + } + ] + }, + { + code: "var { category_id: category_id } = query;", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "category_id" }, + type: "Identifier" + } + ] + }, + { + code: "var { category_id = 1 } = query;", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'category_id' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "import no_camelcased from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camelcased" }, + type: "Identifier" + } + ] + }, + { + code: "import * as no_camelcased from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camelcased" }, + type: "Identifier" + } + ] + }, + { + code: "import { no_camelcased } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camelcased" }, + type: "Identifier" + } + ] + }, + { + code: "import { no_camelcased as no_camel_cased } from \"external module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camel_cased" }, + type: "Identifier" + } + ] + }, + { + code: "import { camelCased as no_camel_cased } from \"external module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camel_cased" }, + type: "Identifier" + } + ] + }, + { + code: "import { camelCased, no_camelcased } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camelcased" }, + type: "Identifier" + } + ] + }, + { + code: "import { no_camelcased as camelCased, another_no_camelcased } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "another_no_camelcased" }, + type: "Identifier" + } + ] + }, + { + code: "import camelCased, { no_camelcased } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camelcased" }, + type: "Identifier" + } + ] + }, + { + code: "import no_camelcased, { another_no_camelcased as camelCased } from \"external-module\";", + parserOptions: { ecmaVersion: 6, sourceType: "module" }, + errors: [ + { + messageId: "notCamelCase", + data: { name: "no_camelcased" }, + type: "Identifier" + } + ] + }, + { + code: "function foo({ no_camelcased }) {};", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'no_camelcased' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "function foo({ no_camelcased = 'default value' }) {};", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'no_camelcased' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "const no_camelcased = 0; function foo({ camelcased_value = no_camelcased}) {}", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'no_camelcased' is not in camel case.", + type: "Identifier" + }, + { + message: "Identifier 'camelcased_value' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "const { bar: no_camelcased } = foo;", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'no_camelcased' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "function foo({ value_1: my_default }) {}", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'my_default' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "function foo({ isCamelcased: no_camelcased }) {};", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'no_camelcased' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "var { foo: bar_baz = 1 } = quz;", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'bar_baz' is not in camel case.", + type: "Identifier" + } + ] + }, + { + code: "const { no_camelcased = false } = bar;", + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Identifier 'no_camelcased' is not in camel case.", + type: "Identifier" + } + ] + } + ] +});