ts2dart
Version:
Transpile TypeScript code to Dart
451 lines (394 loc) • 16.7 kB
text/typescript
#! /usr/bin/env node
require('source-map-support').install();
import {SourceMapGenerator} from 'source-map';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import {TranspilerBase} from './base';
import mkdirP from './mkdirp';
import CallTranspiler from './call';
import DeclarationTranspiler from './declaration';
import ExpressionTranspiler from './expression';
import ModuleTranspiler from './module';
import StatementTranspiler from './statement';
import TypeTranspiler from './type';
import LiteralTranspiler from './literal';
import {FacadeConverter} from './facade_converter';
import * as dartStyle from 'dart-style';
export interface TranspilerOptions {
/**
* Fail on the first error, do not collect multiple. Allows easier debugging as stack traces lead
* directly to the offending line.
*/
failFast?: boolean;
/** Whether to generate 'library a.b.c;' names from relative file paths. */
generateLibraryName?: boolean;
/** Whether to generate source maps. */
generateSourceMap?: boolean;
/** A tsconfig.json to use to configure TypeScript compilation. */
tsconfig?: string;
/**
* A base path to relativize absolute file paths against. This is useful for library name
* generation (see above) and nicer file names in error messages.
*/
basePath?: string;
/**
* Translate calls to builtins, i.e. seemlessly convert from `Array` to `List`, and convert the
* corresponding methods. Requires type checking.
*/
translateBuiltins?: boolean;
/**
* Enforce conventions of public/private keyword and underscore prefix
*/
enforceUnderscoreConventions?: boolean;
}
export const COMPILER_OPTIONS: ts.CompilerOptions = {
allowNonTsExtensions: true,
experimentalDecorators: true,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES6,
};
export class Transpiler {
private output: Output;
private currentFile: ts.SourceFile;
// Comments attach to all following AST nodes before the next 'physical' token. Track the earliest
// offset to avoid printing comments multiple times.
private lastCommentIdx: number = -1;
private errors: string[] = [];
private transpilers: TranspilerBase[];
private fc: FacadeConverter;
constructor(private options: TranspilerOptions = {}) {
// TODO: Remove the angular2 default when angular uses typingsRoot.
this.fc = new FacadeConverter(this);
this.transpilers = [
new CallTranspiler(this, this.fc), // Has to come before StatementTranspiler!
new DeclarationTranspiler(this, this.fc, options.enforceUnderscoreConventions),
new ExpressionTranspiler(this, this.fc),
new LiteralTranspiler(this, this.fc),
new ModuleTranspiler(this, this.fc, options.generateLibraryName),
new StatementTranspiler(this),
new TypeTranspiler(this, this.fc),
];
}
/**
* Transpiles the given files to Dart.
* @param fileNames The input files.
* @param destination Location to write files to. Creates files next to their sources if absent.
*/
transpile(fileNames: string[], destination?: string): void {
if (this.options.basePath) {
this.options.basePath = this.normalizeSlashes(path.resolve(this.options.basePath));
}
fileNames = fileNames.map((f) => this.normalizeSlashes(path.resolve(f)));
let host: ts.CompilerHost;
let compilerOpts: ts.CompilerOptions;
if (this.options.tsconfig) {
let {config, error} =
ts.readConfigFile(this.options.tsconfig, (f) => fs.readFileSync(f, 'utf-8'));
if (error) throw new Error(ts.flattenDiagnosticMessageText(error.messageText, '\n'));
let {options, errors} = ts.convertCompilerOptionsFromJson(
config.compilerOptions, path.dirname(this.options.tsconfig));
if (errors && errors.length) {
throw new Error(errors.map((d) => this.diagnosticToString(d)).join('\n'));
}
host = ts.createCompilerHost(options, /*setParentNodes*/ true);
compilerOpts = options;
if (compilerOpts.rootDir != null && this.options.basePath == null) {
// Use the tsconfig's rootDir if basePath is not set.
this.options.basePath = compilerOpts.rootDir;
}
if (compilerOpts.outDir != null && destination == null) {
destination = compilerOpts.outDir;
}
} else {
host = this.createCompilerHost();
compilerOpts = this.getCompilerOptions();
}
if (this.options.basePath) this.options.basePath = path.resolve(this.options.basePath);
if (this.options.basePath && destination === undefined) {
throw new Error(
'Must have a destination path when a basePath is specified ' + this.options.basePath);
}
let destinationRoot = destination || this.options.basePath || '';
let program = ts.createProgram(fileNames, compilerOpts, host);
if (this.options.translateBuiltins) {
this.fc.initializeTypeBasedConversion(program.getTypeChecker(), compilerOpts, host);
}
// Only write files that were explicitly passed in.
let fileSet: {[s: string]: boolean} = {};
fileNames.forEach((f) => fileSet[f] = true);
this.errors = [];
program.getSourceFiles()
.filter((sourceFile) => fileSet[sourceFile.fileName])
// Do not generate output for .d.ts files.
.filter((sourceFile: ts.SourceFile) => !sourceFile.fileName.match(/\.d\.ts$/))
.forEach((f: ts.SourceFile) => {
let dartCode = this.translate(f);
let outputFile = this.getOutputPath(f.fileName, destinationRoot);
mkdirP(path.dirname(outputFile));
fs.writeFileSync(outputFile, dartCode);
});
this.checkForErrors(program);
}
translateProgram(program: ts.Program, host: ts.CompilerHost): {[path: string]: string} {
if (this.options.translateBuiltins) {
this.fc.initializeTypeBasedConversion(
program.getTypeChecker(), program.getCompilerOptions(), host);
}
let paths: {[path: string]: string} = {};
this.errors = [];
program.getSourceFiles()
.filter(
(sourceFile: ts.SourceFile) =>
(!sourceFile.fileName.match(/\.d\.ts$/) && !!sourceFile.fileName.match(/\.[jt]s$/)))
.forEach((f) => paths[f.fileName] = this.translate(f));
this.checkForErrors(program);
return paths;
}
private getCompilerOptions() {
let opts: ts.CompilerOptions = {};
for (let k of Object.keys(COMPILER_OPTIONS)) opts[k] = COMPILER_OPTIONS[k];
opts.rootDir = this.options.basePath;
return opts;
}
private createCompilerHost(): ts.CompilerHost {
let defaultLibFileName = ts.getDefaultLibFileName(COMPILER_OPTIONS);
defaultLibFileName = this.normalizeSlashes(defaultLibFileName);
let compilerHost: ts.CompilerHost = {
getSourceFile: (sourceName, languageVersion) => {
let sourcePath = sourceName;
if (sourceName === defaultLibFileName) {
sourcePath = ts.getDefaultLibFilePath(COMPILER_OPTIONS);
}
if (!fs.existsSync(sourcePath)) return undefined;
let contents = fs.readFileSync(sourcePath, 'UTF-8');
return ts.createSourceFile(sourceName, contents, COMPILER_OPTIONS.target, true);
},
writeFile(name, text, writeByteOrderMark) { fs.writeFile(name, text); },
fileExists: (filename) => fs.existsSync(filename),
readFile: (filename) => fs.readFileSync(filename, 'utf-8'),
getDefaultLibFileName: () => defaultLibFileName,
useCaseSensitiveFileNames: () => true,
getCanonicalFileName: (filename) => filename,
getCurrentDirectory: () => '',
getNewLine: () => '\n',
};
compilerHost.resolveModuleNames = getModuleResolver(compilerHost);
return compilerHost;
}
// Visible for testing.
getOutputPath(filePath: string, destinationRoot: string): string {
let relative = this.getRelativeFileName(filePath);
let dartFile = relative.replace(/.(js|es6|ts)$/, '.dart');
return this.normalizeSlashes(path.join(destinationRoot, dartFile));
}
private translate(sourceFile: ts.SourceFile): string {
this.currentFile = sourceFile;
this.output = new Output(
sourceFile, this.getRelativeFileName(sourceFile.fileName), this.options.generateSourceMap);
this.lastCommentIdx = -1;
this.visit(sourceFile);
let result = this.output.getResult();
return this.formatCode(result, sourceFile);
}
private formatCode(code: string, context: ts.Node) {
let result = dartStyle.formatCode(code);
if (result.error) {
this.reportError(context, result.error);
}
return result.code;
}
private checkForErrors(program: ts.Program) {
let errors = this.errors;
let diagnostics = program.getGlobalDiagnostics().concat(program.getSyntacticDiagnostics());
if ((errors.length || diagnostics.length) && this.options.translateBuiltins) {
// Only report semantic diagnostics if ts2dart failed; this code is not a generic compiler, so
// only yields TS errors if they could be the cause of ts2dart issues.
// This greatly speeds up tests and execution.
diagnostics = diagnostics.concat(program.getSemanticDiagnostics());
}
let diagnosticErrs = diagnostics.map((d) => this.diagnosticToString(d));
if (diagnosticErrs.length) errors = errors.concat(diagnosticErrs);
if (errors.length) {
let e = new Error(errors.join('\n'));
e.name = 'TS2DartError';
throw e;
}
}
private diagnosticToString(diagnostic: ts.Diagnostic): string {
let msg = '';
if (diagnostic.file) {
let pos = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
let fn = this.getRelativeFileName(diagnostic.file.fileName);
msg += ` ${fn}:${pos.line + 1}:${pos.character + 1}`;
}
msg += ': ';
msg += ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
return msg;
}
/**
* Returns `filePath`, relativized to the program's `basePath`.
* @param filePath path to relativize.
*/
getRelativeFileName(filePath: string) {
let base = this.options.basePath || '';
if (filePath[0] === '/' && filePath.indexOf(base) !== 0 && !filePath.match(/\.d\.ts$/)) {
throw new Error(`Files must be located under base, got ${filePath} vs ${base}`);
}
let rel = path.relative(base, filePath);
if (rel.indexOf('../') === 0) {
// filePath is outside of rel, just use it directly.
rel = filePath;
}
return this.normalizeSlashes(rel);
}
emit(s: string) { this.output.emit(s); }
emitNoSpace(s: string) { this.output.emitNoSpace(s); }
reportError(n: ts.Node, message: string) {
let file = n.getSourceFile() || this.currentFile;
let fileName = this.getRelativeFileName(file.fileName);
let start = n.getStart(file);
let pos = file.getLineAndCharacterOfPosition(start);
// Line and character are 0-based.
let fullMessage = `${fileName}:${pos.line + 1}:${pos.character + 1}: ${message}`;
if (this.options.failFast) throw new Error(fullMessage);
this.errors.push(fullMessage);
}
visit(node: ts.Node) {
this.output.addSourceMapping(node);
try {
let comments = ts.getLeadingCommentRanges(this.currentFile.text, node.getFullStart());
if (comments) {
comments.forEach((c) => {
if (c.pos <= this.lastCommentIdx) return;
this.lastCommentIdx = c.pos;
let text = this.currentFile.text.substring(c.pos, c.end);
this.emitNoSpace('\n');
this.emit(this.translateComment(text));
if (c.hasTrailingNewLine) this.emitNoSpace('\n');
});
}
for (let i = 0; i < this.transpilers.length; i++) {
if (this.transpilers[i].visitNode(node)) return;
}
this.reportError(
node, `Unsupported node type ${(<any>ts).SyntaxKind[node.kind]}: ${node.getFullText()}`);
} catch (e) {
this.reportError(node, 'ts2dart crashed ' + e.stack);
}
}
private normalizeSlashes(path: string) { return path.replace(/\\/g, '/'); }
private translateComment(comment: string): string {
comment = comment.replace(/\{ ([^\}]+)\}/g, '[$1]');
// Remove the following tags and following comments till end of line.
comment = comment.replace(/ .*$/gm, '');
comment = comment.replace(/ .*$/gm, '');
comment = comment.replace(/ .*$/gm, '');
// Remove the following tags.
comment = comment.replace(/ /g, '');
comment = comment.replace(/ /g, '');
comment = comment.replace(/ /g, '');
return comment;
}
}
export function getModuleResolver(compilerHost: ts.CompilerHost) {
return (moduleNames: string[], containingFile: string): ts.ResolvedModule[] => {
let res: ts.ResolvedModule[] = [];
for (let mod of moduleNames) {
let lookupRes =
ts.nodeModuleNameResolver(mod, containingFile, COMPILER_OPTIONS, compilerHost);
if (lookupRes.resolvedModule) {
res.push(lookupRes.resolvedModule);
continue;
}
lookupRes = ts.classicNameResolver(mod, containingFile, COMPILER_OPTIONS, compilerHost);
if (lookupRes.resolvedModule) {
res.push(lookupRes.resolvedModule);
continue;
}
res.push(undefined);
}
return res;
};
}
class Output {
private result: string = '';
private column: number = 1;
private line: number = 1;
// Position information.
private generateSourceMap: boolean;
private sourceMap: SourceMapGenerator;
constructor(
private currentFile: ts.SourceFile, private relativeFileName: string,
generateSourceMap: boolean) {
if (generateSourceMap) {
this.sourceMap = new SourceMapGenerator({file: relativeFileName + '.dart'});
this.sourceMap.setSourceContent(relativeFileName, this.currentFile.text);
}
}
emit(str: string) {
this.emitNoSpace(' ');
this.emitNoSpace(str);
}
emitNoSpace(str: string) {
this.result += str;
for (let i = 0; i < str.length; i++) {
if (str[i] === '\n') {
this.line++;
this.column = 0;
} else {
this.column++;
}
}
}
getResult(): string { return this.result + this.generateSourceMapComment(); }
addSourceMapping(n: ts.Node) {
if (!this.generateSourceMap) return; // source maps disabled.
let file = n.getSourceFile() || this.currentFile;
let start = n.getStart(file);
let pos = file.getLineAndCharacterOfPosition(start);
let mapping: SourceMap.Mapping = {
original: {line: pos.line + 1, column: pos.character},
generated: {line: this.line, column: this.column},
source: this.relativeFileName,
};
this.sourceMap.addMapping(mapping);
}
private generateSourceMapComment() {
if (!this.sourceMap) return '';
let base64map = new Buffer(JSON.stringify(this.sourceMap)).toString('base64');
return '\n\n//# sourceMappingURL=data:application/json;base64,' + base64map;
}
}
function showHelp() {
console.log(`
Usage: ts2dart [input-files] [arguments]
--help show this dialog
--failFast Fail on the first error, do not collect multiple. Allows easier debugging
as stack traces lead directly to the offending line
--generateLibraryName Whether to generate 'library a.b.c;' names from relative file paths.
--generateSourceMap Whether to generate source maps.
--tsconfig A tsconfig.json to use to configure TypeScript compilation.
--basePath A base path to relativize absolute file paths against. This
is useful for library name generation (see above) and nicer
file names in error messages.
--translateBuiltins Translate calls to builtins, i.e. seemlessly convert from \` Array\` to \` List\`,
and convert the corresponding methods. Requires type checking.
--enforceUnderscoreConventions Enforce conventions of public/private keyword and underscore prefix
`);
process.exit(0);
}
// CLI entry point
if (require.main === module) {
let args = require('minimist')(process.argv.slice(2), {base: 'string'});
if (args.help) showHelp();
try {
let transpiler = new Transpiler(args);
console.error('Transpiling', args._, 'to', args.destination);
transpiler.transpile(args._, args.destination);
} catch (e) {
if (e.name !== 'TS2DartError') throw e;
console.error(e.message);
process.exit(1);
}
}