diff --git a/docs/rules/README.md b/docs/rules/README.md index b2f19b512..5b2d22293 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -352,6 +352,7 @@ For example: | [vue/no-v-text-v-html-on-component](./no-v-text-v-html-on-component.md) | disallow v-text / v-html on component | | | [vue/no-v-text](./no-v-text.md) | disallow use of v-text | | | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: | +| [vue/prefer-import-from-vue](./prefer-import-from-vue.md) | enforce import from 'vue' instead of import from '@vue/*' | :wrench: | | [vue/prefer-separate-static-class](./prefer-separate-static-class.md) | require static class names in template to be in a separate `class` attribute | :wrench: | | [vue/prefer-true-attribute-shorthand](./prefer-true-attribute-shorthand.md) | require shorthand form attribute when `v-bind` value is `true` | :bulb: | | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported | | diff --git a/docs/rules/prefer-import-from-vue.md b/docs/rules/prefer-import-from-vue.md new file mode 100644 index 000000000..e645d916a --- /dev/null +++ b/docs/rules/prefer-import-from-vue.md @@ -0,0 +1,52 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/prefer-import-from-vue +description: enforce import from 'vue' instead of import from '@vue/*' +--- +# vue/prefer-import-from-vue + +> enforce import from 'vue' instead of import from '@vue/*' + +- :exclamation: ***This rule has not been released yet.*** +- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. + +## :book: Rule Details + +This rule aims to use imports from `'vue'` instead of imports from `'@vue/*'`. + +Imports from the following modules are almost always wrong. You should import from `vue` instead. + +- `@vue/runtime-dom` +- `@vue/runtime-core` +- `@vue/reactivity` +- `@vue/shared` + + + +```js +/* ✓ GOOD */ +import { createApp, ref, Component } from 'vue' +``` + + + + + +```js +/* ✗ BAD */ +import { createApp } from '@vue/runtime-dom' +import { Component } from '@vue/runtime-core' +import { ref } from '@vue/reactivity' +``` + + + +## :wrench: Options + +Nothing. + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-import-from-vue.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/prefer-import-from-vue.js) diff --git a/lib/index.js b/lib/index.js index 941de0b56..97ff805d3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -157,6 +157,7 @@ module.exports = { 'operator-linebreak': require('./rules/operator-linebreak'), 'order-in-components': require('./rules/order-in-components'), 'padding-line-between-blocks': require('./rules/padding-line-between-blocks'), + 'prefer-import-from-vue': require('./rules/prefer-import-from-vue'), 'prefer-separate-static-class': require('./rules/prefer-separate-static-class'), 'prefer-template': require('./rules/prefer-template'), 'prefer-true-attribute-shorthand': require('./rules/prefer-true-attribute-shorthand'), diff --git a/lib/rules/prefer-import-from-vue.js b/lib/rules/prefer-import-from-vue.js new file mode 100644 index 000000000..1fb4ecf55 --- /dev/null +++ b/lib/rules/prefer-import-from-vue.js @@ -0,0 +1,131 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const vue3ExportNames = new Set(require('../utils/vue3-export-names.json')) + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +const TARGET_AT_VUE_MODULES = new Set([ + '@vue/runtime-dom', + '@vue/runtime-core', + '@vue/reactivity', + '@vue/shared' +]) +// Modules with the names of a subset of vue. +const SUBSET_AT_VUE_MODULES = new Set(['@vue/runtime-dom', '@vue/runtime-core']) + +/** + * @param {ImportDeclaration} node + */ +function* extractImportNames(node) { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportDefaultSpecifier') { + yield 'default' + } else if (specifier.type === 'ImportNamespaceSpecifier') { + yield null // all + } else if (specifier.type === 'ImportSpecifier') { + yield specifier.imported.name + } + } +} + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: "enforce import from 'vue' instead of import from '@vue/*'", + // TODO We will change it in the next major version. + // categories: ['vue3-essential'], + categories: undefined, + url: 'https://eslint.vuejs.org/rules/prefer-import-from-vue.html' + }, + fixable: 'code', + schema: [], + messages: { + importedAtVue: "Import from 'vue' instead of '{{source}}'." + } + }, + /** + * @param {RuleContext} context + * @returns {RuleListener} + */ + create(context) { + /** + * + * @param {Literal & { value: string }} source + * @param { () => boolean } fixable + */ + function verifySource(source, fixable) { + if (!TARGET_AT_VUE_MODULES.has(source.value)) { + return + } + + context.report({ + node: source, + messageId: 'importedAtVue', + data: { source: source.value }, + fix: fixable() + ? (fixer) => + fixer.replaceTextRange( + [source.range[0] + 1, source.range[1] - 1], + 'vue' + ) + : null + }) + } + + return { + ImportDeclaration(node) { + verifySource(node.source, () => { + if (SUBSET_AT_VUE_MODULES.has(node.source.value)) { + // If the module is a subset of 'vue', we can safely change it to 'vue'. + return true + } + for (const name of extractImportNames(node)) { + if (name == null) { + return false // import all + } + if (!vue3ExportNames.has(name)) { + // If there is a name that is not exported from 'vue', it will not be auto-fixed. + return false + } + } + return true + }) + }, + ExportNamedDeclaration(node) { + if (node.source) { + verifySource(node.source, () => { + for (const specifier of node.specifiers) { + if (!vue3ExportNames.has(specifier.local.name)) { + // If there is a name that is not exported from 'vue', it will not be auto-fixed. + return false + } + } + return true + }) + } + }, + ExportAllDeclaration(node) { + verifySource( + node.source, + // If we change it to `from 'vue'`, it will export more, so it will not be auto-fixed. + () => false + ) + } + } + } +} diff --git a/lib/utils/vue3-export-names.json b/lib/utils/vue3-export-names.json new file mode 100644 index 000000000..53c8fae02 --- /dev/null +++ b/lib/utils/vue3-export-names.json @@ -0,0 +1,300 @@ +[ + "compile", + "createApp", + "createSSRApp", + "defineCustomElement", + "defineSSRCustomElement", + "hydrate", + "render", + "Transition", + "TransitionGroup", + "TransitionGroupProps", + "TransitionProps", + "useCssModule", + "useCssVars", + "vModelCheckbox", + "vModelDynamic", + "vModelRadio", + "vModelSelect", + "vModelText", + "vShow", + "VueElement", + "VueElementConstructor", + "withKeys", + "withModifiers", + "AllowedComponentProps", + "App", + "AppConfig", + "AppContext", + "AsyncComponentLoader", + "AsyncComponentOptions", + "BaseTransition", + "BaseTransitionProps", + "callWithAsyncErrorHandling", + "callWithErrorHandling", + "camelize", + "capitalize", + "cloneVNode", + "Comment", + "CompatVue", + "Component", + "ComponentCustomOptions", + "ComponentCustomProperties", + "ComponentCustomProps", + "ComponentInternalInstance", + "ComponentObjectPropsOptions", + "ComponentOptions", + "ComponentOptionsBase", + "ComponentOptionsMixin", + "ComponentOptionsWithArrayProps", + "ComponentOptionsWithObjectProps", + "ComponentOptionsWithoutProps", + "ComponentPropsOptions", + "ComponentPublicInstance", + "computed", + "ComputedGetter", + "ComputedOptions", + "ComputedRef", + "ComputedSetter", + "ConcreteComponent", + "CreateAppFunction", + "createBlock", + "createCommentVNode", + "CreateComponentPublicInstance", + "createElementBlock", + "createElementVNode", + "createHydrationRenderer", + "createRenderer", + "createSlots", + "createStaticVNode", + "createTextVNode", + "createVNode", + "customRef", + "CustomRefFactory", + "DebuggerEvent", + "DebuggerEventExtraInfo", + "DebuggerOptions", + "DeepReadonly", + "defineAsyncComponent", + "DefineComponent", + "defineComponent", + "defineEmits", + "defineExpose", + "defineProps", + "DeprecationTypes", + "devtools", + "Directive", + "DirectiveArguments", + "DirectiveBinding", + "DirectiveHook", + "effect", + "EffectScheduler", + "EffectScope", + "effectScope", + "EmitsOptions", + "ErrorCodes", + "ExtractDefaultPropTypes", + "ExtractPropTypes", + "Fragment", + "FunctionalComponent", + "FunctionDirective", + "getCurrentInstance", + "getCurrentScope", + "getTransitionRawChildren", + "guardReactiveProps", + "h", + "handleError", + "HMRRuntime", + "HydrationRenderer", + "initCustomFormatter", + "inject", + "InjectionKey", + "isMemoSame", + "isProxy", + "isReactive", + "isReadonly", + "isRef", + "isRuntimeOnly", + "isShallow", + "isVNode", + "KeepAlive", + "KeepAliveProps", + "LegacyConfig", + "markRaw", + "mergeProps", + "MethodOptions", + "nextTick", + "normalizeClass", + "normalizeProps", + "normalizeStyle", + "ObjectDirective", + "ObjectEmitsOptions", + "onActivated", + "onBeforeMount", + "onBeforeUnmount", + "onBeforeUpdate", + "onDeactivated", + "onErrorCaptured", + "onMounted", + "onRenderTracked", + "onRenderTriggered", + "onScopeDispose", + "onServerPrefetch", + "onUnmounted", + "onUpdated", + "openBlock", + "OptionMergeFunction", + "Plugin", + "popScopeId", + "Prop", + "PropType", + "provide", + "proxyRefs", + "pushScopeId", + "queuePostFlushCb", + "reactive", + "ReactiveEffect", + "ReactiveEffectOptions", + "ReactiveEffectRunner", + "ReactiveFlags", + "readonly", + "Ref", + "ref", + "registerRuntimeCompiler", + "Renderer", + "RendererElement", + "RendererNode", + "RendererOptions", + "RenderFunction", + "renderList", + "renderSlot", + "resolveComponent", + "resolveDirective", + "resolveDynamicComponent", + "resolveTransitionHooks", + "RootHydrateFunction", + "RootRenderFunction", + "RuntimeCompilerOptions", + "setBlockTracking", + "setDevtoolsHook", + "setTransitionHooks", + "SetupContext", + "ShallowReactive", + "shallowReactive", + "shallowReadonly", + "ShallowRef", + "shallowRef", + "ShallowUnwrapRef", + "Slot", + "Slots", + "ssrContextKey", + "Static", + "stop", + "Suspense", + "SuspenseBoundary", + "SuspenseProps", + "Teleport", + "TeleportProps", + "Text", + "toDisplayString", + "toHandlerKey", + "toHandlers", + "toRaw", + "ToRef", + "toRef", + "ToRefs", + "toRefs", + "TrackOpTypes", + "transformVNodeArgs", + "TransitionHooks", + "TransitionState", + "TriggerOpTypes", + "triggerRef", + "unref", + "UnwrapNestedRefs", + "UnwrapRef", + "useAttrs", + "useSlots", + "useSSRContext", + "useTransitionState", + "version", + "VNode", + "VNodeArrayChildren", + "VNodeChild", + "VNodeNormalizedChildren", + "VNodeProps", + "VNodeTypes", + "warn", + "watch", + "WatchCallback", + "WatchEffect", + "watchEffect", + "WatchOptions", + "WatchOptionsBase", + "watchPostEffect", + "WatchSource", + "WatchStopHandle", + "watchSyncEffect", + "withCtx", + "withDefaults", + "withDirectives", + "withMemo", + "withScopeId", + "WritableComputedOptions", + "WritableComputedRef", + "CSSProperties", + "StyleValue", + "HTMLAttributes", + "AnchorHTMLAttributes", + "AreaHTMLAttributes", + "AudioHTMLAttributes", + "BaseHTMLAttributes", + "BlockquoteHTMLAttributes", + "ButtonHTMLAttributes", + "CanvasHTMLAttributes", + "ColHTMLAttributes", + "ColgroupHTMLAttributes", + "DataHTMLAttributes", + "DetailsHTMLAttributes", + "DelHTMLAttributes", + "DialogHTMLAttributes", + "EmbedHTMLAttributes", + "FieldsetHTMLAttributes", + "FormHTMLAttributes", + "HtmlHTMLAttributes", + "IframeHTMLAttributes", + "ImgHTMLAttributes", + "InsHTMLAttributes", + "InputHTMLAttributes", + "KeygenHTMLAttributes", + "LabelHTMLAttributes", + "LiHTMLAttributes", + "LinkHTMLAttributes", + "MapHTMLAttributes", + "MenuHTMLAttributes", + "MediaHTMLAttributes", + "MetaHTMLAttributes", + "MeterHTMLAttributes", + "QuoteHTMLAttributes", + "ObjectHTMLAttributes", + "OlHTMLAttributes", + "OptgroupHTMLAttributes", + "OptionHTMLAttributes", + "OutputHTMLAttributes", + "ParamHTMLAttributes", + "ProgressHTMLAttributes", + "ScriptHTMLAttributes", + "SelectHTMLAttributes", + "SourceHTMLAttributes", + "StyleHTMLAttributes", + "TableHTMLAttributes", + "TextareaHTMLAttributes", + "TdHTMLAttributes", + "ThHTMLAttributes", + "TimeHTMLAttributes", + "TrackHTMLAttributes", + "VideoHTMLAttributes", + "WebViewHTMLAttributes", + "SVGAttributes", + "Events" +] \ No newline at end of file diff --git a/tests/lib/rules/prefer-import-from-vue.js b/tests/lib/rules/prefer-import-from-vue.js new file mode 100644 index 000000000..64854fd87 --- /dev/null +++ b/tests/lib/rules/prefer-import-from-vue.js @@ -0,0 +1,133 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/prefer-import-from-vue') + +const tester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('prefer-import-from-vue', rule, { + valid: [ + `import { createApp } from 'vue'`, + `import { ref, reactive } from '@vue/composition-api'`, + `export { createApp } from 'vue'`, + `export * from 'vue'`, + `import Foo from 'foo'`, + `import { createApp } from 'vue' + export { createApp }` + ], + invalid: [ + { + code: `import { createApp } from '@vue/runtime-dom'`, + output: `import { createApp } from 'vue'`, + errors: [ + { + message: "Import from 'vue' instead of '@vue/runtime-dom'.", + line: 1, + column: 27 + } + ] + }, + { + code: `import { computed } from '@vue/runtime-core'`, + output: `import { computed } from 'vue'`, + errors: [ + { + message: "Import from 'vue' instead of '@vue/runtime-core'.", + line: 1, + column: 26 + } + ] + }, + { + code: `import { computed } from '@vue/reactivity'`, + output: `import { computed } from 'vue'`, + errors: [ + { + message: "Import from 'vue' instead of '@vue/reactivity'.", + line: 1, + column: 26 + } + ] + }, + { + code: `import { normalizeClass } from '@vue/shared'`, + output: `import { normalizeClass } from 'vue'`, + errors: [ + { + message: "Import from 'vue' instead of '@vue/shared'.", + line: 1, + column: 32 + } + ] + }, + { + code: `import { unknown } from '@vue/reactivity'`, + output: null, + errors: ["Import from 'vue' instead of '@vue/reactivity'."] + }, + { + code: `import { unknown } from '@vue/runtime-dom'`, + output: `import { unknown } from 'vue'`, + errors: ["Import from 'vue' instead of '@vue/runtime-dom'."] + }, + { + code: `import * as Foo from '@vue/reactivity'`, + output: null, + errors: ["Import from 'vue' instead of '@vue/reactivity'."] + }, + { + code: `import * as Foo from '@vue/runtime-dom'`, + output: `import * as Foo from 'vue'`, + errors: ["Import from 'vue' instead of '@vue/runtime-dom'."] + }, + { + code: `export * from '@vue/reactivity'`, + output: null, + errors: ["Import from 'vue' instead of '@vue/reactivity'."] + }, + { + code: `export * from '@vue/runtime-dom'`, + output: null, + errors: ["Import from 'vue' instead of '@vue/runtime-dom'."] + }, + { + code: `export { computed } from '@vue/reactivity'`, + output: `export { computed } from 'vue'`, + errors: ["Import from 'vue' instead of '@vue/reactivity'."] + }, + { + code: `export { computed } from '@vue/runtime-dom'`, + output: `export { computed } from 'vue'`, + errors: ["Import from 'vue' instead of '@vue/runtime-dom'."] + }, + { + code: `export { unknown } from '@vue/reactivity'`, + output: null, + errors: ["Import from 'vue' instead of '@vue/reactivity'."] + }, + { + code: `export { unknown } from '@vue/runtime-dom'`, + output: null, + errors: ["Import from 'vue' instead of '@vue/runtime-dom'."] + }, + { + code: `import unknown from '@vue/reactivity'`, + output: null, + errors: ["Import from 'vue' instead of '@vue/reactivity'."] + }, + { + code: `import unknown from '@vue/runtime-dom'`, + output: `import unknown from 'vue'`, + errors: ["Import from 'vue' instead of '@vue/runtime-dom'."] + } + ] +}) diff --git a/tools/update-vue3-export-names.js b/tools/update-vue3-export-names.js new file mode 100644 index 000000000..fcd31b3e3 --- /dev/null +++ b/tools/update-vue3-export-names.js @@ -0,0 +1,150 @@ +'use strict' + +/* + This script updates `lib/utils/vue3-export-names.json` file from vue type. + */ + +const fs = require('fs') +const path = require('path') +const https = require('https') +const { URL } = require('url') +const tsParser = require('@typescript-eslint/parser') + +main() + +async function main() { + const names = new Set() + + for await (const name of extractExportNames('vue@^3')) { + names.add(name) + } + // Update file. + const filePath = path.resolve( + __dirname, + '../lib/utils/vue3-export-names.json' + ) + + fs.writeFileSync(filePath, JSON.stringify([...names], null, 2)) +} + +async function* extractExportNames(m) { + const rootNode = tsParser.parse(await resolveTypeContents(m), { + loc: true, + range: true + }) + for (const node of rootNode.body) { + if (node.type === 'ExportAllDeclaration') { + if (node.exported) { + yield node.exported.name + } else { + for await (const name of extractExportNames(node.source.value)) { + yield name + } + } + } else if (node.type === 'ExportNamedDeclaration') { + if (node.declaration) { + if ( + node.declaration.type === 'ClassDeclaration' || + node.declaration.type === 'ClassExpression' || + node.declaration.type === 'FunctionDeclaration' || + node.declaration.type === 'TSDeclareFunction' || + node.declaration.type === 'TSEnumDeclaration' || + node.declaration.type === 'TSInterfaceDeclaration' || + node.declaration.type === 'TSTypeAliasDeclaration' + ) { + yield node.declaration.id.name + } else if (node.declaration.type === 'VariableDeclaration') { + for (const decl of node.declaration.declarations) { + yield* extractNamesFromPattern(decl.id) + } + } else if (node.declaration.type === 'TSModuleDeclaration') { + //? + } + } + for (const spec of node.specifiers) { + yield spec.exported.name + } + } else if (node.type === 'ExportDefaultDeclaration') { + yield 'default' + } + } +} + +/** + * @typedef {import('@typescript-eslint/types').TSESTree.ArrayPattern} ArrayPattern + * @typedef {import('@typescript-eslint/types').TSESTree.ObjectPattern} ObjectPattern + * @typedef {import('@typescript-eslint/types').TSESTree.Identifier} Identifier + * @typedef {import('@typescript-eslint/types').TSESTree.AssignmentPattern} AssignmentPattern + * @typedef {import('@typescript-eslint/types').TSESTree.MemberExpression} MemberExpression + * @typedef {import('@typescript-eslint/types').TSESTree.RestElement} RestElement + */ + +/** + * @param {Identifier|ArrayPattern|ObjectPattern|AssignmentPattern|MemberExpression|RestElement} node + */ +function* extractNamesFromPattern(node) { + if (node.type === 'Identifier') { + yield node.name + } else if (node.type === 'ArrayPattern') { + for (const element of node.elements) { + yield* extractNamesFromPattern(element) + } + } else if (node.type === 'ObjectPattern') { + for (const prop of node.properties) { + if (prop.type === 'Property') { + yield prop.key.name + } else if (prop.type === 'RestElement') { + yield* extractNamesFromPattern(prop) + } + } + } else if (node.type === 'AssignmentPattern') { + yield* extractNamesFromPattern(node.left) + } else if (node.type === 'RestElement') { + yield* extractNamesFromPattern(node.argument) + } else if (node.type === 'MemberExpression') { + // ? + } +} +async function resolveTypeContents(m) { + const packageJsonText = await httpGet(`https://unpkg.com/${m}/package.json`) + const packageJson = JSON.parse(packageJsonText) + + let typesPath = + (packageJson.exports && + packageJson.exports['.'] && + packageJson.exports['.'].types) || + packageJson.types + if (typesPath.startsWith('./')) { + typesPath = typesPath.slice(2) + } + return await httpGet(`https://unpkg.com/${m}/${typesPath}`) +} + +function httpGet(url) { + return new Promise((resolve, reject) => { + let result = '' + https + .get(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { + // redirect + let redirectUrl = res.headers.location + if (!redirectUrl.startsWith('http')) { + const baseUrl = new URL(url) + baseUrl.pathname = redirectUrl + redirectUrl = String(baseUrl) + } + resolve(httpGet(redirectUrl)) + return + } + res.setEncoding('utf8') + res.on('data', (chunk) => { + result += String(chunk) + }) + res.on('end', () => { + resolve(result) + }) + res.on('error', reject) + }) + .on('error', reject) + }) +} diff --git a/tools/update.js b/tools/update.js index fc1e2e6bd..266aedbcc 100644 --- a/tools/update.js +++ b/tools/update.js @@ -10,3 +10,7 @@ require('./update-lib-configs') require('./update-lib-index') require('./update-docs') require('./update-docs-rules-index') + +if (process.env.IN_VERSION_SCRIPT) { + require('./update-vue3-export-names') +} diff --git a/typings/eslint-plugin-vue/util-types/ast/es-ast.ts b/typings/eslint-plugin-vue/util-types/ast/es-ast.ts index eff4f983b..bafff6d2b 100644 --- a/typings/eslint-plugin-vue/util-types/ast/es-ast.ts +++ b/typings/eslint-plugin-vue/util-types/ast/es-ast.ts @@ -267,7 +267,7 @@ export interface ImportDeclaration extends HasParentNode { | ImportDefaultSpecifier | ImportNamespaceSpecifier )[] - source: Literal + source: Literal & { value: string } } export interface ImportSpecifier extends HasParentNode { type: 'ImportSpecifier' @@ -286,10 +286,11 @@ export interface ExportNamedDeclaration extends HasParentNode { type: 'ExportNamedDeclaration' declaration?: Declaration | null specifiers: ExportSpecifier[] - source?: Literal | null + source?: (Literal & { value: string }) | null } export interface ExportSpecifier extends HasParentNode { type: 'ExportSpecifier' + local: Identifier exported: Identifier } export interface ExportDefaultDeclaration extends HasParentNode { @@ -298,7 +299,7 @@ export interface ExportDefaultDeclaration extends HasParentNode { } export interface ExportAllDeclaration extends HasParentNode { type: 'ExportAllDeclaration' - source: Literal + source: Literal & { value: string } exported: Identifier | null } export interface ImportExpression extends HasParentNode {