diff --git a/.changeset/lazy-rats-add.md b/.changeset/lazy-rats-add.md new file mode 100644 index 00000000..5ef7255b --- /dev/null +++ b/.changeset/lazy-rats-add.md @@ -0,0 +1,5 @@ +--- +'prettier-eslint': major +--- + +Use Rollup for bundling CJS and ESM artifacts. Use Vitest for tests. diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 72% rename from .eslintrc.js rename to .eslintrc.cjs index f7fac129..a3ee2463 100644 --- a/.eslintrc.js +++ b/.eslintrc.cjs @@ -2,10 +2,10 @@ const config = { extends: [ 'kentcdodds', 'kentcdodds/jest', - 'plugin:node-dependencies/recommended' + 'plugin:node-dependencies/recommended', ], parserOptions: { - ecmaVersion: 2021 + ecmaVersion: 2021, }, rules: { 'valid-jsdoc': 'off', @@ -15,16 +15,16 @@ const config = { { anonymous: 'never', named: 'never', - asyncArrow: 'always' - } + asyncArrow: 'always', + }, ], 'import/no-import-module-exports': 'off', 'arrow-parens': ['error', 'as-needed'], quotes: ['error', 'single', { avoidEscape: true }], }, settings: { - 'import/ignore': ['node_modules', 'src'] // Using CommonJS in src - } + 'import/ignore': ['node_modules', 'src'], // Using CommonJS in src + }, }; module.exports = config; diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c25860b1..1f18b104 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -59,20 +59,41 @@ updates: - 17.3.9 - 17.4.0 - 17.4.1 + - dependency-name: '@rollup/plugin-commonjs' + versions: + - 28.0.1 + - dependency-name: '@rollup/plugin-json' + versions: + - 6.1.0 + - dependency-name: '@rollup/plugin-node-resolve' + versions: + - 15.3.0 + - dependency-name: rollup + versions: + - 4.28.0 + - dependency-name: rollup-plugin-auto-external + versions: + - 2.0.0 + - dependency-name: memfs + versions: + - 4.14.1 - dependency-name: vue-eslint-parser versions: - 7.4.1 - 7.5.0 - 7.6.0 - - dependency-name: jest-cli + - dependency-name: vite + versions: + - 6.0.3 + - dependency-name: vitest versions: - - 26.6.3 - - dependency-name: babel-jest + - 2.1.8 + - dependency-name: '@vitest/coverage-v8' versions: - - 26.6.3 - - dependency-name: jest + - 2.1.8 + - dependency-name: '@vitest/ui' versions: - - 26.6.3 + - 2.1.8 - dependency-name: pretty-format versions: - 26.6.2 diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 48b1b40e..00000000 --- a/.prettierrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - arrowParens: 'avoid', - singleQuote: true -}; diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index c2857c1f..00000000 --- a/babel.config.js +++ /dev/null @@ -1,14 +0,0 @@ -const config = { - presets: [ - [ - '@babel/env', - { - targets: { - node: '10' - } - } - ] - ] -}; - -module.exports = config; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 7a638dae..00000000 --- a/jest.config.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - testEnvironment: 'node', - collectCoverageFrom: ['src/**/*.js'], - testPathIgnorePatterns: ['/node_modules/', '/fixtures/', '/__mocks__/'], - coveragePathIgnorePatterns: ['/node_modules/', '/fixtures/', '/__mocks__/'], - coverageThreshold: { - global: { - branches: 96, - functions: 100, - lines: 100, - statements: 100 - } - } -}; diff --git a/package-scripts.js b/package-scripts.cjs similarity index 75% rename from package-scripts.js rename to package-scripts.cjs index b993df91..0f385227 100644 --- a/package-scripts.js +++ b/package-scripts.cjs @@ -22,23 +22,15 @@ module.exports = { // with ESM. ESM support is needed due to prettier v3’s use of a dynamic // `import()` in its `.cjs` file. The flag can be removed when node // supports modules in the VM API or the import is removed from prettier. - default: crossEnv( - 'NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --coverage' - ), - update: crossEnv( - 'NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --coverage --updateSnapshot' - ), - watch: crossEnv( - 'NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --watch' - ), + default: crossEnv('vitest --coverage run'), + update: crossEnv('vitest --coverage --updateSnapshot'), + watch: crossEnv('vitest'), openCoverage: 'open coverage/lcov-report/index.html', }, build: { - description: 'delete the dist directory and run babel to build the files', - script: series( - rimraf('dist'), - 'babel --out-dir dist --ignore "src/__tests__/**/*","src/__mocks__/**/*" src' - ), + description: + 'delete the dist directory and run Rollup to build the files', + script: series(rimraf('dist'), 'rollup -c'), }, lint: { description: 'lint the entire project', @@ -57,7 +49,11 @@ module.exports = { validate: { description: 'This runs several scripts to make sure things look good before committing or on clean install', - script: concurrent.nps('lint', 'build', 'test'), + script: concurrent([ + 'nps -c ./package-scripts.cjs lint', + 'nps -c ./package-scripts.cjs build', + 'nps -c ./package-scripts.cjs test', + ]), }, format: { description: 'Formats everything with prettier-eslint', diff --git a/package.json b/package.json index 41edec45..aaab0140 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,14 @@ "name": "prettier-eslint", "version": "16.3.0", "description": "Formats your JavaScript using prettier followed by eslint --fix", - "main": "dist/index.js", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", "types": "types/index.d.ts", + "type": "module", "scripts": { "prepare": "husky install", - "start": "nps", - "test": "nps test" + "start": "nps -c ./package-scripts.cjs", + "test": "nps -c ./package-scripts.cjs test" }, "files": [ "dist", @@ -52,20 +54,29 @@ "@babel/preset-env": "^7.22.9", "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.2", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.0", "@types/eslint": "^8.4.2", + "@vitest/coverage-v8": "^2.1.8", + "@vitest/ui": "^2.1.8", "all-contributors-cli": "^6.7.0", "eslint-config-kentcdodds": "^20.5.0", "eslint-plugin-node-dependencies": "^0.11.0", "husky": "^8.0.1", "jest": "^29.6.2", + "memfs": "^4.14.1", "nps": "^5.7.1", "nps-utils": "^1.3.0", "prettier-eslint-cli": "^8.0.0", "prettier-plugin-svelte": "^3.1.2", "rimraf": "^5.0.5", + "rollup": "^4.28.0", + "rollup-plugin-auto-external": "^2.0.0", "strip-indent": "^3.0.0", "svelte": "^4.2.9", - "svelte-eslint-parser": "^0.33.1" + "svelte-eslint-parser": "^0.33.1", + "vitest": "^2.1.8" }, "engines": { "node": ">=16.10.0" diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 00000000..a4ecd0ed --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,4 @@ +export default { + arrowParens: 'avoid', + singleQuote: true, +}; diff --git a/rollup.config.mjs b/rollup.config.mjs new file mode 100644 index 00000000..31bb1f95 --- /dev/null +++ b/rollup.config.mjs @@ -0,0 +1,31 @@ +import autoExternal from 'rollup-plugin-auto-external'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; + +export default { + input: 'src/index.mjs', + output: [ + { + file: 'dist/cjs/index.js', + format: 'cjs', + name: 'cjs-bundle', + }, + { + file: 'dist/esm/index.js', + format: 'esm', + name: 'esm-bundle', + }, + ], + external: ['fs', 'node:path'], + plugins: [ + autoExternal(), + commonjs({ + include: /node_modules/, + requireReturnsDefault: 'auto', + strictRequires: 'debug', + }), + nodeResolve({ preferBuiltins: true }), + json(), + ], +}; diff --git a/src/__mocks__/eslint.js b/src/__mocks__/eslint.mjs similarity index 50% rename from src/__mocks__/eslint.js rename to src/__mocks__/eslint.mjs index dced55a5..749088c1 100644 --- a/src/__mocks__/eslint.js +++ b/src/__mocks__/eslint.mjs @@ -1,40 +1,16 @@ +import { vi } from 'vitest'; + // this mock file is so eslint doesn't attempt to actually // search around the file system for stuff -const eslint = jest.requireActual('eslint'); -const { ESLint } = eslint; - -const mockCalculateConfigForFileSpy = jest.fn(mockCalculateConfigForFile); -mockCalculateConfigForFileSpy.overrides = {}; -const mockLintTextSpy = jest.fn(mockLintText); - -module.exports = Object.assign(eslint, { - ESLint: jest.fn(MockESLint), - mock: { - calculateConfigForFile: mockCalculateConfigForFileSpy, - lintText: mockLintTextSpy - } -}); - -function MockESLint(...args) { - global.__PRETTIER_ESLINT_TEST_STATE__.eslintPath = __filename; - const eslintInstance = new ESLint(...args); - eslintInstance.calculateConfigForFile = mockCalculateConfigForFileSpy; - eslintInstance._originalLintText = eslintInstance.lintText; - eslintInstance.lintText = mockLintTextSpy; - return eslintInstance; -} - -MockESLint.prototype = Object.create(ESLint.prototype); +const eslintActual = await vi.importActual('eslint'); +const { ESLint } = eslintActual; // eslint-disable-next-line complexity function mockCalculateConfigForFile(filePath) { - if (mockCalculateConfigForFileSpy.throwError) { - throw mockCalculateConfigForFileSpy.throwError; - } if (!filePath) { return { - rules: {} + rules: {}, }; } if (filePath.includes('default-config')) { @@ -46,7 +22,7 @@ function mockCalculateConfigForFile(filePath) { quotes: [ 2, 'single', - { avoidEscape: true, allowTemplateLiterals: true } + { avoidEscape: true, allowTemplateLiterals: true }, ], 'comma-dangle': [ 2, @@ -55,26 +31,44 @@ function mockCalculateConfigForFile(filePath) { objects: 'always-multiline', imports: 'always-multiline', exports: 'always-multiline', - functions: 'always-multiline' - } + functions: 'always-multiline', + }, ], - 'arrow-parens': [2, 'as-needed'] - } + 'arrow-parens': [2, 'as-needed'], + }, }; } else if (filePath.includes('fixtures/paths')) { return { rules: {} }; } else { throw new Error( `Your mock filePath (${filePath})` + - ' does not have a handler for finding the config' + ' does not have a handler for finding the config', ); } } -function mockLintText(...args) { +const mockLintTextSpy = vi.fn(function mockLintText(...args) { /* eslint no-invalid-this:0 */ if (mockLintTextSpy.throwError) { throw mockLintTextSpy.throwError; } return this._originalLintText(...args); +}); + +const calculateConfigForFileSpy = vi.spyOn(ESLint.prototype, 'calculateConfigForFile').mockImplementation(mockCalculateConfigForFile); +const lintTextSpy = vi.spyOn(ESLint.prototype, 'lintText'); + +function MockESLint(...args) { + global.__PRETTIER_ESLINT_TEST_STATE__.eslintPath = __filename; + return new ESLint(...args); } + +export default { + ...eslintActual.default, + ESLint: vi.fn(MockESLint), +}; + +export const helpers = { + getCalculateConfigForFileSpy: () => calculateConfigForFileSpy, + getLintTextSpy: () => lintTextSpy, +}; diff --git a/src/__mocks__/fs.js b/src/__mocks__/fs.js deleted file mode 100644 index 02f17367..00000000 --- a/src/__mocks__/fs.js +++ /dev/null @@ -1,13 +0,0 @@ -const fs = jest.requireActual('fs'); -module.exports = { - ...fs, - readFileSync: jest.fn(filename => { - if (/package\.json$/.test(filename)) { - return '{"name": "fake", "version": "0.0.0", "prettier": {}}'; - } else if (/\.(j|t)s$/.test(filename)) { - return 'var fake = true'; - } - - return ''; - }) -}; diff --git a/src/__mocks__/fs.mjs b/src/__mocks__/fs.mjs new file mode 100644 index 00000000..c931ed89 --- /dev/null +++ b/src/__mocks__/fs.mjs @@ -0,0 +1,25 @@ +// This follows the advice of the Vitest Mocking guide, and mocks the full +// filesystem with an in-memory replacement. +// https://vitest.dev/guide/mocking#file-system +const { createFsFromVolume, memfs, Volume } = require('memfs'); +import path from 'node:path'; + +const files = [ + 'foo.js', + 'package.json', + 'node_modules/eslint/index.js', + 'node_modules/prettier/index.js', +]; + +const volume = new Volume(); + +volume.fromJSON( + Object.fromEntries( + files.map(file => { + return [`./${file}`, `.tests/fixtures/paths/${file}`]; + }), + ), + `${path.dirname(__filename)}/../../tests/fixtures/paths`, +); + +export default createFsFromVolume(volume); diff --git a/src/__mocks__/loglevel-colored-level-prefix.js b/src/__mocks__/loglevel-colored-level-prefix.js deleted file mode 100644 index f79f744a..00000000 --- a/src/__mocks__/loglevel-colored-level-prefix.js +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = getLogger; -const logger = { - setLevel: jest.fn(), - trace: jest.fn(getTestImplementation('trace')), - debug: jest.fn(getTestImplementation('debug')), - info: jest.fn(getTestImplementation('info')), - warn: jest.fn(getTestImplementation('warn')), - error: jest.fn(getTestImplementation('error')) -}; -const mock = { clearAll, logger, logThings: [] }; - -Object.assign(module.exports, { - mock -}); - -function getLogger() { - return logger; -} - -function clearAll() { - Object.keys(logger).forEach(name => { - if (logger[name].mock) { - logger[name].mockClear(); - } - }); -} - -function getTestImplementation(level) { - return testLogImplementation; - - function testLogImplementation(...args) { - if (mock.logThings === 'all' || mock.logThings.indexOf(level) !== -1) { - console.log(level, ...args); // eslint-disable-line no-console - } - } -} diff --git a/src/__mocks__/loglevel-colored-level-prefix.mjs b/src/__mocks__/loglevel-colored-level-prefix.mjs new file mode 100644 index 00000000..b3ca386a --- /dev/null +++ b/src/__mocks__/loglevel-colored-level-prefix.mjs @@ -0,0 +1,32 @@ +import { vi } from 'vitest'; + +const logger = { + setLevel: vi.fn(), + trace: vi.fn(getTestImplementation('trace')), + debug: vi.fn(getTestImplementation('debug')), + info: vi.fn(getTestImplementation('info')), + warn: vi.fn(getTestImplementation('warn')), + error: vi.fn(getTestImplementation('error')) +}; + +const logThings = []; + +function mockClear() { + Object.values(logger).forEach((mock) => { + mock.mockClear(); + }); +} + +function getTestImplementation(level) { + return function testLogImplementation(...args) { + if (logThings === 'all' || logThings.indexOf(level) !== -1) { + console.log(level, ...args); // eslint-disable-line no-console + } + } +} + +export default function getLogger() { + return logger; +} + +export const helpers = { mockClear, getLogger: () => logger, logThings }; diff --git a/src/__mocks__/prettier.js b/src/__mocks__/prettier.js deleted file mode 100644 index 52362e84..00000000 --- a/src/__mocks__/prettier.js +++ /dev/null @@ -1,19 +0,0 @@ -const prettier = jest.requireActual('prettier'); -const { format } = prettier; - -module.exports = prettier; - -const mockFormatSpy = jest.fn(mockFormat); - -Object.assign(prettier, { - format: mockFormatSpy, - resolveConfig: jest.fn() -}); - -function mockFormat(...args) { - global.__PRETTIER_ESLINT_TEST_STATE__.prettierPath = __filename; - if (mockFormatSpy.throwError) { - throw mockFormatSpy.throwError; - } - return format(...args); -} diff --git a/src/__mocks__/prettier.mjs b/src/__mocks__/prettier.mjs new file mode 100644 index 00000000..f464af7b --- /dev/null +++ b/src/__mocks__/prettier.mjs @@ -0,0 +1,17 @@ +import { vi } from 'vitest'; +const prettier = await vi.importActual('prettier'); +const { format } = prettier; +import prettier from 'prettier'; + +const mockFormatSpy = vi.fn(mockFormat); + +function mockFormat(...args) { + global.__PRETTIER_ESLINT_TEST_STATE__.prettierPath = __filename; + if (mockFormatSpy.throwError) { + throw mockFormatSpy.throwError; + } + + return format(...args); +} + +export default { ...prettier, format: mockFormatSpy, resolveConfig: vi.fn() }; diff --git a/src/__tests__/index.js b/src/__tests__/index.test.mjs similarity index 60% rename from src/__tests__/index.js rename to src/__tests__/index.test.mjs index 4c83d71e..5648575f 100644 --- a/src/__tests__/index.js +++ b/src/__tests__/index.test.mjs @@ -1,39 +1,68 @@ /* eslint no-console:0, import/default:0 */ -import path from 'path'; -import fsMock from 'fs'; +import path from 'node:path'; +import requireRelative from 'require-relative'; +import { beforeEach, expect, test, vi } from 'vitest'; import stripIndent from 'strip-indent'; -import eslintMock from 'eslint'; -import prettierMock from 'prettier'; -import loglevelMock from 'loglevel-colored-level-prefix'; -import {format, analyze} from '../'; +import eslintMock, { + helpers as eslintMockHelpers, +} from '../__mocks__/eslint.mjs'; +import fsMock from '../__mocks__/fs.mjs'; +import prettierMock from '../__mocks__/prettier.mjs'; +import loglevelMock, { + helpers as loglevelMockHelpers, +} from '../__mocks__/loglevel-colored-level-prefix'; + +// Mock eslint and prettier at their resolved locations. +// vitest module mocking is keyed on the string passed to `import()`, +// so we have to get the on-disk location right. +// We use `doMock` to prevent their being hoisted above this test's +// import statements, since they're dynamically imported. +// https://vitest.dev/api/vi.html#vi-domock +vi.doMock(requireRelative.resolve('eslint'), () => { + return { default: eslintMock }; +}); + +vi.doMock(requireRelative.resolve('prettier'), () => { + return { default: prettierMock }; +}); + +// loglevel-colored-level-prefix is not a dynamic import +vi.mock('fs', () => { + return { default: fsMock }; +}); +vi.mock('loglevel-colored-level-prefix', () => { + return { default: loglevelMock }; +}); -jest.mock('fs'); +import { format, analyze } from '../'; -const { - mock: { logger } -} = loglevelMock; -// loglevelMock.mock.logThings = ['debug'] +const logger = loglevelMockHelpers.getLogger(); -const tests = [ +beforeEach(() => { + vi.clearAllMocks(); + global.__PRETTIER_ESLINT_TEST_STATE__ = {}; +}); + +const formatTests = [ { title: 'sanity test', - input: { + options: { text: defaultInputText(), - eslintConfig: getESLintConfigWithDefaultRules() + eslintConfig: getESLintConfigWithDefaultRules(), }, - output: defaultOutput() + output: defaultOutput(), }, { title: 'README example', - input: { + options: { text: 'const {foo} = bar', eslintConfig: { parserOptions: { ecmaVersion: 7 }, - rules: { semi: ['error', 'never'] } + rules: { semi: ['error', 'never'] }, }, - prettierOptions: { bracketSpacing: true } + prettierOptions: { bracketSpacing: true }, }, - output: 'const { foo } = bar' + output: 'const { foo } = bar', }, { // this one's actually hard to test now. This test doesn't @@ -43,230 +72,237 @@ const tests = [ // honestly not sure how to test that prettier fixed // something that eslint fixed title: 'with prettierLast: true', - input: { + options: { text: defaultInputText(), filePath: path.resolve('./mock/default-config.js'), - prettierLast: true + prettierLast: true, }, - output: prettierLastOutput() + output: prettierLastOutput(), }, { title: 'with a filePath and no config', - input: { + options: { text: defaultInputText(), - filePath: path.resolve('./mock/default-config.js') + filePath: path.resolve('./mock/default-config.js'), }, - output: defaultOutput() + output: defaultOutput(), }, { title: 'with a default config and overrides', - input: { + options: { text: 'const { foo } = bar;', eslintConfig: { // Won't be overridden parserOptions: { - ecmaVersion: 7 + ecmaVersion: 7, }, rules: { // Will be overridden semi: ['error', 'always'], // Won't be overridden - 'object-curly-spacing': ['error', 'never'] - } + 'object-curly-spacing': ['error', 'never'], + }, }, - filePath: path.resolve('./mock/default-config.js') + filePath: path.resolve('./mock/default-config.js'), }, - output: 'const {foo} = bar' + output: 'const {foo} = bar', }, { title: 'with an empty config and fallbacks', - input: { + options: { text: 'const { foo } = bar;', eslintConfig: {}, filePath: path.resolve('./mock/default-config.js'), - fallbackPrettierOptions: { bracketSpacing: false } + fallbackPrettierOptions: { bracketSpacing: false }, }, - output: 'const {foo} = bar' + output: 'const {foo} = bar', }, { title: 'without a filePath and no config', - input: { text: defaultInputText() }, - output: noopOutput() + options: { text: defaultInputText() }, + output: noopOutput(), }, { title: 'inferring bracketSpacing', - input: { + options: { text: 'var foo = {bar: baz};', - eslintConfig: { rules: { 'object-curly-spacing': ['error', 'always'] } } + eslintConfig: { rules: { 'object-curly-spacing': ['error', 'always'] } }, + }, + output: 'var foo = { bar: baz };', + }, + { + title: 'inferring bracketSpacing with eslint object-curly-spacing options', + options: { + text: 'var foo = {bar: {baz: qux}};\nvar fop = {bar: [1, 2, 3]};', + eslintConfig: { + rules: { + 'object-curly-spacing': [ + 'error', + 'always', + { objectsInObjects: false, arraysInObjects: false }, + ], + }, + }, }, - output: 'var foo = { bar: baz };' + output: 'var foo = { bar: { baz: qux }};\nvar fop = { bar: [1, 2, 3]};', }, { title: 'inferring bracketSpacing with eslint object-curly-spacing options', - input: { + options: { text: 'var foo = {bar: {baz: qux}};\nvar fop = {bar: [1, 2, 3]};', eslintConfig: { rules: { 'object-curly-spacing': [ 'error', 'always', - { objectsInObjects: false, arraysInObjects: false } - ] - } - } + { objectsInObjects: false, arraysInObjects: false }, + ], + }, + }, }, - output: 'var foo = { bar: { baz: qux }};\nvar fop = { bar: [1, 2, 3]};' + output: 'var foo = { bar: { baz: qux }};\nvar fop = { bar: [1, 2, 3]};', }, { title: 'with a filePath-aware config', - input: { + options: { text: 'var x = 0;', eslintConfig: { rules: { 'no-var': 'error' }, - ignorePattern: 'should-be-ignored' + ignorePattern: 'should-be-ignored', }, - filePath: path.resolve('should-be-ignored.js') + filePath: path.resolve('should-be-ignored.js'), }, - output: 'var x = 0;' + output: 'var x = 0;', }, - // if you have a bug report or something, - // go ahead and add a test case here { + // if you have a bug report or something, + // go ahead and add a test case here title: 'with code that needs no fixing', - input: { + options: { text: 'var [foo, { bar }] = window.APP;', - eslintConfig: { rules: {} } + eslintConfig: { rules: {} }, }, - output: 'var [foo, { bar }] = window.APP;' + output: 'var [foo, { bar }] = window.APP;', }, { title: 'accepts config globals as array', - input: { + options: { text: defaultInputText(), - eslintConfig: { globals: ['window:writable'] } + eslintConfig: { globals: ['window:writable'] }, }, - output: noopOutput() + output: noopOutput(), }, { title: 'CSS example', - input: { + options: { text: '.stop{color:red};', - filePath: path.resolve('./test.css') + filePath: path.resolve('./test.css'), }, - output: '.stop {\n color: red;\n}' + output: '.stop {\n color: red;\n}', }, { title: 'LESS example', - input: { + options: { text: '.stop{color:red};', - filePath: path.resolve('./test.less') + filePath: path.resolve('./test.less'), }, - output: '.stop {\n color: red;\n}' + output: '.stop {\n color: red;\n}', }, { title: 'SCSS example', - input: { + options: { text: '.stop{color:red};', - filePath: path.resolve('./test.scss') + filePath: path.resolve('./test.scss'), }, - output: '.stop {\n color: red;\n}' + output: '.stop {\n color: red;\n}', }, { title: 'TypeScript example', - input: { + options: { text: 'function Foo (this: void) { return this; }', - filePath: path.resolve('./test.ts') + filePath: path.resolve('./test.ts'), }, - output: 'function Foo(this: void) {\n return this;\n}' + output: 'function Foo(this: void) {\n return this;\n}', }, { title: 'Vue.js example', - input: { + options: { eslintConfig: { rules: { - 'space-before-function-paren': [2, 'always'] - } + 'space-before-function-paren': [2, 'always'], + }, }, text: '\n\n', - filePath: path.resolve('./test.vue') + filePath: path.resolve('./test.vue'), }, + output: - '\n\n' + '\n\n', }, { title: 'Svelte example', - input: { + options: { prettierOptions: { - plugins: ['prettier-plugin-svelte'], - overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }] - }, + plugins: ['prettier-plugin-svelte'], + overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }], + }, text: '\n
test
\n', - filePath: path.resolve('./test.svelte') + filePath: path.resolve('./test.svelte'), }, + output: - '\n\n
test
\n\n' + '\n\n
test
\n\n', }, { title: 'GraphQL example', - input: { + options: { text: 'type Query{test: Test}', - filePath: path.resolve('./test.gql') + filePath: path.resolve('./test.gql'), }, - output: 'type Query {\n test: Test\n}' + output: 'type Query {\n test: Test\n}', }, { title: 'JSON example', - input: { + options: { text: '{ "foo": "bar"}', - filePath: path.resolve('./test.json') + filePath: path.resolve('./test.json'), }, - output: '{ "foo": "bar" }' + output: '{ "foo": "bar" }', }, + { title: 'Markdown example', - input: { + options: { text: '# Foo\n _bar_', - filePath: path.resolve('./test.md') + filePath: path.resolve('./test.md'), }, - output: '# Foo\n\n_bar_' + output: '# Foo\n\n_bar_', }, + { title: 'Test eslintConfig.globals as an object', - input: { + options: { text: 'var foo = { "bar": "baz"}', eslintConfig: { globals: { - someGlobal: true - } - } + someGlobal: true, + }, + }, }, - output: 'var foo = { bar: "baz" };' - } + output: 'var foo = { bar: "baz" };', + }, ]; -beforeEach(() => { - eslintMock.mock.lintText.mockClear(); - eslintMock.mock.calculateConfigForFile.mockClear(); - prettierMock.format.mockClear(); - prettierMock.resolveConfig.mockClear(); - fsMock.readFileSync.mockClear(); - loglevelMock.mock.clearAll(); - global.__PRETTIER_ESLINT_TEST_STATE__ = {}; -}); - -tests.forEach(({ title, modifier, input, output }) => { - let fn = test; - if (modifier) { - fn = test[modifier]; - } - fn(title, async () => { - input.text = stripIndent(input.text).trim(); - const expected = stripIndent(output).trim(); - const actual = await format(input); - // adding the newline in the expected because - // prettier adds a newline to the end of the input - expect(actual).toBe(`${expected}\n`); +test.for(formatTests)('format $title', async ({ options, output }) => { + // A newline is added to the expected output to account + // for prettier's behavior. + const expected = `${stripIndent(output).trim()}\n`; + const actual = await format({ + ...options, + text: stripIndent(options.text).trim(), }); + + expect(actual).toBe(expected); }); test('analyze returns the messages', async () => { @@ -274,29 +310,33 @@ test('analyze returns the messages', async () => { const result = await analyze({ text, eslintConfig: { - rules: { 'no-var': 'error' } - } - }) + rules: { 'no-var': 'error' }, + }, + }); expect(result.output).toBe(`${text}\n`); expect(result.messages).toHaveLength(1); const msg = result.messages[0]; expect(msg.ruleId).toBe('no-var'); expect(msg.column).toBe(1); expect(msg.endColumn).toBe(11); -}) +}); test('failure to fix with eslint throws and logs an error', async () => { - const { lintText } = eslintMock.mock; + const lintText = eslintMockHelpers.getLintTextSpy(); + const error = new Error('Something happened'); - lintText.throwError = error; + lintText.mockImplementationOnce(() => { + throw error; + }); - await expect(() => format({ text: '' })).rejects.toThrowError(error); + await expect(async () => await format({ text: '' })).rejects.toThrowError( + error, + ); expect(logger.error).toHaveBeenCalledTimes(1); - lintText.throwError = null; }); test('logLevel is used to configure the logger', async () => { - logger.setLevel = jest.fn(); + logger.setLevel = vi.fn(); await format({ text: '', logLevel: 'silent' }); expect(logger.setLevel).toHaveBeenCalledTimes(1); expect(logger.setLevel).toHaveBeenCalledWith('silent'); @@ -312,24 +352,24 @@ test('when prettier throws, log to logger.error and throw the error', async () = }); test('can accept a path to an eslint module and uses that instead.', async () => { - const eslintPath = path.join(__dirname, '../__mocks__/eslint'); + const eslintPath = path.join(__dirname, '../__mocks__/eslint.mjs'); await format({ text: '', eslintPath }); - expect(eslintMock.mock.lintText).toHaveBeenCalledTimes(1); + expect(eslintMockHelpers.getLintTextSpy()).toHaveBeenCalledTimes(1); }); test('fails with an error if the eslint module cannot be resolved.', async () => { const eslintPath = path.join( __dirname, - '../__mocks__/non-existent-eslint-module' + '../__mocks__/non-existent-eslint-module', ); await expect(() => format({ text: '', eslintPath })).rejects.toThrowError( - /non-existent-eslint-module/ + /non-existent-eslint-module/, ); expect(logger.error).toHaveBeenCalledTimes(1); const errorString = expect.stringMatching( - /trouble getting.*?eslint.*non-existent-eslint-module/ + /trouble getting.*?eslint.*non-existent-eslint-module/, ); expect(logger.error).toHaveBeenCalledWith(errorString); @@ -345,15 +385,15 @@ test('can accept a path to a prettier module and uses that instead.', async () = test('fails with an error if the prettier module cannot be resolved.', async () => { const prettierPath = path.join( __dirname, - '../__mocks__/non-existent-prettier-module' + '../__mocks__/non-existent-prettier-module', ); await expect(() => format({ text: '', prettierPath })).rejects.toThrowError( - /non-existent-prettier-module/ + /non-existent-prettier-module/, ); expect(logger.error).toHaveBeenCalledTimes(1); const errorString = expect.stringMatching( - /trouble getting.*?eslint.*non-existent-prettier-module/ + /trouble getting.*?eslint.*non-existent-prettier-module/, ); expect(logger.error).toHaveBeenCalledWith(errorString); }); @@ -363,12 +403,13 @@ test('resolves to the eslint module relative to the given filePath', async () => await format({ text: '', filePath }); const stateObj = { eslintPath: require.resolve( - '../../tests/fixtures/paths/node_modules/eslint/index.js' + '../../tests/fixtures/paths/node_modules/eslint/index.js', ), prettierPath: require.resolve( - '../../tests/fixtures/paths/node_modules/prettier/index.js' - ) + '../../tests/fixtures/paths/node_modules/prettier/index.js', + ), }; + // console.dir({stateObj}); expect(global.__PRETTIER_ESLINT_TEST_STATE__).toMatchObject(stateObj); }); @@ -377,32 +418,32 @@ test('resolves to the local eslint module', async () => { await format({ text: '', filePath }); expect(global.__PRETTIER_ESLINT_TEST_STATE__).toMatchObject({ // without Jest's mocking, these would actually resolve to the - // project modules :) The fact that jest's mocking is being + // project modules :) The fact that vitest's mocking is being // applied is good enough for this test. - eslintPath: require.resolve('../__mocks__/eslint'), - prettierPath: require.resolve('../__mocks__/prettier') + eslintPath: require.resolve('../__mocks__/eslint.mjs'), + prettierPath: require.resolve('../__mocks__/prettier.mjs'), }); }); test('reads text from fs if filePath is provided but not text', async () => { - const readFileSyncMockSpy = jest.spyOn(fsMock, 'readFileSync'); - + const spy = vi.spyOn(fsMock, 'readFileSync').mockImplementation(() => { + return defaultInputText(); + }); const filePath = '/blah-blah/some-file.js'; await format({ filePath }); - expect(readFileSyncMockSpy).toHaveBeenCalledWith(filePath, 'utf8'); + expect(spy).toHaveBeenCalledWith(filePath, 'utf8'); }); test('logs error if it cannot read the file from the filePath', async () => { - const originalMock = fsMock.readFileSync; - fsMock.readFileSync = jest.fn(() => { + const spy = vi.spyOn(fsMock, 'readFileSync').mockImplementationOnce(() => { throw new Error('some error'); }); + await expect(() => - format({ filePath: '/some-path.js' }) + format({ filePath: '/some-path.js' }), ).rejects.toThrowError(/some error/); expect(logger.error).toHaveBeenCalledTimes(1); - fsMock.readFileSync = originalMock; }); test('calls prettier.resolveConfig with the file path', async () => { @@ -410,7 +451,7 @@ test('calls prettier.resolveConfig with the file path', async () => { await format({ filePath, text: defaultInputText(), - eslintConfig: getESLintConfigWithDefaultRules() + eslintConfig: getESLintConfigWithDefaultRules(), }); expect(prettierMock.resolveConfig).toHaveBeenCalledTimes(1); expect(prettierMock.resolveConfig).toHaveBeenCalledWith(filePath); @@ -425,7 +466,7 @@ test('does not raise an error if prettier.resolveConfig is not defined', async ( return format({ filePath, text: defaultInputText(), - eslintConfig: getESLintConfigWithDefaultRules() + eslintConfig: getESLintConfigWithDefaultRules(), }); } @@ -440,7 +481,7 @@ test('logs if there is a problem making the CLIEngine', async () => { throw error; }); await expect(() => format({ text: '' })).rejects.toThrowError(error); - eslintMock.ESLint.mockReset(); + eslintMock.ESLint.mockRestore(); expect(logger.error).toHaveBeenCalledTimes(1); }); @@ -459,12 +500,12 @@ function getESLintConfigWithDefaultRules(overrides) { objects: 'always-multiline', imports: 'always-multiline', exports: 'always-multiline', - functions: 'always-multiline' - } + functions: 'always-multiline', + }, ], 'arrow-parens': [2, 'as-needed'], - ...overrides - } + ...overrides, + }, }; } diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js deleted file mode 100644 index 62f1fa69..00000000 --- a/src/__tests__/utils.js +++ /dev/null @@ -1,308 +0,0 @@ -import path from 'path'; -import { getOptionsForFormatting } from '../utils'; - -const getPrettierOptionsFromESLintRulesTests = [ - { - rules: { - 'max-len': [2, 120, 2], - indent: [2, 2, { SwitchCase: 1 }], - quotes: [2, 'single', { avoidEscape: true, allowTemplateLiterals: true }], - 'comma-dangle': [ - 2, - { - arrays: 'always-multiline', - objects: 'always-multiline', - imports: 'always-multiline', - exports: 'always-multiline', - functions: 'always-multiline' - } - ], - 'object-curly-spacing': [2, 'never'] - }, - options: { - printWidth: 120, - tabWidth: 2, - singleQuote: true, - trailingComma: 'all', - bracketSpacing: false - } - }, - { - rules: { 'object-curly-spacing': [2, 'always'] }, - options: { bracketSpacing: true } - }, - { - rules: { 'object-curly-spacing': [2, 'never'] }, - options: { bracketSpacing: false } - }, - { rules: { 'max-len': 2 }, options: {} }, - { - rules: { 'comma-dangle': [2, 'never'] }, - options: { trailingComma: 'none' } - }, - { - rules: { 'comma-dangle': [2, 'always'] }, - options: { trailingComma: 'es5' } - }, - { - rules: { - 'comma-dangle': [ - 2, - { - arrays: 'always-multiline', - objects: 'always-multiline', - imports: 'always-multiline', - exports: 'always-multiline', - functions: 'always-multiline' - } - ] - }, - options: { trailingComma: 'all' } - }, - { - rules: { - 'comma-dangle': [ - 2, - { - arrays: 'always-multiline', - objects: 'always-multiline', - imports: 'always-multiline', - exports: 'always-multiline', - functions: 'never' - } - ] - }, - options: { trailingComma: 'es5' } - }, - { - rules: { - 'comma-dangle': [ - 2, - { - arrays: 'never', - objects: 'never', - imports: 'never', - exports: 'never', - functions: 'never' - } - ] - }, - options: { trailingComma: 'none' } - }, - { - rules: { 'max-len': ['error', { code: 120 }] }, - options: { printWidth: 120 } - }, - { rules: { quotes: [2, 'double'] }, options: { singleQuote: false } }, - { rules: { quotes: [2, 'backtick'] }, options: { singleQuote: false } }, - { - rules: { - 'comma-dangle': [ - 2, - { - imports: 'never', - exports: 'never' - } - ] - }, - options: { trailingComma: 'none' } - }, - { - rules: { 'comma-dangle': [2, 'always-multiline'] }, - options: { trailingComma: 'es5' } - }, - { - rules: {}, - options: { bracketSameLine: true }, - fallbackPrettierOptions: { bracketSameLine: true } - }, - { - rules: { 'react/jsx-closing-bracket-location': [2, 'after-props'] }, - options: { bracketSameLine: true } - }, - { - rules: { 'react/jsx-closing-bracket-location': [2, 'tag-aligned'] }, - options: { bracketSameLine: false } - }, - { - rules: { - 'react/jsx-closing-bracket-location': [ - 2, - { - nonEmpty: 'after-props' - } - ] - }, - options: { bracketSameLine: true } - }, - { - rules: { - 'arrow-parens': [2, 'always'] - }, - options: { arrowParens: 'always' } - }, - { - rules: { - 'arrow-parens': [2, 'as-needed'] - }, - options: { arrowParens: 'avoid' } - }, - { - rules: { - 'prettier/prettier': [2, { singleQuote: false }], - quotes: [2, 'single'] - }, - options: { - singleQuote: false - } - }, - - // If an ESLint rule is disabled fall back to prettier defaults. - { rules: { 'max-len': [0, { code: 120 }] }, options: {} }, - { rules: { quotes: ['off', 'single'] }, options: {} }, - { rules: { quotes: ['off', 'backtick'] }, options: {} }, - { rules: { semi: 'off' }, options: {} }, - { rules: { semi: ['off', 'never'] }, options: {} }, - { rules: { semi: ['warn', 'always'] }, options: {} }, - { rules: { semi: ['warn', 'always'] }, options: { semi: true } }, - { rules: { semi: ['error', 'never'] }, options: { semi: false } }, - { rules: { semi: [2, 'never'] }, options: { semi: false } }, - { rules: { semi: [2, 'never'] }, options: { semi: false } }, - { rules: { indent: 'off' }, options: {} }, - { rules: { indent: ['off', 'tab'] }, options: {} }, - { rules: { indent: ['warn', 2] }, options: { tabWidth: 2 } }, - { rules: { indent: ['warn', 4] }, options: { tabWidth: 4 } }, - { rules: { indent: ['error', 'tab'] }, options: { useTabs: true } }, - { rules: { indent: [2, 'tab'] }, options: { useTabs: true } }, - { rules: { 'react/jsx-closing-bracket-location': [0] }, options: {} }, - { rules: { 'arrow-parens': [0] }, options: {} } -]; - -const eslintPath = path.join(__dirname, '../__mocks__/eslint'); - -beforeEach(() => { - global.__PRETTIER_ESLINT_TEST_STATE__ = {}; -}); - -getPrettierOptionsFromESLintRulesTests.forEach( - ({ rules, options, prettierOptions, fallbackPrettierOptions }, index) => { - test(`getPrettierOptionsFromESLintRulesTests ${index}`, () => { - const { prettier } = getOptionsForFormatting( - { rules }, - prettierOptions, - fallbackPrettierOptions - ); - expect(prettier).toMatchObject(options); - }); - } -); - -test('if prettierOptions are provided, those are preferred', () => { - const { prettier } = getOptionsForFormatting( - { rules: { quotes: [2, 'single'] } }, - { - singleQuote: false - }, - undefined, - eslintPath - ); - expect(prettier).toMatchObject({ singleQuote: false }); -}); - -// eslint-disable-next-line max-len -test('if fallbacks are provided, those are preferred over disabled eslint rules', () => { - const { prettier } = getOptionsForFormatting( - { - rules: { - quotes: [0] - } - }, - {}, - { - singleQuote: true - } - ); - expect(prettier).toMatchObject({ singleQuote: true }); -}); - -test('if fallbacks are provided, those are used if not found in eslint', () => { - const { prettier } = getOptionsForFormatting( - { rules: {} }, - undefined, - { - singleQuote: false - } - ); - expect(prettier).toMatchObject({ singleQuote: false }); -}); - -test('eslint max-len.tabWidth value should be used for tabWidth when tabs are used', () => { - const { prettier } = getOptionsForFormatting( - { - rules: { - indent: ['error', 'tab'], - 'max-len': [ - 2, - { - tabWidth: 4 - } - ] - } - }, - undefined, - undefined - ); - - expect(prettier).toMatchObject({ - tabWidth: 4, - useTabs: true - }); -}); - -test('eslint config has only necessary properties', () => { - const { eslint } = getOptionsForFormatting( - { - globals: ['window:false'], - rules: { 'no-with': 'error', quotes: [2, 'single'] } - }, - undefined, - undefined - ); - expect(eslint).toMatchObject({ - fix: true, - useEslintrc: false, - rules: { quotes: [2, 'single'] } - }); -}); - -test('useEslintrc is set to the given config value', () => { - const { eslint } = getOptionsForFormatting( - { useEslintrc: true, rules: {} }, - undefined, - undefined - ); - expect(eslint).toMatchObject({ fix: true, useEslintrc: true }); -}); - -test('Turn off unfixable rules', () => { - const { eslint } = getOptionsForFormatting( - { - rules: { - 'global-require': 'error', - quotes: ['error', 'double'] - } - }, - undefined, - undefined - ); - - expect(eslint).toMatchObject({ - rules: { - 'global-require': ['off'], - quotes: ['error', 'double'] - }, - fix: true, - globals: {}, - useEslintrc: false - }); -}); diff --git a/src/__tests__/utils.test.mjs b/src/__tests__/utils.test.mjs new file mode 100644 index 00000000..7e41da88 --- /dev/null +++ b/src/__tests__/utils.test.mjs @@ -0,0 +1,399 @@ +import path from 'node:path'; +import { beforeEach, expect, test } from 'vitest'; +import { getOptionsForFormatting } from '../utils.mjs'; + +const getPrettierOptionsFromESLintRulesTests = [ + { + title: 'all rules interaction', + rules: { + title: 'all rules interaction', + 'max-len': [2, 120, 2], + indent: [2, 2, { SwitchCase: 1 }], + quotes: [2, 'single', { avoidEscape: true, allowTemplateLiterals: true }], + 'comma-dangle': [ + 2, + { + arrays: 'always-multiline', + objects: 'always-multiline', + imports: 'always-multiline', + exports: 'always-multiline', + functions: 'always-multiline', + }, + ], + 'object-curly-spacing': [2, 'never'], + }, + options: { + printWidth: 120, + tabWidth: 2, + singleQuote: true, + trailingComma: 'all', + bracketSpacing: false, + }, + }, + { + title: 'object-curly-spacing, always', + rules: { 'object-curly-spacing': [2, 'always'] }, + options: { bracketSpacing: true }, + }, + { + title: 'object-curly-spacing, never', + rules: { 'object-curly-spacing': [2, 'never'] }, + options: { bracketSpacing: false }, + }, + { + title: 'max-len', + rules: { 'max-len': 2 }, + options: {}, + }, + { + title: 'comma-dangle, never', + rules: { 'comma-dangle': [2, 'never'] }, + options: { trailingComma: 'none' }, + }, + { + title: 'comma-dangle, always', + rules: { 'comma-dangle': [2, 'always'] }, + options: { trailingComma: 'es5' }, + }, + { + title: 'comma-dangle, always-multiline', + rules: { + 'comma-dangle': [ + 2, + { + arrays: 'always-multiline', + objects: 'always-multiline', + imports: 'always-multiline', + exports: 'always-multiline', + functions: 'always-multiline', + }, + ], + }, + options: { trailingComma: 'all' }, + }, + { + title: 'comma-dangle, always-multiline except functions', + rules: { + 'comma-dangle': [ + 2, + { + arrays: 'always-multiline', + objects: 'always-multiline', + imports: 'always-multiline', + exports: 'always-multiline', + functions: 'never', + }, + ], + }, + options: { trailingComma: 'es5' }, + }, + { + title: 'comma-dangle, never', + rules: { + 'comma-dangle': [ + 2, + { + arrays: 'never', + objects: 'never', + imports: 'never', + exports: 'never', + functions: 'never', + }, + ], + }, + options: { trailingComma: 'none' }, + }, + { + title: 'max-len, code 120', + rules: { 'max-len': ['error', { code: 120 }] }, + options: { printWidth: 120 }, + }, + { + title: 'quotes, double', + rules: { quotes: [2, 'double'] }, + options: { singleQuote: false }, + }, + { + title: 'quotes, backtick', + rules: { quotes: [2, 'backtick'] }, + options: { singleQuote: false }, + }, + { + title: 'comma-dangle, imports never, exports never', + rules: { + 'comma-dangle': [ + 2, + { + imports: 'never', + exports: 'never', + }, + ], + }, + options: { trailingComma: 'none' }, + }, + { + title: 'comma-dangle, always-multiline', + rules: { 'comma-dangle': [2, 'always-multiline'] }, + options: { trailingComma: 'es5' }, + }, + { + title: 'no rules', + rules: {}, + options: { bracketSameLine: true }, + fallbackPrettierOptions: { bracketSameLine: true }, + }, + { + title: 'react/jsx-closing-bracket-location, after-props', + rules: { 'react/jsx-closing-bracket-location': [2, 'after-props'] }, + options: { bracketSameLine: true }, + }, + { + title: 'react/jsx-closing-bracket-location, tag-aligned', + rules: { 'react/jsx-closing-bracket-location': [2, 'tag-aligned'] }, + options: { bracketSameLine: false }, + }, + { + title: 'react/jsx-closing-bracket-location, after-props', + rules: { + 'react/jsx-closing-bracket-location': [ + 2, + { + nonEmpty: 'after-props', + }, + ], + }, + options: { bracketSameLine: true }, + }, + { + title: 'arrow-parens, always', + rules: { + 'arrow-parens': [2, 'always'], + }, + options: { arrowParens: 'always' }, + }, + { + title: 'arrow-parens as-needed', + rules: { + 'arrow-parens': [2, 'as-needed'], + }, + options: { arrowParens: 'avoid' }, + }, + { + title: 'prettier/prettier & quotes conflict', + rules: { + 'prettier/prettier': [2, { singleQuote: false }], + quotes: [2, 'single'], + }, + options: { + singleQuote: false, + }, + }, + + // If an ESLint rule is disabled fall back to prettier defaults. + { + title: 'max-len off, prettier defaults', + rules: { 'max-len': [0, { code: 120 }] }, + options: {}, + }, + { + title: 'quotes off, single, prettier defaults', + rules: { quotes: ['off', 'single'] }, + options: {}, + }, + { + title: 'quotes off, backtick, prettier defaults', + rules: { quotes: ['off', 'backtick'] }, + options: {}, + }, + { + title: 'semi off, prettier defaults', + rules: { semi: 'off' }, + options: {}, + }, + { + title: 'semi off, never, prettier defaults', + rules: { semi: ['off', 'never'] }, + options: {}, + }, + { + title: 'semi warn, always, prettier defaults', + rules: { semi: ['warn', 'always'] }, + options: { semi: true }, + }, + { + title: 'semi error, never, prettier defaults', + rules: { semi: ['error', 'never'] }, + options: { semi: false }, + }, + { + title: 'semi error, never (2), prettier defaults', + rules: { semi: [2, 'never'] }, + options: { semi: false }, + }, + { + title: 'indent off, prettier defaults', + rules: { indent: 'off' }, + options: {}, + }, + { + title: 'indent off, tab, prettier defaults', + rules: { indent: ['off', 'tab'] }, + options: {}, + }, + { + title: 'indent warn, 2, prettier defaults', + rules: { indent: ['warn', 2] }, + options: { tabWidth: 2 }, + }, + { + title: 'indent warn, 4, prettier defaults', + rules: { indent: ['warn', 4] }, + options: { tabWidth: 4 }, + }, + { + title: 'indent error, tab, prettier defaults', + rules: { indent: ['error', 'tab'] }, + options: { useTabs: true }, + }, + { + title: 'indent error (2), tab, prettier defaults', + rules: { indent: [2, 'tab'] }, + options: { useTabs: true }, + }, + { + title: 'react/jsx-closing-bracket-location off, prettier defaults', + rules: { 'react/jsx-closing-bracket-location': [0] }, + options: {}, + }, + { + title: 'arrow-parents off, prettier defaults', + rules: { 'arrow-parens': [0] }, + options: {}, + }, +]; + +const eslintPath = path.join(__dirname, '../__mocks__/eslint'); + +beforeEach(() => { + global.__PRETTIER_ESLINT_TEST_STATE__ = {}; +}); + +test.for(getPrettierOptionsFromESLintRulesTests)( + 'getPrettierOptionsFromESLintRules $title', + ({ rules, options, prettierOptions, fallbackPrettierOptions }) => { + const { prettier } = getOptionsForFormatting( + { rules }, + prettierOptions, + fallbackPrettierOptions, + ); + + expect(prettier).toStrictEqual(options); + }, +); + +test('if prettierOptions are provided, those are preferred', () => { + const { prettier } = getOptionsForFormatting( + { rules: { quotes: [2, 'single'] } }, + { + singleQuote: false, + }, + undefined, + eslintPath, + ); + expect(prettier).toMatchObject({ singleQuote: false }); +}); + +// eslint-disable-next-line max-len +test('if fallbacks are provided, those are preferred over disabled eslint rules', () => { + const { prettier } = getOptionsForFormatting( + { + rules: { + quotes: [0], + }, + }, + {}, + { + singleQuote: true, + }, + ); + expect(prettier).toMatchObject({ singleQuote: true }); +}); + +test('if fallbacks are provided, those are used if not found in eslint', () => { + const { prettier } = getOptionsForFormatting({ rules: {} }, undefined, { + singleQuote: false, + }); + expect(prettier).toMatchObject({ singleQuote: false }); +}); + +test('eslint max-len.tabWidth value should be used for tabWidth when tabs are used', () => { + const { prettier } = getOptionsForFormatting( + { + rules: { + indent: ['error', 'tab'], + 'max-len': [ + 2, + { + tabWidth: 4, + }, + ], + }, + }, + undefined, + undefined, + ); + + expect(prettier).toMatchObject({ + tabWidth: 4, + useTabs: true, + }); +}); + +test('eslint config has only necessary properties', () => { + const { eslint } = getOptionsForFormatting( + { + globals: ['window:false'], + rules: { 'no-with': 'error', quotes: [2, 'single'] }, + }, + undefined, + undefined, + ); + expect(eslint).toStrictEqual({ + fix: true, + useEslintrc: false, + globals: ['window:false'], + rules: { ['no-with']: 'error', quotes: [2, 'single'] }, + }); +}); + +test('useEslintrc is set to the given config value', () => { + const { eslint } = getOptionsForFormatting( + { useEslintrc: true, rules: {} }, + undefined, + undefined, + ); + expect(eslint).toMatchObject({ fix: true, useEslintrc: true }); +}); + +test('Turn off unfixable rules', () => { + const { eslint } = getOptionsForFormatting( + { + rules: { + 'global-require': ['off'], + quotes: ['error', 'double'], + }, + }, + undefined, + undefined, + ); + + expect(eslint).toMatchObject({ + rules: { + 'global-require': ['off'], + quotes: ['error', 'double'], + }, + fix: true, + globals: {}, + useEslintrc: false, + }); +}); diff --git a/src/index.js b/src/index.mjs similarity index 74% rename from src/index.js rename to src/index.mjs index 4661b274..ef63fa3d 100644 --- a/src/index.js +++ b/src/index.mjs @@ -2,20 +2,17 @@ /* eslint complexity: [1, 13] */ import fs from 'fs'; -import path from 'path'; +import path from 'node:path'; import requireRelative from 'require-relative'; import prettyFormat from 'pretty-format'; import { oneLine, stripIndent } from 'common-tags'; import indentString from 'indent-string'; import getLogger from 'loglevel-colored-level-prefix'; import merge from 'lodash.merge'; -import { getESLint, getOptionsForFormatting, requireModule } from './utils'; +import { getESLint, getOptionsForFormatting, importModule } from './utils.mjs'; const logger = getLogger({ prefix: 'prettier-eslint' }); -// CommonJS + ES6 modules... is it worth it? Probably not... -module.exports = format; - /** * Formats the text with prettier and then eslint based on the given options * @param {String} options.filePath - the path of the file being formatted @@ -40,8 +37,8 @@ module.exports = format; * @param {Boolean} options.prettierLast - Run Prettier Last * @return {Promise} - the formatted string */ -async function format(options) { - const {output} = await analyze(options); +export async function format(options) { + const { output } = await analyze(options); return output; } @@ -52,12 +49,13 @@ async function format(options) { * properties `output` giving the formatted code and `messages` giving * any error messages generated in the analysis. * @param {Object} identical to options parameter of `format` - * @returns {Promise} the return value is an object `r` such that + * @returns {Promise<{ output: string; messages: string[] }>} + * The return value is an object `r` such that * `r.output` is the formatted string and `r.messages` is an array of * message specifications from eslint. */ // eslint-disable-next-line complexity -async function analyze(options) { +export async function analyze(options) { const { logLevel = getDefaultLogLevel() } = options; logger.setLevel(logLevel); logger.trace('called analyze with options:', prettyFormat(options)); @@ -68,13 +66,24 @@ async function analyze(options) { eslintPath = getModulePath(filePath, 'eslint'), prettierPath = getModulePath(filePath, 'prettier'), prettierLast, - fallbackPrettierOptions + fallbackPrettierOptions, } = options; + const eslintConfigFromFilePath = await getESLintConfig( + filePath, + eslintPath, + options.eslintConfig || {}, + ); + const eslintConfig = merge( {}, options.eslintConfig, - await getESLintConfig(filePath, eslintPath, options.eslintConfig || {}) + eslintConfigFromFilePath, + ); + + const prettierConfigFromFilePath = await getPrettierConfig( + filePath, + prettierPath, ); const prettierOptions = merge( @@ -82,15 +91,15 @@ async function analyze(options) { // Let prettier infer the parser using the filepath, if present. Otherwise // assume the file is JS and default to the babel parser. filePath ? { filepath: filePath } : { parser: 'babel' }, - await getPrettierConfig(filePath, prettierPath), - options.prettierOptions + prettierConfigFromFilePath, + options.prettierOptions, ); const formattingOptions = getOptionsForFormatting( eslintConfig, prettierOptions, fallbackPrettierOptions, - eslintPath + eslintPath, ); logger.debug( @@ -103,8 +112,8 @@ async function analyze(options) { eslintConfig: formattingOptions.eslint, prettierOptions: formattingOptions.prettier, logLevel, - prettierLast - }) + prettierLast, + }), ); const eslintExtensions = eslintConfig.extensions || [ @@ -114,11 +123,11 @@ async function analyze(options) { '.tsx', '.mjs', '.vue', - '.svelte' + '.svelte', ]; const fileExtension = path.extname(filePath || ''); - // If we don't get filePath run eslint on text, otherwise only run eslint + // If we don't get filePath, run eslint on text, otherwise only run eslint // if it's a configured extension or fall back to a "supported" file type. const onlyPrettier = filePath ? !eslintExtensions.includes(fileExtension) @@ -130,19 +139,17 @@ async function analyze(options) { return prettify(text); } - if (['.ts', '.tsx'].includes(fileExtension)) { - formattingOptions.eslint.parser ||= require.resolve( - '@typescript-eslint/parser' - ); - } - - if (['.vue'].includes(fileExtension)) { - formattingOptions.eslint.parser ||= require.resolve('vue-eslint-parser'); - } - - if (['.svelte'].includes(fileExtension)) { - formattingOptions.eslint.parser ||= require.resolve('svelte-eslint-parser'); - } + formattingOptions.eslint.parser = + formattingOptions.eslint.parser ?? + (['.ts', '.tsx'].includes(fileExtension) + ? require.resolve('@typescript-eslint/parser') + : undefined) ?? + (['.vue'].includes(fileExtension) + ? require.resolve('vue-eslint-parser') + : undefined) ?? + (['.svelte'].includes(fileExtension) + ? require.resolve('svelte-eslint-parser') + : undefined); const eslintFix = await createEslintFix(formattingOptions.eslint, eslintPath); @@ -150,16 +157,17 @@ async function analyze(options) { const eslintFixed = await eslintFix(text, filePath); return prettify(eslintFixed); } + return eslintFix((await prettify(text)).output, filePath); } function createPrettify(formatOptions, prettierPath) { return async function prettify(param) { - let text = param - let messages = [] + let text = param; + let messages = []; if (typeof param !== 'string') { - text = param.output - messages = param.text + text = param.output; + messages = param.text; } logger.debug('calling prettier on text'); logger.trace( @@ -167,21 +175,21 @@ function createPrettify(formatOptions, prettierPath) { prettier input: ${indentString(text, 2)} - ` + `, ); - const prettier = requireModule(prettierPath, 'prettier'); + const Prettier = await importModule(prettierPath, 'prettier'); try { logger.trace('calling prettier.format with the text and prettierOptions'); - const output = await prettier.format(text, formatOptions); + const output = await Prettier.format(text, formatOptions); logger.trace('prettier: output === input', output === text); logger.trace( stripIndent` prettier output: ${indentString(output, 2)} - ` + `, ); - return {output, messages}; + return { output, messages }; } catch (error) { logger.error('prettier formatting failed due to a prettier error'); throw error; @@ -200,39 +208,49 @@ function createEslintFix(eslintConfig, eslintPath) { eslintConfig.globals = tempGlobals; } - eslintConfig.overrideConfig = { - rules: eslintConfig.rules, - parser: eslintConfig.parser, - globals: eslintConfig.globals, - parserOptions: eslintConfig.parserOptions, - ignorePatterns: eslintConfig.ignorePatterns || eslintConfig.ignorePattern, - plugins: eslintConfig.plugins, - env: eslintConfig.env, - settings: eslintConfig.settings, - noInlineConfig: eslintConfig.noInlineConfig, - ...eslintConfig.overrideConfig + // Pluck values from base configuration and apply to overrideConfig + const { + env, + globals, + ignorePatterns, + ignorePattern, + noInlineConfig, + parser, + parserOptions, + plugins, + rules, + settings, + ...rest + } = eslintConfig; + + const nextEslintConfig = { + ...rest, + overrideConfig: { + env, + globals, + ignorePatterns: ignorePatterns ?? ignorePattern, + noInlineConfig, + parser, + parserOptions, + plugins, + rules, + settings, + ...eslintConfig.overrideConfig, + }, }; - delete eslintConfig.rules; - delete eslintConfig.parser; - delete eslintConfig.parserOptions; - delete eslintConfig.globals; - delete eslintConfig.ignorePatterns; - delete eslintConfig.ignorePattern; - delete eslintConfig.plugins; - delete eslintConfig.env; - delete eslintConfig.noInlineConfig; - delete eslintConfig.settings; - - const eslint = getESLint(eslintPath, eslintConfig); + + const eslint = await getESLint(eslintPath, nextEslintConfig); + try { logger.trace('calling cliEngine.executeOnText with the text'); + // console.dir({ eslint }); const report = await eslint.lintText(text, { filePath, - warnIgnored: true + warnIgnored: true, }); logger.trace( 'executeOnText returned the following report:', - prettyFormat(report) + prettyFormat(report), ); // default the output to text because if there's nothing // to fix, eslint doesn't provide `output` @@ -247,9 +265,9 @@ function createEslintFix(eslintConfig, eslintPath) { eslint --fix output: ${indentString(output, 2)} - ` + `, ); - return {output, messages}; + return { output, messages }; } catch (error) { logger.error('eslint fix failed due to an eslint error'); throw error; @@ -263,7 +281,7 @@ function getTextFromFilePath(filePath) { oneLine` attempting fs.readFileSync to get the text for file at "${filePath}" - ` + `, ); return fs.readFileSync(filePath, 'utf8'); } catch (error) { @@ -271,7 +289,7 @@ function getTextFromFilePath(filePath) { oneLine` failed to get the text to format from the given filePath: "${filePath}" - ` + `, ); throw error; } @@ -290,7 +308,7 @@ function getESLintApiOptions(eslintConfig) { plugins: eslintConfig.plugins || null, resolvePluginsRelativeTo: eslintConfig.resolvePluginsRelativeTo || null, rulePaths: eslintConfig.rulePaths || [], - useEslintrc: eslintConfig.useEslintrc || true + useEslintrc: eslintConfig.useEslintrc || true, }; } @@ -302,20 +320,25 @@ async function getESLintConfig(filePath, eslintPath, eslintOptions) { oneLine` creating ESLint CLI Engine to get the config for "${filePath || process.cwd()}" - ` + `, + ); + const eslint = await getESLint( + eslintPath, + getESLintApiOptions(eslintOptions), ); - const eslint = getESLint(eslintPath, getESLintApiOptions(eslintOptions)); try { logger.debug(`getting eslint config for file at "${filePath}"`); const config = await eslint.calculateConfigForFile(filePath); + logger.trace( `eslint config for "${filePath}" received`, - prettyFormat(config) + prettyFormat(config), ); + return { ...eslintOptions, - ...config + ...config, }; } catch (error) { // is this noisy? Try setting options.disableLog to false @@ -324,12 +347,12 @@ async function getESLintConfig(filePath, eslintPath, eslintOptions) { } } -function getPrettierConfig(filePath, prettierPath) { - const prettier = requireModule(prettierPath, 'prettier'); +async function getPrettierConfig(filePath, prettierPath) { + const prettier = await importModule(prettierPath, 'prettier'); return prettier.resolveConfig && prettier.resolveConfig(filePath); } -function getModulePath(filePath = __filename, moduleName) { +function getModulePath(filePath, moduleName) { try { return requireRelative.resolve(moduleName, filePath); } catch (error) { @@ -339,7 +362,7 @@ function getModulePath(filePath = __filename, moduleName) { module. Using prettier-eslint's version. `, error.message, - error.stack + error.stack, ); return require.resolve(moduleName); } @@ -348,8 +371,3 @@ function getModulePath(filePath = __filename, moduleName) { function getDefaultLogLevel() { return process.env.LOG_LEVEL || 'warn'; } - -// Allow named imports of either `analyze` or `format` from this module, -// while leaving `format` in place as the default import: -module.exports.format = format -module.exports.analyze = analyze diff --git a/src/utils.js b/src/utils.mjs similarity index 85% rename from src/utils.js rename to src/utils.mjs index 02e19ea1..cdf29e6d 100644 --- a/src/utils.js +++ b/src/utils.mjs @@ -14,7 +14,7 @@ const ruleValueExists = prettierRuleValue => const OPTION_GETTERS = { printWidth: { ruleValue: rules => getRuleValue(rules, 'max-len', 'code'), - ruleValueToPrettierOption: getPrintWidth + ruleValueToPrettierOption: getPrintWidth, }, tabWidth: { ruleValue: rules => { @@ -24,54 +24,52 @@ const OPTION_GETTERS = { } return value; }, - ruleValueToPrettierOption: getTabWidth + ruleValueToPrettierOption: getTabWidth, }, singleQuote: { ruleValue: rules => getRuleValue(rules, 'quotes'), - ruleValueToPrettierOption: getSingleQuote + ruleValueToPrettierOption: getSingleQuote, }, trailingComma: { ruleValue: rules => getRuleValue(rules, 'comma-dangle', []), - ruleValueToPrettierOption: getTrailingComma + ruleValueToPrettierOption: getTrailingComma, }, bracketSpacing: { ruleValue: rules => getRuleValue(rules, 'object-curly-spacing'), - ruleValueToPrettierOption: getBracketSpacing + ruleValueToPrettierOption: getBracketSpacing, }, semi: { ruleValue: rules => getRuleValue(rules, 'semi'), - ruleValueToPrettierOption: getSemi + ruleValueToPrettierOption: getSemi, }, useTabs: { ruleValue: rules => getRuleValue(rules, 'indent'), - ruleValueToPrettierOption: getUseTabs + ruleValueToPrettierOption: getUseTabs, }, bracketSameLine: { ruleValue: rules => getRuleValue(rules, 'react/jsx-closing-bracket-location', 'nonEmpty'), - ruleValueToPrettierOption: getBracketSameLine + ruleValueToPrettierOption: getBracketSameLine, }, arrowParens: { ruleValue: rules => getRuleValue(rules, 'arrow-parens'), - ruleValueToPrettierOption: getArrowParens - } + ruleValueToPrettierOption: getArrowParens, + }, }; -/* eslint import/prefer-default-export:0 */ -export { getESLint, getOptionsForFormatting, requireModule }; - -function getOptionsForFormatting( +export function getOptionsForFormatting( eslintConfig, prettierOptions = {}, - fallbackPrettierOptions = {} + fallbackPrettierOptions = {}, ) { - const eslint = getRelevantESLintConfig(eslintConfig); - const prettier = getPrettierOptionsFromESLintRules( - eslintConfig, - prettierOptions, - fallbackPrettierOptions - ); - return { eslint, prettier }; + return { + eslint: getRelevantESLintConfig(eslintConfig), + prettier: getPrettierOptionsFromESLintRules( + eslintConfig, + prettierOptions, + fallbackPrettierOptions, + ), + }; } function getRelevantESLintConfig(eslintConfig) { @@ -79,29 +77,25 @@ function getRelevantESLintConfig(eslintConfig) { const rules = linter.getRules(); logger.debug('turning off unfixable rules'); - const relevantRules = {}; - + // Mutate the config, turning off unfixable rules rules.forEach((rule, name) => { - const { - meta: { fixable } - } = rule; - - if (!fixable) { - logger.trace('turning off rule:', JSON.stringify({ [name]: rule })); - rule = ['off']; - relevantRules[name] = rule; + if (!rule.meta.fixable) { + logger.trace('turning off rule:', name); + rules.set(name, ['off']); } - }, {}); + }); - return { - // defaults + const finalConfig = { useEslintrc: false, ...eslintConfig, - // overrides - rules: { ...eslintConfig.rules, ...relevantRules }, + ...(eslintConfig.rules.size + ? { rules: Object.fromEntries(Object.entries(eslintConfig.rules)) } + : undefined), fix: true, - globals: eslintConfig.globals || {} + globals: eslintConfig.globals ?? {}, }; + + return finalConfig; } /** @@ -111,7 +105,7 @@ function getRelevantESLintConfig(eslintConfig) { function getPrettierOptionsFromESLintRules( eslintConfig, prettierOptions, - fallbackPrettierOptions + fallbackPrettierOptions, ) { const { rules } = eslintConfig; @@ -128,9 +122,9 @@ function getPrettierOptionsFromESLintRules( fallbackPrettierOptions, key, options, - rules + rules, ), - prettierOptions + prettierOptions, ); } @@ -141,7 +135,7 @@ function configureOptions( fallbackPrettierOptions, key, options, - rules + rules, ) { const givenOption = prettierOptions[key]; const optionIsGiven = givenOption !== undefined; @@ -155,7 +149,7 @@ function configureOptions( const option = ruleValueToPrettierOption( eslintRuleValue, fallbackPrettierOptions, - rules + rules, ); if (option !== undefined) { @@ -305,7 +299,7 @@ function extractRuleValue(objPath, name, value) { oneLine` Getting the value from object configuration of ${name}. delving into ${JSON.stringify(value)} with path "${objPath}" - ` + `, ); return delve(value, objPath, RULE_NOT_CONFIGURED); @@ -319,7 +313,7 @@ function extractRuleValue(objPath, name, value) { not currently capable of getting the prettier value based on an object configuration for ${name}. Please file an issue (and make a pull request?) - ` + `, ); // istanbul ignore next @@ -348,7 +342,7 @@ function getRuleValue(rules, name, objPath) { oneLine` The ${name} rule is configured with a non-object value of ${value}. Using that value. - ` + `, ); return value; } @@ -372,7 +366,7 @@ function makePrettierOption(prettierRuleName, prettierRuleValue, fallbacks) { oneLine` The ${prettierRuleName} rule is not configured, using provided fallback of ${fallback} - ` + `, ); return fallback; } @@ -381,28 +375,30 @@ function makePrettierOption(prettierRuleName, prettierRuleValue, fallbacks) { oneLine` The ${prettierRuleName} rule is not configured, let prettier decide - ` + `, ); return undefined; } -function requireModule(modulePath, name) { +export async function importModule(modulePath, name) { try { - logger.trace(`requiring "${name}" module at "${modulePath}"`); - return require(modulePath); + logger.trace(`importing "${name}" module at "${modulePath}"`); + return await import(modulePath).then( + ({ default: defaultExport }) => defaultExport, + ); } catch (error) { logger.error( oneLine` There was trouble getting "${name}". Is "${modulePath}" a correct path to the "${name}" module? - ` + `, ); throw error; } } -function getESLint(eslintPath, eslintOptions) { - const { ESLint } = requireModule(eslintPath, 'eslint'); +export async function getESLint(eslintPath, eslintOptions) { + const { ESLint } = await importModule(eslintPath, 'eslint'); try { return new ESLint(eslintOptions); } catch (error) { diff --git a/tests/fixtures/paths/foo.js b/tests/fixtures/paths/foo.js index 8b137891..e69de29b 100644 --- a/tests/fixtures/paths/foo.js +++ b/tests/fixtures/paths/foo.js @@ -1 +0,0 @@ - diff --git a/tests/fixtures/paths/node_modules/eslint/index.js b/tests/fixtures/paths/node_modules/eslint/index.js index 592f4f48..d3102748 100644 --- a/tests/fixtures/paths/node_modules/eslint/index.js +++ b/tests/fixtures/paths/node_modules/eslint/index.js @@ -1,6 +1,6 @@ -const eslintMock = require('../../../../../src/__mocks__/eslint') +import eslintMock from '../../../../../src/__mocks__/eslint.mjs' module.exports = Object.assign({}, eslintMock, { - ESLint: MockMockESLint + ESLint: MockMockESLint, }) function MockMockESLint(...args) { diff --git a/tests/fixtures/paths/node_modules/prettier/index.js b/tests/fixtures/paths/node_modules/prettier/index.js index 080f81a8..cbc9a0cf 100644 --- a/tests/fixtures/paths/node_modules/prettier/index.js +++ b/tests/fixtures/paths/node_modules/prettier/index.js @@ -1,6 +1,6 @@ -const mockPrettier = require('../../../../../src/__mocks__/prettier') +import mockPrettier from '../../../../../src/__mocks__/prettier.mjs' -module.exports = Object.assign({}, mockPrettier, {format: mockMockFormat}) +module.exports = Object.assign({}, mockPrettier, { format: mockMockFormat }) function mockMockFormat(...args) { try { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..0837d551 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + exclude: ['**/node_modules/**', '**/fixtures/**', '**/__mocks__/**'], + include: ['src/**/*.js'], + thresholds: { + branches: 96, + functions: 100, + lines: 100, + statements: 100, + } + }, + exclude: ['**/node_modules/**', '**/fixtures/**', '**/__mocks__/**'], + }, +});