Skip to content

Add prefer-import-meta-properties rule #2607

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 38 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c2a6c45
`prefer-module`: Support `import.meta.{dirname,filename}`
ota-meshi Mar 26, 2025
0bc4a01
chore: fix linting errors
ota-meshi Mar 26, 2025
7107b20
chore: revert `test/unit/get-documentation-url.js`
ota-meshi Mar 26, 2025
9e1bff8
feat: check for filename vars
ota-meshi Mar 26, 2025
fd842aa
test: fix comment
ota-meshi Mar 26, 2025
93c7dac
docs: add `import.meta.{dirname,filename}`
ota-meshi Mar 26, 2025
e098150
fix: allow `new URL()` operations
ota-meshi Mar 26, 2025
d54ee2a
fix: support for `new URL('./', ...)` and add tests
ota-meshi Mar 26, 2025
e82b019
fix: update error messages
ota-meshi Mar 27, 2025
948e222
change to always auto-fix
ota-meshi Mar 27, 2025
c510e0c
split test cases
ota-meshi Mar 27, 2025
d7d508c
support `process.getBuiltinModule()`
ota-meshi Mar 27, 2025
859a275
use ast utils and some refactor
ota-meshi Mar 29, 2025
c600810
Fix style
fisker Mar 29, 2025
bfdbd2d
Ignore computed and optional
fisker Mar 29, 2025
c4dba43
Strict `Property` check
fisker Mar 29, 2025
d21168a
Code style
fisker Mar 29, 2025
f611d8a
Test other `MetaProperty`
fisker Mar 29, 2025
5df79f1
Stricter property access
fisker Mar 29, 2025
5852350
Minor tweak
fisker Mar 29, 2025
a7293cd
`CALC` -> `CALCULATE`
fisker Mar 29, 2025
e9f4a57
Stricter
fisker Mar 29, 2025
5847068
Stricter `new URL()` check
fisker Mar 29, 2025
d490f06
Stricter check `url.fileURLToPath` and `path.dirname`
fisker Mar 29, 2025
2908767
Function renaming
fisker Mar 29, 2025
0bb4574
Rename and comments
fisker Mar 29, 2025
b6a0b54
Revert rename, misunderstanding
fisker Mar 29, 2025
3ccba73
Remove misleading `targetNode`
fisker Mar 29, 2025
f97138d
Comment tweak
fisker Mar 29, 2025
7816928
Broken case
fisker Mar 29, 2025
c3bcf1c
False alarm
fisker Mar 29, 2025
e953258
One more test
fisker Mar 29, 2025
a2f4442
ignore URL.pathname
ota-meshi Mar 30, 2025
259c6f8
add `prefer-import-meta-properties`, and revert `prefer-module`
ota-meshi Mar 31, 2025
f9b359f
docs: fix docs
ota-meshi Mar 31, 2025
f790666
update eslint.dogfooding.config.js
ota-meshi Mar 31, 2025
50f05e8
Minor tweak
fisker Mar 31, 2025
701d27d
Update prefer-import-meta-properties.md
sindresorhus Mar 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/rules/prefer-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Prefer using the [JavaScript module](https://developer.mozilla.org/en-US/docs/We

`export …` should be used in JavaScript modules.

1. Disallows `fileURLToPath(import.meta.url)` and similar operations.

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.

`fileURLToPath(import.meta.url)` can be replaced by `import.meta.filename`.

`path.dirname(import.meta.filename)` can be replaced by `import.meta.dirname`.

*`.cjs` files are ignored.*

## Fail
Expand Down
247 changes: 246 additions & 1 deletion rules/prefer-module.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,41 @@
import {findVariable, getPropertyName} from '@eslint-community/eslint-utils';
import isShadowed from './utils/is-shadowed.js';
import assertToken from './utils/assert-token.js';
import {getCallExpressionTokens} from './utils/index.js';
import {isStaticRequire, isReferenceIdentifier, isFunction} from './ast/index.js';
import {
isStaticRequire, isReferenceIdentifier, isFunction,
} from './ast/index.js';
import {removeParentheses, replaceReferenceIdentifier, removeSpacesAfter} from './fix/index.js';

const ERROR_USE_STRICT_DIRECTIVE = 'error/use-strict-directive';
const ERROR_GLOBAL_RETURN = 'error/global-return';
const ERROR_IDENTIFIER = 'error/identifier';
const ERROR_CALC_DIRNAME = 'error/calc-dirname';
const ERROR_CALC_FILENAME = 'error/calc-filename';
const SUGGESTION_USE_STRICT_DIRECTIVE = 'suggestion/use-strict-directive';
const SUGGESTION_IMPORT_META_DIRNAME = 'suggestion/import-meta-dirname';
const SUGGESTION_IMPORT_META_URL_TO_DIRNAME = 'suggestion/import-meta-url-to-dirname';
const SUGGESTION_IMPORT_META_FILENAME = 'suggestion/import-meta-filename';
const SUGGESTION_IMPORT_META_URL_TO_FILENAME = 'suggestion/import-meta-url-to-filename';
const SUGGESTION_IMPORT = 'suggestion/import';
const SUGGESTION_EXPORT = 'suggestion/export';
const SUGGESTION_IMPORT_META_DIRNAME_FROM_URL = 'suggestion/import-meta-dirname-from-url';
const SUGGESTION_IMPORT_META_FILENAME_FROM_URL = 'suggestion/import-meta-filename-from-url';
const messages = {
[ERROR_USE_STRICT_DIRECTIVE]: 'Do not use "use strict" directive.',
[ERROR_GLOBAL_RETURN]: '"return" should be used inside a function.',
[ERROR_IDENTIFIER]: 'Do not use "{{name}}".',
[ERROR_CALC_DIRNAME]: 'Do not construct dirname.',
[ERROR_CALC_FILENAME]: 'Do not construct filename using `fileURLToPath()`.',
[SUGGESTION_USE_STRICT_DIRECTIVE]: 'Remove "use strict" directive.',
[SUGGESTION_IMPORT_META_DIRNAME]: 'Replace `__dirname` with `import.meta.dirname`.',
[SUGGESTION_IMPORT_META_URL_TO_DIRNAME]: 'Replace `__dirname` with `…(import.meta.url)`.',
[SUGGESTION_IMPORT_META_FILENAME]: 'Replace `__filename` with `import.meta.filename`.',
[SUGGESTION_IMPORT_META_URL_TO_FILENAME]: 'Replace `__filename` with `…(import.meta.url)`.',
[SUGGESTION_IMPORT]: 'Switch to `import`.',
[SUGGESTION_EXPORT]: 'Switch to `export`.',
[SUGGESTION_IMPORT_META_DIRNAME_FROM_URL]: 'Replace `…(import.meta.url)` with `import.meta.dirname`.',
[SUGGESTION_IMPORT_META_FILENAME_FROM_URL]: 'Replace `…(import.meta.url)` with `import.meta.filename`.',
};

const suggestions = new Map([
Expand Down Expand Up @@ -198,6 +209,94 @@ const isTopLevelReturnStatement = node => {
return true;
};

const isParentLiteral = node => {
if (node?.type !== 'Literal') {
return false;
}

return node.value === '.' || node.value === './';
};

const isImportMeta = node =>
node.type === 'MetaProperty'
&& node.meta.name === 'import'
&& node.property.name === 'meta';

/** @returns {node is import('estree').NewExpression} */
const isNewURL = node =>
node.type === 'NewExpression'
&& node.callee.type === 'Identifier'
&& node.callee.name === 'URL';

/** @returns {node is import('estree').MemberExpression} */
const isAccessPathname = node =>
node.type === 'MemberExpression'
&& getPropertyName(node) === 'pathname';

function isCallNodeBuiltinModule(node, propertyName, nodeModuleName, sourceCode) {
if (node.type !== 'CallExpression') {
return false;
}

/** @type {{callee: import('estree').Expression}} */
const {callee} = node;
if (callee.type === 'MemberExpression') {
// Check for nodeModuleName.propertyName(...);
if (callee.object.type !== 'Identifier') {
return false;
}

if (getPropertyName(callee) !== propertyName) {
return false;
}

const specifier = getImportSpecifier(callee.object);
return specifier?.type === 'ImportDefaultSpecifier' || specifier?.type === 'ImportNamespaceSpecifier';
}

if (callee.type === 'Identifier') {
// Check for propertyName(...);
const specifier = getImportSpecifier(callee);

return specifier?.type === 'ImportSpecifier' && specifier.imported.name === propertyName;
}

return false;

function getImportSpecifier(node) {
const scope = sourceCode.getScope(node);
const variable = findVariable(scope, node);
if (!variable || variable.defs.length !== 1) {
return;
}

/** @type {import('eslint').Scope.Definition} */
const define = variable.defs[0];
if (
define.type !== 'ImportBinding'
|| (define.parent.source.value !== nodeModuleName && define.parent.source.value !== 'node:' + nodeModuleName)
) {
return;
}

return define.node;
}
}

/**
@returns {node is import('estree').SimpleCallExpression}
*/
function isCallFileURLToPath(node, sourceCode) {
return isCallNodeBuiltinModule(node, 'fileURLToPath', 'url', sourceCode);
}

/**
@returns {node is import('estree').SimpleCallExpression}
*/
function isCallPathDirname(node, sourceCode) {
return isCallNodeBuiltinModule(node, 'dirname', 'path', sourceCode);
}

function fixDefaultExport(node, sourceCode) {
return function * (fixer) {
yield fixer.replaceText(node, 'export default ');
Expand Down Expand Up @@ -358,6 +457,152 @@ function create(context) {

return problem;
});

context.on('MetaProperty', function * (node) {
if (!isImportMeta(node)) {
return;
}

/** @type {{parent?: import('estree').Node}} */
const {parent} = node;
if (
parent.type !== 'MemberExpression'
|| parent.object !== node
) {
return;
}

/** @type {import('estree').Node} */
const targetNode = parent.parent;

const propertyName = getPropertyName(parent);
if (propertyName === 'url') {
if (
isCallFileURLToPath(targetNode, sourceCode)
&& targetNode.arguments[0] === parent
) {
// Report `fileURLToPath(import.meta.url)`
yield * iterateProblemsFromFilename(targetNode, {
reportFilenameNode: true,
});
return;
}

if (isNewURL(targetNode, sourceCode)) {
const urlParent = targetNode.parent;

if (targetNode.arguments[0] === parent) {
if (
isCallFileURLToPath(urlParent, sourceCode)
&& urlParent.arguments[0] === targetNode
) {
// Report `fileURLToPath(new URL(import.meta.url))`
yield * iterateProblemsFromFilename(urlParent, {
reportFilenameNode: true,
});
} else if (isAccessPathname(urlParent)) {
// Process for `new URL(import.meta.url).pathname`
yield * iterateProblemsFromFilename(urlParent);
}

return;
}

if (
isParentLiteral(targetNode.arguments[0])
&& targetNode.arguments[1] === parent
&& isCallFileURLToPath(urlParent, sourceCode)
&& urlParent.arguments[0] === targetNode
) {
// Report `fileURLToPath(new URL(".", import.meta.url))`
yield buildProblem(urlParent, 'dirname');
}
}

return;
}

if (propertyName === 'filename') {
yield * iterateProblemsFromFilename(parent);
}

/**
Iterates over reports where a given filename expression node
would be used to convert it to a dirname.
@param { import('estree').Expression} node
*/
function * iterateProblemsFromFilename(node, {reportFilenameNode = false} = {}) {
/** @type {{parent: import('estree').Node}} */
const {parent} = node;

if (
isCallPathDirname(parent, sourceCode)
&& parent.arguments[0] === node
) {
// Report `path.dirname(filename)`
yield buildProblem(parent, 'dirname');
return;
}

if (reportFilenameNode) {
yield buildProblem(node, 'filename');
}

if (parent.type !== 'VariableDeclarator' || parent.init !== node || parent.id.type !== 'Identifier') {
return;
}

/** @type {import('eslint').Scope.Variable|null} */
const variable = findVariable(sourceCode.getScope(parent.id), parent.id);
if (!variable) {
return;
}

for (const reference of variable.references) {
if (!reference.isReadOnly()) {
continue;
}

/** @type {{parent: import('estree').Node}} */
const {parent} = reference.identifier;
if (
isCallPathDirname(parent, sourceCode)
&& parent.arguments[0] === reference.identifier
) {
// Report `path.dirname(identifier)`
yield buildProblem(parent, 'dirname');
}
}
}

/**
@param { import('estree').Node} node
@param {'dirname' | 'filename'} name
*/
function buildProblem(node, name) {
const problem = {
node,
messageId: name === 'dirname' ? ERROR_CALC_DIRNAME : ERROR_CALC_FILENAME,
data: {name},
};
const fix = fixer =>
fixer.replaceText(node, `import.meta.${name}`);

if (filename.endsWith('.mjs')) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should import.meta already ensure these files are in ESM? Why this check needed?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we give the community some time to drop Node.js 18, and add an option instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, you're right, no need to check for mjs 😓
It's definitely an ESM because it already uses import.meta.
I'll change that.

Copy link
Contributor Author

@ota-meshi ota-meshi Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we give the community some time to drop Node.js 18, and add an option instead?

With Node v18 reaching EOL next month, I'm not sure if it should be an option 😅

#2255 (comment)

problem.fix = fix;
} else {
problem.suggest = [{
messageId:
name === 'dirname'
? SUGGESTION_IMPORT_META_DIRNAME_FROM_URL
: SUGGESTION_IMPORT_META_FILENAME_FROM_URL,
fix,
}];
}

return problem;
}
});
}

/** @type {import('eslint').Rule.RuleModule} */
Expand Down
3 changes: 1 addition & 2 deletions scripts/create-rule.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import enquirer from 'enquirer';
import {template} from 'lodash-es';
import openEditor from 'open-editor';
import spawn from 'nano-spawn';

const dirname = path.dirname(fileURLToPath(import.meta.url));
const {dirname} = import.meta;
const ROOT = path.join(dirname, '..');

function checkFiles(ruleId) {
Expand Down
3 changes: 1 addition & 2 deletions scripts/internal-rules/no-restricted-property-access.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {isMemberExpression} from '../../rules/ast/index.js';
import {removeMemberExpressionProperty} from '../../rules/fix/index.js';

const messageId = path.basename(fileURLToPath(import.meta.url), '.js');
const messageId = path.basename(import.meta.filename, '.js');

const properties = new Map([
['range', 'sourceCode.getRange'],
Expand Down
3 changes: 1 addition & 2 deletions scripts/internal-rules/no-test-only.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const messageId = path.basename(fileURLToPath(import.meta.url), '.js');
const messageId = path.basename(import.meta.filename, '.js');

const config = {
create(context) {
Expand Down
3 changes: 1 addition & 2 deletions scripts/internal-rules/prefer-fixer-remove-range.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {isMethodCall, isLiteral} from '../../rules/ast/index.js';
import {removeArgument} from '../../rules/fix/index.js';

const messageId = path.basename(fileURLToPath(import.meta.url), '.js');
const messageId = path.basename(import.meta.filename, '.js');

const config = {
create(context) {
Expand Down
3 changes: 1 addition & 2 deletions scripts/internal-rules/prefer-negative-boolean-attribute.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const messageId = path.basename(fileURLToPath(import.meta.url), '.js');
const messageId = path.basename(import.meta.filename, '.js');

const shouldReport = (string, value) => {
const index = string.indexOf(`=${value}]`);
Expand Down
3 changes: 1 addition & 2 deletions test/integration/projects.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const dirname = path.dirname(fileURLToPath(import.meta.url));
const {dirname} = import.meta;

function normalizeProject(project) {
if (typeof project === 'string') {
Expand Down
Loading
Loading