eslint-plugin-ocavue 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import type { Rule, Scope } from 'eslint'
2
+ import type { ImportDeclaration, ImportSpecifier, Node, Program } from 'estree'
3
+
4
+ import { hasDependency } from './has-dependency'
5
+
6
+ export const UTILS_PACKAGE = '@ocavue/utils'
7
+
8
+ interface PackageRuleOptions {
9
+ checkDependency?: boolean
10
+ }
11
+
12
+ export const packageRuleSchema: Rule.RuleMetaData['schema'] = [
13
+ {
14
+ type: 'object',
15
+ properties: {
16
+ checkDependency: { type: 'boolean' },
17
+ },
18
+ additionalProperties: false,
19
+ },
20
+ ]
21
+
22
+ function getImportKind(node: ImportDeclaration | ImportSpecifier): string | undefined {
23
+ return (node as { importKind?: string }).importKind
24
+ }
25
+
26
+ function getImportedName(specifier: ImportSpecifier): string {
27
+ const imported = specifier.imported
28
+ return imported.type === 'Identifier' ? imported.name : String(imported.value)
29
+ }
30
+
31
+ /**
32
+ * Returns the value `import` declarations of `packageName` in the program.
33
+ */
34
+ function getPackageImports(program: Program, packageName: string): ImportDeclaration[] {
35
+ return program.body.filter((statement): statement is ImportDeclaration => {
36
+ return (
37
+ statement.type === 'ImportDeclaration' &&
38
+ statement.source.value === packageName &&
39
+ getImportKind(statement) !== 'type'
40
+ )
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Returns true when the linted file can import from `packageName`: the
46
+ * nearest `package.json` depends on it, or the file already imports it. The
47
+ * `checkDependency: false` option skips the check.
48
+ */
49
+ export function isPackageAvailable(context: Rule.RuleContext, packageName: string): boolean {
50
+ const options = (context.options[0] ?? {}) as PackageRuleOptions
51
+ if (options.checkDependency === false) {
52
+ return true
53
+ }
54
+ return (
55
+ hasDependency(context.physicalFilename, packageName) ||
56
+ getPackageImports(context.sourceCode.ast, packageName).length > 0
57
+ )
58
+ }
59
+
60
+ export function findVariable(scope: Scope.Scope | null, name: string): Scope.Variable | undefined {
61
+ for (let current = scope; current; current = current.upper) {
62
+ const variable = current.set.get(name)
63
+ if (variable) {
64
+ return variable
65
+ }
66
+ }
67
+ return undefined
68
+ }
69
+
70
+ interface ImportFixOptions {
71
+ context: Rule.RuleContext
72
+ /** The node to replace. */
73
+ node: Node
74
+ /** The named export to import from `packageName`. */
75
+ name: string
76
+ packageName: string
77
+ /** Returns the replacement text, given the local name of the import. */
78
+ replace: (local: string) => string
79
+ }
80
+
81
+ /**
82
+ * Creates a fix that replaces `node` and makes sure `name` is imported from
83
+ * `packageName`. Returns `undefined` when the fix is not safe: the file is
84
+ * not an ES module, the node holds comments, or another binding already uses
85
+ * the name.
86
+ */
87
+ export function createImportFix({
88
+ context,
89
+ node,
90
+ name,
91
+ packageName,
92
+ replace,
93
+ }: ImportFixOptions): Rule.ReportFixer | undefined {
94
+ const sourceCode = context.sourceCode
95
+ const program = sourceCode.ast as Program
96
+
97
+ if (context.languageOptions.sourceType !== 'module') {
98
+ return undefined
99
+ }
100
+ if (sourceCode.getCommentsInside(node).length > 0) {
101
+ return undefined
102
+ }
103
+
104
+ const scope = sourceCode.getScope(node)
105
+ const packageImports = getPackageImports(program, packageName)
106
+
107
+ // Reuse an existing import, including an aliased one, when it is visible
108
+ // from the node.
109
+ for (const declaration of packageImports) {
110
+ for (const specifier of declaration.specifiers) {
111
+ if (
112
+ specifier.type !== 'ImportSpecifier' ||
113
+ getImportKind(specifier) === 'type' ||
114
+ getImportedName(specifier) !== name
115
+ ) {
116
+ continue
117
+ }
118
+ const local = specifier.local.name
119
+ const variable = findVariable(scope, local)
120
+ if (variable?.defs[0]?.node === specifier) {
121
+ return (fixer) => fixer.replaceText(node, replace(local))
122
+ }
123
+ }
124
+ }
125
+
126
+ // Another binding with the same name is in scope.
127
+ if (findVariable(scope, name)) {
128
+ return undefined
129
+ }
130
+ // An undeclared global with the same name is used somewhere in the file.
131
+ const globalScope = sourceCode.scopeManager.globalScope
132
+ if (globalScope?.through.some((reference) => reference.identifier.name === name)) {
133
+ return undefined
134
+ }
135
+
136
+ const replacement = replace(name)
137
+
138
+ const namedImport = packageImports.find((declaration) => {
139
+ return declaration.specifiers.some((specifier) => specifier.type === 'ImportSpecifier')
140
+ })
141
+ if (namedImport) {
142
+ const lastSpecifier = namedImport.specifiers.at(-1)!
143
+ return (fixer) => [
144
+ fixer.replaceText(node, replacement),
145
+ fixer.insertTextAfter(lastSpecifier, `, ${name}`),
146
+ ]
147
+ }
148
+
149
+ const importText = `import { ${name} } from '${packageName}'`
150
+ const lastImport = program.body.findLast((statement) => statement.type === 'ImportDeclaration')
151
+ if (lastImport) {
152
+ const semicolon = sourceCode.getText(lastImport).endsWith(';') ? ';' : ''
153
+ return (fixer) => [
154
+ fixer.replaceText(node, replacement),
155
+ fixer.insertTextAfter(lastImport, `\n${importText}${semicolon}`),
156
+ ]
157
+ }
158
+
159
+ // Insert the import after directives such as 'use client'.
160
+ const firstStatement = program.body.find((statement) => {
161
+ // typescript-eslint sets `directive: undefined` on every expression statement.
162
+ return !(
163
+ statement.type === 'ExpressionStatement' &&
164
+ typeof (statement as { directive?: unknown }).directive === 'string'
165
+ )
166
+ })
167
+ if (!firstStatement) {
168
+ return undefined
169
+ }
170
+ return (fixer) => [
171
+ fixer.replaceText(node, replacement),
172
+ fixer.insertTextBefore(firstStatement, `${importText}\n`),
173
+ ]
174
+ }
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { afterAll } from 'vitest'
6
+
7
+ function createProject(packageJson: object): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'eslint-plugin-ocavue-'))
9
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(packageJson))
10
+ return dir
11
+ }
12
+
13
+ /**
14
+ * Creates temporary projects for rules that depend on `@ocavue/utils`, and
15
+ * removes them after the tests. Each property is the path of a file inside
16
+ * one project.
17
+ */
18
+ export function useUtilsProjects(extension: string) {
19
+ const withUtilsDir = createProject({ dependencies: { '@ocavue/utils': '^1.0.0' } })
20
+ const withoutUtilsDir = createProject({ dependencies: { lodash: '^4.0.0' } })
21
+ const utilsRepoDir = createProject({ name: '@ocavue/utils' })
22
+
23
+ afterAll(() => {
24
+ for (const dir of [withUtilsDir, withoutUtilsDir, utilsRepoDir]) {
25
+ fs.rmSync(dir, { recursive: true, force: true })
26
+ }
27
+ })
28
+
29
+ const file = `src/index.${extension}`
30
+ return {
31
+ /** A file in a project that depends on `@ocavue/utils`. */
32
+ withUtils: path.join(withUtilsDir, file),
33
+ /** A file in a project that does not depend on `@ocavue/utils`. */
34
+ withoutUtils: path.join(withoutUtilsDir, file),
35
+ /** A file in the `@ocavue/utils` package itself. */
36
+ utilsRepo: path.join(utilsRepoDir, file),
37
+ }
38
+ }