Repository: import-js/eslint-plugin-import
Pull request #2714 (2023-02-10 20:04:30)

[utils] [new] `parse`: support flat config
Flat ESLint [no longer supports](https://eslint/eslint/issues/16878) `context.parserPath` which is required for [utils/parse](https://github.com/import-js/eslint-plugin-import/blob/d5fc8b670dc8e6903dbb7b0894452f60c03089f5/utils/parse.js#L60).
This PR uses `context.languageOptions.parse` which has the loaded parser as an alternative.
Some notes:
- Backwards compatible as it only checks if `context.parserPath` is unset
- Currently it does check if `parse` or `parseForESLint` in `context.languageOptions.parser` is a function (I do not if "constructed" context objects are officially supported)
- I added a test to the rule to no-unused-modules for testing with flat eslint rule tester


Patch:
diff --git a/utils/parse.js b/utils/parse.js
index ac728ec5..dd0746aa 100644
--- a/utils/parse.js
+++ b/utils/parse.js
@@ -23,10 +23,10 @@ function keysFromParser(parserPath, parserInstance, parsedResult) {
   if (parsedResult && parsedResult.visitorKeys) {
     return parsedResult.visitorKeys;
   }
-  if (/.*espree.*/.test(parserPath)) {
+  if (typeof parserPath === 'string' && /.*espree.*/.test(parserPath)) {
     return parserInstance.VisitorKeys;
   }
-  if (/.*babel-eslint.*/.test(parserPath)) {
+  if (typeof parserPath === 'string' && /.*babel-eslint.*/.test(parserPath)) {
     return getBabelEslintVisitorKeys(parserPath);
   }
   return null;
@@ -51,13 +51,13 @@ function transformHashbang(text) {
 }
 
 exports.default = function parse(path, content, context) {
-
   if (context == null) throw new Error('need context to parse properly');
 
-  let parserOptions = context.parserOptions;
-  const parserPath = getParserPath(path, context);
+  // ESLint in "flat" mode only sets context.languageOptions.parserOptions
+  let parserOptions = (context.languageOptions && context.languageOptions.parserOptions) || context.parserOptions;
+  const parserOrPath = getParser(path, context);
 
-  if (!parserPath) throw new Error('parserPath is required!');
+  if (!parserOrPath) throw new Error('parserPath or languageOptions.parser is required!');
 
   // hack: espree blows up with frozen options
   parserOptions = Object.assign({}, parserOptions);
@@ -84,7 +84,7 @@ exports.default = function parse(path, content, context) {
   delete parserOptions.projects;
 
   // require the parser relative to the main module (i.e., ESLint)
-  const parser = moduleRequire(parserPath);
+  const parser = typeof parserOrPath === 'string' ? moduleRequire(parserOrPath) : parserOrPath;
 
   // replicate bom strip and hashbang transform of ESLint
   // https://github.com/eslint/eslint/blob/b93af98b3c417225a027cabc964c38e779adb945/lib/linter/linter.js#L779
@@ -95,7 +95,7 @@ exports.default = function parse(path, content, context) {
     try {
       const parserRaw = parser.parseForESLint(content, parserOptions);
       ast = parserRaw.ast;
-      return makeParseReturn(ast, keysFromParser(parserPath, parser, parserRaw));
+      return makeParseReturn(ast, keysFromParser(parserOrPath, parser, parserRaw));
     } catch (e) {
       console.warn();
       console.warn('Error while parsing ' + parserOptions.filePath);
@@ -104,18 +104,34 @@ exports.default = function parse(path, content, context) {
     if (!ast || typeof ast !== 'object') {
       console.warn(
         '`parseForESLint` from parser `' +
-          parserPath +
+          (typeof parserOrPath === 'string' ? parserOrPath : '`context.languageOptions.parser`') + // Can only be invalid for custom parser per imports/parser
           '` is invalid and will just be ignored'
       );
     } else {
-      return makeParseReturn(ast, keysFromParser(parserPath, parser, undefined));
+      return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined));
     }
   }
 
   const ast = parser.parse(content, parserOptions);
-  return makeParseReturn(ast, keysFromParser(parserPath, parser, undefined));
+  return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined));
 };
 
+function getParser(path, context) {
+  const parserPath = getParserPath(path, context);
+  if (parserPath) {
+    return parserPath;
+  }
+  const isFlat = context.languageOptions
+    && context.languageOptions.parser
+    && typeof context.languageOptions.parser !== 'string'
+    && (
+      typeof context.languageOptions.parser.parse === 'function'
+      || typeof context.languageOptions.parser.parseForESLint === 'function'
+    );
+
+  return isFlat ? context.languageOptions.parser : null;
+}
+
 function getParserPath(path, context) {
   const parsers = context.settings['import/parsers'];
   if (parsers != null) {


Test patch:
diff --git a/tests/src/core/parse.js b/tests/src/core/parse.js
index 407070aa..4ab8370e 100644
--- a/tests/src/core/parse.js
+++ b/tests/src/core/parse.js
@@ -69,4 +69,63 @@ describe('parse(content, { settings, ecmaFeatures })', function () {
     expect(parse.bind(null, path, content, { settings: { 'import/parsers': { [parseStubParserPath]: [ '.js' ] } }, parserPath: null, parserOptions })).not.to.throw(Error);
     expect(parseSpy.callCount, 'custom parser to be called once').to.equal(1);
   });
+
+  it('throws on invalid languageOptions', function () {
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: null, languageOptions: null })).to.throw(Error);
+  });
+
+  it('throws on non-object languageOptions.parser', function () {
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: null, languageOptions: { parser: 'espree' } })).to.throw(Error);
+  });
+
+  it('throws on null languageOptions.parser', function () {
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: null, languageOptions: { parser: null } })).to.throw(Error);
+  });
+
+  it('throws on empty languageOptions.parser', function () {
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: null, languageOptions: { parser: {} } })).to.throw(Error);
+  });
+
+  it('throws on non-function languageOptions.parser.parse', function () {
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: null, languageOptions: { parser: { parse: 'espree' } } })).to.throw(Error);
+  });
+
+  it('throws on non-function languageOptions.parser.parse', function () {
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: null, languageOptions: { parser: { parseForESLint: 'espree' } } })).to.throw(Error);
+  });
+
+  it('requires only one of the parse methods', function () {
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: null, languageOptions: { parser: { parseForESLint: () => ({ ast: {} }) } } })).not.to.throw(Error);
+  });
+
+  it('uses parse from languageOptions.parser', function () {
+    const parseSpy = sinon.spy();
+    expect(parse.bind(null, path, content, { settings: {}, languageOptions: { parser: { parse: parseSpy } } })).not.to.throw(Error);
+    expect(parseSpy.callCount, 'passed parser to be called once').to.equal(1);
+  });
+
+  it('uses parseForESLint from languageOptions.parser', function () {
+    const parseSpy = sinon.spy(() => ({ ast: {} }));
+    expect(parse.bind(null, path, content, { settings: {}, languageOptions: { parser: { parseForESLint: parseSpy } } })).not.to.throw(Error);
+    expect(parseSpy.callCount, 'passed parser to be called once').to.equal(1);
+  });
+
+  it('prefers parsers specified in the settings over languageOptions.parser', () => {
+    const parseSpy = sinon.spy();
+    parseStubParser.parse = parseSpy;
+    expect(parse.bind(null, path, content, { settings: { 'import/parsers': { [parseStubParserPath]: [ '.js' ] } }, parserPath: null, languageOptions: { parser: { parse() {} } } })).not.to.throw(Error);
+    expect(parseSpy.callCount, 'custom parser to be called once').to.equal(1);
+  });
+
+  it('ignores parser options from language options set to null', () => {
+    const parseSpy = sinon.spy();
+    parseStubParser.parse = parseSpy;
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: 'espree', languageOptions: { parserOptions: null }, parserOptions: { sourceType: 'module', ecmaVersion: 2015, ecmaFeatures: { jsx: true } } })).not.to.throw(Error);
+  });
+
+  it('prefers languageOptions.parserOptions over parserOptions', () => {
+    const parseSpy = sinon.spy();
+    parseStubParser.parse = parseSpy;
+    expect(parse.bind(null, path, content, { settings: {}, parserPath: 'espree', languageOptions: { parserOptions: { sourceType: 'module', ecmaVersion: 2015, ecmaFeatures: { jsx: true } } }, parserOptions: { sourceType: 'script' } })).not.to.throw(Error);
+  });
 });
diff --git a/tests/src/rules/no-unused-modules.js b/tests/src/rules/no-unused-modules.js
index de169c65..87714b59 100644
--- a/tests/src/rules/no-unused-modules.js
+++ b/tests/src/rules/no-unused-modules.js
@@ -7,6 +7,11 @@ import fs from 'fs';
 import eslintPkg from 'eslint/package.json';
 import semver from 'semver';
 
+let FlatRuleTester;
+try {
+  ({ FlatRuleTester } = require('eslint/use-at-your-own-risk'));
+} catch (e) { /**/ }
+
 // TODO: figure out why these tests fail in eslint 4 and 5
 const isESLint4TODO = semver.satisfies(eslintPkg.version, '^4 || ^5');
 
@@ -1371,3 +1376,20 @@ describe('parser ignores prefixes like BOM and hashbang', () => {
     invalid: [],
   });
 });
+
+describe('supports flat eslint', { skip: !FlatRuleTester }, () => {
+  const flatRuleTester = new FlatRuleTester();
+  flatRuleTester.run('no-unused-modules', rule, {
+    valid: [{
+      options: unusedExportsOptions,
+      code: 'import { o2 } from "./file-o";export default () => 12',
+      filename: testFilePath('./no-unused-modules/file-a.js'),
+    }],
+    invalid: [{
+      options: unusedExportsOptions,
+      code: 'export default () => 13',
+      filename: testFilePath('./no-unused-modules/file-f.js'),
+      errors: [error(`exported declaration 'default' not used within other modules`)],
+    }],
+  });
+});