src/helpers.ts (199 lines of code) (raw):
/**
* Helper routines for use with the jsii compiler
*
* These are mostly used for testing, but all projects that need to exercise
* the JSII compiler to test something need to share this code, so might as
* well put it in one reusable place.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { PackageJson, loadAssemblyFromPath, writeAssembly } from '@jsii/spec';
import * as spec from '@jsii/spec';
import { DiagnosticCategory } from 'typescript';
import { Compiler, CompilerOptions } from './compiler';
import { loadProjectInfo, ProjectInfo } from './project-info';
import { formatDiagnostic, JsiiError } from './utils';
/**
* A set of source files for `sourceToAssemblyHelper`, at least containing 'index.ts'
*/
export type MultipleSourceFiles = {
'index.ts': string;
[name: string]: string;
};
/**
* Compile a piece of source and return the JSII assembly for it
*
* Only usable for trivial cases and tests.
*
* @param source can either be a single `string` (the content of `index.ts`), or
* a map of fileName to content, which *must* include `index.ts`.
* @param options accepts a callback for historical reasons but really expects to
* take an options object.
*/
export function sourceToAssemblyHelper(
source: string | MultipleSourceFiles,
options?: TestCompilationOptions | ((obj: PackageJson) => void),
): spec.Assembly {
return compileJsiiForTest(source, options).assembly;
}
export interface HelperCompilationResult {
/**
* The generated assembly
*/
readonly assembly: spec.Assembly;
/**
* Generated .js/.d.ts file(s)
*/
readonly files: Record<string, string>;
/**
* The packageInfo used
*/
readonly packageJson: PackageJson;
/**
* Whether to compress the assembly file
*/
readonly compressAssembly: boolean;
}
/**
* Compile a piece of source and return the assembly and compiled sources for it
*
* Only usable for trivial cases and tests.
*
* @param source can either be a single `string` (the content of `index.ts`), or
* a map of fileName to content, which *must* include `index.ts`.
* @param options accepts a callback for historical reasons but really expects to
* take an options object.
*/
export function compileJsiiForTest(
source: string | { 'index.ts': string; [name: string]: string },
options?: TestCompilationOptions | ((obj: PackageJson) => void),
compilerOptions?: Omit<CompilerOptions, 'projectInfo' | 'watch'>,
): HelperCompilationResult {
if (typeof source === 'string') {
source = { 'index.ts': source };
}
const inSomeLocation =
isOptionsObject(options) && options.compilationDirectory ? inOtherDir(options.compilationDirectory) : inTempDir;
// Easiest way to get the source into the compiler is to write it to disk somewhere.
// I guess we could make an in-memory compiler host but that seems like work...
return inSomeLocation(() => {
for (const [fileName, content] of Object.entries(source)) {
fs.mkdirSync(path.dirname(fileName), { recursive: true });
fs.writeFileSync(fileName, content, { encoding: 'utf-8' });
}
const { projectInfo, packageJson } = makeProjectInfo(
'index.ts',
typeof options === 'function'
? options
: (pi) => {
Object.assign(pi, options?.packageJson ?? options?.projectInfo ?? {});
},
);
const compiler = new Compiler({
projectInfo,
...compilerOptions,
});
const emitResult = compiler.emit();
const errors = emitResult.diagnostics.filter((d) => d.category === DiagnosticCategory.Error);
for (const error of errors) {
console.error(formatDiagnostic(error, projectInfo.projectRoot));
// logDiagnostic() doesn't work out of the box, so console.error() it is.
}
if (errors.length > 0 || emitResult.emitSkipped) {
throw new JsiiError('There were compiler errors');
}
const assembly = loadAssemblyFromPath(process.cwd(), false);
const files: Record<string, string> = {};
for (const filename of Object.keys(source)) {
let jsFile = filename.replace(/\.ts$/, '.js');
let dtsFile = filename.replace(/\.ts$/, '.d.ts');
if (projectInfo.tsc?.outDir && filename !== 'README.md') {
jsFile = path.join(projectInfo.tsc.outDir, jsFile);
dtsFile = path.join(projectInfo.tsc.outDir, dtsFile);
}
// eslint-disable-next-line no-await-in-loop
files[jsFile] = fs.readFileSync(jsFile, { encoding: 'utf-8' });
// eslint-disable-next-line no-await-in-loop
files[dtsFile] = fs.readFileSync(dtsFile, { encoding: 'utf-8' });
const warningsFileName = '.warnings.jsii.js';
if (fs.existsSync(warningsFileName)) {
// eslint-disable-next-line no-await-in-loop
files[warningsFileName] = fs.readFileSync(warningsFileName, {
encoding: 'utf-8',
});
}
}
return {
assembly,
files,
packageJson,
compressAssembly: isOptionsObject(options) && options.compressAssembly ? true : false,
} as HelperCompilationResult;
});
}
function inTempDir<T>(block: () => T): T {
const origDir = process.cwd();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsii'));
process.chdir(tmpDir);
const ret = block();
process.chdir(origDir);
fs.rmSync(tmpDir, { force: true, recursive: true });
return ret;
}
function inOtherDir(dir: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
return <T extends unknown>(block: () => T): T => {
const origDir = process.cwd();
process.chdir(dir);
try {
return block();
} finally {
process.chdir(origDir);
}
};
}
/**
* Obtain project info so we can call the compiler
*
* Creating this directly in-memory leads to slightly different behavior from calling
* jsii from the command-line, and I don't want to figure out right now.
*
* Most consistent behavior seems to be to write a package.json to disk and
* then calling the same functions as the CLI would.
*/
function makeProjectInfo(
types: string,
cb?: (obj: PackageJson) => void,
): { projectInfo: ProjectInfo; packageJson: PackageJson } {
const packageJson: PackageJson = {
types,
main: types.replace(/(?:\.d)?\.ts(x?)/, '.js$1'),
name: 'testpkg', // That's what package.json would tell if we look up...
version: '0.0.1',
license: 'Apache-2.0',
author: { name: 'John Doe' },
repository: { type: 'git', url: 'https://github.com/aws/jsii.git' },
jsii: {},
};
if (cb) {
cb(packageJson);
}
fs.writeFileSync(
'package.json',
JSON.stringify(packageJson, (_: string, v: any) => v, 2),
'utf-8',
);
const { projectInfo } = loadProjectInfo(path.resolve(process.cwd(), '.'));
return { projectInfo, packageJson };
}
export interface TestCompilationOptions {
/**
* The directory in which we write and compile the files
*/
readonly compilationDirectory?: string;
/**
* Parts of projectInfo to override (package name etc)
*
* @deprecated Prefer using `packageJson` instead.
*/
readonly projectInfo?: Partial<PackageJson>;
/**
* Parts of projectInfo to override (package name etc)
*
* @default - Use some default values
*/
readonly packageJson?: Partial<PackageJson>;
/**
* Whether to compress the assembly file.
*
* @default false
*/
readonly compressAssembly?: boolean;
}
function isOptionsObject(
x: TestCompilationOptions | ((obj: PackageJson) => void) | undefined,
): x is TestCompilationOptions {
return x ? typeof x === 'object' : false;
}
/**
* An NPM-ready workspace where we can install test-compile dependencies and compile new assemblies
*/
export class TestWorkspace {
/**
* Create a new workspace.
*
* Creates a temporary directory, don't forget to call cleanUp
*/
public static create(): TestWorkspace {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsii-testworkspace'));
fs.mkdirSync(tmpDir, { recursive: true });
return new TestWorkspace(tmpDir);
}
/**
* Execute a block with a temporary workspace
*/
public static withWorkspace<A>(block: (ws: TestWorkspace) => A): A {
const ws = TestWorkspace.create();
try {
return block(ws);
} finally {
ws.cleanup();
}
}
private readonly installed = new Set<string>();
private constructor(public readonly rootDirectory: string) {}
/**
* Add a test-compiled jsii assembly as a dependency
*/
public addDependency(dependencyAssembly: HelperCompilationResult) {
if (this.installed.has(dependencyAssembly.assembly.name)) {
throw new JsiiError(
`A dependency with name '${dependencyAssembly.assembly.name}' was already installed. Give one a different name.`,
);
}
this.installed.add(dependencyAssembly.assembly.name);
// The following is silly, however: the helper has compiled the given source to
// an assembly, and output files, and then removed their traces from disk.
// We need those files back on disk, so write them back out again.
//
// We will drop them in 'node_modules/<name>' so they can be imported
// as if they were installed.
const modDir = path.join(this.rootDirectory, 'node_modules', dependencyAssembly.assembly.name);
fs.mkdirSync(modDir, { recursive: true });
writeAssembly(modDir, dependencyAssembly.assembly, {
compress: dependencyAssembly.compressAssembly,
});
fs.writeFileSync(
path.join(modDir, 'package.json'),
JSON.stringify(dependencyAssembly.packageJson, null, 2),
'utf-8',
);
for (const [fileName, fileContents] of Object.entries(dependencyAssembly.files)) {
fs.mkdirSync(path.dirname(path.join(modDir, fileName)), {
recursive: true,
});
fs.writeFileSync(path.join(modDir, fileName), fileContents);
}
}
public dependencyDir(name: string) {
if (!this.installed.has(name)) {
throw new JsiiError(`No dependency with name '${name}' has been installed`);
}
return path.join(this.rootDirectory, 'node_modules', name);
}
public cleanup() {
fs.rmSync(this.rootDirectory, { force: true, recursive: true });
}
}
// Alias for backwards compatibility
export type PackageInfo = PackageJson;
/**
* TSConfig paths can either be relative to the project or absolute.
* This function normalizes paths to be relative to the provided root.
* After normalization, code using these paths can be much simpler.
*
* @param root the project root
* @param pathToNormalize the path to normalize, might be empty
*/
export function normalizeConfigPath(root: string, pathToNormalize?: string): string | undefined {
if (pathToNormalize == null || !path.isAbsolute(pathToNormalize)) {
return pathToNormalize;
}
return path.relative(root, pathToNormalize);
}