diff --git a/src/BsConfig.ts b/src/BsConfig.ts index e35849b65..38627144d 100644 --- a/src/BsConfig.ts +++ b/src/BsConfig.ts @@ -209,3 +209,22 @@ export interface BsConfig { */ bslibDestinationDir?: string; } + +type OptionalBsConfigFields = + | '_ancestors' + | 'sourceRoot' + | 'project' + | 'manifest' + | 'noProject' + | 'extends' + | 'host' + | 'password' + | 'require' + | 'stagingFolderPath' + | 'diagnosticLevel' + | 'rootDir' + | 'stagingDir'; + +export type FinalizedBsConfig = + Omit, OptionalBsConfigFields> + & Pick; diff --git a/src/PluginInterface.ts b/src/PluginInterface.ts index e25de31d7..ce851cf51 100644 --- a/src/PluginInterface.ts +++ b/src/PluginInterface.ts @@ -2,10 +2,26 @@ import type { CompilerPlugin } from './interfaces'; import type { Logger } from './Logger'; import { LogLevel } from './Logger'; -// inspiration: https://github.com/andywer/typed-emitter/blob/master/index.d.ts -export type Arguments = [T] extends [(...args: infer U) => any] - ? U - : [T] extends [void] ? [] : [T]; +/* + * we use `Required` everywhere here because we expect that the methods on plugin objects will + * be optional, and we don't want to deal with `undefined`. + * `extends (...args: any[]) => any` determines whether the thing we're dealing with is a function. + * Returning `never` in the `as` clause of the `[key in object]` step deletes that key from the + * resultant object. + * on the right-hand side of the mapped type we are forced to use a conditional type a second time, + * in order to be able to use the `Parameters` utility type on `Required[K]`. This will always + * be true because of the filtering done by the `[key in object]` clause, but TS requires the duplication. + * + * so put together: we iterate over all of the fields in T, deleting ones which are not (potentially + * optional) functions. For the ones that are, we replace them with their parameters. + * + * this returns the type of an object whose keys are the names of the methods of T and whose values + * are tuples containing the arguments that each method accepts. + */ +export type PluginEventArgs = { + [K in keyof Required as Required[K] extends (...args: any[]) => any ? K : never]: + Required[K] extends (...args: any[]) => any ? Parameters[K]> : never +}; export default class PluginInterface { @@ -48,7 +64,7 @@ export default class PluginInterface /** * Call `event` on plugins */ - public emit(event: K, ...args: Arguments) { + public emit & string>(event: K, ...args: PluginEventArgs[K]) { for (let plugin of this.plugins) { if ((plugin as any)[event]) { try { diff --git a/src/Program.spec.ts b/src/Program.spec.ts index a72d916c7..3b8a92299 100644 --- a/src/Program.spec.ts +++ b/src/Program.spec.ts @@ -3111,9 +3111,9 @@ describe('Program', () => { supports_input_launch=1 bs_const=DEBUG=false `); - program.options = { + program.options = util.normalizeConfig({ rootDir: tempDir - }; + }); }); afterEach(() => { diff --git a/src/Program.ts b/src/Program.ts index 5ffb9e9e1..8f262db2e 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -3,7 +3,7 @@ import * as fsExtra from 'fs-extra'; import * as path from 'path'; import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location } from 'vscode-languageserver'; import { CompletionItemKind } from 'vscode-languageserver'; -import type { BsConfig } from './BsConfig'; +import type { BsConfig, FinalizedBsConfig } from './BsConfig'; import { Scope } from './Scope'; import { DiagnosticMessages } from './DiagnosticMessages'; import { BrsFile } from './files/BrsFile'; @@ -60,7 +60,7 @@ export class Program { /** * The root directory for this program */ - public options: BsConfig, + options: BsConfig, logger?: Logger, plugins?: PluginInterface ) { @@ -77,6 +77,7 @@ export class Program { this.createGlobalScope(); } + public options: FinalizedBsConfig; public logger: Logger; private createGlobalScope() { @@ -109,7 +110,7 @@ export class Program { * A scope that contains all built-in global functions. * All scopes should directly or indirectly inherit from this scope */ - public globalScope: Scope; + public globalScope: Scope = undefined as any; /** * Plugins which can provide extra diagnostics or transform AST @@ -320,7 +321,7 @@ export class Program { /** * roku filesystem is case INsensitive, so find the scope by key case insensitive */ - public getScopeByName(scopeName: string) { + public getScopeByName(scopeName: string): Scope | undefined { if (!scopeName) { return undefined; } @@ -328,7 +329,7 @@ export class Program { //so it's safe to run the standardizePkgPath method scopeName = s`${scopeName}`; let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase()); - return this.scopes[key]; + return this.scopes[key!]; } /** @@ -495,8 +496,8 @@ export class Program { * @param rootDir must be a pre-normalized path */ private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) { - let srcPath: string; - let pkgPath: string; + let srcPath: string | undefined; + let pkgPath: string | undefined; assert.ok(fileParam, 'fileParam is required'); @@ -631,7 +632,7 @@ export class Program { this.plugins.emit('beforeScopeDispose', scope); scope.dispose(); //notify dependencies of this scope that it has been removed - this.dependencyGraph.remove(scope.dependencyGraphKey); + this.dependencyGraph.remove(scope.dependencyGraphKey!); delete this.scopes[file.pkgPath]; this.plugins.emit('afterScopeDispose', scope); } @@ -777,15 +778,15 @@ export class Program { * @param file the file */ public getScopesForFile(file: XmlFile | BrsFile | string) { - if (typeof file === 'string') { - file = this.getFile(file); - } + + const resolvedFile = typeof file === 'string' ? this.getFile(file) : file; + let result = [] as Scope[]; - if (file) { + if (resolvedFile) { for (let key in this.scopes) { let scope = this.scopes[key]; - if (scope.hasFile(file)) { + if (scope.hasFile(resolvedFile)) { result.push(scope); } } diff --git a/src/ProgramBuilder.ts b/src/ProgramBuilder.ts index f8ff73066..43aa4947a 100644 --- a/src/ProgramBuilder.ts +++ b/src/ProgramBuilder.ts @@ -1,7 +1,7 @@ import * as debounce from 'debounce-promise'; import * as path from 'path'; import { rokuDeploy } from 'roku-deploy'; -import type { BsConfig } from './BsConfig'; +import type { BsConfig, FinalizedBsConfig } from './BsConfig'; import type { BscFile, BsDiagnostic, FileObj, FileResolver } from './interfaces'; import { Program } from './Program'; import { standardizePath as s, util } from './util'; @@ -33,10 +33,10 @@ export class ProgramBuilder { */ public allowConsoleClearing = true; - public options: BsConfig; + public options: FinalizedBsConfig = util.normalizeConfig({}); private isRunning = false; - private watcher: Watcher; - public program: Program; + private watcher: Watcher | undefined; + public program: Program | undefined; public logger = new Logger(); public plugins: PluginInterface = new PluginInterface([], { logger: this.logger }); private fileResolvers = [] as FileResolver[]; @@ -68,7 +68,10 @@ export class ProgramBuilder { private staticDiagnostics = [] as BsDiagnostic[]; public addDiagnostic(srcPath: string, diagnostic: Partial) { - let file: BscFile = this.program.getFile(srcPath); + if (!this.program) { + throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`'); + } + let file: BscFile | undefined = this.program.getFile(srcPath); if (!file) { file = { pkgPath: this.program.getPkgPath(srcPath), @@ -180,7 +183,7 @@ export class ProgramBuilder { * A handle for the watch mode interval that keeps the process alive. * We need this so we can clear it if the builder is disposed */ - private watchInterval: NodeJS.Timer; + private watchInterval: NodeJS.Timer | undefined; public enableWatchMode() { this.watcher = new Watcher(this.options); @@ -211,6 +214,9 @@ export class ProgramBuilder { //on any file watcher event this.watcher.on('all', async (event: string, thePath: string) => { //eslint-disable-line @typescript-eslint/no-misused-promises + if (!this.program) { + throw new Error('Internal invariant exception: somehow file watcher ran before `ProgramBuilder.run()`'); + } thePath = s`${path.resolve(this.rootDir, thePath)}`; if (event === 'add' || event === 'change') { const fileObj = { @@ -238,6 +244,9 @@ export class ProgramBuilder { * The rootDir for this program. */ public get rootDir() { + if (!this.program) { + throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`'); + } return this.program.options.rootDir; } @@ -412,7 +421,9 @@ export class ProgramBuilder { logLevel: this.options.logLevel as LogLevel, outDir: util.getOutDir(this.options), outFile: path.basename(this.options.outFile) - }); + + //rokuDeploy's return type says all its fields can be nullable, but it sets values for all of them. + }) as any as Required>; }); //get every file referenced by the files array @@ -420,8 +431,9 @@ export class ProgramBuilder { //remove files currently loaded in the program, we will transpile those instead (even if just for source maps) let filteredFileMap = [] as FileObj[]; + for (let fileEntry of fileMap) { - if (this.program.hasFile(fileEntry.src) === false) { + if (this.program!.hasFile(fileEntry.src) === false) { filteredFileMap.push(fileEntry); } } @@ -441,7 +453,7 @@ export class ProgramBuilder { await this.logger.time(LogLevel.log, ['Transpiling'], async () => { //transpile any brighterscript files - await this.program.transpile(fileMap, options.stagingDir); + await this.program!.transpile(fileMap, options.stagingDir); }); this.plugins.emit('afterPublish', this, fileMap); @@ -492,12 +504,12 @@ export class ProgramBuilder { } if (manifestFile) { - this.program.loadManifest(manifestFile); + this.program!.loadManifest(manifestFile); } const loadFile = async (fileObj) => { try { - this.program.setFile(fileObj, await this.getFileContents(fileObj.src)); + this.program!.setFile(fileObj, await this.getFileContents(fileObj.src)); } catch (e) { this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later } diff --git a/src/Scope.ts b/src/Scope.ts index 7302a1f3d..f6a045f17 100644 --- a/src/Scope.ts +++ b/src/Scope.ts @@ -369,8 +369,8 @@ export class Scope { * XmlScope overrides this to return the parent xml scope if available. * For globalScope this will return null. */ - public getParentScope() { - let scope: Scope; + public getParentScope(): Scope | null { + let scope: Scope | undefined; //use the global scope if we didn't find a sope and this is not the global scope if (this.program.globalScope !== this) { scope = this.program.globalScope; diff --git a/src/XmlScope.ts b/src/XmlScope.ts index bd9efa627..3731604f4 100644 --- a/src/XmlScope.ts +++ b/src/XmlScope.ts @@ -27,7 +27,7 @@ export class XmlScope extends Scope { */ public getParentScope() { return this.cache.getOrAdd('parentScope', () => { - let scope: Scope; + let scope: Scope | undefined; let parentComponentName = this.xmlFile.parentComponentName?.text; if (parentComponentName) { scope = this.program.getComponentScope(parentComponentName); @@ -35,7 +35,7 @@ export class XmlScope extends Scope { if (scope) { return scope; } else { - return this.program.globalScope; + return this.program.globalScope ?? null; } }); } @@ -64,7 +64,7 @@ export class XmlScope extends Scope { } else if (!callableContainerMap.has(name.toLowerCase())) { this.diagnostics.push({ ...DiagnosticMessages.xmlFunctionNotFound(name), - range: fun.getAttribute('name').value.range, + range: fun.getAttribute('name')?.value.range, file: this.xmlFile }); } @@ -82,7 +82,7 @@ export class XmlScope extends Scope { } else if (!SGFieldTypes.includes(type.toLowerCase())) { this.diagnostics.push({ ...DiagnosticMessages.xmlInvalidFieldType(type), - range: field.getAttribute('type').value.range, + range: field.getAttribute('type')?.value.range, file: this.xmlFile }); } @@ -90,7 +90,7 @@ export class XmlScope extends Scope { if (!callableContainerMap.has(onChange.toLowerCase())) { this.diagnostics.push({ ...DiagnosticMessages.xmlFunctionNotFound(onChange), - range: field.getAttribute('onchange').value.range, + range: field.getAttribute('onchange')?.value.range, file: this.xmlFile }); } diff --git a/src/files/XmlFile.ts b/src/files/XmlFile.ts index ae4380712..cb7af0544 100644 --- a/src/files/XmlFile.ts +++ b/src/files/XmlFile.ts @@ -398,8 +398,8 @@ export class XmlFile { /** * Walk up the ancestor chain and aggregate all of the script tag imports */ - public getAncestorScriptTagImports() { - let result = []; + public getAncestorScriptTagImports(): FileReference[] { + let result = [] as FileReference[]; let parent = this.parentComponent; while (parent) { result.push(...parent.scriptTagImports); diff --git a/src/util.ts b/src/util.ts index 8ebd461b9..e158b7d9f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -7,7 +7,7 @@ import { rokuDeploy, DefaultFiles, standardizePath as rokuDeployStandardizePath import type { Diagnostic, Position, Range, Location, DiagnosticRelatedInformation } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import * as xml2js from 'xml2js'; -import type { BsConfig } from './BsConfig'; +import type { BsConfig, FinalizedBsConfig } from './BsConfig'; import { DiagnosticMessages } from './DiagnosticMessages'; import type { CallableContainer, BsDiagnostic, FileReference, CallableContainerMap, CompilerPluginFactory, CompilerPlugin, ExpressionInfo } from './interfaces'; import { BooleanType } from './types/BooleanType'; @@ -295,7 +295,7 @@ export class Util { * merge with bsconfig.json and the provided options. * @param config a bsconfig object to use as the baseline for the resulting config */ - public normalizeAndResolveConfig(config: BsConfig) { + public normalizeAndResolveConfig(config: BsConfig | undefined): FinalizedBsConfig { let result = this.normalizeConfig({}); if (config?.noProject) { @@ -322,43 +322,58 @@ export class Util { * Set defaults for any missing items * @param config a bsconfig object to use as the baseline for the resulting config */ - public normalizeConfig(config: BsConfig) { - config = config || {} as BsConfig; - config.cwd = config.cwd ?? process.cwd(); - config.deploy = config.deploy === true ? true : false; - //use default files array from rokuDeploy - config.files = config.files ?? [...DefaultFiles]; - config.createPackage = config.createPackage === false ? false : true; - let rootFolderName = path.basename(config.cwd); - config.outFile = config.outFile ?? `./out/${rootFolderName}.zip`; - config.sourceMap = config.sourceMap === true; - config.username = config.username ?? 'rokudev'; - config.watch = config.watch === true ? true : false; - config.emitFullPaths = config.emitFullPaths === true ? true : false; - config.retainStagingDir = (config.retainStagingDir ?? config.retainStagingFolder) === true ? true : false; - config.retainStagingFolder = config.retainStagingDir; - config.copyToStaging = config.copyToStaging === false ? false : true; - config.ignoreErrorCodes = config.ignoreErrorCodes ?? []; - config.diagnosticSeverityOverrides = config.diagnosticSeverityOverrides ?? {}; - config.diagnosticFilters = config.diagnosticFilters ?? []; - config.plugins = config.plugins ?? []; - config.pruneEmptyCodeFiles = config.pruneEmptyCodeFiles === true ? true : false; - config.autoImportComponentScript = config.autoImportComponentScript === true ? true : false; - config.showDiagnosticsInConsole = config.showDiagnosticsInConsole === false ? false : true; - config.sourceRoot = config.sourceRoot ? standardizePath(config.sourceRoot) : undefined; - config.allowBrighterScriptInBrightScript = config.allowBrighterScriptInBrightScript === true ? true : false; - config.emitDefinitions = config.emitDefinitions === true ? true : false; - config.removeParameterTypes = config.removeParameterTypes === true ? true : false; + public normalizeConfig(config: BsConfig | undefined): FinalizedBsConfig { + config = config ?? {} as BsConfig; + + const cwd = config.cwd ?? process.cwd(); + const rootFolderName = path.basename(cwd); + const retainStagingDir = (config.retainStagingDir ?? config.retainStagingFolder) === true ? true : false; + + let logLevel: LogLevel = LogLevel.log; + if (typeof config.logLevel === 'string') { - config.logLevel = LogLevel[(config.logLevel as string).toLowerCase()]; + logLevel = LogLevel[(config.logLevel as string).toLowerCase()] ?? LogLevel.log; } - config.logLevel = config.logLevel ?? LogLevel.log; - config.bslibDestinationDir = config.bslibDestinationDir ?? 'source'; - if (config.bslibDestinationDir !== 'source') { + + let bslibDestinationDir = config.bslibDestinationDir ?? 'source'; + if (bslibDestinationDir !== 'source') { // strip leading and trailing slashes - config.bslibDestinationDir = config.bslibDestinationDir.replace(/^(\/*)(.*?)(\/*)$/, '$2'); - } - return config; + bslibDestinationDir = bslibDestinationDir.replace(/^(\/*)(.*?)(\/*)$/, '$2'); + } + + const configWithDefaults: Omit = { + cwd: cwd, + deploy: config.deploy === true ? true : false, + //use default files array from rokuDeploy + files: config.files ?? [...DefaultFiles], + createPackage: config.createPackage === false ? false : true, + outFile: config.outFile ?? `./out/${rootFolderName}.zip`, + sourceMap: config.sourceMap === true, + username: config.username ?? 'rokudev', + watch: config.watch === true ? true : false, + emitFullPaths: config.emitFullPaths === true ? true : false, + retainStagingDir: retainStagingDir, + retainStagingFolder: retainStagingDir, + copyToStaging: config.copyToStaging === false ? false : true, + ignoreErrorCodes: config.ignoreErrorCodes ?? [], + diagnosticSeverityOverrides: config.diagnosticSeverityOverrides ?? {}, + diagnosticFilters: config.diagnosticFilters ?? [], + plugins: config.plugins ?? [], + pruneEmptyCodeFiles: config.pruneEmptyCodeFiles === true ? true : false, + autoImportComponentScript: config.autoImportComponentScript === true ? true : false, + showDiagnosticsInConsole: config.showDiagnosticsInConsole === false ? false : true, + sourceRoot: config.sourceRoot ? standardizePath(config.sourceRoot) : undefined, + allowBrighterScriptInBrightScript: config.allowBrighterScriptInBrightScript === true ? true : false, + emitDefinitions: config.emitDefinitions === true ? true : false, + removeParameterTypes: config.removeParameterTypes === true ? true : false, + logLevel: logLevel, + bslibDestinationDir: bslibDestinationDir + }; + + //mutate `config` in case anyone is holding a reference to the incomplete one + const merged: FinalizedBsConfig = Object.assign(config, configWithDefaults); + + return merged; } /** @@ -568,7 +583,7 @@ export class Util { * Test if `position` is in `range`. If the position is at the edges, will return true. * Adapted from core vscode */ - public rangeContains(range: Range, position: Position) { + public rangeContains(range: Range | undefined, position: Position | undefined) { return this.comparePositionToRange(position, range) === 0; } @@ -693,7 +708,7 @@ export class Util { /** * Get the outDir from options, taking into account cwd and absolute outFile paths */ - public getOutDir(options: BsConfig) { + public getOutDir(options: FinalizedBsConfig) { options = this.normalizeConfig(options); let cwd = path.normalize(options.cwd ? options.cwd : process.cwd()); if (path.isAbsolute(options.outFile)) { @@ -706,7 +721,7 @@ export class Util { /** * Get paths to all files on disc that match this project's source list */ - public async getFilePaths(options: BsConfig) { + public async getFilePaths(options: FinalizedBsConfig) { let rootDir = this.getRootDir(options); let files = await rokuDeploy.getFilePaths(options.files, rootDir);