import { path } from './path' import { ts } from './typescript' import { Diagnostic } from './diagnostic' import { FileSystem, TsMorphFileSystemWrapper } from './fs' import { NowConfig } from './now-config' import { traverseDirectory } from './util' export const NOW_FILE_EXTENSION = '.now.ts' export type CompilerOptions = ts.CompilerOptions export const DEFAULT_COMPILER_OPTIONS: CompilerOptions = { noEmit: true, lib: ['lib.es2023.full.d.ts'], target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.NodeNext, esModuleInterop: true, isolatedModules: true, allowJs: true, checkJs: false, strict: true, alwaysStrict: true, exactOptionalPropertyTypes: true, noEmitOnError: true, noFallthroughCasesInSwitch: true, noImplicitOverride: true, noImplicitReturns: true, noImplicitThis: true, noUncheckedIndexedAccess: true, noUnusedLocals: true, noUnusedParameters: true, noPropertyAccessFromIndexSignature: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true, allowImportingTsExtensions: true, } export enum FluentScriptKinds { IncludeFile = 100, } const SUPPORTED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.cts', '.cjs', '.mts', '.mjs', '.json'] const UNSUPPORTED_EXTENSIONS = ['.test.ts', '.test.js'] export class Compiler extends ts.Project { private readonly rootDir: string private readonly sourceFileToRelativeModuleSpecifier: Record = {} private readonly generatedTableFilePath: string constructor( private readonly fs: FileSystem, { rootDir = '/', sourceFilePaths = [], compilerOptions = DEFAULT_COMPILER_OPTIONS, tsConfigFilePath, }: { rootDir?: string sourceFilePaths?: string[] compilerOptions?: CompilerOptions tsConfigFilePath?: string } = {} ) { super({ compilerOptions, fileSystem: new TsMorphFileSystemWrapper(fs), ...(!!tsConfigFilePath && { tsConfigFilePath }), }) this.rootDir = rootDir this.generatedTableFilePath = path.join(this.rootDir, '$$GENERATED$$_common_table.ts') this.addGlobalTableDefinitionFile() this.addFluentSourceFilesAtPaths(sourceFilePaths) } addGlobalTableDefinitionFile() { const globalTableDefinitionContent = ` import '@servicenow/sdk/global' import { Table } from '@servicenow/sdk/core' declare global { namespace Now { namespace Internal { namespace TableSchemas { } interface Tables { } } } } ` this.createSourceFile(this.generatedTableFilePath, globalTableDefinitionContent, { overwrite: true, scriptKind: ts.ScriptKind.TS, }) } /** * Recursively adds any source files in the specified directory, and returns the added * files. Glob patterns can be supplied to control which files are added. This will also * resolve the dependencies of the added files. If the directory does not exist, an empty * array will be returned. */ addSourceFilesFromDirectory( dir: string, extensions: string[] = SUPPORTED_EXTENSIONS, ignore_extensions: string[] = UNSUPPORTED_EXTENSIONS ): ts.SourceFile[] { const files = [] as ts.SourceFile[] // Using the filesystem API directly instead of using ts-morph's directory APIs is a // workaround for this issue: https://github.com/dsherret/ts-morph/issues/1554 traverseDirectory(this.fs, dir, { extensions, ignore_extensions, visitor: ((filePath: string) => { files.push(this.addSourceFileAtPath(filePath)) }).bind(this), }) if (files.length > 0) { this.resolveSourceFileDependencies() } return files } /** * Recursively adds any .now.ts files in the specified directory, and returns the added * files. Glob patterns can be supplied to control which files are added. This will also * resolve the dependencies of the added files. If the directory does not exist, an empty * array will be returned. */ addFluentSourceFilesFromDirectory(dir: string): ts.SourceFile[] { return this.addSourceFilesFromDirectory(dir, [NOW_FILE_EXTENSION]) } addFluentSourceFilesAtPaths(paths: string[]): ts.SourceFile[] { const files = [] as ts.SourceFile[] if (paths.length > 0) { files.push(...this.addSourceFilesAtPaths(paths)) this.resolveSourceFileDependencies() } return files.filter((file) => file.getBaseName().endsWith(NOW_FILE_EXTENSION)) } getFluentSourceFilesFromDirectory(dir: string) { const directory = this.getDirectoryOrThrow(dir) return directory.getDescendantSourceFiles().filter((file) => file.getBaseName().endsWith(NOW_FILE_EXTENSION)) } getFluentSourceFiles() { return this.getFluentSourceFilesFromDirectory(this.rootDir) } getDiagnosticsForSourceFile(fileOrPath: string | ts.SourceFile): TypeScriptDiagnostic[] { const file = fileOrPath instanceof ts.SourceFile ? fileOrPath : this.getSourceFileOrThrow(fileOrPath) return file .getPreEmitDiagnostics() .map((diagnostic) => { try { return new TypeScriptDiagnostic(diagnostic) } catch (e) { return undefined } }) .filter((d) => d) as TypeScriptDiagnostic[] } visitNodeTree(node: ts.Node, visitor: (node: ts.Node) => void) { node.forEachChild((child) => { this.visitNodeTree(child, visitor) }) visitor(node) } private getTableSchemaModule(sourceFile: ts.SourceFile) { return sourceFile?.getModule('global')?.getModule('Now')?.getModule('Internal')?.getModule('TableSchemas') } private getTableInterface(sourceFile: ts.SourceFile) { return sourceFile?.getModule('global')?.getModule('Now')?.getModule('Internal')?.getInterface('Tables') } interfaceExistsInGlobalDeclaration(tableName: string) { const generatedTableFile = this.getSourceFile(this.generatedTableFilePath) const tableSchema = this.getTableSchemaModule(generatedTableFile!) return tableSchema?.getInterfaces().find((value) => value.getName() === tableName) } propertyExistsInGlobalDeclaration(tableName: string) { const generatedTableFile = this.getSourceFile(this.generatedTableFilePath) const tableInterface = this.getTableInterface(generatedTableFile!) return tableInterface?.getProperties().find((value) => value.getName() === tableName) } importExistsInGlobalDeclaration(resolvedModuleSpecifier: string) { const generatedTableFile = this.getSourceFile(this.generatedTableFilePath) return generatedTableFile ?.getImportDeclarations() .find((value) => value.getModuleSpecifierValue() === resolvedModuleSpecifier) } getGeneratedTableFile() { return this.getSourceFile(this.generatedTableFilePath) } addTableInterfacesToGlobalDeclaration(data: { interfaces; properties; imports; namedImports }) { const { interfaces, properties, imports, namedImports } = data const generatedTableFile = this.getSourceFile(this.generatedTableFilePath) const tableSchema = this.getTableSchemaModule(generatedTableFile!) const tableInterface = this.getTableInterface(generatedTableFile!) tableSchema?.addInterfaces(interfaces) tableInterface?.addProperties(properties) generatedTableFile?.addImportDeclarations(Object.values(imports)) namedImports.forEach((i) => { i.existingImport.addNamedImport({ name: i.name, }) }) Object.keys(imports).forEach((filePath) => { this.sourceFileToRelativeModuleSpecifier[filePath] = imports[filePath].moduleSpecifier }) } removeTableInterfaceFromGlobalDeclaration(sourceFile: string | ts.SourceFile) { const filePath = sourceFile instanceof ts.SourceFile ? sourceFile.getFilePath() : sourceFile const generatedTableFile = this.getSourceFile(this.generatedTableFilePath) const relativeModuleSpecifier = this.sourceFileToRelativeModuleSpecifier[filePath] if (relativeModuleSpecifier) { const foundDecl = generatedTableFile ?.getImportDeclarations() .find((v) => v.getModuleSpecifierValue() === relativeModuleSpecifier) if (foundDecl) { const tables = foundDecl.getNamedImports().map((v) => v.getName()) const tableSchema = this.getTableSchemaModule(generatedTableFile!) const tableInterface = this.getTableInterface(generatedTableFile!) tables.forEach((table) => { tableSchema?.getInterface(table)?.remove() tableInterface?.getProperty(table)?.remove() }) foundDecl.remove() } } } isGeneratedTableFile(sourceFile: string | ts.SourceFile): boolean { const sourcePath = sourceFile instanceof ts.SourceFile ? sourceFile.getFilePath() : sourceFile return this.generatedTableFilePath === sourcePath } } export function createCompilerWithSourceFilesFromDirectory( rootDir: string, fs: FileSystem, config: NowConfig ): Compiler { const compiler = new Compiler(fs, { rootDir }) compiler.addFluentSourceFilesFromDirectory(path.resolve(rootDir, config.fluentDir)) compiler.addSourceFilesFromDirectory(path.resolve(rootDir, config.serverModulesDir)) return compiler } export class TypeScriptDiagnostic extends Diagnostic { constructor(private readonly diagnostic: ts.Diagnostic) { const message = diagnostic.getMessageText() const messageText = typeof message === 'string' ? message : message.getMessageText() const start = diagnostic.getStart() ?? 0 const end = start + (diagnostic.getLength() ?? 0) const file = diagnostic.getSourceFile() if (!file) { throw new Error(`TypeScript diagnostic does not have a source file (Message: ${messageText})`) } super( messageText, file, { start, end }, diagnostic.getCode(), 'ts', Diagnostic.Level.fromCategory(diagnostic.getCategory()) ) } public override asTypeScriptDiagnostic(): ts.ts.Diagnostic { return this.diagnostic.compilerObject } }