Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
121 changes: 90 additions & 31 deletions src/parser/gjs-gts-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,46 @@ const { transformForLint, preprocessGlimmerTemplates, convertAst } = require('./
/**
* @param {string} tsconfigPath
* @param {string} rootDir
* @param {string} property - The compiler option property to extract
* @returns {boolean|undefined}
*/
function parseAllowJsFromTsconfig(tsconfigPath, rootDir) {
function parseCompilerOptionFromTsconfig(tsconfigPath, rootDir, property) {
try {
const parserPath = require.resolve('@typescript-eslint/parser');
// eslint-disable-next-line n/no-unpublished-require
const tsPath = require.resolve('typescript', { paths: [parserPath] });
const ts = require(tsPath);
const parsed = tsconfigUtils.getParsedConfigFile(ts, tsconfigPath, rootDir);
return parsed?.options?.allowJs;
return parsed?.options?.[property];
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[ember-eslint-parser] Failed to parse tsconfig:', tsconfigPath, e);
console.warn(
`[ember-eslint-parser] Failed to parse tsconfig for ${property}:`,
tsconfigPath,
e
);
return undefined;
}
}

/**
* @param {Array<boolean|undefined>} values
* @param {string} source
* @param {Array<{getCompilerOptions?: Function}>|undefined} programs
* @returns {boolean|null}
*/
function resolveAllowJs(values, source) {
const filtered = values.filter((val) => typeof val !== 'undefined');
function getAllowJsFromPrograms(programs) {
if (!Array.isArray(programs) || programs.length === 0) return null;
const allowJsValues = programs
.map((p) => p.getCompilerOptions?.())
.filter(Boolean)
.map((opts) => opts.allowJs);

const filtered = allowJsValues.filter((val) => typeof val !== 'undefined');
if (filtered.length > 0) {
const uniqueValues = [...new Set(filtered)];
if (uniqueValues.length > 1) {
// eslint-disable-next-line no-console
console.warn(
`[ember-eslint-parser] Conflicting allowJs values in ${source}. Defaulting allowGjs to false.`
`[ember-eslint-parser] Conflicting allowJs values in programs. Defaulting allowGjs to false.`
);
return false;
} else {
Expand All @@ -59,19 +69,6 @@ function resolveAllowJs(values, source) {
return null;
}

/**
* @param {Array<{getCompilerOptions?: Function}>|undefined} programs
* @returns {boolean|null}
*/
function getAllowJsFromPrograms(programs) {
if (!Array.isArray(programs) || programs.length === 0) return null;
const allowJsValues = programs
.map((p) => p.getCompilerOptions?.())
.filter(Boolean)
.map((opts) => opts.allowJs);
return resolveAllowJs(allowJsValues, 'programs');
}

/**
* @param {boolean|object|undefined} projectService
* @returns {string|null}
Expand Down Expand Up @@ -99,17 +96,25 @@ function getProjectServiceTsconfigPath(projectService) {
}

/**
* Returns the resolved allowJs value based on priority: programs > projectService > project/tsconfig
* Generic function to resolve compiler options based on priority: programs > projectService > project/tsconfig
* @param {object} options - Parser options
* @param {string} property - The compiler option property to resolve (e.g., 'allowJs', 'allowArbitraryExtensions')
* @param {Function} [programsExtractor] - Function to extract the property from programs (optional)
* @returns {boolean} - The resolved value
*/
function getAllowJs(options) {
const allowJsFromPrograms = getAllowJsFromPrograms(options.programs);
if (allowJsFromPrograms !== null) return allowJsFromPrograms;
function getCompilerOption(options, property, programsExtractor) {
// Check programs first (if extractor provided)
if (programsExtractor) {
const programsValue = programsExtractor(options.programs);
if (programsValue !== null) return programsValue;
}

const rootDir = options.tsconfigRootDir || process.cwd();

const projectServiceTsconfigPath = getProjectServiceTsconfigPath(options.projectService);
if (projectServiceTsconfigPath) {
return parseAllowJsFromTsconfig(projectServiceTsconfigPath, rootDir);
const result = parseCompilerOptionFromTsconfig(projectServiceTsconfigPath, rootDir, property);
if (result !== undefined) return result;
}

let tsconfigPaths = [];
Expand All @@ -120,12 +125,29 @@ function getAllowJs(options) {
} else if (options.project) {
tsconfigPaths = ['tsconfig.json'];
}

if (tsconfigPaths.length > 0) {
const allowJsValues = tsconfigPaths.map((cfg) => parseAllowJsFromTsconfig(cfg, rootDir));
return resolveAllowJs(allowJsValues, 'project');
for (const tsconfigPath of tsconfigPaths) {
const result = parseCompilerOptionFromTsconfig(tsconfigPath, rootDir, property);
if (result !== undefined) return result;
}
}

return false;
return false; // Default to false if not found
}

/**
* Returns the resolved allowJs value based on priority: programs > projectService > project/tsconfig
*/
function getAllowJs(options) {
return getCompilerOption(options, 'allowJs', getAllowJsFromPrograms);
}

/**
* Returns the resolved allowArbitraryExtensions value based on priority: projectService > project/tsconfig
*/
function getAllowArbitraryExtensions(options) {
return getCompilerOption(options, 'allowArbitraryExtensions');
}

/**
Expand All @@ -140,10 +162,30 @@ module.exports = {
parseForESLint(code, options) {
const allowGjsWasSet = options.allowGjs !== undefined;
const allowGjs = allowGjsWasSet ? options.allowGjs : getAllowJs(options);
let actualAllowGjs;
const allowArbitraryExtensionsWasSet = options.allowArbitraryExtensions !== undefined;
const allowArbitraryExtensions = allowArbitraryExtensionsWasSet
? options.allowArbitraryExtensions
: getAllowArbitraryExtensions(options);
let actualAllowGjs, actualAllowArbitraryExtensions;
// Only patch TypeScript if we actually need it.
if (options.programs || options.projectService || options.project) {
({ allowGjs: actualAllowGjs } = patchTs({ allowGjs }));
({ allowGjs: actualAllowGjs, allowArbitraryExtensions: actualAllowArbitraryExtensions } =
patchTs({
allowGjs,
allowArbitraryExtensions,
}));

if (actualAllowGjs !== allowGjs) {
console.warn(
`ember-eslint-parser: allowGjs changed from ${allowGjs} to ${actualAllowGjs} due to TypeScript configuration`
);
}

if (actualAllowArbitraryExtensions !== allowArbitraryExtensions) {
console.warn(
`ember-eslint-parser: allowArbitraryExtensions changed from ${allowArbitraryExtensions} to ${actualAllowArbitraryExtensions} due to TypeScript configuration`
);
}
}
registerParsedFile(options.filePath);
let jsCode = code;
Expand Down Expand Up @@ -205,6 +247,23 @@ module.exports = {
` Current: ${allowGjs}, Program: ${programAllowJs}`
);
}

// Compare allowArbitraryExtensions with the actual program's compiler options
const programAllowArbitraryExtensions =
result.services.program.getCompilerOptions?.()?.allowArbitraryExtensions;
if (
!allowArbitraryExtensionsWasSet &&
Copy link
Member

Choose a reason for hiding this comment

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

how does this situation happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure. I wish I knew! I've seen it actually happen with allowGjs.

programAllowArbitraryExtensions !== undefined &&
actualAllowArbitraryExtensions !== undefined &&
actualAllowArbitraryExtensions !== programAllowArbitraryExtensions
) {
// eslint-disable-next-line no-console
console.warn(
'[ember-eslint-parser] allowArbitraryExtensions does not match the actual program. Consider setting allowArbitraryExtensions explicitly.\n' +
` Current: ${allowArbitraryExtensions}, Program: ${programAllowArbitraryExtensions}`
);
}

syncMtsGtsSourceFiles(result.services.program);
}
return { ...result, visitorKeys };
Expand Down
143 changes: 125 additions & 18 deletions src/parser/ts-patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,81 @@ const fs = require('node:fs');
const { transformForLint } = require('./transforms');
const { replaceRange } = require('./transforms');

let patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser, isPatched, allowGjs;
let patchTs,
replaceExtensions,
syncMtsGtsSourceFiles,
typescriptParser,
isPatched,
allowGjs,
allowArbitraryExtensions;

/**
* Helper function to find the first existing file among possible variants
* @param {string} fileName - The original file name to resolve
* @param {boolean} allowGjs - Whether .gjs files are allowed
* @param {boolean} allowArbitraryExtensions - Whether allowArbitraryExtensions is enabled
* @returns {string|null} - The first existing file path, or null if none exist
*/
function findExistingFile(fileName, allowGjs, allowArbitraryExtensions) {
// Check .gts first
const gtsFile = fileName.replace(/\.m?ts$/, '.gts');
if (fs.existsSync(gtsFile)) return gtsFile;

// Check .gjs (if allowed)
if (allowGjs) {
const gjsFile = fileName.replace(/\.m?js$/, '.gjs');
if (fs.existsSync(gjsFile)) return gjsFile;

// Check .gjs.d.ts (multiple patterns)
const gjsDtsFile1 = fileName.replace(/\.mjs\.d\.ts$/, '.gjs.d.ts');
const gjsDtsFile2 = fileName.replace(/\.d\.mts$/, '.gjs.d.ts');
if (fs.existsSync(gjsDtsFile1)) return gjsDtsFile1;
if (fs.existsSync(gjsDtsFile2)) return gjsDtsFile2;

// Check .d.gjs.ts pattern (only if allowArbitraryExtensions is enabled)
if (allowArbitraryExtensions) {
const dGjsFile = fileName.replace(/\.d\.mts$/, '.d.gjs.ts');
if (fs.existsSync(dGjsFile)) return dGjsFile;
}
}

// Check original file
if (fs.existsSync(fileName)) return fileName;

return null;
}

/**
* Helper function to resolve the actual file path for reading
* @param {string} fileName - The original file name to resolve
* @param {boolean} allowGjs - Whether .gjs files are allowed
* @param {boolean} allowArbitraryExtensions - Whether allowArbitraryExtensions is enabled
* @returns {string} - The resolved file path to read from
*/
function resolveFileForReading(fileName, allowGjs, allowArbitraryExtensions) {
// Handle declaration files first (more specific patterns)
if (fileName.endsWith('.d.mts')) {
// .d.mts files could map to .gjs declaration patterns
// Only check .d.gjs.ts if allowArbitraryExtensions is enabled
if (
allowGjs &&
allowArbitraryExtensions &&
fs.existsSync(fileName.replace(/\.d\.mts$/, '.d.gjs.ts'))
) {
return fileName.replace(/\.d\.mts$/, '.d.gjs.ts');
} else if (allowGjs && fs.existsSync(fileName.replace(/\.d\.mts$/, '.gjs.d.ts'))) {
return fileName.replace(/\.d\.mts$/, '.gjs.d.ts');
}
} else if (allowGjs && fileName.endsWith('.mjs.d.ts')) {
return fileName.replace(/\.mjs\.d\.ts$/, '.gjs.d.ts');
} else if (fileName.match(/\.m?ts$/) && !fileName.endsWith('.d.ts')) {
return fileName.replace(/\.m?ts$/, '.gts');
} else if (allowGjs && fileName.match(/\.m?js$/) && !fileName.endsWith('.d.ts')) {
return fileName.replace(/\.m?js$/, '.gjs');
}

return fileName;
}

try {
const parserPath = require.resolve('@typescript-eslint/parser');
Expand All @@ -11,9 +85,11 @@ try {
const ts = require(tsPath);
typescriptParser = require('@typescript-eslint/parser');
patchTs = function patchTs(options = {}) {
if (isPatched) return { allowGjs };
if (isPatched) return { allowGjs, allowArbitraryExtensions };
isPatched = true;
allowGjs = options.allowGjs !== undefined ? options.allowGjs : true;
allowArbitraryExtensions =
options.allowArbitraryExtensions !== undefined ? options.allowArbitraryExtensions : false;
const sys = { ...ts.sys };
const newSys = {
...ts.sys,
Expand All @@ -25,12 +101,27 @@ try {
const gjsVirtuals = allowGjs
? results.filter((x) => x.endsWith('.gjs')).map((f) => f.replace(/\.gjs$/, '.mjs'))
: [];
return results.concat(gtsVirtuals, gjsVirtuals);
// Map .gjs.d.ts to both .mjs.d.ts AND .d.mts patterns
// Also handle .d.gjs.ts (allowArbitraryExtensions pattern for .gjs files)
const gjsDtsVirtuals = allowGjs
? results
.filter((x) => x.endsWith('.gjs.d.ts'))
.flatMap((f) => [
f.replace(/\.gjs\.d\.ts$/, '.mjs.d.ts'),
f.replace(/\.gjs\.d\.ts$/, '.d.mts'),
])
: [];
// Handle .d.gjs.ts pattern (allowArbitraryExtensions for .gjs files only)
const dGjsVirtuals =
allowGjs && allowArbitraryExtensions
? results
.filter((x) => x.endsWith('.d.gjs.ts'))
.map((f) => f.replace(/\.d\.gjs\.ts$/, '.d.mts'))
: [];
return results.concat(gtsVirtuals, gjsVirtuals, gjsDtsVirtuals, dGjsVirtuals);
},
fileExists(fileName) {
const gtsExists = fs.existsSync(fileName.replace(/\.m?ts$/, '.gts'));
const gjsExists = allowGjs ? fs.existsSync(fileName.replace(/\.m?js$/, '.gjs')) : false;
return gtsExists || gjsExists || fs.existsSync(fileName);
return findExistingFile(fileName, allowGjs, allowArbitraryExtensions) !== null;
},
readFile(fname) {
let fileName = fname;
Expand All @@ -42,25 +133,26 @@ try {
try {
content = fs.readFileSync(fileName).toString();
} catch {
if (fileName.match(/\.m?ts$/)) {
fileName = fileName.replace(/\.m?ts$/, '.gts');
} else if (allowGjs && fileName.match(/\.m?js$/)) {
fileName = fileName.replace(/\.m?js$/, '.gjs');
}
fileName = resolveFileForReading(fileName, allowGjs, allowArbitraryExtensions);
content = fs.readFileSync(fileName).toString();
}
if (fileName.endsWith('.gts') || (allowGjs && fileName.endsWith('.gjs'))) {
// Only transform template files, not declaration files
if (
Copy link
Member

Choose a reason for hiding this comment

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

does this need to account for .d.gjs.ts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, but we should clean it up. Since a file can't both end with .gts and also end with .d.ts.

(fileName.endsWith('.gts') && !fileName.endsWith('.d.ts')) ||
(allowGjs && fileName.endsWith('.gjs') && !fileName.endsWith('.d.ts'))
) {
try {
content = transformForLint(content).output;
} catch (e) {
console.error('failed to transformForLint for gts/gjs processing');
console.error(e);
}
}
// Only replace extensions in non-declaration files
if (
(!fileName.endsWith('.d.ts') && fileName.endsWith('.ts')) ||
fileName.endsWith('.gts') ||
(allowGjs && fileName.endsWith('.gjs'))
(fileName.endsWith('.gts') && !fileName.endsWith('.d.ts')) ||
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We should clean this up too.

(allowGjs && fileName.endsWith('.gjs') && !fileName.endsWith('.d.ts'))
) {
try {
content = replaceExtensions(content);
Expand All @@ -73,17 +165,32 @@ try {
},
};
ts.setSys(newSys);
return { allowGjs };
return { allowGjs, allowArbitraryExtensions };
};

replaceExtensions = function replaceExtensions(code) {
let jsCode = code;
const sourceFile = ts.createSourceFile('__x__.ts', code, ts.ScriptTarget.Latest);
const length = jsCode.length;
for (const b of sourceFile.statements) {
if (b.kind === ts.SyntaxKind.ImportDeclaration && b.moduleSpecifier.text.endsWith('.gts')) {
const value = b.moduleSpecifier.text.replace(/\.gts$/, '.mts');
jsCode = replaceRange(jsCode, b.moduleSpecifier.pos + 2, b.moduleSpecifier.end - 1, value);
if (b.kind === ts.SyntaxKind.ImportDeclaration) {
if (b.moduleSpecifier.text.endsWith('.gts')) {
const value = b.moduleSpecifier.text.replace(/\.gts$/, '.mts');
jsCode = replaceRange(
jsCode,
b.moduleSpecifier.pos + 2,
b.moduleSpecifier.end - 1,
value
);
} else if (allowGjs && b.moduleSpecifier.text.endsWith('.gjs')) {
const value = b.moduleSpecifier.text.replace(/\.gjs$/, '.mjs');
jsCode = replaceRange(
jsCode,
b.moduleSpecifier.pos + 2,
b.moduleSpecifier.end - 1,
value
);
}
}
}
if (length !== jsCode.length) {
Expand Down
Loading