diff --git a/README.md b/README.md index 871384de1..54bf19e28 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi | | Rule ID | Description | |:---|:--------|:------------| +| | [filename-case](./docs/rules/filename-case.md) | enforce file-name conventions | | :wrench: | [html-quotes](./docs/rules/html-quotes.md) | enforce quotes style of HTML attributes | | | [no-confusing-v-for-v-if](./docs/rules/no-confusing-v-for-v-if.md) | disallow confusing `v-for` and `v-if` on the same element | | | [order-in-components](./docs/rules/order-in-components.md) | enforce order of properties in components | diff --git a/docs/rules/filename-case.md b/docs/rules/filename-case.md new file mode 100644 index 000000000..46e5ae427 --- /dev/null +++ b/docs/rules/filename-case.md @@ -0,0 +1,51 @@ +# enforce file-name conventions (filename-case) + +eslint-plugin-unicorn has a rule to check filename formats. However Vue apps have a convention of .vue files being `pascalCase` and and .js files not being `pascalCase`. This rule can be used as an alternative allowing the file format for files to be configured, but all .vue files will be set to pascalCase. + +## :book: Rule Details + +This ruleall linted files to have their names in a certain case style. Default is `kebabCase`. + +Files named `index.js` are ignored as they can't change case (Only a problem with `pascalCase`). + +### `kebabCase` + +- `foo-bar.js` +- `foo-bar.test.js` +- `foo-bar.test-utils.js` + +### `camelCase` + +- `fooBar.js` +- `fooBar.test.js` +- `fooBar.testUtils.js` + +### `snakeCase` + +- `foo_bar.js` +- `foo_bar.test.js` +- `foo_bar.test_utils.js` + +### `pascalCase` + +- `FooBar.js` +- `FooBar.Test.js` +- `FooBar.TestUtils.js` + + +## Options + +## :wrench: Options + +```json +{ + "vue/filename-case": [ + "error", + { + "case": "kebabCase" + } + ] +} +``` + +- `case` (`string"`) ... The filename case. Default is `kebabCase`. \ No newline at end of file diff --git a/lib/recommended-rules.js b/lib/recommended-rules.js index b11f218a5..9056ad9b0 100644 --- a/lib/recommended-rules.js +++ b/lib/recommended-rules.js @@ -5,6 +5,7 @@ */ module.exports = { "vue/attribute-hyphenation": "error", + "vue/filename-case": "error", "vue/html-end-tags": "error", "vue/html-indent": "error", "vue/html-quotes": "error", diff --git a/lib/rules/filename-case.js b/lib/rules/filename-case.js new file mode 100644 index 000000000..9ef9432aa --- /dev/null +++ b/lib/rules/filename-case.js @@ -0,0 +1,132 @@ +'use strict' +const path = require('path') +const camelCase = require('lodash.camelcase') +const kebabCase = require('lodash.kebabcase') +const snakeCase = require('lodash.snakecase') +const upperfirst = require('lodash.upperfirst') + +const pascalCase = str => upperfirst(camelCase(str)) +const numberRegex = /(\d+)/ +const PLACEHOLDER = '\uFFFF\uFFFF\uFFFF' +const PLACEHOLDER_REGEX = new RegExp(PLACEHOLDER, 'i') + +function ignoreNumbers (fn) { + return str => { + const stack = [] + let execResult = numberRegex.exec(str) + + while (execResult) { + stack.push(execResult[0]) + str = str.replace(execResult[0], PLACEHOLDER) + execResult = numberRegex.exec(str) + } + + let withCase = fn(str) + + while (stack.length > 0) { + withCase = withCase.replace(PLACEHOLDER_REGEX, stack.shift()) + } + + return withCase + } +} + +const cases = { + camelCase: { + fn: camelCase, + name: 'camel case' + }, + kebabCase: { + fn: kebabCase, + name: 'kebab case' + }, + snakeCase: { + fn: snakeCase, + name: 'snake case' + }, + pascalCase: { + fn: pascalCase, + name: 'pascal case' + } +} + +function fixFilename (chosenCase, filename) { + return filename + .split('.') + .map(ignoreNumbers(chosenCase.fn)) + .join('.') +} + +const leadingUnserscoresRegex = /^(_+)(.*)$/ +function splitFilename (filename) { + const res = leadingUnserscoresRegex.exec(filename) + return { + leading: (res && res[1]) || '', + trailing: (res && res[2]) || filename + } +} + +const create = context => { + let chosenCase = cases['camelCase'] + + if (context.options[0] && context.options[0].case) { + chosenCase = cases[context.options[0].case] + } + + const filenameWithExt = context.getFilename() + + if (filenameWithExt === '') { + return {} + } + + return { + Program: node => { + const extension = path.extname(filenameWithExt) + const filename = path.basename(filenameWithExt, extension) + + if (filename + extension === 'index.js') { + return + } + + if (extension === '.vue') { + chosenCase = cases['pascalCase'] + } + + const splitName = splitFilename(filename) + const fixedFilename = fixFilename(chosenCase, splitName.trailing) + const renameFilename = splitName.leading + fixedFilename + extension + + if (fixedFilename !== splitName.trailing) { + context.report({ + node, + message: `Filename is not in ${chosenCase.name}. Rename it to \`${renameFilename}\`.` + }) + } + } + } +} + +const schema = [{ + type: 'object', + properties: { + case: { + enum: [ + 'camelCase', + 'snakeCase', + 'kebabCase', + 'pascalCase' + ] + } + } +}] + +module.exports = { + create, + meta: { + docs: { + description: 'enforce file-name conventions', + category: 'recommended' + }, + schema + } +} diff --git a/package.json b/package.json index 062cd9e19..8bca6f342 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,10 @@ }, "dependencies": { "require-all": "^2.2.0", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.upperfirst": "^4.3.1", "vue-eslint-parser": "^2.0.1-beta.1" }, "devDependencies": { diff --git a/tests/lib/rules/filename-case.js b/tests/lib/rules/filename-case.js new file mode 100644 index 000000000..cc1e30ceb --- /dev/null +++ b/tests/lib/rules/filename-case.js @@ -0,0 +1,173 @@ +/** + * @fileoverview Enforces component's data property to be a function. + * @author Armano + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/filename-case') + +const RuleTester = require('eslint').RuleTester +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: 'vue-eslint-parser', + parserOptions: { ecmaVersion: 2015 } +}) + +function testCase (filename, chosenCase, errorMessage) { + return { + code: 'foo()', + filename, + options: [{ case: chosenCase }], + errors: errorMessage && [{ + ruleId: 'filename-case', + message: errorMessage + }] + } +} + +ruleTester.run('filename-case', rule, { + valid: [ + testCase('src/foo/bar.js', 'camelCase'), + testCase('src/foo/fooBar.js', 'camelCase'), + testCase('src/foo/bar.test.js', 'camelCase'), + testCase('src/foo/fooBar.test.js', 'camelCase'), + testCase('src/foo/fooBar.testUtils.js', 'camelCase'), + testCase('src/foo/foo.js', 'snakeCase'), + testCase('src/foo/foo_bar.js', 'snakeCase'), + testCase('src/foo/foo.test.js', 'snakeCase'), + testCase('src/foo/foo_bar.test.js', 'snakeCase'), + testCase('src/foo/foo_bar.test_utils.js', 'snakeCase'), + testCase('src/foo/foo.js', 'kebabCase'), + testCase('src/foo/foo-bar.js', 'kebabCase'), + testCase('src/foo/foo.test.js', 'kebabCase'), + testCase('src/foo/foo-bar.test.js', 'kebabCase'), + testCase('src/foo/foo-bar.test-utils.js', 'kebabCase'), + testCase('src/foo/Foo.js', 'pascalCase'), + testCase('src/foo/FooBar.js', 'pascalCase'), + testCase('src/foo/Foo.Test.js', 'pascalCase'), + testCase('src/foo/FooBar.Test.js', 'pascalCase'), + testCase('src/foo/FooBar.TestUtils.js', 'pascalCase'), + testCase('spec/iss47Spec.js', 'camelCase'), + testCase('spec/iss47Spec100.js', 'camelCase'), + testCase('spec/i18n.js', 'camelCase'), + testCase('spec/iss47-spec.js', 'kebabCase'), + testCase('spec/iss-47-spec.js', 'kebabCase'), + testCase('spec/iss47-100spec.js', 'kebabCase'), + testCase('spec/i18n.js', 'kebabCase'), + testCase('spec/iss47_spec.js', 'snakeCase'), + testCase('spec/iss_47_spec.js', 'snakeCase'), + testCase('spec/iss47_100spec.js', 'snakeCase'), + testCase('spec/i18n.js', 'snakeCase'), + testCase('spec/Iss47Spec.js', 'pascalCase'), + testCase('spec/Iss47.100Spec.js', 'pascalCase'), + testCase('spec/I18n.js', 'pascalCase'), + testCase('spec/index.js', 'pascalCase'), + testCase('', 'camelCase'), + testCase('', 'snakeCase'), + testCase('', 'kebabCase'), + testCase('', 'pascalCase'), + testCase('src/foo/_fooBar.js', 'camelCase'), + testCase('src/foo/___fooBar.js', 'camelCase'), + testCase('src/foo/_foo_bar.js', 'snakeCase'), + testCase('src/foo/___foo_bar.js', 'snakeCase'), + testCase('src/foo/_foo-bar.js', 'kebabCase'), + testCase('src/foo/___foo-bar.js', 'kebabCase'), + testCase('src/foo/_FooBar.js', 'pascalCase'), + testCase('src/foo/___FooBar.js', 'pascalCase'), + testCase('App.vue', 'kebabCase'), + testCase('HelloWorld.vue', 'kebabCase') + ], + invalid: [ + testCase('src/foo/foo_bar.js', + undefined, + 'Filename is not in camel case. Rename it to `fooBar.js`.' + ), + testCase('src/foo/foo_bar.js', + 'camelCase', + 'Filename is not in camel case. Rename it to `fooBar.js`.' + ), + testCase('src/foo/foo_bar.test.js', + 'camelCase', + 'Filename is not in camel case. Rename it to `fooBar.test.js`.' + ), + testCase('test/foo/foo_bar.test_utils.js', + 'camelCase', + 'Filename is not in camel case. Rename it to `fooBar.testUtils.js`.' + ), + testCase('test/foo/fooBar.js', + 'snakeCase', + 'Filename is not in snake case. Rename it to `foo_bar.js`.' + ), + testCase('test/foo/fooBar.test.js', + 'snakeCase', + 'Filename is not in snake case. Rename it to `foo_bar.test.js`.' + ), + testCase('test/foo/fooBar.testUtils.js', + 'snakeCase', + 'Filename is not in snake case. Rename it to `foo_bar.test_utils.js`.' + ), + testCase('test/foo/fooBar.js', + 'kebabCase', + 'Filename is not in kebab case. Rename it to `foo-bar.js`.' + ), + testCase('test/foo/fooBar.test.js', + 'kebabCase', + 'Filename is not in kebab case. Rename it to `foo-bar.test.js`.' + ), + testCase('test/foo/fooBar.testUtils.js', + 'kebabCase', + 'Filename is not in kebab case. Rename it to `foo-bar.test-utils.js`.' + ), + testCase('test/foo/fooBar.js', + 'pascalCase', + 'Filename is not in pascal case. Rename it to `FooBar.js`.' + ), + testCase('test/foo/foo_bar.test.js', + 'pascalCase', + 'Filename is not in pascal case. Rename it to `FooBar.Test.js`.' + ), + testCase('test/foo/foo-bar.test-utils.js', + 'pascalCase', + 'Filename is not in pascal case. Rename it to `FooBar.TestUtils.js`.' + ), + testCase('src/foo/_FOO-BAR.js', + 'camelCase', + 'Filename is not in camel case. Rename it to `_fooBar.js`.' + ), + testCase('src/foo/___FOO-BAR.js', + 'camelCase', + 'Filename is not in camel case. Rename it to `___fooBar.js`.' + ), + testCase('src/foo/_FOO-BAR.js', + 'snakeCase', + 'Filename is not in snake case. Rename it to `_foo_bar.js`.' + ), + testCase('src/foo/___FOO-BAR.js', + 'snakeCase', + 'Filename is not in snake case. Rename it to `___foo_bar.js`.' + ), + testCase('src/foo/_FOO-BAR.js', + 'kebabCase', + 'Filename is not in kebab case. Rename it to `_foo-bar.js`.' + ), + testCase('src/foo/___FOO-BAR.js', + 'kebabCase', + 'Filename is not in kebab case. Rename it to `___foo-bar.js`.' + ), + testCase('src/foo/_FOO-BAR.js', + 'pascalCase', + 'Filename is not in pascal case. Rename it to `_FooBar.js`.' + ), + testCase('src/foo/___FOO-BAR.js', + 'pascalCase', + 'Filename is not in pascal case. Rename it to `___FooBar.js`.' + ) + ] +})