Skip to content

Commit

Permalink
feat(typescript): Add typechecking
Browse files Browse the repository at this point in the history
  • Loading branch information
NotWoods committed Jan 24, 2020
1 parent 8d35423 commit 0af29fb
Show file tree
Hide file tree
Showing 24 changed files with 503 additions and 132 deletions.
33 changes: 27 additions & 6 deletions packages/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,33 @@ typescript({
});
```

### Typescript compiler options

Some of Typescript's [CompilerOptions](https://www.typescriptlang.org/docs/handbook/compiler-options.html) affect how Rollup builds files.

#### `noEmitOnError`

Type: `Boolean`<br>
Default: `true`

If a type error is detected, the Rollup build is aborted when this option is set to true.

#### `files`, `include`, `exclude`

Type: `Array[...String]`<br>
Default: `[]`

Declaration files are automatically included if they are listed in the `files` field in your `tsconfig.json` file. Source files in these fields are ignored as Rollup's configuration is used instead.

#### Ignored options

These compiler options are ignored by Rollup:
- `declaration`, `declarationMap`: This plugin currently cannot emit declaration files.
- `incremental`, `tsBuildInfoFile`: This plugin currently does not support incremental compilation using Typescript.
- `noEmitHelpers`, `importHelpers`: The `tslib` helper module always must be used.
- `noEmit`, `emitDeclarationOnly`: Typescript needs to emit code for the plugin to work with.
- `noResolve`: Preventing Typescript from resolving code may break compilation

### Importing CommonJS

Though it is not recommended, it is possible to configure this plugin to handle imports of CommonJS files from TypeScript. For this, you need to specify `CommonJS` as the module format and add `rollup-plugin-commonjs` to transpile the CommonJS output generated by TypeScript to ES Modules so that rollup can process it.
Expand Down Expand Up @@ -158,12 +185,6 @@ export default {
};
```

## Issues

This plugin will currently **not warn for any type violations**. This plugin relies on TypeScript's [transpileModule](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API#a-simple-transform-function) function which basically transpiles TypeScript to JavaScript by stripping any type information on a per-file basis. While this is faster than using the language service, no cross-file type checks are possible with this approach.

This also causes issues with emit-less types, see [rollup/rollup-plugin-typescript#28](https://github.com/rollup/rollup-plugin-typescript/issues/28).

## Meta

[CONTRIBUTING](/.github/CONTRIBUTING.md)
Expand Down
23 changes: 20 additions & 3 deletions packages/typescript/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@ const CANNOT_COMPILE_ESM = 1204;
export function emitDiagnostics(
ts: typeof import('typescript'),
context: PluginContext,
host: import('typescript').FormatDiagnosticsHost &
Pick<import('typescript').LanguageServiceHost, 'getCompilationSettings'>,
diagnostics: readonly import('typescript').Diagnostic[] | undefined
) {
if (!diagnostics) return;
const { noEmitOnError } = host.getCompilationSettings();

diagnostics
.filter((diagnostic) => diagnostic.code !== CANNOT_COMPILE_ESM)
.forEach((diagnostic) => {
// Build a Rollup warning object from the diagnostics object.
const warning = diagnosticToWarning(ts, diagnostic);
const warning = diagnosticToWarning(ts, host, diagnostic);

// Errors are fatal. Otherwise emit warnings.
if (diagnostic.category === ts.DiagnosticCategory.Error) {
if (noEmitOnError && diagnostic.category === ts.DiagnosticCategory.Error) {
context.error(warning);
} else {
context.warn(warning);
Expand All @@ -33,6 +36,7 @@ export function emitDiagnostics(
*/
export function diagnosticToWarning(
ts: typeof import('typescript'),
host: import('typescript').FormatDiagnosticsHost | null,
diagnostic: import('typescript').Diagnostic
) {
const pluginCode = `TS${diagnostic.code}`;
Expand All @@ -44,15 +48,28 @@ export function diagnosticToWarning(
message: `@rollup/plugin-typescript ${pluginCode}: ${message}`
};

// Add information about the file location
if (diagnostic.file) {
// Add information about the file location
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);

warning.loc = {
column: character + 1,
line: line + 1,
file: diagnostic.file.fileName
};

if (host) {
// Extract a code frame from Typescript
const formatted = ts.formatDiagnosticsWithColorAndContext([diagnostic], host);
// Typescript only exposes this formatter as a string prefixed with the flattened message.
// We need to remove it here since Rollup treats the properties as separate parts.
let frame = formatted.slice(formatted.indexOf(message) + message.length);
const newLine = host.getNewLine();
if (frame.startsWith(newLine)) {
frame = frame.slice(frame.indexOf(newLine) + newLine.length);
}
warning.frame = frame;
}
}

return warning;
Expand Down
26 changes: 26 additions & 0 deletions packages/typescript/src/documentRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Map of Typescript instances to paths to DocumentRegistries.
*/
const globalRegistryCache = new Map<
typeof import('typescript'),
Map<string, import('typescript').DocumentRegistry>
>();

/**
* Return a `DocumentRegistry` instance that matches the given Typescript instance
* and working directory. If there is no a pre-existing instance, one will be
* created and set in the map.
*/
export default function getDocumentRegistry(ts: typeof import('typescript'), cwd: string) {
if (!globalRegistryCache.has(ts)) {
globalRegistryCache.set(ts, new Map());
}
const instanceRegistryCache = globalRegistryCache.get(ts);
if (!instanceRegistryCache.has(cwd)) {
instanceRegistryCache.set(
cwd,
ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, cwd)
);
}
return instanceRegistryCache.get(cwd)!;
}
128 changes: 128 additions & 0 deletions packages/typescript/src/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import createModuleResolver, { Resolver } from './resolver';

type BaseHost = import('typescript').LanguageServiceHost &
import('typescript').ModuleResolutionHost &
import('typescript').FormatDiagnosticsHost;

export interface TypescriptHost extends BaseHost {
/**
* Lets the host know about a file by adding it to its memory.
* @param id Filename
* @param code Body of the file
* @see https://blog.scottlogic.com/2015/01/20/typescript-compiler-api.html
*/
addFile(id: string, code: string): void;
/**
* Reads the given file.
* Used for both `LanguageServiceHost` (2 params) and `ModuleResolutionHost` (1 param).
*/
readFile(path: string, encoding?: string): string | undefined;
/**
* Uses Typescript to resolve a module path.
* The `compilerOptions` parameter from `LanguageServiceHost.resolveModuleNames`
* is ignored and omitted in this signature.
*/
resolveModuleNames(
moduleNames: string[],
containingFile: string
): Array<import('typescript').ResolvedModuleFull | undefined>;
}

interface File {
file: import('typescript').IScriptSnapshot;
version: number;
}

/**
* Create a language service host to use with the Typescript compiler & type checking APIs.
* @param parsedOptions Parsed options for Typescript.
* @param parsedOptions.options Typescript compiler options. Affects functions such as `getNewLine`.
* @param parsedOptions.fileNames Declaration files to include for typechecking.
* @see https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API
*/
export default function createHost(
ts: typeof import('typescript'),
parsedOptions: import('typescript').ParsedCommandLine
): TypescriptHost {
const files = new Map<string, File>();

/** Get the code stored in a File snapshot. */
function getCode({ file }: File) {
return file.getText(0, file.getLength());
}

/** @see TypescriptHost.addFile */
function addFile(id: string, code: string) {
const existing = files.get(id);
// Don't need to update if nothing changed
if (existing && getCode(existing) === code) return;

files.set(id, {
file: ts.ScriptSnapshot.fromString(code),
version: existing ? existing.version + 1 : 0
});
}

/** Helper that tries to read the file if it hasn't been stored yet */
function getFile(id: string) {
if (!files.has(id)) {
const code = ts.sys.readFile(id);
if (code == null) {
throw new Error(`@rollup/plugin-typescript: Could not find ${id}`);
}
addFile(id, code);
}
return files.get(id);
}

parsedOptions.fileNames.forEach((id) => getFile(id));

let resolver: Resolver;
const host: TypescriptHost = {
getCompilationSettings: () => parsedOptions.options,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => getNewLine(ts, parsedOptions.options.newLine),
getCanonicalFileName: (fileName) =>
ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(),
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
getDefaultLibFileName: ts.getDefaultLibFilePath,
getDirectories: ts.sys.getDirectories,
directoryExists: ts.sys.directoryExists,
realpath: ts.sys.realpath,
readDirectory: ts.sys.readDirectory,
readFile(fileName, encoding) {
const file = files.get(fileName);
if (file != null) return getCode(file);
return ts.sys.readFile(fileName, encoding);
},
fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
getScriptFileNames: () => Array.from(files.keys()),
getScriptSnapshot: (fileName) => getFile(fileName).file,
getScriptVersion: (fileName) => getFile(fileName).version.toString(),
resolveModuleNames(moduleNames, containingFile) {
return moduleNames.map((moduleName) => resolver(moduleName, containingFile));
},
addFile
};
// Declared here because this has a circular reference
resolver = createModuleResolver(ts, host);

return host;
}

/**
* Returns the string that corresponds with the selected `NewLineKind`.
*/
function getNewLine(
ts: typeof import('typescript'),
kind: import('typescript').NewLineKind | undefined
) {
switch (kind) {
case ts.NewLineKind.CarriageReturnLineFeed:
return '\r\n';
case ts.NewLineKind.LineFeed:
return '\n';
default:
return ts.sys.newLine;
}
}
59 changes: 33 additions & 26 deletions packages/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@ import { Plugin } from 'rollup';
import { RollupTypescriptOptions } from '../types';

import { diagnosticToWarning, emitDiagnostics } from './diagnostics';
import getDocumentRegistry from './documentRegistry';
import createHost from './host';
import { getPluginOptions, parseTypescriptConfig } from './options';
import typescriptOutputToRollupTransformation from './outputToRollupTransformation';
import { TSLIB_ID } from './tslib';

export default function typescript(options: RollupTypescriptOptions = {}): Plugin {
const { filter, tsconfig, compilerOptions, tslib, typescript: ts } = getPluginOptions(options);
const parsedConfig = parseTypescriptConfig(ts, tsconfig, compilerOptions);

const parsedOptions = parseTypescriptConfig(ts, tsconfig, compilerOptions);
const host = createHost(ts, parsedOptions);
const services = ts.createLanguageService(host, getDocumentRegistry(ts, process.cwd()));

return {
name: 'typescript',

buildStart() {
if (parsedConfig.errors.length > 0) {
parsedConfig.errors.forEach((error) => this.warn(diagnosticToWarning(ts, error)));
if (parsedOptions.errors.length > 0) {
parsedOptions.errors.forEach((error) => this.warn(diagnosticToWarning(ts, host, error)));

this.error(`@rollup/plugin-typescript: Couldn't process compiler options`);
}
Expand All @@ -29,21 +35,16 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
}

if (!importer) return null;
const containingFile = importer.split(path.win32.sep).join(path.posix.sep);

const result = ts.nodeModuleNameResolver(
importee,
containingFile,
parsedConfig.options,
ts.sys
);
// Convert path from windows separators to posix separators
const containingFile = importer.split(path.win32.sep).join(path.posix.sep);

if (result.resolvedModule && result.resolvedModule.resolvedFileName) {
if (result.resolvedModule.resolvedFileName.endsWith('.d.ts')) {
return null;
}
const resolved = host.resolveModuleNames([importee], containingFile);
const resolvedFile = resolved[0]?.resolvedFileName;

return result.resolvedModule.resolvedFileName;
if (resolvedFile) {
if (resolvedFile.endsWith('.d.ts')) return null;
return resolvedFile;
}

return null;
Expand All @@ -59,20 +60,26 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
transform(code, id) {
if (!filter(id)) return null;

const transformed = ts.transpileModule(code, {
fileName: id,
reportDiagnostics: true,
compilerOptions: parsedConfig.options
});
host.addFile(id, code);
const output = services.getEmitOutput(id);

emitDiagnostics(ts, this, transformed.diagnostics);
if (output.emitSkipped) {
// Emit failed, print all diagnostics for this file
const allDiagnostics: import('typescript').Diagnostic[] = []
.concat(services.getSyntacticDiagnostics(id))
.concat(services.getSemanticDiagnostics(id));
emitDiagnostics(ts, this, host, allDiagnostics);

return {
code: transformed.outputText,
throw new Error(`Couldn't compile ${id}`);
}

return typescriptOutputToRollupTransformation(output.outputFiles);
},

// Rollup expects `map` to be an object so we must parse the string
map: transformed.sourceMapText ? JSON.parse(transformed.sourceMapText) : null
};
generateBundle() {
const program = services.getProgram();
if (program == null) return;
emitDiagnostics(ts, this, host, ts.getPreEmitDiagnostics(program));
}
};
}
2 changes: 1 addition & 1 deletion packages/typescript/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function getTsConfigPath(ts: typeof import('typescript'), relativePath: string |
function readTsConfigFile(ts: typeof import('typescript'), tsConfigPath: string) {
const { config, error } = ts.readConfigFile(tsConfigPath, (path) => readFileSync(path, 'utf8'));
if (error) {
throw Object.assign(Error(), diagnosticToWarning(ts, error));
throw Object.assign(Error(), diagnosticToWarning(ts, null, error));
}

const extendedTsConfig: string = config?.extends;
Expand Down
Loading

0 comments on commit 0af29fb

Please sign in to comment.