diff --git a/src/parser/gjs-gts-parser.js b/src/parser/gjs-gts-parser.js index ce9702f..3a2ef50 100644 --- a/src/parser/gjs-gts-parser.js +++ b/src/parser/gjs-gts-parser.js @@ -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} 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 { @@ -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} @@ -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 = []; @@ -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'); } /** @@ -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; @@ -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 && + 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 }; diff --git a/src/parser/ts-patch.js b/src/parser/ts-patch.js index efc6fbb..153983d 100644 --- a/src/parser/ts-patch.js +++ b/src/parser/ts-patch.js @@ -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'); @@ -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, @@ -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; @@ -42,14 +133,14 @@ 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 ( + (fileName.endsWith('.gts') && !fileName.endsWith('.d.ts')) || + (allowGjs && fileName.endsWith('.gjs') && !fileName.endsWith('.d.ts')) + ) { try { content = transformForLint(content).output; } catch (e) { @@ -57,10 +148,11 @@ try { 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')) || + (allowGjs && fileName.endsWith('.gjs') && !fileName.endsWith('.d.ts')) ) { try { content = replaceExtensions(content); @@ -73,7 +165,7 @@ try { }, }; ts.setSys(newSys); - return { allowGjs }; + return { allowGjs, allowArbitraryExtensions }; }; replaceExtensions = function replaceExtensions(code) { @@ -81,9 +173,24 @@ try { 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) { diff --git a/test-projects/configs/flat-ts/src/api-consumer.ts b/test-projects/configs/flat-ts/src/api-consumer.ts new file mode 100644 index 0000000..76c8f6e --- /dev/null +++ b/test-projects/configs/flat-ts/src/api-consumer.ts @@ -0,0 +1,21 @@ +import type { ApiResponse } from './api-service.gjs'; +import { DataService, ApiComponent } from './api-service.gjs'; + +// Test that we can use the imported type +async function processApiData(): Promise { + const service = new DataService(); + return await service.fetchData(); +} + +// Test that we can reference the imported component +const component = ApiComponent; + +// This should trigger a type error if types aren't resolved +// because we're trying to assign a number to a string property +const badApiResponse: ApiResponse = { + status: 200, + data: { message: 'test' }, + message: 123 // This should be a string, not a number +}; + +export { processApiData, component, badApiResponse }; diff --git a/test-projects/configs/flat-ts/src/api-service.gjs b/test-projects/configs/flat-ts/src/api-service.gjs new file mode 100644 index 0000000..c263b36 --- /dev/null +++ b/test-projects/configs/flat-ts/src/api-service.gjs @@ -0,0 +1,22 @@ +export interface ApiResponse { + status: number; + data: unknown; + message: string; +} + +export class DataService { + apiUrl = 'https://api.example.com'; + + async fetchData(): Promise { + const response = await fetch(this.apiUrl); + return { + status: response.status, + data: await response.json(), + message: response.statusText + }; + } +} + +export const ApiComponent = ; diff --git a/test-projects/configs/flat-ts/src/arbitrary-ext.d.gjs.ts b/test-projects/configs/flat-ts/src/arbitrary-ext.d.gjs.ts new file mode 100644 index 0000000..069a421 --- /dev/null +++ b/test-projects/configs/flat-ts/src/arbitrary-ext.d.gjs.ts @@ -0,0 +1,19 @@ +// Type declarations for arbitrary extension pattern (.d.gjs.ts) +export interface ArbitraryConfig { + id: string; + enabled: boolean; +} + +export type ArbitraryResponse = + | { + success: true; + result: T; + } + | { + success: false; + error: string; + }; + +export declare class ArbitraryService { + process(data: T): ArbitraryResponse; +} diff --git a/test-projects/configs/flat-ts/src/arbitrary-ext.gjs b/test-projects/configs/flat-ts/src/arbitrary-ext.gjs new file mode 100644 index 0000000..3014992 --- /dev/null +++ b/test-projects/configs/flat-ts/src/arbitrary-ext.gjs @@ -0,0 +1,6 @@ +// Dummy .gjs file for arbitrary-ext +export class ArbitraryService { + process(data) { + return { success: true, result: data }; + } +} diff --git a/test-projects/configs/flat-ts/src/debug-import.ts b/test-projects/configs/flat-ts/src/debug-import.ts new file mode 100644 index 0000000..8a52d47 --- /dev/null +++ b/test-projects/configs/flat-ts/src/debug-import.ts @@ -0,0 +1,13 @@ +import type { ServiceResponse } from './typed-service.gjs'; + +// Test what TypeScript actually thinks the type is +function debugType(): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + const response: ServiceResponse = {} as any; + + // Let's see what properties TypeScript thinks are available + const keys = Object.keys(response); + console.log(keys); +} + +export { debugType }; diff --git a/test-projects/configs/flat-ts/src/debug-regular.ts b/test-projects/configs/flat-ts/src/debug-regular.ts new file mode 100644 index 0000000..c6d9404 --- /dev/null +++ b/test-projects/configs/flat-ts/src/debug-regular.ts @@ -0,0 +1,16 @@ +import type { ServiceResponse } from './regular-types'; + +// Simple test to see if we can use the discriminated union from regular .d.ts +function testRegularDiscriminatedUnion(): void { + const response: ServiceResponse = { + success: true, + data: "test" + }; + + if (response.success) { + // This should be fine - TypeScript should narrow the type + console.log(response.data); + } +} + +export { testRegularDiscriminatedUnion }; diff --git a/test-projects/configs/flat-ts/src/debug-types.ts b/test-projects/configs/flat-ts/src/debug-types.ts new file mode 100644 index 0000000..77ac5f6 --- /dev/null +++ b/test-projects/configs/flat-ts/src/debug-types.ts @@ -0,0 +1,16 @@ +import type { ServiceResponse } from './typed-service.gjs'; + +// Simple test to see if we can use the discriminated union +function testDiscriminatedUnion(): void { + const response: ServiceResponse = { + success: true, + data: "test" + }; + + if (response.success) { + // This should be fine - TypeScript should narrow the type + console.log(response.data); + } +} + +export { testDiscriminatedUnion }; diff --git a/test-projects/configs/flat-ts/src/regular-types.d.ts b/test-projects/configs/flat-ts/src/regular-types.d.ts new file mode 100644 index 0000000..29bdd31 --- /dev/null +++ b/test-projects/configs/flat-ts/src/regular-types.d.ts @@ -0,0 +1,25 @@ +// Test with regular .d.ts file +export interface ServiceConfig { + apiKey: string; + baseUrl: string; + timeout?: number; +} + +export type ServiceResponse = + | { + success: true; + data: T; + error?: undefined; + } + | { + success: false; + data?: undefined; + error: string; + }; + +export declare class TypedService { + constructor(config: ServiceConfig); + config: ServiceConfig; + request(path: string): Promise>; + isConnected(): boolean; +} diff --git a/test-projects/configs/flat-ts/src/simple-test.ts b/test-projects/configs/flat-ts/src/simple-test.ts new file mode 100644 index 0000000..18abb14 --- /dev/null +++ b/test-projects/configs/flat-ts/src/simple-test.ts @@ -0,0 +1,16 @@ +import { TypedService } from './typed-service.gjs'; + +// Very simple test - if this compiles without 'any' errors, +// then TypeScript is finding the .gjs.d.ts file +const service = new TypedService({ + apiKey: 'test', + baseUrl: 'https://api.example.com' +}); + +// This should be typed as boolean +const connected: boolean = service.isConnected(); + +console.log('Service connected:', connected); + +// eslint-disable-next-line ember/no-test-import-export +export { service }; diff --git a/test-projects/configs/flat-ts/src/test-arbitrary.ts b/test-projects/configs/flat-ts/src/test-arbitrary.ts new file mode 100644 index 0000000..53572e4 --- /dev/null +++ b/test-projects/configs/flat-ts/src/test-arbitrary.ts @@ -0,0 +1,25 @@ +import type { ArbitraryConfig, ArbitraryResponse } from './arbitrary-ext.gjs'; +import { ArbitraryService } from './arbitrary-ext.gjs'; + +// Test that TypeScript can resolve types from .d.gjs.ts files +function testArbitraryExtensions(): void { + const config: ArbitraryConfig = { + id: 'test', + enabled: true + }; + + const service = new ArbitraryService(); + const response: ArbitraryResponse = service.process('hello'); + + if (response.success) { + // TypeScript should know that response.result is a string when success is true + console.log(response.result.toUpperCase()); + } else { + console.error(response.error); + } + + // Use the config to avoid unused variable warning + console.log('Config ID:', config.id); +} + +export { testArbitraryExtensions }; diff --git a/test-projects/configs/flat-ts/src/typed-consumer.ts b/test-projects/configs/flat-ts/src/typed-consumer.ts new file mode 100644 index 0000000..aa7f987 --- /dev/null +++ b/test-projects/configs/flat-ts/src/typed-consumer.ts @@ -0,0 +1,38 @@ +import type { ServiceConfig, ServiceResponse } from './typed-service.gjs'; +import { TypedService, ServiceComponent } from './typed-service.gjs'; + +// Test that we can use the imported types from the .gjs.d.ts file +const config: ServiceConfig = { + apiKey: 'test-key', + baseUrl: 'https://api.example.com', + timeout: 3000 +}; + +async function testTypedService(): Promise { + const service = new TypedService(config); + + // Test that the service is properly typed + const isConnected: boolean = service.isConnected(); + console.log(`Service connected: ${isConnected}`); + + // Test that the generic response typing works + interface User { + id: number; + name: string; + email: string; + } + + const userResponse: ServiceResponse = await service.request('/user/1'); + + if (userResponse.success && userResponse.data) { + // TypeScript should know that userResponse.data is of type User when success is true + console.log(`User: ${userResponse.data.name} (${userResponse.data.email})`); + } else if (userResponse.error) { + console.error(`Error: ${userResponse.error}`); + } +} + +// Test that we can reference the component +const component = ServiceComponent; + +export { testTypedService, component, config }; diff --git a/test-projects/configs/flat-ts/src/typed-service.gjs b/test-projects/configs/flat-ts/src/typed-service.gjs new file mode 100644 index 0000000..f5f0322 --- /dev/null +++ b/test-projects/configs/flat-ts/src/typed-service.gjs @@ -0,0 +1,51 @@ +// Runtime implementation for typed-service.gjs +export class TypedService { + constructor(config) { + this.config = config; + } + + async request(path) { + try { + const response = await fetch(`${this.config.baseUrl}${path}`, { + headers: { + 'Authorization': `Bearer ${this.config.apiKey}`, + }, + timeout: this.config.timeout || 5000, + }); + + if (!response.ok) { + return { + success: false, + data: null, + error: `HTTP ${response.status}: ${response.statusText}` + }; + } + + const data = await response.json(); + return { + success: true, + data, + }; + } catch (error) { + return { + success: false, + data: null, + error: error.message + }; + } + } + + isConnected() { + return Boolean(this.config.apiKey && this.config.baseUrl); + } +} + +export const ServiceComponent = ; diff --git a/test-projects/configs/flat-ts/src/typed-service.gjs.d.ts b/test-projects/configs/flat-ts/src/typed-service.gjs.d.ts new file mode 100644 index 0000000..38f78a0 --- /dev/null +++ b/test-projects/configs/flat-ts/src/typed-service.gjs.d.ts @@ -0,0 +1,27 @@ +// Type declarations for typed-service.gjs +export interface ServiceConfig { + apiKey: string; + baseUrl: string; + timeout?: number; +} + +export type ServiceResponse = + | { + success: true; + data: T; + error?: undefined; + } + | { + success: false; + data?: undefined; + error: string; + }; + +export declare class TypedService { + constructor(config: ServiceConfig); + config: ServiceConfig; + request(path: string): Promise>; + isConnected(): boolean; +} + +export declare const ServiceComponent: unknown; diff --git a/test-projects/configs/flat-ts/tsconfig.json b/test-projects/configs/flat-ts/tsconfig.json index 48ae5f1..a4ff7bf 100644 --- a/test-projects/configs/flat-ts/tsconfig.json +++ b/test-projects/configs/flat-ts/tsconfig.json @@ -44,6 +44,13 @@ */ "allowImportingTsExtensions": true, + /** + https://www.typescriptlang.org/tsconfig#allowArbitraryExtensions + + Allow importing files with arbitrary extensions like .d.gjs.ts + */ + "allowArbitraryExtensions": true, + "types": ["ember-source/types"] } } diff --git a/test-projects/gjs/src/placeholer.gjs b/test-projects/gjs/src/placeholder.gjs similarity index 100% rename from test-projects/gjs/src/placeholer.gjs rename to test-projects/gjs/src/placeholder.gjs diff --git a/test-projects/gts/src-fixable/test-fix.gts b/test-projects/gts/src-fixable/test-fix.gts index 338a0de..638134f 100644 --- a/test-projects/gts/src-fixable/test-fix.gts +++ b/test-projects/gts/src-fixable/test-fix.gts @@ -1,2 +1,2 @@ -const Bar = () => ; +const Bar = () => {return }; export default Bar diff --git a/tests/fixtures/api-client.gjs b/tests/fixtures/api-client.gjs new file mode 100644 index 0000000..5669286 --- /dev/null +++ b/tests/fixtures/api-client.gjs @@ -0,0 +1,15 @@ +// This is a .gjs file with runtime implementation +export class ApiClient { + constructor(baseUrl) { + this.baseUrl = baseUrl; + } + + async get(path) { + const response = await fetch(`${this.baseUrl}${path}`); + return response.json(); + } +} + +export const DefaultClient = ; diff --git a/tests/fixtures/api-client.gjs.d.ts b/tests/fixtures/api-client.gjs.d.ts new file mode 100644 index 0000000..7309a8b --- /dev/null +++ b/tests/fixtures/api-client.gjs.d.ts @@ -0,0 +1,15 @@ +// Type declarations for api-client.gjs +export interface ApiResponse { + data: T; + status: number; + message?: string; +} + +export declare class ApiClient { + constructor(baseUrl: string); + baseUrl: string; + get(path: string): Promise>; +} + +// Component type - this would typically be inferred from the template +export declare const DefaultClient: unknown; diff --git a/tests/fixtures/imports-from-gjs.ts b/tests/fixtures/imports-from-gjs.ts new file mode 100644 index 0000000..3ffccb6 --- /dev/null +++ b/tests/fixtures/imports-from-gjs.ts @@ -0,0 +1,18 @@ +import type { UserData } from './types-export.gjs'; +import { UserService, ExampleComponent } from './types-export.gjs'; + +// Test that we can use the imported type +const userData: UserData = { + id: 1, + name: 'John Doe', + email: 'john@example.com' +}; + +// Test that we can use the imported class +const userService = new UserService(); +userService.addUser(userData); + +// Test that we can reference the imported component +const component = ExampleComponent; + +export { userData, userService, component }; diff --git a/tests/fixtures/imports-with-dts.ts b/tests/fixtures/imports-with-dts.ts new file mode 100644 index 0000000..02a12f7 --- /dev/null +++ b/tests/fixtures/imports-with-dts.ts @@ -0,0 +1,36 @@ +import type { ApiResponse } from './api-client.gjs'; +import { ApiClient, DefaultClient } from './api-client.gjs'; + +// Test that we can use the imported types from the .gjs.d.ts file +async function testApiClient(): Promise { + const client = new ApiClient('https://api.example.com'); + + // This should be properly typed as ApiResponse + const response = await client.get('/users'); + + // Test that the response has the correct structure from the .d.ts file + console.log(`Status: ${response.status}`); + console.log(`Data:`, response.data); + if (response.message) { + console.log(`Message: ${response.message}`); + } +} + +// Test that we can use generics from the .d.ts file +async function testTypedApiClient(): Promise { + const client = new ApiClient('https://api.example.com'); + + // This should be properly typed as ApiResponse<{ id: number; name: string }> + const userResponse = await client.get<{ id: number; name: string }>('/user/1'); + + // TypeScript should know that userResponse.data has id and name properties + const userId: number = userResponse.data.id; + const userName: string = userResponse.data.name; + + console.log(`User: ${userName} (ID: ${userId})`); +} + +// Test that we can reference the component +const component = DefaultClient; + +export { testApiClient, testTypedApiClient, component }; diff --git a/tests/fixtures/types-export.gjs b/tests/fixtures/types-export.gjs new file mode 100644 index 0000000..7350261 --- /dev/null +++ b/tests/fixtures/types-export.gjs @@ -0,0 +1,17 @@ +export interface UserData { + id: number; + name: string; + email: string; +} + +export class UserService { + users = []; + + addUser(user) { + this.users.push(user); + } +} + +export const ExampleComponent = ; diff --git a/tests/parser.test.js b/tests/parser.test.js index 97b30ae..e52f428 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -2755,4 +2755,186 @@ export const NotFound =