diff --git a/lib/Declaration.ts b/lib/Declaration.ts index 8448b37..d6abb6e 100644 --- a/lib/Declaration.ts +++ b/lib/Declaration.ts @@ -1,18 +1,27 @@ -// tslint:disable-next-line:no-implicit-dependencies -import { ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, SimpleLiteral } from 'estree'; import * as fs from 'fs'; import { basename, dirname, join, relative } from 'path'; import * as ts from 'typescript'; -import ResolutionError from '@lib/error/Resolution'; -import File from '@lib/File'; +import ResolutionError from '@error/Resolution'; +import { DeclarationInterface as Interface, File } from '@lib/convert'; import Path from '@lib/Path'; -export interface IOptions { +export interface IOptionsDeclaration { declaration: T; +} + +export interface IOptionsFile { file: File; } +export interface IOptionsPath { + path: string; +} + +export type IDerivedOptions = IOptionsDeclaration & IOptionsFile; + +export type IOptions = IDerivedOptions & IOptionsPath; + function isBuiltinModule(module: string): boolean { // TODO: change to use 'is-builtin-module', need to submit @types/is-builtin-module const builtin = [ @@ -57,28 +66,16 @@ function isBuiltinModule(module: string): boolean { return builtin.indexOf(module) !== -1; } -export type Base = (ExportAllDeclaration | ExportNamedDeclaration) | ImportDeclaration; - -export default abstract class Declaration { +export default abstract class Declaration { protected readonly declaration: T; readonly file: File; private processed: boolean = false; readonly original: string; - constructor(options: IOptions) { - this.declaration = options.declaration; - this.file = options.file; - - // RAII checks - const { type, value } = this.literal; - if (type !== 'Literal') { - throw new TypeError(`Invalid export declaration source type: ${type}`); - } - if (typeof value !== 'string') { - throw new TypeError(`The type of the export source value was not a 'string': ${typeof value}`); - } - - this.original = this.path; + constructor({ file, declaration, path }: IOptions) { + this.declaration = declaration; + this.file = file; + this.original = path; } get isMapped(): Promise { @@ -89,20 +86,9 @@ export default abstract class Declaration { return this.file.destination; } - get literal(): SimpleLiteral { - return (this.declaration.source as SimpleLiteral); - } - - get path(): string { - return this.literal.value as string; - } + abstract get path(): string; - private update(value: string): void { - if (this.literal.raw) { - this.literal.raw = this.literal.raw.replace(this.path, value); - } - this.literal.value = value; - } + protected abstract update(value: string): void; toString(): string { return `${this.module}: ${this.path}`; diff --git a/lib/Export.ts b/lib/Export.ts deleted file mode 100644 index 78e4a86..0000000 --- a/lib/Export.ts +++ /dev/null @@ -1,19 +0,0 @@ -// tslint:disable-next-line:no-implicit-dependencies -import { ExportAllDeclaration, ExportNamedDeclaration } from 'estree'; - -import Declaration, { IOptions as IDeclarationOptions } from '@lib/Declaration'; - -export type IOptions = IDeclarationOptions; - -export default class Export extends Declaration { - - constructor(options: IOptions) { - // RAII checks - const { type } = options.declaration; - if (type === 'ExportNamedDeclaration' && (options.declaration as ExportNamedDeclaration).source === null) { - throw new TypeError(`Cannot create an export with a 'null' literal`); - } - - super(options); - } -} diff --git a/lib/File.ts b/lib/File.ts index 050d57b..2dd0d9b 100644 --- a/lib/File.ts +++ b/lib/File.ts @@ -1,25 +1,30 @@ -import * as acorn from 'acorn'; -import * as escodegen from 'escodegen'; -// tslint:disable-next-line:no-implicit-dependencies -import * as estree from 'estree'; -import * as fs from 'fs'; -import * as ts from 'typescript'; -import { promisify } from 'util'; - -import ParseError from '@lib/error/Parse'; -import Export from '@lib/Export'; -import Import from '@lib/Import'; -import Path from '@lib/Path'; +import { existsSync as fileExistsSync, PathLike } from 'fs'; +import { CompilerOptions as ICompilerOptions, ParsedCommandLine as ICompilerConfig } from 'typescript'; -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); +import FileNotFoundError from '@error/FileNotFound'; +import { Declaration } from '@lib/convert'; +import Path from '@lib/Path'; -export interface IOptions { +export interface IOptionsPath { path: string | Path; - options: ts.CompilerOptions; - config: ts.ParsedCommandLine; } +export interface IOptionsOptions { + options: ICompilerOptions; +} + +export interface IOptionsConfig { + config: ICompilerConfig; +} + +export interface IOptionsExtension { + extension: string; +} + +export type IDerivedOptions = IOptionsPath & IOptionsOptions & IOptionsConfig; + +export type IOptions = IDerivedOptions & IOptionsExtension; + function commonPathPrefix(paths: IterableIterator): string { const { done, value: left } = paths.next(); if (done) { @@ -37,16 +42,20 @@ function commonPathPrefix(paths: IterableIterator): string { return left.substring(0, index); } -export default class File { +export default abstract class File { readonly source: Path; - readonly root: Path; - readonly options: ts.CompilerOptions; - private program: estree.Program | undefined; + protected readonly root: Path; + protected readonly options: ICompilerOptions; + protected readonly extension: string; - constructor({ path, options, config: { fileNames } }: IOptions) { + constructor({ path, options, config: { fileNames }, extension }: IOptions) { this.source = new Path(path.toString()); this.root = new Path(commonPathPrefix(fileNames[Symbol.iterator]())); this.options = options; + this.extension = extension; + if (!fileExistsSync(this.destination.toString())) { + throw new FileNotFoundError({path: this.destination}); + } } get isMapped(): Promise { @@ -62,56 +71,9 @@ export default class File { })(); } - get ast(): Promise { - if (this.program) { - return Promise.resolve(this.program); - } else { - return (async () => { - const data = await readFile(this.destination.toString(), 'utf-8'); - try { - const comments: Array = []; - const tokens: Array = []; - const program = acorn.parse(data, { - allowHashBang: true, - ranges: true, - onComment: comments, - onToken: tokens, - sourceType: 'module', - ecmaVersion: 8, - }); - escodegen.attachComments(program, comments, tokens); - return this.program = program; - } catch (error) { - if (error instanceof SyntaxError) { - throw new ParseError({file: this, error, data}); - } else { - throw error; - } - } - })(); - } - } - - async *imports(): AsyncIterableIterator { - const { body } = await this.ast; - yield* body - .filter(({type}) => type === 'ImportDeclaration') - .map(n => new Import({file: this, declaration: n as estree.ImportDeclaration})); - } + abstract imports(): AsyncIterableIterator; - async *exports(): AsyncIterableIterator { - const { body } = await this.ast; - yield* body - .filter(n => - n.type === 'ExportAllDeclaration' || - (n.type === 'ExportNamedDeclaration' && (n).source !== null)) - .map(n => new Export({ - file: this, - declaration: n.type === 'ExportAllDeclaration' ? - n : - n as estree.ExportNamedDeclaration, - })); - } + abstract exports(): AsyncIterableIterator; get destination(): Path { const { outDir } = this.options; @@ -122,21 +84,17 @@ export default class File { const out = new Path(outDir); const destination = out.join(this.source.relative(this.root)); - destination.extension = '.js'; + destination.extension = this.extension; return destination; } - async write(path?: fs.PathLike | number, options?: { + abstract write(path?: PathLike | number, options?: { encoding?: string | null; mode?: number | string; flag?: string; - } | string | null): Promise { - const ast = await this.ast; - const data = escodegen.generate(ast, {comment: true}); - return writeFile((path === undefined) ? this.destination.toString() : path, data, options); - } + } | string | null): Promise; - async *map(options: ts.CompilerOptions): AsyncIterableIterator { + async *map(options: ICompilerOptions): AsyncIterableIterator { for await (const imprt of this.imports()) { const mapped = await imprt.map(options); if (mapped) { diff --git a/lib/Import.ts b/lib/Import.ts deleted file mode 100644 index aac640d..0000000 --- a/lib/Import.ts +++ /dev/null @@ -1,8 +0,0 @@ -// tslint:disable-next-line:no-implicit-dependencies -import { ImportDeclaration } from 'estree'; - -import Declaration, { IOptions as IDeclarationOptions } from '@lib/Declaration'; - -export type IOptions = IDeclarationOptions; - -export default class Import extends Declaration {} diff --git a/lib/Mapper.ts b/lib/Mapper.ts index a92782a..cc5b264 100644 --- a/lib/Mapper.ts +++ b/lib/Mapper.ts @@ -1,7 +1,7 @@ -import Export from '@lib/Export'; -import File from '@lib/File'; -import Import from '@lib/Import'; -import * as fs from 'fs'; +import EsExport from '@es/Export'; +import EsFile from '@es/File'; +import EsImport from '@es/Import'; +import { existsSync as fileExistsSync, readFileSync as fileReadSync } from 'fs'; import { dirname, resolve } from 'path'; import * as ts from 'typescript'; @@ -14,7 +14,7 @@ export default class Mapper { private readonly parsed: ts.ParsedCommandLine; constructor({ tsconfig, projectRoot }: IOptions) { - const config = ts.readConfigFile(tsconfig, path => fs.readFileSync(path, 'utf-8')); + const config = ts.readConfigFile(tsconfig, path => fileReadSync(path, 'utf-8')); if (config.error) { throw new TypeError(ts.formatDiagnostics([config.error], { getCanonicalFileName: f => f, @@ -24,9 +24,9 @@ export default class Mapper { } const parseConfig: ts.ParseConfigHost = { - fileExists: fs.existsSync, + fileExists: fileExistsSync, readDirectory: ts.sys.readDirectory, - readFile: file => fs.readFileSync(file, 'utf8'), + readFile: f => fileReadSync(f, 'utf8'), useCaseSensitiveFileNames: true, }; @@ -48,12 +48,12 @@ export default class Mapper { this.parsed = parsed; } - async *files(): AsyncIterableIterator { + async *files(): AsyncIterableIterator { const { options } = this.parsed; - yield* this.parsed.fileNames.map(path => new File({ path, options, config: this.parsed })); + yield* this.parsed.fileNames.map(path => new EsFile({ path, options, config: this.parsed })); } - async *map(): AsyncIterableIterator { + async *map(): AsyncIterableIterator { for await (const file of this.files()) { yield* file.map(this.parsed.options); } diff --git a/lib/convert.ts b/lib/convert.ts index f6aadab..95aced3 100644 --- a/lib/convert.ts +++ b/lib/convert.ts @@ -1,10 +1,17 @@ -import Export from '@lib/Export'; -import Import from '@lib/Import'; +import EsImport, { Interface as EsImportInterface } from '@es/Export'; +import EsFile from '@es/File'; +import EsExport, { Interface as EsExportInterface } from '@es/Import'; import Mapper, { IOptions as IMapperOptions } from '@lib/Mapper'; +import TsImport, { Interface as TsImportInterface } from '@ts/Export'; +import TsFile from '@ts/File'; +import TsExport, { Interface as TsExportInterface } from '@ts/Import'; +export type DeclarationInterface = EsImportInterface | EsExportInterface | TsImportInterface | TsExportInterface; +export type Declaration = EsImport | EsExport | TsImport | TsExport; +export type File = EsFile | TsFile; export type IOptions = IMapperOptions; -export default async function *convert({ ...other}: IOptions): AsyncIterableIterator { +export default async function *convert({ ...other}: IOptions): AsyncIterableIterator { const mapper = new Mapper({ ...other }); yield* mapper.map(); } diff --git a/lib/error/FileNotFound.ts b/lib/error/FileNotFound.ts new file mode 100644 index 0000000..2c8d48f --- /dev/null +++ b/lib/error/FileNotFound.ts @@ -0,0 +1,16 @@ + +import TspmError from '@lib/Error'; +import Path from '@lib/Path'; + +export interface IOptions { + path: Path; +} + +export default class ResolutionError extends TspmError { + readonly path: Path; + + constructor({ path }: IOptions) { + super(`File not found: '${path}'`); + this.path = path; + } +} diff --git a/lib/error/Parse.ts b/lib/error/Parse.ts index 1a20a27..76f5c37 100644 --- a/lib/error/Parse.ts +++ b/lib/error/Parse.ts @@ -1,13 +1,16 @@ +import { File } from '@lib/convert'; import TspmError from '@lib/Error'; -import File from '@lib/File'; export interface IOptions { file: File; data: string; error: Error; range?: number; + regex?: RegExp; } +const regex = /(.+) \(([0-9]+):([0-9]+)\)/; + export default class ParseError extends TspmError { readonly file: File; readonly data: string; @@ -18,7 +21,7 @@ export default class ParseError extends TspmError { readonly target: string; readonly after: string; - constructor({ file, data, error, range }: IOptions) { + constructor({ file, data, error, range, regex: re }: IOptions) { const buffer = range || 5; let { message } = error; let line = 0; @@ -27,8 +30,8 @@ export default class ParseError extends TspmError { let target = 'unknown'; let after = '>'; - // acorn specific parsing, to get line and column - const matches = /(.+) \(([0-9]+):([0-9]+)\)/.exec(message); + // Attempt to parse syntax error locations + const matches = (re || regex).exec(message); if (matches) { message = matches[1]; line = parseInt(matches[2], 10); diff --git a/lib/error/Resolution.ts b/lib/error/Resolution.ts index afc94a0..a4f0de2 100644 --- a/lib/error/Resolution.ts +++ b/lib/error/Resolution.ts @@ -1,19 +1,18 @@ -// tslint:disable-next-line:no-implicit-dependencies +import { DeclarationInterface as Interface } from '@lib/convert'; import Declaration from '@lib/Declaration'; import TspmError from '@lib/Error'; import Path from '@lib/Path'; -import { ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration } from 'estree'; export interface IOptions { path: string; module: Path; - declaration: Declaration<(ExportAllDeclaration | ExportNamedDeclaration) | ImportDeclaration>; + declaration: Declaration; } export default class ResolutionError extends TspmError { readonly path: string; readonly module: Path; - readonly declaration: Declaration<(ExportAllDeclaration | ExportNamedDeclaration) | ImportDeclaration>; + readonly declaration: Declaration; constructor({ path, module, declaration }: IOptions) { super(`Failed to resolve '${path}' in '${module}'`); diff --git a/lib/es/Declaration.ts b/lib/es/Declaration.ts new file mode 100644 index 0000000..3fbca7d --- /dev/null +++ b/lib/es/Declaration.ts @@ -0,0 +1,37 @@ +import { ExportAllDeclaration, ExportNamedDeclaration, ImportDeclaration, SimpleLiteral } from 'estree'; + +import Base, { IDerivedOptions as IBaseOptions } from '@lib/Declaration'; + +export type Interface = (ExportAllDeclaration | ExportNamedDeclaration) | ImportDeclaration; + +export type IOptions = IBaseOptions; + +export default class Declaration extends Base { + constructor({ declaration, ...rest}: IOptions) { + // RAII checks + const { type, value } = (declaration.source as SimpleLiteral); + if (type !== 'Literal') { + throw new TypeError(`Invalid ES declaration source type: ${type}`); + } + if (typeof value !== 'string') { + throw new TypeError(`The type of the ES source value was not a 'string': ${typeof value}`); + } + + super({declaration, ...rest, path: value}); + } + + private get literal(): SimpleLiteral { + return (this.declaration.source as SimpleLiteral); + } + + get path(): string { + return this.literal.value as string; + } + + protected update(value: string): void { + if (this.literal.raw) { + this.literal.raw = this.literal.raw.replace(this.path, value); + } + this.literal.value = value; + } +} diff --git a/lib/es/Export.ts b/lib/es/Export.ts new file mode 100644 index 0000000..48e1ba4 --- /dev/null +++ b/lib/es/Export.ts @@ -0,0 +1,20 @@ +import { ExportAllDeclaration, ExportNamedDeclaration } from 'estree'; + +import Declaration, { IOptions as IDeclarationOptions } from '@es/Declaration'; + +export type Interface = ExportNamedDeclaration | ExportAllDeclaration; + +export type IOptions = IDeclarationOptions; + +export default class Export extends Declaration { + + constructor(options: IOptions) { + // RAII checks + const { type } = options.declaration; + if (type === 'ExportNamedDeclaration' && (options.declaration as ExportNamedDeclaration).source === null) { + throw new TypeError(`Cannot create an ES export with a 'null' literal`); + } + + super(options); + } +} diff --git a/lib/es/File.ts b/lib/es/File.ts new file mode 100644 index 0000000..4ace54e --- /dev/null +++ b/lib/es/File.ts @@ -0,0 +1,85 @@ +import { Comment, parse, Token } from 'acorn'; +import { attachComments, generate } from 'escodegen'; +import { ExportNamedDeclaration, ImportDeclaration, Program } from 'estree'; +import { PathLike, readFile as readFileSync, writeFile as writeFileSync } from 'fs'; +import { promisify } from 'util'; + +import ParseError from '@error/Parse'; +import Export from '@es/Export'; +import Import from '@es/Import'; +import Base, { IDerivedOptions as IBaseOptions } from '@lib/File'; + +const readFile = promisify(readFileSync); +const writeFile = promisify(writeFileSync); + +export type IOptions = IBaseOptions; + +export default class File extends Base { + private program: Program | undefined; + + constructor({ ...options }: IOptions) { + super({...options, extension: '.js'}); + this.program = undefined; + } + + private get ast(): Promise { + if (this.program) { + return Promise.resolve(this.program); + } else { + return (async () => { + const data = await readFile(this.destination.toString(), 'utf-8'); + try { + const comments: Array = []; + const tokens: Array = []; + const program = parse(data, { + allowHashBang: true, + ranges: true, + onComment: comments, + onToken: tokens, + sourceType: 'module', + ecmaVersion: 8, + }); + attachComments(program, comments, tokens); + return this.program = program; + } catch (error) { + if (error instanceof SyntaxError) { + throw new ParseError({file: this, error, data}); + } else { + throw error; + } + } + })(); + } + } + + async *imports(): AsyncIterableIterator { + const { body } = await this.ast; + yield* body + .filter(({type}) => type === 'ImportDeclaration') + .map(n => new Import({file: this, declaration: n as ImportDeclaration})); + } + + async *exports(): AsyncIterableIterator { + const { body } = await this.ast; + yield* body + .filter(n => + n.type === 'ExportAllDeclaration' || + (n.type === 'ExportNamedDeclaration' && (n).source !== null)) + .map(n => new Export({ + file: this, + declaration: n.type === 'ExportAllDeclaration' ? + n : + n as ExportNamedDeclaration, + })); + } + + async write(path?: PathLike | number, options?: { + encoding?: string | null; + mode?: number | string; + flag?: string; + } | string | null): Promise { + const ast = await this.ast; + const data = generate(ast, {comment: true}); + return writeFile((path === undefined) ? this.destination.toString() : path, data, options); + } +} diff --git a/lib/es/Import.ts b/lib/es/Import.ts new file mode 100644 index 0000000..6de22ec --- /dev/null +++ b/lib/es/Import.ts @@ -0,0 +1,9 @@ +import { ImportDeclaration } from 'estree'; + +import Declaration, { IOptions as IDeclarationOptions } from '@es/Declaration'; + +export type Interface = ImportDeclaration; + +export type IOptions = IDeclarationOptions; + +export default class Import extends Declaration {} diff --git a/lib/index.ts b/lib/index.ts index 9ad58b5..d78eca2 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,7 +1,3 @@ -import File from '@lib/File'; -export { File }; -export { IOptions as IFileOptions } from '@lib/File'; - import convert from '@lib/convert'; export * from '@lib/convert'; export default convert; diff --git a/lib/main.ts b/lib/main.ts index 236db53..94ce4af 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -2,10 +2,9 @@ import * as fs from 'fs'; import * as process from 'process'; import * as yargs from 'yargs'; -import convert from '@lib/convert'; +import convert, { File } from '@lib/convert'; import TspmError from '@lib/Error'; import ParseError from '@lib/error/Parse'; -import File from '@lib/File'; interface IColours { [name: string]: string; diff --git a/package.json b/package.json index 41e3ba0..15ac939 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,9 @@ "testEnvironment": "node", "moduleNameMapper": { "^@test(.*)$": "/test$1", - "^@lib(.*)$": "/lib$1" + "^@lib(.*)$": "/lib$1", + "^@error(.*)$": "/lib/error$1", + "^@es(.*)$": "/lib/es$1" } } } diff --git a/tsconfig.json b/tsconfig.json index 1e4049e..79e06ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,9 @@ "rootDir": "./", "baseUrl": "./", "paths": { - "@lib/*": ["lib/*"] + "@lib/*": ["lib/*"], + "@es/*": ["lib/es/*"], + "@error/*": ["lib/error/*"] }, "newLine": "LF", "lib": [