diff --git a/lib/parsers/gjs-gts-parser.js b/lib/parsers/gjs-gts-parser.js index c4f4e11eeb..d0fe31e97f 100644 --- a/lib/parsers/gjs-gts-parser.js +++ b/lib/parsers/gjs-gts-parser.js @@ -4,10 +4,13 @@ const DocumentLines = require('../utils/document'); const { visitorKeys: glimmerVisitorKeys } = require('@glimmer/syntax'); const babelParser = require('@babel/eslint-parser'); const typescriptParser = require('@typescript-eslint/parser'); +const ts = require('typescript'); const TypescriptScope = require('@typescript-eslint/scope-manager'); const { Reference, Scope, Variable, Definition } = require('eslint-scope'); const { registerParsedFile } = require('../preprocessors/noop'); const htmlTags = require('html-tags'); +const path = require('node:path'); +const fs = require('node:fs'); /** * finds the nearest node scope @@ -460,9 +463,10 @@ function replaceRange(s, start, end, substitute) { return s.slice(0, start) + substitute + s.slice(end); } +const processor = new ContentTag.Preprocessor(); + function transformForLint(code) { let jsCode = code; - const processor = new ContentTag.Preprocessor(); /** * * @type {{ @@ -516,6 +520,8 @@ function transformForLint(code) { }; } +const TsProgramMap = {}; + /** * implements https://eslint.org/docs/latest/extend/custom-parsers * 1. transforms gts/gjs files into parseable ts/js without changing the offsets and locations around it @@ -533,12 +539,77 @@ module.exports = { let jsCode = code; const info = transformForLint(code); jsCode = info.output; + jsCode = jsCode.replaceAll(/\.gts(["'])/g, '.ts$1 '); const isTypescript = options.filePath.endsWith('.gts'); let result = null; + + const projectFile = path.resolve(options.tsconfigRootDir, options.project); + + if (!TsProgramMap[projectFile]) { + const config = ts.getParsedCommandLineOfConfigFile( + projectFile, + {}, + { + ...ts.sys, + readDirectory(dir) { + const results = ts.sys.readDirectory(dir); + return results.map((f) => f.replace(/\.gts$/, '.ts')); + }, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + let { messageText } = diagnostic; + if (typeof messageText !== 'string') { + messageText = messageText.messageText; + } + + throw new Error(messageText); + }, + } + ); + + const host = ts.createCompilerHost(config.options); + host.fileExists = function (fileName) { + return fs.existsSync(fileName.replace(/\.ts$/, '.gts')) || fs.existsSync(fileName); + }; + + const readDirectory = host.readDirectory; + host.readDirectory = function(dir) { + const results = readDirectory.call(this, dir); + return results.map((f) => f.replace(/\.gts$/, '.ts')); + }; + host.readFile = function(fname) { + let fileName = fname; + if (fileName === options.filePath.replace(/\\/g, '/').replace(/\.gts$/, '.ts')) { + return jsCode; + } + let content = ''; + try { + content = fs.readFileSync(fileName).toString(); + } catch { + fileName = fileName.replace(/\.ts$/, '.gts'); + content = fs.readFileSync(fileName).toString(); + } + if (fileName.endsWith('.gts')) { + content = transformForLint(content).output; + } + content = content.replaceAll(/\.gts(["'])/g, '.ts$1 '); + return content; + }; + + TsProgramMap[projectFile] = ts.createProgram({ + rootNames: config.fileNames, + options: config.options, + host, + }); + } + + const program = TsProgramMap[projectFile]; + + options.filePath = options.filePath.replace(/\.gts$/, '.ts'); + result = isTypescript - ? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true }) + ? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true, programs: [program] }) : babelParser.parseForESLint(jsCode, { ...options, ranges: true }); if (!info.templateInfos?.length) { return result; diff --git a/tests/lib/rules-preprocessor/ember_ts/bar.gts b/tests/lib/rules-preprocessor/ember_ts/bar.gts new file mode 100644 index 0000000000..32a5baebb3 --- /dev/null +++ b/tests/lib/rules-preprocessor/ember_ts/bar.gts @@ -0,0 +1,5 @@ +export const fortyTwoFromGTS = '42'; + + diff --git a/tests/lib/rules-preprocessor/ember_ts/baz.ts b/tests/lib/rules-preprocessor/ember_ts/baz.ts new file mode 100644 index 0000000000..84e700e1a6 --- /dev/null +++ b/tests/lib/rules-preprocessor/ember_ts/baz.ts @@ -0,0 +1 @@ +export const fortyTwoFromTS = '42'; diff --git a/tests/lib/rules-preprocessor/ember_ts/foo.gts b/tests/lib/rules-preprocessor/ember_ts/foo.gts new file mode 100644 index 0000000000..35aaba7c6b --- /dev/null +++ b/tests/lib/rules-preprocessor/ember_ts/foo.gts @@ -0,0 +1,14 @@ +import { fortyTwoFromGTS } from './bar.gts'; +import { fortyTwoFromTS } from './baz.ts'; + +export const fortyTwoLocal = '42'; + +const helloWorldFromTS = fortyTwoFromTS[0] === '4' ? 'hello' : 'world'; +const helloWorldFromGTS = fortyTwoFromGTS[0] === '4' ? 'hello' : 'world'; +const helloWorld = fortyTwoLocal[0] === '4' ? 'hello' : 'world'; +// + diff --git a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js index cbd5b86b8b..c80a7784e7 100644 --- a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js +++ b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js @@ -761,4 +761,80 @@ describe('multiple tokens in same file', () => { expect(resultErrors[2].message).toBe("'bar' is not defined."); expect(resultErrors[2].line).toBe(17); }); + + it('lints while being type aware', async () => { + const eslint = new ESLint({ + ignore: false, + useEslintrc: false, + plugins: { ember: plugin }, + overrideConfig: { + root: true, + env: { + browser: true, + }, + plugins: ['ember'], + extends: ['plugin:ember/recommended'], + overrides: [ + { + files: ['**/*.gts'], + parser: 'eslint-plugin-ember/gjs-gts-parser', + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + extraFileExtensions: ['.gts'], + }, + extends: [ + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:ember/recommended', + ], + rules: { + 'no-trailing-spaces': 'error', + '@typescript-eslint/prefer-string-starts-ends-with': 'error', + }, + }, + { + files: ['**/*.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + extraFileExtensions: ['.gts'], + }, + extends: [ + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:ember/recommended', + ], + rules: { + 'no-trailing-spaces': 'error', + }, + }, + ], + rules: { + quotes: ['error', 'single'], + semi: ['error', 'always'], + 'object-curly-spacing': ['error', 'always'], + 'lines-between-class-members': 'error', + 'no-undef': 'error', + 'no-unused-vars': 'error', + 'ember/no-get': 'off', + 'ember/no-array-prototype-extensions': 'error', + 'ember/no-unused-services': 'error', + }, + }, + }); + + const results = await eslint.lintFiles(['**/*.gts', '**/*.ts']); + + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(3); + + expect(resultErrors[0].line).toBe(6); + expect(resultErrors[0].message).toBe("Use 'String#startsWith' method instead."); // Actual result is "Unsafe member access [0] on an `any` value." + + expect(resultErrors[1].line).toBe(7); + expect(resultErrors[1].message).toBe("Use 'String#startsWith' method instead."); + + expect(resultErrors[2].line).toBe(8); + expect(resultErrors[2].message).toBe("Use 'String#startsWith' method instead."); + }); }); diff --git a/tests/lib/rules-preprocessor/tsconfig.eslint.json b/tests/lib/rules-preprocessor/tsconfig.eslint.json index 2767865654..23f2d756df 100644 --- a/tests/lib/rules-preprocessor/tsconfig.eslint.json +++ b/tests/lib/rules-preprocessor/tsconfig.eslint.json @@ -5,6 +5,6 @@ "strictNullChecks": true }, "include": [ - "*" - ] + "**/*" + ], }