diff --git a/src/parser.ts b/src/parser.ts index 41135b8..64d6c88 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5,7 +5,10 @@ * @copyright jQuery Foundation and other contributors, https://jquery.org/ * MIT License */ -import calculateProjectParserOptions from './tsconfig-parser'; +import { + calculateProjectParserOptions, + createProgram +} from './tsconfig-parser'; import semver from 'semver'; import ts from 'typescript'; import convert from './ast-converter'; @@ -57,7 +60,8 @@ function resetExtra(): void { projects: [], errorOnUnknownASTType: false, code: '', - tsconfigRootDir: process.cwd() + tsconfigRootDir: process.cwd(), + extraFileExtensions: [] }; } @@ -73,7 +77,7 @@ function getASTFromProject(code: string, options: ParserOptions) { options.filePath || getFileName(options), extra ), - (currentProgram: ts.Program) => { + currentProgram => { const ast = currentProgram.getSourceFile( options.filePath || getFileName(options) ); @@ -82,6 +86,18 @@ function getASTFromProject(code: string, options: ParserOptions) { ); } +/** + * @param {string} code The code of the file being linted + * @param {Object} options The config object + * @returns {{ast: ts.SourceFile, program: ts.Program} | undefined} If found, returns the source file corresponding to the code and the containing program + */ +function getASTAndDefaultProject(code: string, options: ParserOptions) { + const fileName = options.filePath || getFileName(options); + const program = createProgram(code, fileName, extra); + const ast = program && program.getSourceFile(fileName); + return ast && { ast, program }; +} + /** * @param {string} code The code of the file being linted * @returns {{ast: ts.SourceFile, program: ts.Program}} Returns a new source file and program corresponding to the linted code @@ -154,6 +170,7 @@ function getProgramAndAST( ) { return ( (shouldProvideParserServices && getASTFromProject(code, options)) || + (shouldProvideParserServices && getASTAndDefaultProject(code, options)) || createNewProgram(code) ); } @@ -254,6 +271,13 @@ function generateAST( if (typeof options.tsconfigRootDir === 'string') { extra.tsconfigRootDir = options.tsconfigRootDir; } + + if ( + Array.isArray(options.extraFileExtensions) && + options.extraFileExtensions.every(ext => typeof ext === 'string') + ) { + extra.extraFileExtensions = options.extraFileExtensions; + } } if (!isRunningSupportedTypeScriptVersion && !warnedAboutTSVersion) { diff --git a/src/temp-types-based-on-js-source.ts b/src/temp-types-based-on-js-source.ts index af416e4..c3819ef 100644 --- a/src/temp-types-based-on-js-source.ts +++ b/src/temp-types-based-on-js-source.ts @@ -70,6 +70,7 @@ export interface Extra { log: Function; projects: string[]; tsconfigRootDir: string; + extraFileExtensions: string[]; } export interface ParserOptions { @@ -84,4 +85,5 @@ export interface ParserOptions { project?: string | string[]; filePath?: string; tsconfigRootDir?: string; + extraFileExtensions?: string[]; } diff --git a/src/tsconfig-parser.ts b/src/tsconfig-parser.ts index 63d472f..9995327 100644 --- a/src/tsconfig-parser.ts +++ b/src/tsconfig-parser.ts @@ -8,6 +8,15 @@ import { Extra } from './temp-types-based-on-js-source'; // Environment calculation //------------------------------------------------------------------------------ +/** + * Default compiler options for program generation from single root file + * @type {ts.CompilerOptions} + */ +const defaultCompilerOptions: ts.CompilerOptions = { + allowNonTsExtensions: true, + allowJs: true +}; + /** * Maps tsconfig paths to their corresponding file contents and resulting watches * @type {Map>} @@ -54,7 +63,7 @@ const noopFileWatcher = { close: () => {} }; * @param {string[]} extra.project Provided tsconfig paths * @returns {ts.Program[]} The programs corresponding to the supplied tsconfig paths */ -export default function calculateProjectParserOptions( +export function calculateProjectParserOptions( code: string, filePath: string, extra: Extra @@ -90,7 +99,7 @@ export default function calculateProjectParserOptions( // create compiler host const watchCompilerHost = ts.createWatchCompilerHost( tsconfigPath, - /*optionsToExtend*/ undefined, + /*optionsToExtend*/ { allowNonTsExtensions: true } as ts.CompilerOptions, ts.sys, ts.createSemanticDiagnosticsBuilderProgram, diagnosticReporter, @@ -136,6 +145,32 @@ export default function calculateProjectParserOptions( // ensure fileWatchers aren't created for directories watchCompilerHost.watchDirectory = () => noopFileWatcher; + // allow files with custom extensions to be included in program (uses internal ts api) + const oldOnDirectoryStructureHostCreate = (watchCompilerHost as any) + .onCachedDirectoryStructureHostCreate; + (watchCompilerHost as any).onCachedDirectoryStructureHostCreate = ( + host: any + ) => { + const oldReadDirectory = host.readDirectory; + host.readDirectory = ( + path: string, + extensions?: ReadonlyArray, + exclude?: ReadonlyArray, + include?: ReadonlyArray, + depth?: number + ) => + oldReadDirectory( + path, + !extensions + ? undefined + : extensions.concat(extra.extraFileExtensions), + exclude, + include, + depth + ); + oldOnDirectoryStructureHostCreate(host); + }; + // create program const programWatch = ts.createWatchProgram(watchCompilerHost); const program = programWatch.getProgram().getProgram(); @@ -147,3 +182,43 @@ export default function calculateProjectParserOptions( return results; } + +/** + * Create program from single root file. Requires a single tsconfig to be specified. + * @param code The code being linted + * @param filePath The file being linted + * @param {string} extra.tsconfigRootDir The root directory for relative tsconfig paths + * @param {string[]} extra.project Provided tsconfig paths + * @returns {ts.Program} The program containing just the file being linted and associated library files + */ +export function createProgram(code: string, filePath: string, extra: Extra) { + if (!extra.projects || extra.projects.length !== 1) { + return undefined; + } + + let tsconfigPath = extra.projects[0]; + + // if absolute paths aren't provided, make relative to tsconfigRootDir + if (!path.isAbsolute(tsconfigPath)) { + tsconfigPath = path.join(extra.tsconfigRootDir, tsconfigPath); + } + + const commandLine = ts.getParsedCommandLineOfConfigFile( + tsconfigPath, + defaultCompilerOptions, + { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} } + ); + + if (!commandLine) { + return undefined; + } + + const compilerHost = ts.createCompilerHost(commandLine.options); + const oldReadFile = compilerHost.readFile; + compilerHost.readFile = (fileName: string) => + path.normalize(fileName) === path.normalize(filePath) + ? code + : oldReadFile(fileName); + + return ts.createProgram([filePath], commandLine.options, compilerHost); +} diff --git a/tests/fixtures/semanticInfo/extra-file-extension.vue b/tests/fixtures/semanticInfo/extra-file-extension.vue new file mode 100644 index 0000000..ca04667 --- /dev/null +++ b/tests/fixtures/semanticInfo/extra-file-extension.vue @@ -0,0 +1 @@ +const x = [3, 4, 5]; \ No newline at end of file diff --git a/tests/lib/semanticInfo.ts b/tests/lib/semanticInfo.ts index d0c1f37..6abda98 100644 --- a/tests/lib/semanticInfo.ts +++ b/tests/lib/semanticInfo.ts @@ -77,48 +77,17 @@ describe('semanticInfo', () => { createOptions(fileName) ); - // get type checker - expect(parseResult).toHaveProperty('services.program.getTypeChecker'); - const checker = parseResult.services.program!.getTypeChecker(); - - // get number node (ast shape validated by snapshot) - const arrayMember = (parseResult.ast as any).body[0].declarations[0].init - .elements[0]; - expect(parseResult).toHaveProperty('services.esTreeNodeToTSNodeMap'); - - // get corresponding TS node - const tsArrayMember = parseResult.services.esTreeNodeToTSNodeMap!.get( - arrayMember - ); - expect(tsArrayMember).toBeDefined(); - expect(tsArrayMember.kind).toBe(ts.SyntaxKind.NumericLiteral); - expect(tsArrayMember.text).toBe('3'); - - // get type of TS node - const arrayMemberType: any = checker.getTypeAtLocation(tsArrayMember); - expect(arrayMemberType.flags).toBe(ts.TypeFlags.NumberLiteral); - expect(arrayMemberType.value).toBe(3); - - // make sure it maps back to original ESTree node - expect(parseResult).toHaveProperty('services.tsNodeToESTreeNodeMap'); - expect(parseResult.services.tsNodeToESTreeNodeMap!.get(tsArrayMember)).toBe( - arrayMember - ); - - // get bound name - const boundName = (parseResult.ast as any).body[0].declarations[0].id; - expect(boundName.name).toBe('x'); - - const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap!.get( - boundName - ); - expect(tsBoundName).toBeDefined(); + testIsolatedFile(parseResult); + }); - checkNumberArrayType(checker, tsBoundName); + test('isolated-vue-file tests', () => { + const fileName = path.resolve(FIXTURES_DIR, 'extra-file-extension.vue'); + const parseResult = parseCodeAndGenerateServices(shelljs.cat(fileName), { + ...createOptions(fileName), + extraFileExtensions: ['.vue'] + }); - expect(parseResult.services.tsNodeToESTreeNodeMap!.get(tsBoundName)).toBe( - boundName - ); + testIsolatedFile(parseResult); }); test('imported-file tests', () => { @@ -150,6 +119,32 @@ describe('semanticInfo', () => { ).toBe(arrayBoundName); }); + test('non-existent file tests', () => { + const parseResult = parseCodeAndGenerateServices( + `const x = [parseInt("5")];`, + createOptions('') + ); + + // get type checker + expect(parseResult).toHaveProperty('services.program.getTypeChecker'); + const checker = parseResult.services.program!.getTypeChecker(); + + // get bound name + const boundName = (parseResult.ast as any).body[0].declarations[0].id; + expect(boundName.name).toBe('x'); + + const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap!.get( + boundName + ); + expect(tsBoundName).toBeDefined(); + + checkNumberArrayType(checker, tsBoundName); + + expect(parseResult.services.tsNodeToESTreeNodeMap!.get(tsBoundName)).toBe( + boundName + ); + }); + test('non-existent project file', () => { const fileName = path.resolve(FIXTURES_DIR, 'isolated-file.src.ts'); const badConfig = createOptions(fileName); @@ -178,6 +173,48 @@ describe('semanticInfo', () => { }); }); +function testIsolatedFile(parseResult: any) { + // get type checker + expect(parseResult).toHaveProperty('services.program.getTypeChecker'); + const checker = parseResult.services.program!.getTypeChecker(); + + // get number node (ast shape validated by snapshot) + const arrayMember = (parseResult.ast as any).body[0].declarations[0].init + .elements[0]; + expect(parseResult).toHaveProperty('services.esTreeNodeToTSNodeMap'); + + // get corresponding TS node + const tsArrayMember = parseResult.services.esTreeNodeToTSNodeMap!.get( + arrayMember + ); + expect(tsArrayMember).toBeDefined(); + expect(tsArrayMember.kind).toBe(ts.SyntaxKind.NumericLiteral); + expect((tsArrayMember as ts.NumericLiteral).text).toBe('3'); + + // get type of TS node + const arrayMemberType: any = checker.getTypeAtLocation(tsArrayMember); + expect(arrayMemberType.flags).toBe(ts.TypeFlags.NumberLiteral); + expect(arrayMemberType.value).toBe(3); + + // make sure it maps back to original ESTree node + expect(parseResult).toHaveProperty('services.tsNodeToESTreeNodeMap'); + expect(parseResult.services.tsNodeToESTreeNodeMap!.get(tsArrayMember)).toBe( + arrayMember + ); + + // get bound name + const boundName = (parseResult.ast as any).body[0].declarations[0].id; + expect(boundName.name).toBe('x'); + const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap!.get( + boundName + ); + expect(tsBoundName).toBeDefined(); + checkNumberArrayType(checker, tsBoundName); + expect(parseResult.services.tsNodeToESTreeNodeMap!.get(tsBoundName)).toBe( + boundName + ); +} + /** * Verifies that the type of a TS node is number[] as expected * @param {ts.TypeChecker} checker