Skip to content

Commit 1f6e172

Browse files
ota-meshifiskersindresorhus
authored
Add prefer-import-meta-properties rule (#2607)
Co-authored-by: fisker <[email protected]> Co-authored-by: Sindre Sorhus <[email protected]>
1 parent 1922df1 commit 1f6e172

8 files changed

+1023
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Prefer `import.meta.{dirname,filename}` over legacy techniques for getting file paths
2+
3+
🚫 This rule is _disabled_ in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config).
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Starting with Node.js 20.11, [`import.meta.dirname`](https://nodejs.org/api/esm.html#importmetadirname) and [`import.meta.filename`](https://nodejs.org/api/esm.html#importmetafilename) have been introduced in ES modules.
11+
12+
> `import.meta.filename` is the same as the `url.fileURLToPath()` of the `import.meta.url`.\
13+
> `import.meta.dirname` is the same as the `path.dirname()` of the `import.meta.filename`.
14+
15+
This rule replaces legacy patterns with `import.meta.{dirname,filename}`.
16+
17+
## Examples
18+
19+
```js
20+
import path from 'node:path';
21+
import {fileURLToPath} from "node:url";
22+
23+
//
24+
const filename = fileURLToPath(import.meta.url);
25+
26+
//
27+
const filename = import.meta.filename;
28+
```
29+
30+
```js
31+
import path from 'node:path';
32+
import {fileURLToPath} from 'node:url';
33+
34+
//
35+
const dirname = path.dirname(fileURLToPath(import.meta.url));
36+
const dirname = path.dirname(import.meta.filename);
37+
const dirname = fileURLToPath(new URL('.', import.meta.url));
38+
39+
//
40+
const dirname = import.meta.dirname;
41+
```

eslint.dogfooding.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const config = [
4545
],
4646
rules: {
4747
'unicorn/prefer-module': 'off',
48+
'unicorn/prefer-import-meta-properties': 'off', // We can enable this rule when we drop support for Node.js v18.
4849
},
4950
},
5051
];

readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export default [
142142
| [prefer-event-target](docs/rules/prefer-event-target.md) | Prefer `EventTarget` over `EventEmitter`. || | |
143143
| [prefer-export-from](docs/rules/prefer-export-from.md) | Prefer `export…from` when re-exporting. || 🔧 | 💡 |
144144
| [prefer-global-this](docs/rules/prefer-global-this.md) | Prefer `globalThis` over `window`, `self`, and `global`. || 🔧 | |
145+
| [prefer-import-meta-properties](docs/rules/prefer-import-meta-properties.md) | Prefer `import.meta.{dirname,filename}` over legacy techniques for getting file paths. | | 🔧 | |
145146
| [prefer-includes](docs/rules/prefer-includes.md) | Prefer `.includes()` over `.indexOf()`, `.lastIndexOf()`, and `Array#some()` when checking for existence or non-existence. || 🔧 | 💡 |
146147
| [prefer-json-parse-buffer](docs/rules/prefer-json-parse-buffer.md) | Prefer reading a JSON file as a buffer. | | 🔧 | |
147148
| [prefer-keyboard-event-key](docs/rules/prefer-keyboard-event-key.md) | Prefer `KeyboardEvent#key` over `KeyboardEvent#keyCode`. || 🔧 | |

rules/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import preferDomNodeTextContent from './prefer-dom-node-text-content.js';
8787
import preferEventTarget from './prefer-event-target.js';
8888
import preferExportFrom from './prefer-export-from.js';
8989
import preferGlobalThis from './prefer-global-this.js';
90+
import preferImportMetaProperties from './prefer-import-meta-properties.js';
9091
import preferIncludes from './prefer-includes.js';
9192
import preferJsonParseBuffer from './prefer-json-parse-buffer.js';
9293
import preferKeyboardEventKey from './prefer-keyboard-event-key.js';
@@ -217,6 +218,7 @@ const rules = {
217218
'prefer-event-target': createRule(preferEventTarget, 'prefer-event-target'),
218219
'prefer-export-from': createRule(preferExportFrom, 'prefer-export-from'),
219220
'prefer-global-this': createRule(preferGlobalThis, 'prefer-global-this'),
221+
'prefer-import-meta-properties': createRule(preferImportMetaProperties, 'prefer-import-meta-properties'),
220222
'prefer-includes': createRule(preferIncludes, 'prefer-includes'),
221223
'prefer-json-parse-buffer': createRule(preferJsonParseBuffer, 'prefer-json-parse-buffer'),
222224
'prefer-keyboard-event-key': createRule(preferKeyboardEventKey, 'prefer-keyboard-event-key'),
+320
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import {findVariable} from '@eslint-community/eslint-utils';
2+
import {
3+
isMemberExpression,
4+
isCallExpression,
5+
isNewExpression,
6+
isMethodCall,
7+
} from './ast/index.js';
8+
9+
const ERROR_DIRNAME = 'error/calculate-dirname';
10+
const ERROR_FILENAME = 'error/calculate-filename';
11+
const messages = {
12+
[ERROR_DIRNAME]: 'Do not construct dirname.',
13+
[ERROR_FILENAME]: 'Do not construct filename using `fileURLToPath()`.',
14+
};
15+
16+
const isParentLiteral = node => {
17+
if (node?.type !== 'Literal') {
18+
return false;
19+
}
20+
21+
return node.value === '.' || node.value === './';
22+
};
23+
24+
const isImportMeta = node =>
25+
node.type === 'MetaProperty'
26+
&& node.meta.name === 'import'
27+
&& node.property.name === 'meta';
28+
29+
function isNodeBuiltinModuleFunctionCall(node, {modules, functionName, sourceCode}) {
30+
if (!isCallExpression(node, {optional: false, argumentsLength: 1})) {
31+
return false;
32+
}
33+
34+
const visited = new Set();
35+
36+
return checkExpression(node.callee, 'property');
37+
38+
/** @param {import('estree').Expression} node */
39+
function checkExpression(node, checkKind) {
40+
if (node.type === 'MemberExpression') {
41+
if (!(
42+
checkKind === 'property'
43+
&& isMemberExpression(node, {property: functionName, computed: false, optional: false})
44+
)) {
45+
return false;
46+
}
47+
48+
return checkExpression(node.object, 'module');
49+
}
50+
51+
if (node.type === 'CallExpression') {
52+
if (checkKind !== 'module') {
53+
return false;
54+
}
55+
56+
// `process.getBuiltinModule('x')`
57+
return (
58+
isMethodCall(node, {
59+
object: 'process',
60+
method: 'getBuiltinModule',
61+
argumentsLength: 1,
62+
optionalMember: false,
63+
optionalCall: false,
64+
})
65+
&& isModuleLiteral(node.arguments[0])
66+
);
67+
}
68+
69+
if (node.type !== 'Identifier') {
70+
return false;
71+
}
72+
73+
if (visited.has(node)) {
74+
return false;
75+
}
76+
77+
visited.add(node);
78+
79+
const variable = findVariable(sourceCode.getScope(node), node);
80+
if (!variable || variable.defs.length !== 1) {
81+
return;
82+
}
83+
84+
return checkDefinition(variable.defs[0], checkKind);
85+
}
86+
87+
/** @param {import('eslint').Scope.Definition} define */
88+
function checkDefinition(define, checkKind) {
89+
if (define.type === 'ImportBinding') {
90+
if (!isModuleLiteral(define.parent.source)) {
91+
return false;
92+
}
93+
94+
const specifier = define.node;
95+
return checkKind === 'module'
96+
? (specifier?.type === 'ImportDefaultSpecifier' || specifier?.type === 'ImportNamespaceSpecifier')
97+
: (specifier?.type === 'ImportSpecifier' && specifier.imported.name === functionName);
98+
}
99+
100+
return define.type === 'Variable' && checkPattern(define.name, checkKind);
101+
}
102+
103+
/** @param {import('estree').Identifier | import('estree').ObjectPattern} node */
104+
function checkPattern(node, checkKind) {
105+
/** @type {{parent?: import('estree').Node}} */
106+
const {parent} = node;
107+
if (parent.type === 'VariableDeclarator') {
108+
if (
109+
!parent.init
110+
|| parent.id !== node
111+
|| parent.parent.type !== 'VariableDeclaration'
112+
|| parent.parent.kind !== 'const'
113+
) {
114+
return false;
115+
}
116+
117+
return checkExpression(parent.init, checkKind);
118+
}
119+
120+
if (parent.type === 'Property') {
121+
if (!(
122+
checkKind === 'property'
123+
&& parent.value === node
124+
&& !parent.computed
125+
&& parent.key.type === 'Identifier'
126+
&& parent.key.name === functionName
127+
)) {
128+
return false;
129+
}
130+
131+
// Check for ObjectPattern
132+
return checkPattern(parent.parent, 'module');
133+
}
134+
135+
return false;
136+
}
137+
138+
function isModuleLiteral(node) {
139+
return node?.type === 'Literal' && modules.has(node.value);
140+
}
141+
}
142+
143+
/**
144+
@returns {node is import('estree').SimpleCallExpression}
145+
*/
146+
function isUrlFileURLToPathCall(node, sourceCode) {
147+
return isNodeBuiltinModuleFunctionCall(node, {
148+
modules: new Set(['url', 'node:url']),
149+
functionName: 'fileURLToPath',
150+
sourceCode,
151+
});
152+
}
153+
154+
/**
155+
@returns {node is import('estree').SimpleCallExpression}
156+
*/
157+
function isPathDirnameCall(node, sourceCode) {
158+
return isNodeBuiltinModuleFunctionCall(node, {
159+
modules: new Set(['path', 'node:path']),
160+
functionName: 'dirname',
161+
sourceCode,
162+
});
163+
}
164+
165+
function create(context) {
166+
const {sourceCode} = context;
167+
168+
context.on('MetaProperty', function * (node) {
169+
if (!isImportMeta(node)) {
170+
return;
171+
}
172+
173+
/** @type {import('estree').Node} */
174+
const memberExpression = node.parent;
175+
if (!isMemberExpression(memberExpression, {
176+
properties: ['url', 'filename'],
177+
computed: false,
178+
optional: false,
179+
})) {
180+
return;
181+
}
182+
183+
const propertyName = memberExpression.property.name;
184+
if (propertyName === 'url') {
185+
// `url.fileURLToPath(import.meta.url)`
186+
if (
187+
isUrlFileURLToPathCall(memberExpression.parent, sourceCode)
188+
&& memberExpression.parent.arguments[0] === memberExpression
189+
) {
190+
yield * iterateProblemsFromFilename(memberExpression.parent, {
191+
reportFilenameNode: true,
192+
});
193+
return;
194+
}
195+
196+
// `new URL(import.meta.url)`
197+
// `new URL('.', import.meta.url)`
198+
// `new URL('./', import.meta.url)`
199+
if (isNewExpression(memberExpression.parent, {name: 'URL', minimumArguments: 1, maximumArguments: 2})) {
200+
const newUrl = memberExpression.parent;
201+
const urlParent = newUrl.parent;
202+
203+
// `new URL(import.meta.url)`
204+
if (
205+
newUrl.arguments.length === 1
206+
&& newUrl.arguments[0] === memberExpression
207+
// `url.fileURLToPath(new URL(import.meta.url))`
208+
&& isUrlFileURLToPathCall(urlParent, sourceCode)
209+
&& urlParent.arguments[0] === newUrl
210+
) {
211+
yield * iterateProblemsFromFilename(urlParent, {
212+
reportFilenameNode: true,
213+
});
214+
return;
215+
}
216+
217+
// `url.fileURLToPath(new URL(".", import.meta.url))`
218+
// `url.fileURLToPath(new URL("./", import.meta.url))`
219+
if (
220+
newUrl.arguments.length === 2
221+
&& isParentLiteral(newUrl.arguments[0])
222+
&& newUrl.arguments[1] === memberExpression
223+
&& isUrlFileURLToPathCall(urlParent, sourceCode)
224+
&& urlParent.arguments[0] === newUrl
225+
) {
226+
yield getProblem(urlParent, 'dirname');
227+
}
228+
}
229+
230+
return;
231+
}
232+
233+
if (propertyName === 'filename') {
234+
yield * iterateProblemsFromFilename(memberExpression);
235+
}
236+
237+
/**
238+
Iterates over reports where a given filename expression node
239+
would be used to convert it to a dirname.
240+
@param { import('estree').Expression} node
241+
*/
242+
function * iterateProblemsFromFilename(node, {reportFilenameNode = false} = {}) {
243+
/** @type {{parent: import('estree').Node}} */
244+
const {parent} = node;
245+
246+
// `path.dirname(filename)`
247+
if (
248+
isPathDirnameCall(parent, sourceCode)
249+
&& parent.arguments[0] === node
250+
) {
251+
yield getProblem(parent, 'dirname');
252+
return;
253+
}
254+
255+
if (reportFilenameNode) {
256+
yield getProblem(node, 'filename');
257+
}
258+
259+
if (
260+
parent.type !== 'VariableDeclarator'
261+
|| parent.init !== node
262+
|| parent.id.type !== 'Identifier'
263+
|| parent.parent.type !== 'VariableDeclaration'
264+
|| parent.parent.kind !== 'const'
265+
) {
266+
return;
267+
}
268+
269+
/** @type {import('eslint').Scope.Variable|null} */
270+
const variable = findVariable(sourceCode.getScope(parent.id), parent.id);
271+
if (!variable) {
272+
return;
273+
}
274+
275+
for (const reference of variable.references) {
276+
if (!reference.isReadOnly()) {
277+
continue;
278+
}
279+
280+
/** @type {{parent: import('estree').Node}} */
281+
const {parent} = reference.identifier;
282+
if (
283+
isPathDirnameCall(parent, sourceCode)
284+
&& parent.arguments[0] === reference.identifier
285+
) {
286+
// Report `path.dirname(identifier)`
287+
yield getProblem(parent, 'dirname');
288+
}
289+
}
290+
}
291+
292+
/**
293+
@param { import('estree').Node} node
294+
@param {'dirname' | 'filename'} name
295+
*/
296+
function getProblem(node, name) {
297+
return {
298+
node,
299+
messageId: name === 'dirname' ? ERROR_DIRNAME : ERROR_FILENAME,
300+
fix: fixer => fixer.replaceText(node, `import.meta.${name}`),
301+
};
302+
}
303+
});
304+
}
305+
306+
/** @type {import('eslint').Rule.RuleModule} */
307+
const config = {
308+
create,
309+
meta: {
310+
type: 'suggestion',
311+
docs: {
312+
description: 'Prefer `import.meta.{dirname,filename}` over legacy techniques for getting file paths.',
313+
recommended: false,
314+
},
315+
fixable: 'code',
316+
messages,
317+
},
318+
};
319+
320+
export default config;

0 commit comments

Comments
 (0)