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';
+
+
+ {{fortyTwoFromGTS}}
+
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';
+//
+
+ {{helloWorldFromGTS}}
+ {{helloWorldFromTS}}
+ {{helloWorld}}
+
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": [
- "*"
- ]
+ "**/*"
+ ],
}