diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts index e7223e93332..0e4b7780c17 100644 --- a/packages/parser/src/parser.ts +++ b/packages/parser/src/parser.ts @@ -105,6 +105,7 @@ function parseForESLint( jsx: validateBoolean(options.ecmaFeatures.jsx), }); const analyzeOptions: AnalyzeOptions = { + ecmaVersion: options.ecmaVersion === 'latest' ? 1e8 : options.ecmaVersion, globalReturn: options.ecmaFeatures.globalReturn, jsxPragma: options.jsxPragma, jsxFragmentName: options.jsxFragmentName, diff --git a/packages/parser/tests/lib/parser.ts b/packages/parser/tests/lib/parser.ts index e554c4bfde7..7f2f193e11d 100644 --- a/packages/parser/tests/lib/parser.ts +++ b/packages/parser/tests/lib/parser.ts @@ -19,6 +19,11 @@ describe('parser', () => { expect(() => parseForESLint(code, null)).not.toThrow(); }); + it("parseForESLint() should work if options.ecmaVersion is `'latest'`", () => { + const code = 'const valid = true;'; + expect(() => parseForESLint(code, { ecmaVersion: 'latest' })).not.toThrow(); + }); + it('parseAndGenerateServices() should be called with options', () => { const code = 'const valid = true;'; const spy = jest.spyOn(typescriptESTree, 'parseAndGenerateServices'); @@ -28,6 +33,7 @@ describe('parser', () => { range: false, tokens: false, sourceType: 'module' as const, + ecmaVersion: 2018, ecmaFeatures: { globalReturn: false, jsx: false, @@ -78,6 +84,7 @@ describe('parser', () => { range: false, tokens: false, sourceType: 'module' as const, + ecmaVersion: 2018, ecmaFeatures: { globalReturn: false, jsx: false, @@ -97,6 +104,7 @@ describe('parser', () => { parseForESLint(code, config); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenLastCalledWith(expect.anything(), { + ecmaVersion: 2018, globalReturn: false, lib: ['dom.iterable'], jsxPragma: 'Foo', diff --git a/packages/scope-manager/README.md b/packages/scope-manager/README.md index 3d9ef751032..b0a745f3fa5 100644 --- a/packages/scope-manager/README.md +++ b/packages/scope-manager/README.md @@ -36,6 +36,13 @@ interface AnalyzeOptions { */ childVisitorKeys?: Record | null; + /** + * Which ECMAScript version is considered. + * Defaults to `2018`. + * `'latest'` is converted to 1e8 at parser. + */ + ecmaVersion?: EcmaVersion | 1e8; + /** * Whether the whole script is executed under node.js environment. * When enabled, the scope manager adds a function scope immediately following the global scope. @@ -44,7 +51,7 @@ interface AnalyzeOptions { globalReturn?: boolean; /** - * Implied strict mode. + * Implied strict mode (if ecmaVersion >= 5). * Defaults to `false`. */ impliedStrict?: boolean; @@ -69,7 +76,7 @@ interface AnalyzeOptions { * This automatically defines a type variable for any types provided by the configured TS libs. * For more information, see https://www.typescriptlang.org/tsconfig#lib * - * Defaults to ['esnext']. + * Defaults to the lib for the provided `ecmaVersion`. */ lib?: Lib[]; @@ -98,6 +105,7 @@ const ast = parse(code, { range: true, }); const scope = analyze(ast, { + ecmaVersion: 2020, sourceType: 'module', }); ``` diff --git a/packages/scope-manager/src/ScopeManager.ts b/packages/scope-manager/src/ScopeManager.ts index d8beafba4aa..5368cca1dc3 100644 --- a/packages/scope-manager/src/ScopeManager.ts +++ b/packages/scope-manager/src/ScopeManager.ts @@ -28,11 +28,9 @@ interface ScopeManagerOptions { globalReturn?: boolean; sourceType?: 'module' | 'script'; impliedStrict?: boolean; + ecmaVersion?: number; } -/** - * @see https://eslint.org/docs/latest/developer-guide/scope-manager-interface#scopemanager-interface - */ class ScopeManager { public currentScope: Scope | null; public readonly declaredVariables: WeakMap; @@ -79,13 +77,12 @@ class ScopeManager { public isImpliedStrict(): boolean { return this.#options.impliedStrict === true; } - public isStrictModeSupported(): boolean { - return true; + return this.#options.ecmaVersion != null && this.#options.ecmaVersion >= 5; } public isES6(): boolean { - return true; + return this.#options.ecmaVersion != null && this.#options.ecmaVersion >= 6; } /** diff --git a/packages/scope-manager/src/analyze.ts b/packages/scope-manager/src/analyze.ts index 1cb1d989209..e227d1e45ad 100644 --- a/packages/scope-manager/src/analyze.ts +++ b/packages/scope-manager/src/analyze.ts @@ -1,6 +1,7 @@ -import type { Lib, TSESTree } from '@typescript-eslint/types'; +import type { EcmaVersion, Lib, TSESTree } from '@typescript-eslint/types'; import { visitorKeys } from '@typescript-eslint/visitor-keys'; +import { lib as TSLibraries } from './lib'; import type { ReferencerOptions } from './referencer'; import { Referencer } from './referencer'; import { ScopeManager } from './ScopeManager'; @@ -15,6 +16,13 @@ interface AnalyzeOptions { */ childVisitorKeys?: ReferencerOptions['childVisitorKeys']; + /** + * Which ECMAScript version is considered. + * Defaults to `2018`. + * `'latest'` is converted to 1e8 at parser. + */ + ecmaVersion?: EcmaVersion | 1e8; + /** * Whether the whole script is executed under node.js environment. * When enabled, the scope manager adds a function scope immediately following the global scope. @@ -23,7 +31,7 @@ interface AnalyzeOptions { globalReturn?: boolean; /** - * Implied strict mode. + * Implied strict mode (if ecmaVersion >= 5). * Defaults to `false`. */ impliedStrict?: boolean; @@ -46,7 +54,7 @@ interface AnalyzeOptions { /** * The lib used by the project. * This automatically defines a type variable for any types provided by the configured TS libs. - * Defaults to ['esnext']. + * Defaults to the lib for the provided `ecmaVersion`. * * https://www.typescriptlang.org/tsconfig#lib */ @@ -66,6 +74,7 @@ interface AnalyzeOptions { const DEFAULT_OPTIONS: Required = { childVisitorKeys: visitorKeys, + ecmaVersion: 2018, globalReturn: false, impliedStrict: false, jsxPragma: 'React', @@ -75,6 +84,21 @@ const DEFAULT_OPTIONS: Required = { emitDecoratorMetadata: false, }; +/** + * Convert ecmaVersion to lib. + * `'latest'` is converted to 1e8 at parser. + */ +function mapEcmaVersion(version: EcmaVersion | 1e8 | undefined): Lib { + if (version == null || version === 3 || version === 5) { + return 'es5'; + } + + const year = version > 2000 ? version : 2015 + (version - 6); + const lib = `es${year}`; + + return lib in TSLibraries ? (lib as Lib) : year > 2020 ? 'esnext' : 'es5'; +} + /** * Takes an AST and returns the analyzed scopes. */ @@ -82,9 +106,12 @@ function analyze( tree: TSESTree.Node, providedOptions?: AnalyzeOptions, ): ScopeManager { + const ecmaVersion = + providedOptions?.ecmaVersion ?? DEFAULT_OPTIONS.ecmaVersion; const options: Required = { childVisitorKeys: providedOptions?.childVisitorKeys ?? DEFAULT_OPTIONS.childVisitorKeys, + ecmaVersion, globalReturn: providedOptions?.globalReturn ?? DEFAULT_OPTIONS.globalReturn, impliedStrict: providedOptions?.impliedStrict ?? DEFAULT_OPTIONS.impliedStrict, @@ -95,7 +122,7 @@ function analyze( jsxFragmentName: providedOptions?.jsxFragmentName ?? DEFAULT_OPTIONS.jsxFragmentName, sourceType: providedOptions?.sourceType ?? DEFAULT_OPTIONS.sourceType, - lib: providedOptions?.lib ?? ['esnext'], + lib: providedOptions?.lib ?? [mapEcmaVersion(ecmaVersion)], emitDecoratorMetadata: providedOptions?.emitDecoratorMetadata ?? DEFAULT_OPTIONS.emitDecoratorMetadata, diff --git a/packages/scope-manager/src/referencer/Referencer.ts b/packages/scope-manager/src/referencer/Referencer.ts index ecadb7e9fd6..a69209e86c6 100644 --- a/packages/scope-manager/src/referencer/Referencer.ts +++ b/packages/scope-manager/src/referencer/Referencer.ts @@ -371,7 +371,9 @@ class Referencer extends Visitor { } protected BlockStatement(node: TSESTree.BlockStatement): void { - this.scopeManager.nestBlockScope(node); + if (this.scopeManager.isES6()) { + this.scopeManager.nestBlockScope(node); + } this.visitChildren(node); @@ -485,7 +487,7 @@ class Referencer extends Visitor { protected ImportDeclaration(node: TSESTree.ImportDeclaration): void { assert( - this.scopeManager.isModule(), + this.scopeManager.isES6() && this.scopeManager.isModule(), 'ImportDeclaration should appear when the mode is ES6 and in the module context.', ); @@ -577,11 +579,14 @@ class Referencer extends Visitor { this.scopeManager.nestFunctionScope(node, false); } - if (this.scopeManager.isModule()) { + if (this.scopeManager.isES6() && this.scopeManager.isModule()) { this.scopeManager.nestModuleScope(node); } - if (this.scopeManager.isImpliedStrict()) { + if ( + this.scopeManager.isStrictModeSupported() && + this.scopeManager.isImpliedStrict() + ) { this.currentScope().isStrict = true; } @@ -596,7 +601,9 @@ class Referencer extends Visitor { protected SwitchStatement(node: TSESTree.SwitchStatement): void { this.visit(node.discriminant); - this.scopeManager.nestSwitchScope(node); + if (this.scopeManager.isES6()) { + this.scopeManager.nestSwitchScope(node); + } for (const switchCase of node.cases) { this.visit(switchCase); diff --git a/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts b/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts index 82611eb5147..23e02b9af0c 100644 --- a/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts +++ b/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts @@ -12,6 +12,7 @@ describe('ScopeManager.prototype.getDeclaredVariables', () => { expectedNamesList: string[][], ): void { const scopeManager = analyze(ast, { + ecmaVersion: 6, sourceType: 'module', }); diff --git a/packages/scope-manager/tests/eslint-scope/implied-strict.test.ts b/packages/scope-manager/tests/eslint-scope/implied-strict.test.ts index 893da6048c2..34151be8c3d 100644 --- a/packages/scope-manager/tests/eslint-scope/implied-strict.test.ts +++ b/packages/scope-manager/tests/eslint-scope/implied-strict.test.ts @@ -8,7 +8,7 @@ import { } from '../util'; describe('impliedStrict option', () => { - it('ensures all user scopes are strict', () => { + it('ensures all user scopes are strict if ecmaVersion >= 5', () => { const { scopeManager } = parseAndAnalyze( ` function foo() { @@ -18,6 +18,7 @@ describe('impliedStrict option', () => { } `, { + ecmaVersion: 5, impliedStrict: true, }, ); @@ -41,12 +42,38 @@ describe('impliedStrict option', () => { expect(scope.isStrict).toBeTruthy(); }); + it('ensures impliedStrict option is only effective when ecmaVersion option >= 5', () => { + const { scopeManager } = parseAndAnalyze( + ` + function foo() {} + `, + { + ecmaVersion: 3, + impliedStrict: true, + }, + ); + + expect(scopeManager.scopes).toHaveLength(2); + + let scope = scopeManager.scopes[0]; + + expectToBeGlobalScope(scope); + expect(scope.block.type).toBe(AST_NODE_TYPES.Program); + expect(scope.isStrict).toBeFalsy(); + + scope = scopeManager.scopes[1]; + expectToBeFunctionScope(scope); + expect(scope.block.type).toBe(AST_NODE_TYPES.FunctionDeclaration); + expect(scope.isStrict).toBeFalsy(); + }); + it('omits a nodejs global scope when ensuring all user scopes are strict', () => { const { scopeManager } = parseAndAnalyze( ` function foo() {} `, { + ecmaVersion: 5, globalReturn: true, impliedStrict: true, }, @@ -73,6 +100,7 @@ describe('impliedStrict option', () => { it('omits a module global scope when ensuring all user scopes are strict', () => { const { scopeManager } = parseAndAnalyze('function foo() {}', { + ecmaVersion: 6, impliedStrict: true, sourceType: 'module', }); diff --git a/packages/scope-manager/tests/eslint-scope/map-ecma-version.test.ts b/packages/scope-manager/tests/eslint-scope/map-ecma-version.test.ts new file mode 100644 index 00000000000..becca474ff3 --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/map-ecma-version.test.ts @@ -0,0 +1,51 @@ +import type { EcmaVersion, Lib, TSESTree } from '@typescript-eslint/types'; + +import { analyze } from '../../src/analyze'; +import { Referencer } from '../../src/referencer'; + +jest.mock('../../src/referencer'); +jest.mock('../../src/ScopeManager'); + +describe('ecma version mapping', () => { + it("should map to 'esnext' when unsuported and new", () => { + expectMapping(2042, 'esnext'); + expectMapping(42, 'esnext'); + }); + + it("should map to 'es5' when unsuported and old", () => { + expectMapping(2002, 'es5'); + expectMapping(2, 'es5'); + }); + + it("should map to 'es{year}' when supported and >= 6", () => { + expectMapping(2015, 'es2015'); + expectMapping(6, 'es2015'); + expectMapping(2020, 'es2020'); + expectMapping(11, 'es2020'); + }); + + it("should map to 'es5' when 5 or 3", () => { + expectMapping(5, 'es5'); + expectMapping(3, 'es5'); + }); + + it("should map to 'es2018' when undefined", () => { + expectMapping(undefined, 'es2018'); + }); + + it("should map to 'esnext' when 'latest'", () => { + // `'latest'` is converted to 1e8 at parser. + expectMapping(1e8, 'esnext'); + }); +}); + +const fakeNode = {} as unknown as TSESTree.Node; + +function expectMapping(ecmaVersion: number | undefined, lib: Lib): void { + (Referencer as jest.Mock).mockClear(); + analyze(fakeNode, { ecmaVersion: ecmaVersion as EcmaVersion }); + expect(Referencer).toHaveBeenCalledWith( + expect.objectContaining({ lib: [lib] }), + expect.any(Object), + ); +} diff --git a/packages/scope-manager/tests/fixtures.test.ts b/packages/scope-manager/tests/fixtures.test.ts index ac6b39c860e..fa86c1544c7 100644 --- a/packages/scope-manager/tests/fixtures.test.ts +++ b/packages/scope-manager/tests/fixtures.test.ts @@ -42,6 +42,7 @@ const ALLOWED_OPTIONS: Map = new Map< keyof AnalyzeOptions, ALLOWED_VALUE >([ + ['ecmaVersion', ['number']], ['globalReturn', ['boolean']], ['impliedStrict', ['boolean']], ['jsxPragma', ['string']], diff --git a/packages/website/src/components/linter/WebLinter.ts b/packages/website/src/components/linter/WebLinter.ts index acd3c569ff3..378e34ed136 100644 --- a/packages/website/src/components/linter/WebLinter.ts +++ b/packages/website/src/components/linter/WebLinter.ts @@ -111,6 +111,10 @@ export class WebLinter { ); const scopeManager = this.lintUtils.analyze(ast, { + ecmaVersion: + eslintOptions.ecmaVersion === 'latest' + ? 1e8 + : eslintOptions.ecmaVersion, globalReturn: eslintOptions.ecmaFeatures?.globalReturn ?? false, sourceType: eslintOptions.sourceType ?? 'script', });