diff --git a/src/bin.ts b/src/bin.ts index c303129a3..a01594532 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -7,7 +7,7 @@ import Module = require('module') import arg = require('arg') import { diffLines } from 'diff' import { Script } from 'vm' -import { readFileSync, statSync } from 'fs' +import { readFileSync, statSync, realpathSync } from 'fs' import { homedir } from 'os' import { VERSION, TSError, parse, Register, register } from './index' @@ -154,6 +154,7 @@ export function main (argv: string[]) { } const cwd = dir || process.cwd() + /** Unresolved. May point to a symlink, not realpath. May be missing file extension */ const scriptPath = args._.length ? resolve(cwd, args._[0]) : undefined const state = new EvalState(scriptPath || join(cwd, EVAL_FILENAME)) @@ -251,7 +252,21 @@ function getCwd (dir?: string, scriptMode?: boolean, scriptPath?: string) { throw new TypeError('Script mode cannot be combined with `--dir`') } - return dirname(scriptPath) + // Use node's own resolution behavior to ensure we follow symlinks + // This may affect which tsconfig we discover + // This happens before we are registered, so tell node's resolver to consider .ts and tsx files + // TODO in extremely rare cases, if a foo.js and foo.ts both exist, we may follow the wrong one, + // because we are not obeying `--prefer-ts-exts` + const hadTsExt = hasOwnProperty(require.extensions, '.ts') // tslint:disable-line + const hadTsxExt = hasOwnProperty(require.extensions, '.tsx') // tslint:disable-line + try { + if(!hadTsExt) require.extensions['.ts'] = function() {} // tslint:disable-line + if(!hadTsxExt) require.extensions['.tsx'] = function() {} // tslint:disable-line + return dirname(require.resolve(scriptPath)) + } finally { + if(!hadTsExt) delete require.extensions['.ts'] // tslint:disable-line + if(!hadTsxExt) delete require.extensions['.tsx'] // tslint:disable-line + } } return dir @@ -481,6 +496,11 @@ function isRecoverable (error: TSError) { return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code)) } +/** Safe `hasOwnProperty` */ +function hasOwnProperty (object: any, property: string): boolean { + return Object.prototype.hasOwnProperty.call(object, property) +} + if (require.main === module) { main(process.argv.slice(2)) } diff --git a/src/index.spec.ts b/src/index.spec.ts index 725bb8b1d..7a6221ea2 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -379,6 +379,14 @@ describe('ts-node', function () { expect(err).to.equal(null) expect(stdout).to.equal('.ts\n') + return done() + }) + }) + it('should read tsconfig relative to realpath, not symlink, in scriptMode', function (done) { + exec(`node ${BIN_SCRIPT_PATH} tests/main-realpath/symlink/symlink.tsx`, function (err, stdout) { + expect(err).to.equal(null) + expect(stdout).to.equal('') + return done() }) }) @@ -467,6 +475,13 @@ describe('ts-node', function () { }) }) }) + + it('should transpile files inside a node_modules directory when not ignored', function (done) { + exec(`${cmd} --skip-ignore tests/from-node-modules`, function (err, stdout) { + if (err) return done('Unexpected error') + done() + }) + }) }) describe('register', function () { diff --git a/src/index.ts b/src/index.ts index e3d293a53..f579d1158 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,21 @@ export interface TSCommon { formatDiagnosticsWithColorAndContext: typeof _ts.formatDiagnosticsWithColorAndContext } +/** + * Compiler APIs we use that are marked internal and not included in TypeScript's public API declarations + */ +interface TSInternal { + // https://github.com/microsoft/TypeScript/blob/4a34294908bed6701dcba2456ca7ac5eafe0ddff/src/compiler/core.ts#L1906-L1909 + createGetCanonicalFileName (useCaseSensitiveFileNames: boolean): TSInternal.GetCanonicalFileName + + // https://github.com/microsoft/TypeScript/blob/4a34294908bed6701dcba2456ca7ac5eafe0ddff/src/compiler/core.ts#L1430-L1445 + toFileNameLowerCase (x: string): string +} +namespace TSInternal { + // https://github.com/microsoft/TypeScript/blob/4a34294908bed6701dcba2456ca7ac5eafe0ddff/src/compiler/core.ts#L1906 + export type GetCanonicalFileName = (fileName: string) => string +} + /** * Export the current version. */ @@ -453,13 +468,13 @@ export function create (rawOptions: CreateOptions = {}): Register { // Use full language services when the fast option is disabled. if (!transpileOnly) { const fileContents = new Map() - const rootFileNames = config.fileNames.slice() + const rootFileNames = new Set(config.fileNames) const cachedReadFile = cachedLookup(debugFn('readFile', readFile)) // Use language services by default (TODO: invert next major version). if (!options.compilerHost) { let projectVersion = 1 - const fileVersions = new Map(rootFileNames.map(fileName => [fileName, 0])) + const fileVersions = new Map(Array.from(rootFileNames).map(fileName => [fileName, 0])) const getCustomTransformers = () => { if (typeof transformers === 'function') { @@ -471,9 +486,9 @@ export function create (rawOptions: CreateOptions = {}): Register { } // Create the compiler host for type checking. - const serviceHost: _ts.LanguageServiceHost = { + const serviceHost: _ts.LanguageServiceHost & Required> = { getProjectVersion: () => String(projectVersion), - getScriptFileNames: () => Array.from(fileVersions.keys()), + getScriptFileNames: () => Array.from(rootFileNames), getScriptVersion: (fileName: string) => { const version = fileVersions.get(fileName) return version ? version.toString() : '' @@ -497,21 +512,55 @@ export function create (rawOptions: CreateOptions = {}): Register { getDirectories: cachedLookup(debugFn('getDirectories', ts.sys.getDirectories)), fileExists: cachedLookup(debugFn('fileExists', fileExists)), directoryExists: cachedLookup(debugFn('directoryExists', ts.sys.directoryExists)), + realpath: ts.sys.realpath ? cachedLookup(debugFn('realpath', ts.sys.realpath)) : undefined, getNewLine: () => ts.sys.newLine, useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, getCurrentDirectory: () => cwd, getCompilationSettings: () => config.options, getDefaultLibFileName: () => ts.getDefaultLibFilePath(config.options), - getCustomTransformers: getCustomTransformers + getCustomTransformers: getCustomTransformers, + /* + * NOTE: + * Older ts versions do not pass `redirectedReference` nor `options`. + * We must pass `redirectedReference` to newer ts versions, but cannot rely on `options` + */ + resolveModuleNames (moduleNames: string[], containingFile: string, reusedNames: string[] | undefined, redirectedReference: _ts.ResolvedProjectReference | undefined, options: _ts.CompilerOptions): (_ts.ResolvedModule | undefined)[] { + return moduleNames.map(moduleName => { + const { resolvedModule } = ts.resolveModuleName(moduleName, containingFile, config.options, serviceHost, moduleResolutionCache, redirectedReference) + if (resolvedModule) fixupResolvedModule(resolvedModule) + return resolvedModule + }) + }, + getResolvedModuleWithFailedLookupLocationsFromCache (moduleName, containingFile): _ts.ResolvedModuleWithFailedLookupLocations | undefined { + const ret = ts.resolveModuleNameFromCache(moduleName, containingFile, moduleResolutionCache) + if (ret && ret.resolvedModule) { + fixupResolvedModule(ret.resolvedModule) + } + return ret + } } + /** + * If we need to emit JS for a file, force TS to consider it non-external + */ + const fixupResolvedModule = (resolvedModule: _ts.ResolvedModule) => { + const canonical = getCanonicalFileName(resolvedModule.resolvedFileName) + if (Array.from(rootFileNames).some(rootFileName => canonical === getCanonicalFileName(rootFileName))) { + resolvedModule.isExternalLibraryImport = false + } + } + const getCanonicalFileName = (ts as unknown as TSInternal).createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames) + const moduleResolutionCache = ts.createModuleResolutionCache(cwd, (s) => getCanonicalFileName(s), config.options) const registry = ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, cwd) const service = ts.createLanguageService(serviceHost, registry) const updateMemoryCache = (contents: string, fileName: string) => { - // Add to `rootFiles` when discovered for the first time. - if (!fileVersions.has(fileName)) { - rootFileNames.push(fileName) + // Add to `rootFiles` if not already there + // This is necessary to force TS to emit output + if (!rootFileNames.has(fileName)) { + rootFileNames.add(fileName) + // Increment project version for every change to rootFileNames. + projectVersion++ } const previousVersion = fileVersions.get(fileName) || 0 @@ -613,14 +662,14 @@ export function create (rawOptions: CreateOptions = {}): Register { // Fallback for older TypeScript releases without incremental API. let builderProgram = ts.createIncrementalProgram ? ts.createIncrementalProgram({ - rootNames: rootFileNames.slice(), + rootNames: Array.from(rootFileNames), options: config.options, host: host, configFileParsingDiagnostics: config.errors, projectReferences: config.projectReferences }) : ts.createEmitAndSemanticDiagnosticsBuilderProgram( - rootFileNames.slice(), + Array.from(rootFileNames), config.options, host, undefined, @@ -641,13 +690,13 @@ export function create (rawOptions: CreateOptions = {}): Register { // Add to `rootFiles` when discovered by compiler for the first time. if (sourceFile === undefined) { - rootFileNames.push(fileName) + rootFileNames.add(fileName) } // Update program when file changes. if (sourceFile === undefined || sourceFile.text !== contents) { builderProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( - rootFileNames.slice(), + Array.from(rootFileNames), config.options, host, builderProgram, @@ -756,8 +805,9 @@ export function create (rawOptions: CreateOptions = {}): Register { // Create a simple TypeScript compiler proxy. function compile (code: string, fileName: string, lineOffset = 0) { - const [value, sourceMap] = getOutput(code, fileName, lineOffset) - const output = updateOutput(value, fileName, sourceMap, getExtension) + const normalizedFileName = normalizeSlashes(fileName) + const [value, sourceMap] = getOutput(code, normalizedFileName, lineOffset) + const output = updateOutput(value, normalizedFileName, sourceMap, getExtension) outputCache.set(fileName, output) return output } diff --git a/tests/issue-884/index-2.ts b/tests/issue-884/index-2.ts new file mode 100644 index 000000000..ddacd3c42 --- /dev/null +++ b/tests/issue-884/index-2.ts @@ -0,0 +1,7 @@ +export {}; + +const timeout = setTimeout(() => {}, 0); + +if (timeout.unref) { + timeout.unref(); +} diff --git a/tests/issue-884/index.ts b/tests/issue-884/index.ts index 84dc7d9a6..8c23188df 100644 --- a/tests/issue-884/index.ts +++ b/tests/issue-884/index.ts @@ -1,5 +1,3 @@ -const timeout = setTimeout(() => {}, 0); - -if (timeout.unref) { - timeout.unref(); -} +// 2x index files required so that memory cache is populated with all build-in lib and @types +// declarations *before* this require() call. +require('./index-2'); diff --git a/tests/main-realpath/symlink/symlink.tsx b/tests/main-realpath/symlink/symlink.tsx new file mode 120000 index 000000000..c2c3efb1f --- /dev/null +++ b/tests/main-realpath/symlink/symlink.tsx @@ -0,0 +1 @@ +../target/target.tsx \ No newline at end of file diff --git a/tests/main-realpath/symlink/tsconfig.json b/tests/main-realpath/symlink/tsconfig.json new file mode 100644 index 000000000..9f78b68d0 --- /dev/null +++ b/tests/main-realpath/symlink/tsconfig.json @@ -0,0 +1 @@ +this tsconfig is intentionally invalid, to confirm that ts-node does *not* attempt to parse it diff --git a/tests/main-realpath/target/target.tsx b/tests/main-realpath/target/target.tsx new file mode 100644 index 000000000..1a206f56d --- /dev/null +++ b/tests/main-realpath/target/target.tsx @@ -0,0 +1,4 @@ +// Will throw a compiler error unless ./tsconfig.json is parsed, which enables JSX +function foo() { +
+} diff --git a/tests/main-realpath/target/tsconfig.json b/tests/main-realpath/target/tsconfig.json new file mode 100644 index 000000000..986627de2 --- /dev/null +++ b/tests/main-realpath/target/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsx": "react" + } +}