diff --git a/.eslintrc.js b/.eslintrc.js index bb838e724..7b67af53b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -91,6 +91,7 @@ module.exports = { 'func-style': 'off', 'function-call-argument-newline': 'off', 'function-paren-newline': 'off', + 'getter-return': 'off', 'guard-for-in': 'off', 'id-length': 'off', 'indent': 'off', diff --git a/.vscode/launch.json b/.vscode/launch.json index 6b49782a6..606a0f72f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,7 @@ "type": "node", "request": "launch", // "preLaunchTask": "build", - "smartStep": false, + "smartStep": true, "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "sourceMaps": true, "args": [ @@ -36,7 +36,12 @@ ], "cwd": "${workspaceRoot}", "protocol": "inspector", - "internalConsoleOptions": "openOnSessionStart" + "internalConsoleOptions": "openOnSessionStart", + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/typescript/**", + "!**/node_modules/vscode-languageserver/**" + ] } ] } \ No newline at end of file diff --git a/bsconfig.schema.json b/bsconfig.schema.json index dab37b198..5010f23f2 100644 --- a/bsconfig.schema.json +++ b/bsconfig.schema.json @@ -128,6 +128,11 @@ "type": "boolean", "default": false }, + "emitDefinitions": { + "description": "Emit type definition files (`d.bs`) during transpile", + "type": "boolean", + "default": true + }, "diagnosticFilters": { "description": "A collection of filters used to hide diagnostics for certain files", "type": "array", diff --git a/package-lock.json b/package-lock.json index 5ab7bf4c8..1b1cd6868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3876,6 +3876,11 @@ "util": "^0.10.3" } }, + "path-complete-extname": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-complete-extname/-/path-complete-extname-1.0.0.tgz", + "integrity": "sha512-CVjiWcMRdGU8ubs08YQVzhutOR5DEfO97ipRIlOGMK5Bek5nQySknBpuxVAVJ36hseTNs+vdIcv57ZrWxH7zvg==" + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", diff --git a/package.json b/package.json index 9c2c6b400..ae7cdbafa 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "moment": "^2.23.0", "p-settle": "^2.1.0", "parse-ms": "^2.1.0", + "path-complete-extname": "^1.0.0", "roku-deploy": "^3.2.3", "serialize-error": "^7.0.1", "source-map": "^0.7.3", diff --git a/src/BsConfig.ts b/src/BsConfig.ts index 76e32b028..366199c77 100644 --- a/src/BsConfig.ts +++ b/src/BsConfig.ts @@ -98,6 +98,12 @@ export interface BsConfig { */ emitFullPaths?: boolean; + /** + * Emit type definition files (`d.bs`) + * @default true + */ + emitDefinitions?: boolean; + /** * A list of filters used to exclude diagnostics from the output */ diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts index f6e830422..4764f0f97 100644 --- a/src/LanguageServer.ts +++ b/src/LanguageServer.ts @@ -891,7 +891,6 @@ export class LanguageServer { this.connection.tracer.log(e); this.sendCriticalFailure(`Critical error parsing/ validating ${filePath}: ${e.message}`); } - console.log('Validate done'); } private async validateAll() { diff --git a/src/Program.spec.ts b/src/Program.spec.ts index 47169ddb5..bffe9449c 100644 --- a/src/Program.spec.ts +++ b/src/Program.spec.ts @@ -61,7 +61,10 @@ describe('Program', () => { //resolve lib.brs from memory instead of going to disk program.fileResolvers.push((pathAbsolute) => { - if (pathAbsolute === s`${rootDir}/source/lib.brs`) { + if ( + pathAbsolute === s`${rootDir}/source/lib.brs` || + pathAbsolute === s`${rootDir}/source/lib.d.bs` + ) { return `'comment`; } }); @@ -736,7 +739,7 @@ describe('Program', () => { describe('getCompletions', () => { it('should include first-level namespace names for brighterscript files', async () => { - await program.addOrReplaceFile({ src: `${rootDir}/source/main.bs`, dest: 'source/main.brs' }, ` + await program.addOrReplaceFile('source/main.bs', ` namespace NameA.NameB.NameC sub DoSomething() end sub @@ -754,7 +757,7 @@ describe('Program', () => { }); it('resolves completions for namespaces with next namespace part for brighterscript file', async () => { - await program.addOrReplaceFile({ src: `${rootDir}/source/main.bs`, dest: 'source/main.brs' }, ` + const file = await program.addOrReplaceFile({ src: `${rootDir}/source/main.bs`, dest: 'source/main.brs' }, ` namespace NameA.NameB.NameC sub DoSomething() end sub @@ -763,6 +766,7 @@ describe('Program', () => { NameA. end sub `); + await file.isReady(); let completions = (await program.getCompletions(`${rootDir}/source/main.bs`, Position.create(6, 26))).map(x => x.label); expect(completions).to.include('NameB'); expect(completions).not.to.include('NameA'); diff --git a/src/Program.ts b/src/Program.ts index 7d38a1883..e1dab7cfc 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -20,12 +20,13 @@ import type { ManifestValue } from './preprocessor/Manifest'; import { parseManifest } from './preprocessor/Manifest'; import { URI } from 'vscode-uri'; import PluginInterface from './PluginInterface'; -import { isXmlFile } from './astUtils/reflection'; +import { isBrsFile, isXmlFile } from './astUtils/reflection'; const startOfSourcePkgPath = `source${path.sep}`; export interface SourceObj { pathAbsolute: string; source: string; + definitions?: string; } export interface TranspileObj { @@ -111,7 +112,7 @@ export class Program { private diagnostics = [] as BsDiagnostic[]; /** - * A map of every file loaded into this program + * A map of every file loaded into this program, indexed by its original file location */ public files = {} as Record; private pkgMap = {} as Record; @@ -297,38 +298,38 @@ export class Program { public async addOrReplaceFile(fileEntry: FileObj, fileContents?: string): Promise; public async addOrReplaceFile(fileParam: FileObj | string, fileContents?: string): Promise { assert.ok(fileParam, 'fileEntry is required'); - let pathAbsolute: string; + let srcPath: string; let pkgPath: string; if (typeof fileParam === 'string') { - pathAbsolute = s`${this.options.rootDir}/${fileParam}`; + srcPath = s`${this.options.rootDir}/${fileParam}`; pkgPath = s`${fileParam}`; } else { - pathAbsolute = s`${fileParam.src}`; + srcPath = s`${fileParam.src}`; pkgPath = s`${fileParam.dest}`; } - let file = await this.logger.time(LogLevel.debug, ['Program.addOrReplaceFile()', chalk.green(pathAbsolute)], async () => { + let file = await this.logger.time(LogLevel.debug, ['Program.addOrReplaceFile()', chalk.green(srcPath)], async () => { - assert.ok(pathAbsolute, 'fileEntry.src is required'); + assert.ok(srcPath, 'fileEntry.src is required'); assert.ok(pkgPath, 'fileEntry.dest is required'); //if the file is already loaded, remove it - if (this.hasFile(pathAbsolute)) { - this.removeFile(pathAbsolute); + if (this.hasFile(srcPath)) { + this.removeFile(srcPath); } - let fileExtension = path.extname(pathAbsolute).toLowerCase(); + let fileExtension = path.extname(srcPath).toLowerCase(); let file: BscFile | undefined; //load the file contents by file path if not provided let getFileContents = async () => { if (fileContents === undefined) { - return this.getFileContents(pathAbsolute); + return this.getFileContents(srcPath); } else { return fileContents; } }; - //get the extension of the file + if (fileExtension === '.brs' || fileExtension === '.bs') { - let brsFile = new BrsFile(pathAbsolute, pkgPath, this); + let brsFile = new BrsFile(srcPath, pkgPath, this); //add file to the `source` dependency list if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) { @@ -336,24 +337,22 @@ export class Program { this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey); } + //add the file to the program - this.files[pathAbsolute] = brsFile; + this.files[srcPath] = brsFile; this.pkgMap[brsFile.pkgPath.toLowerCase()] = brsFile; let fileContents: SourceObj = { - pathAbsolute: pathAbsolute, + pathAbsolute: srcPath, source: await getFileContents() }; this.plugins.emit('beforeFileParse', fileContents); - this.logger.time(LogLevel.info, ['parse', chalk.green(pathAbsolute)], () => { + this.logger.time(LogLevel.info, ['parse', chalk.green(srcPath)], () => { brsFile.parse(fileContents.source); }); file = brsFile; - this.dependencyGraph.addOrReplace( - brsFile.dependencyGraphKey, - brsFile.ownScriptImports.filter(x => !!x.pkgPath).map(x => x.pkgPath.toLowerCase()) - ); + brsFile.attachDependencyGraph(this.dependencyGraph); this.plugins.emit('afterFileValidate', brsFile); } else if ( @@ -362,11 +361,11 @@ export class Program { //resides in the components folder (Roku will only parse xml files in the components folder) pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`)) ) { - let xmlFile = new XmlFile(pathAbsolute, pkgPath, this); + let xmlFile = new XmlFile(srcPath, pkgPath, this); //add the file to the program - this.files[pathAbsolute] = xmlFile; + this.files[srcPath] = xmlFile; let fileContents: SourceObj = { - pathAbsolute: pathAbsolute, + pathAbsolute: srcPath, source: await getFileContents() }; this.plugins.emit('beforeFileParse', fileContents); @@ -418,11 +417,11 @@ export class Program { * with the same path with only case being different. * @param pathAbsolute */ - public getFileByPathAbsolute(pathAbsolute: string) { + public getFileByPathAbsolute(pathAbsolute: string) { pathAbsolute = s`${pathAbsolute}`; for (let filePath in this.files) { if (filePath.toLowerCase() === pathAbsolute.toLowerCase()) { - return this.files[filePath]; + return this.files[filePath] as T; } } } @@ -460,7 +459,10 @@ export class Program { * @param pathAbsolute */ public removeFile(pathAbsolute: string) { - pathAbsolute = s`${pathAbsolute}`; + if (!path.isAbsolute(pathAbsolute)) { + throw new Error(`Path must be absolute: "${pathAbsolute}"`); + } + let file = this.getFile(pathAbsolute); if (file) { this.plugins.emit('beforeFileDispose', file); @@ -476,7 +478,7 @@ export class Program { this.plugins.emit('afterScopeDispose', scope); } //remove the file from the program - delete this.files[pathAbsolute]; + delete this.files[file.pathAbsolute]; delete this.pkgMap[file.pkgPath.toLowerCase()]; this.dependencyGraph.remove(file.dependencyGraphKey); @@ -792,6 +794,13 @@ export class Program { fsExtra.writeFile(outputPath, result.code), writeMapPromise ]); + + if (isBrsFile(file) && this.options.emitDefinitions !== false) { + const typedef = file.getTypedef(); + const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs'); + await fsExtra.writeFile(typedefPath, typedef); + } + this.plugins.emit('afterFileTranspile', entry); }); @@ -843,4 +852,4 @@ export class Program { } } -export type FileResolver = (pathAbsolute: string) => string | undefined | Thenable; +export type FileResolver = (pathAbsolute: string) => string | undefined | Thenable | void; diff --git a/src/ProgramBuilder.spec.ts b/src/ProgramBuilder.spec.ts index f641ec15e..5168e405d 100644 --- a/src/ProgramBuilder.spec.ts +++ b/src/ProgramBuilder.spec.ts @@ -12,13 +12,14 @@ import { Range } from '.'; import { DiagnosticSeverity } from './astUtils'; import { BrsFile } from './files/BrsFile'; -let tmpPath = s`${process.cwd()}/.tmp`; -let rootDir = s`${tmpPath}/rootDir`; -let stagingFolderPath = s`${tmpPath}/staging`; - describe('ProgramBuilder', () => { + + let tmpPath = s`${process.cwd()}/.tmp`; + let rootDir = s`${tmpPath}/rootDir`; + let stagingFolderPath = s`${tmpPath}/staging`; + beforeEach(() => { - fsExtra.ensureDirSync(tmpPath); + fsExtra.ensureDirSync(rootDir); fsExtra.emptyDirSync(tmpPath); }); afterEach(() => { @@ -28,13 +29,13 @@ describe('ProgramBuilder', () => { }); let builder: ProgramBuilder; - let b: any; beforeEach(async () => { builder = new ProgramBuilder(); - b = builder; - b.options = await util.normalizeAndResolveConfig(undefined); - b.program = new Program(b.options); - b.logger = new Logger(); + builder.options = await util.normalizeAndResolveConfig({ + rootDir: rootDir + }); + builder.program = new Program(builder.options); + builder.logger = new Logger(); }); afterEach(() => { @@ -54,13 +55,45 @@ describe('ProgramBuilder', () => { dest: 'file.xml' }])); - b.program = { - addOrReplaceFile: () => { } - }; - let stub = sinon.stub(b.program, 'addOrReplaceFile'); - await b.loadAllFilesAST(); + let stub = sinon.stub(builder.program, 'addOrReplaceFile'); + await builder['loadAllFilesAST'](); expect(stub.getCalls()).to.be.lengthOf(3); }); + + it('loads all type definitions first', async () => { + const requestedFiles = [] as string[]; + builder.program.fileResolvers.push((filePath) => { + requestedFiles.push(s(filePath)); + }); + fsExtra.outputFileSync(s`${rootDir}/source/main.brs`, ''); + fsExtra.outputFileSync(s`${rootDir}/source/main.d.bs`, ''); + fsExtra.outputFileSync(s`${rootDir}/source/lib.d.bs`, ''); + fsExtra.outputFileSync(s`${rootDir}/source/lib.brs`, ''); + const stub = sinon.stub(builder.program, 'addOrReplaceFile'); + await builder['loadAllFilesAST'](); + const srcPaths = stub.getCalls().map(x => x.args[0].src); + //the d files should be first + expect(srcPaths.indexOf(s`${rootDir}/source/main.d.bs`)).within(0, 1); + expect(srcPaths.indexOf(s`${rootDir}/source/lib.d.bs`)).within(0, 1); + //the non-d files should be last + expect(srcPaths.indexOf(s`${rootDir}/source/main.brs`)).within(2, 3); + expect(srcPaths.indexOf(s`${rootDir}/source/lib.brs`)).within(2, 3); + + //the d files should NOT be requested from the FS + expect(requestedFiles).not.to.include(s`${rootDir}/source/lib.d.bs`); + expect(requestedFiles).not.to.include(s`${rootDir}/source/main.d.bs`); + }); + + it('does not load non-existent type definition file', async () => { + const requestedFiles = [] as string[]; + builder.program.fileResolvers.push((filePath) => { + requestedFiles.push(s(filePath)); + }); + fsExtra.outputFileSync(s`${rootDir}/source/main.brs`, ''); + await builder['loadAllFilesAST'](); + //the d file should not be requested because `loadAllFilesAST` knows it doesn't exist + expect(requestedFiles).not.to.include(s`${rootDir}/source/main.d.bs`); + }); }); describe('run', () => { diff --git a/src/ProgramBuilder.ts b/src/ProgramBuilder.ts index 4abf6e46d..132f87c0f 100644 --- a/src/ProgramBuilder.ts +++ b/src/ProgramBuilder.ts @@ -388,9 +388,33 @@ export class ProgramBuilder { return util.getFilePaths(this.options); }); this.logger.debug('ProgramBuilder.loadAllFilesAST() files:', files); - //parse every file + + const typedefFiles = [] as FileObj[]; + const nonTypedefFiles = [] as FileObj[]; + for (const file of files) { + const srcLower = file.src.toLowerCase(); + if (srcLower.endsWith('.d.bs')) { + typedefFiles.push(file); + } else { + nonTypedefFiles.push(file); + } + } + + //preload every type definition file first, which eliminates duplicate file loading + await Promise.all( + typedefFiles.map(async (file) => { + try { + await this.program.addOrReplaceFile(file); + } catch (e) { + //log the error, but don't fail this process because the file might be fixable later + this.logger.log(e); + } + }) + ); + + //parse every file other than the type definitions await Promise.all( - files.map(async (file) => { + nonTypedefFiles.map(async (file) => { try { let fileExtension = path.extname(file.src).toLowerCase(); diff --git a/src/Scope.ts b/src/Scope.ts index be3cc39bb..abcf9bad1 100644 --- a/src/Scope.ts +++ b/src/Scope.ts @@ -86,15 +86,14 @@ export class Scope { */ public isKnownNamespace(namespaceName: string) { let namespaceNameLower = namespaceName.toLowerCase(); - let files = this.getFiles(); - for (let file of files) { + this.enumerateFiles((file) => { for (let namespace of file.parser.references.namespaceStatements) { let loopNamespaceNameLower = namespace.name.toLowerCase(); if (loopNamespaceNameLower === namespaceNameLower || loopNamespaceNameLower.startsWith(namespaceNameLower + '.')) { return true; } } - } + }); return false; } @@ -160,11 +159,10 @@ export class Scope { public getDiagnostics() { let diagnosticLists = [this.diagnostics] as BsDiagnostic[][]; - let files = this.getFiles(); //add diagnostics from every referenced file - for (let file of files) { + this.enumerateFiles((file) => { diagnosticLists.push(file.getDiagnostics()); - } + }); let allDiagnostics = Array.prototype.concat.apply([], diagnosticLists) as BsDiagnostic[]; let filteredDiagnostics = allDiagnostics.filter((x) => { @@ -208,25 +206,34 @@ export class Scope { } } + public enumerateFiles(callback: (file: BscFile) => void) { + const files = this.getFiles(); + for (const file of files) { + //skip files that have a typedef + if (file.hasTypedef) { + continue; + } + callback(file); + } + } + /** * Get the list of callables explicitly defined in files in this scope. * This excludes ancestor callables */ public getOwnCallables(): CallableContainer[] { let result = [] as CallableContainer[]; - let files = this.getFiles(); - this.logDebug('getOwnCallables() files: ', () => this.getFiles().map(x => x.pkgPath)); //get callables from own files - for (let file of files) { + this.enumerateFiles((file) => { for (let callable of file.callables) { result.push({ callable: callable, scope: this }); } - } + }); return result; } @@ -235,8 +242,7 @@ export class Scope { */ public buildNamespaceLookup() { let namespaceLookup = {} as Record; - let files = this.getFiles(); - for (let file of files) { + this.enumerateFiles((file) => { for (let namespace of file.parser.references.namespaceStatements) { //TODO should we handle non-brighterscript? let name = namespace.nameExpression.getName(ParseMode.BrighterScript); @@ -282,27 +288,25 @@ export class Scope { namespaceLookup[parentName.toLowerCase()].namespaces[ns.lastPartName.toLowerCase()] = ns; } } - } + }); return namespaceLookup; } private buildClassLookup() { let lookup = {} as Record; - let files = this.getFiles(); - for (let file of files) { + this.enumerateFiles((file) => { for (let cls of file.parser.references.classStatements) { lookup[cls.getName(ParseMode.BrighterScript).toLowerCase()] = cls; } - } + }); return lookup; } public getNamespaceStatements() { let result = [] as NamespaceStatement[]; - let files = this.getFiles(); - for (let file of files) { + this.enumerateFiles((file) => { result.push(...file.parser.references.namespaceStatements); - } + }); return result; } @@ -358,13 +362,13 @@ export class Scope { this.validateClasses(); //do many per-file checks - for (let file of files) { + this.enumerateFiles((file) => { this.diagnosticDetectCallsToUnknownFunctions(file, callableContainerMap); this.diagnosticDetectFunctionCallsWithWrongParamCount(file, callableContainerMap); this.diagnosticDetectShadowedLocalVars(file, callableContainerMap); this.diagnosticDetectFunctionCollisions(file); this.detectVariableNamespaceCollisions(file); - } + }); this.program.plugins.emit('afterScopeValidate', this, files, callableContainerMap); @@ -443,14 +447,13 @@ export class Scope { public getNewExpressions() { let result = [] as AugmentedNewExpression[]; - let files = this.getFiles(); - for (let file of files) { + this.enumerateFiles((file) => { let expressions = file.parser.references.newExpressions as AugmentedNewExpression[]; for (let expression of expressions) { expression.file = file; result.push(expression); } - } + }); return result; } @@ -671,14 +674,13 @@ export class Scope { */ private getScriptImports() { let result = [] as FileReference[]; - let files = this.getFiles(); - for (let file of files) { + this.enumerateFiles((file) => { if (isBrsFile(file)) { result.push(...file.ownScriptImports); } else if (isXmlFile(file)) { result.push(...file.scriptTagImports); } - } + }); return result; } @@ -774,10 +776,9 @@ export class Scope { */ public getPropertyNameCompletions() { let results = [] as CompletionItem[]; - let files = this.getFiles(); - for (let file of files) { + this.enumerateFiles((file) => { results.push(...file.propertyNameCompletions); - } + }); return results; } } diff --git a/src/XmlScope.ts b/src/XmlScope.ts index ec5be4af8..1dbb6140a 100644 --- a/src/XmlScope.ts +++ b/src/XmlScope.ts @@ -85,7 +85,7 @@ export class XmlScope extends Scope { let result = [ this.xmlFile ] as BscFile[]; - let scriptPkgPaths = this.xmlFile.getAllScriptImports(); + let scriptPkgPaths = this.xmlFile.getAllDependencies(); for (let scriptPkgPath of scriptPkgPaths) { let file = this.program.getFileByPkgPath(scriptPkgPath); if (file) { diff --git a/src/astUtils/reflection.spec.ts b/src/astUtils/reflection.spec.ts index f983395c5..70d313162 100644 --- a/src/astUtils/reflection.spec.ts +++ b/src/astUtils/reflection.spec.ts @@ -14,13 +14,15 @@ import { XmlFile } from '../files/XmlFile'; describe('reflection', () => { describe('Files', () => { - const program = new Program({}); - const file = new BrsFile('path/to/source/file.brs', 'pkg:/source/file.brs', program); - const comp = new XmlFile('path/to/components/file.xml', 'pkg:/components/file.brs', program); - expect(isBrsFile(file)).to.be.true; - expect(isXmlFile(file)).to.be.false; - expect(isBrsFile(comp)).to.be.false; - expect(isXmlFile(comp)).to.be.true; + it('recognizes files', () => { + const program = new Program({}); + const file = new BrsFile('path/to/source/file.brs', 'pkg:/source/file.brs', program); + const comp = new XmlFile('path/to/components/file.xml', 'pkg:/components/file.brs', program); + expect(isBrsFile(file)).to.be.true; + expect(isXmlFile(file)).to.be.false; + expect(isBrsFile(comp)).to.be.false; + expect(isXmlFile(comp)).to.be.true; + }); }); describe('Statements', () => { diff --git a/src/files/BrsFile.spec.ts b/src/files/BrsFile.spec.ts index 49ae8a2f2..342ecc882 100644 --- a/src/files/BrsFile.spec.ts +++ b/src/files/BrsFile.spec.ts @@ -1,7 +1,6 @@ import { assert, expect } from 'chai'; import * as sinonImport from 'sinon'; import { CompletionItemKind, Position, Range } from 'vscode-languageserver'; - import type { Callable, CallableArg, CommentFlag, BsDiagnostic, VariableDeclaration } from '../interfaces'; import { Program } from '../Program'; import { BooleanType } from '../types/BooleanType'; @@ -14,21 +13,24 @@ import { SourceMapConsumer } from 'source-map'; import { TokenKind, Lexer, Keywords } from '../lexer'; import { DiagnosticMessages } from '../DiagnosticMessages'; import type { StandardizedFileEntry } from 'roku-deploy'; -import { standardizePath as s } from '../util'; +import { loadPlugins, standardizePath as s } from '../util'; import PluginInterface from '../PluginInterface'; -import { loadPlugins } from '..'; +import { trim } from '../testHelpers.spec'; +import { ParseMode } from '../parser/Parser'; let sinon = sinonImport.createSandbox(); describe('BrsFile', () => { - let rootDir = process.cwd(); + let rootDir = s`${process.cwd()}/.tmp/rootDir`; let program: Program; + let srcPath = s`${rootDir}/source/main.brs`; + let destPath = 'source/main.brs'; let file: BrsFile; let testTranspile = getTestTranspile(() => [program, rootDir]); beforeEach(() => { program = new Program({ rootDir: rootDir }); - file = new BrsFile('abs', 'rel', program); + file = new BrsFile(srcPath, destPath, program); }); afterEach(() => { sinon.restore(); @@ -350,6 +352,21 @@ describe('BrsFile', () => { }); describe('parse', () => { + it('uses the proper parse mode based on file extension', async () => { + async function testParseMode(destPath: string, expectedParseMode: ParseMode) { + const file = await program.addOrReplaceFile(destPath, ''); + expect(file.parseMode).to.equal(expectedParseMode); + } + + await testParseMode('source/main.brs', ParseMode.BrightScript); + await testParseMode('source/main.spec.brs', ParseMode.BrightScript); + await testParseMode('source/main.d.brs', ParseMode.BrightScript); + + await testParseMode('source/main.bs', ParseMode.BrighterScript); + await testParseMode('source/main.d.bs', ParseMode.BrighterScript); + await testParseMode('source/main.spec.bs', ParseMode.BrighterScript); + }); + it('supports labels and goto statements', async () => { let file = await program.addOrReplaceFile({ src: `${rootDir}/source/main.brs`, dest: 'source/main.brs' }, ` sub Main() @@ -2096,6 +2113,253 @@ describe('BrsFile', () => { }); }); + describe('typedefKey', () => { + it('works for .brs files', async () => { + expect( + s((await program.addOrReplaceFile('source/main.brs', '')).typedefKey) + ).to.equal( + s`${rootDir.toLowerCase()}/source/main.d.bs` + ); + }); + it('returns undefined for files that should not have a typedef', async () => { + expect((await program.addOrReplaceFile('source/main.bs', '')).typedefKey).to.be.undefined; + + expect((await program.addOrReplaceFile('source/main.d.bs', '')).typedefKey).to.be.undefined; + + const xmlFile = await program.addOrReplaceFile('components/comp.xml', ''); + expect(xmlFile.typedefKey).to.be.undefined; + }); + }); + + + describe('type definitions', () => { + it('only exposes defined functions even if source has more', async () => { + await program.addOrReplaceFile('source/main.d.bs', ` + sub main() + end sub + `); + + const file = await program.addOrReplaceFile('source/main.brs', ` + sub main() + end sub + sub speak() + end sub + `); + expect(file.parser.references.functionStatements).to.be.empty; + const sourceScope = program.getScopeByName('source'); + const functionNames = sourceScope.getAllCallables().map(x => x.callable.name); + expect(functionNames).to.include('main'); + expect(functionNames).not.to.include('speak'); + }); + + it('reacts to typedef file changes', async () => { + let file = await program.addOrReplaceFile('source/main.brs', ` + sub main() + end sub + sub speak() + end sub + `); + expect(file.hasTypedef).to.be.false; + expect(file.typedefFile).not.to.exist; + + await program.addOrReplaceFile('source/main.d.bs', ` + sub main() + end sub + `); + expect(file.hasTypedef).to.be.true; + expect(file.typedefFile).to.exist; + + //add replace file, does it still find the typedef + file = await program.addOrReplaceFile('source/main.brs', ` + sub main() + end sub + sub speak() + end sub + `); + expect(file.hasTypedef).to.be.true; + expect(file.typedefFile).to.exist; + + program.removeFile(s`${rootDir}/source/main.d.bs`); + + expect(file.hasTypedef).to.be.false; + expect(file.typedefFile).not.to.exist; + }); + }); + + describe('typedef', () => { + it('sets typedef path properly', async () => { + expect((await program.addOrReplaceFile('source/main1.brs', '')).typedefKey).to.equal(s`${rootDir}/source/main1.d.bs`.toLowerCase()); + expect((await program.addOrReplaceFile('source/main2.d.bs', '')).typedefKey).to.equal(undefined); + expect((await program.addOrReplaceFile('source/main3.bs', '')).typedefKey).to.equal(undefined); + //works for dest with `.brs` extension + expect((await program.addOrReplaceFile({ src: 'source/main4.bs', dest: 'source/main4.brs' }, '')).typedefKey).to.equal(undefined); + }); + + it('does not link when missing from program', async () => { + const file = await program.addOrReplaceFile('source/main.brs', ``); + expect(file.typedefFile).not.to.exist; + }); + + it('links typedef when added BEFORE .brs file', async () => { + const typedef = await program.addOrReplaceFile('source/main.d.bs', ``); + const file = await program.addOrReplaceFile('source/main.brs', ``); + expect(file.typedefFile).to.equal(typedef); + }); + + it('links typedef when added AFTER .brs file', async () => { + const file = await program.addOrReplaceFile('source/main.brs', ``); + const typedef = await program.addOrReplaceFile('source/main.d.bs', ``); + expect(file.typedefFile).to.eql(typedef); + }); + + it('removes typedef link when typedef is removed', async () => { + const typedef = await program.addOrReplaceFile('source/main.d.bs', ``); + const file = await program.addOrReplaceFile('source/main.brs', ``); + program.removeFile(typedef.pathAbsolute); + expect(file.typedefFile).to.be.undefined; + }); + }); + + describe('getTypedef', () => { + async function testTypedef(original: string, expected: string) { + let file = await program.addOrReplaceFile('source/main.brs', original); + expect(file.getTypedef()).to.eql(expected); + } + + it('strips function body', async () => { + await testTypedef(` + sub main(param1 as string) + print "main" + end sub + `, trim` + sub main(param1 as string) + end sub + `); + }); + + it('includes import statements', async () => { + await testTypedef(` + import "pkg:/source/lib.brs" + `, trim` + import "pkg:/source/lib.brs" + `); + }); + + it('includes namespace statements', async () => { + await testTypedef(` + namespace Name + sub logInfo() + end sub + end namespace + namespace NameA.NameB + sub logInfo() + end sub + end namespace + `, trim` + namespace Name + sub logInfo() + end sub + end namespace + namespace NameA.NameB + sub logInfo() + end sub + end namespace + `); + }); + + it('includes classes', async () => { + await testTypedef(` + class Person + public name as string + public age = 12 + public sub getAge() as integer + return m.age + end sub + end class + namespace NameA.NameB + class Person + public name as string + public age = 12 + public sub getAge() as integer + return m.age + end sub + end class + end namespace + `, trim` + class Person + public name as string + public age as integer + public sub getAge() as integer + end sub + end class + namespace NameA.NameB + class Person + public name as string + public age as integer + public sub getAge() as integer + end sub + end class + end namespace + `); + }); + + it('includes class inheritance', async () => { + await testTypedef(` + class Human + sub new(name as string) + m.name = name + end sub + end class + class Person extends Human + sub new(name as string) + super(name) + end sub + end class + `, trim` + class Human + sub new(name as string) + end sub + end class + class Person extends Human + sub new(name as string) + end sub + end class + `); + }); + + it('includes class inheritance cross-namespace', async () => { + await testTypedef(` + namespace NameA + class Human + sub new(name as string) + m.name = name + end sub + end class + end namespace + namespace NameB + class Person extends NameA.Human + sub new(name as string) + super(name) + end sub + end class + end namespace + `, trim` + namespace NameA + class Human + sub new(name as string) + end sub + end class + end namespace + namespace NameB + class Person extends NameA.Human + sub new(name as string) + end sub + end class + end namespace + `); + }); + }); + describe('Plugins', () => { it('can load a plugin which transforms the AST', async () => { program.plugins = new PluginInterface( diff --git a/src/files/BrsFile.ts b/src/files/BrsFile.ts index 6bec7fc67..88321bbce 100644 --- a/src/files/BrsFile.ts +++ b/src/files/BrsFile.ts @@ -1,4 +1,3 @@ -import * as path from 'path'; import { SourceNode } from 'source-map'; import type { CompletionItem, Hover, Range } from 'vscode-languageserver'; import { CompletionItemKind, Position } from 'vscode-languageserver'; @@ -24,6 +23,8 @@ import { Preprocessor } from '../preprocessor/Preprocessor'; import { LogLevel } from '../Logger'; import { serializeError } from 'serialize-error'; import { isCallExpression, isClassStatement, isCommentStatement, isDottedGetExpression, isFunctionExpression, isFunctionStatement, isFunctionType, isImportStatement, isLibraryStatement, isLiteralExpression, isStringType, isVariableExpression } from '../astUtils/reflection'; +import type { DependencyGraph } from '../DependencyGraph'; +import * as extname from 'path-complete-extname'; /** * Holds all details about this file within the scope of the whole program @@ -41,12 +42,28 @@ export class BrsFile { this.pkgPath = s`${this.pkgPath}`; this.dependencyGraphKey = this.pkgPath.toLowerCase(); - this.extension = path.extname(pathAbsolute).toLowerCase(); + this.extension = extname(this.pkgPath).toLowerCase(); //all BrighterScript files need to be transpiled - if (this.extension === '.bs') { + if (this.extension.endsWith('.bs')) { this.needsTranspiled = true; } + this.isTypedef = this.extension === '.d.bs'; + if (!this.isTypedef) { + this.typedefKey = util.getTypedefPath(this.pathAbsolute); + } + + //global file doesn't have a program, so only resolve typedef info if we have a program + if (this.program) { + this.resolveTypdef(); + } + } + + /** + * The parseMode used for the parser for this file + */ + public get parseMode() { + return this.extension.endsWith('.bs') ? ParseMode.BrighterScript : ParseMode.BrightScript; } /** @@ -54,7 +71,7 @@ export class BrsFile { */ public dependencyGraphKey: string; /** - * The extension for this file + * The all-lowercase extension for this file (including the leading dot) */ public extension: string; @@ -121,11 +138,81 @@ export class BrsFile { } } - - public parser: Parser; + public parser = new Parser(); public fileContents: string; + /** + * If this is a typedef file + */ + public isTypedef: boolean; + + /** + * The key to find the typedef file in the program's files map. + * A falsey value means this file is ineligable for a typedef + */ + public typedefKey?: string; + + /** + * If the file was given type definitions during parse + */ + public hasTypedef; + + /** + * A reference to the typedef file (if one exists) + */ + public typedefFile?: BrsFile; + + /** + * An unsubscribe function for the dependencyGraph subscription + */ + private unsubscribeFromDependencyGraph: () => void; + + /** + * Find and set the typedef variables (if a matching typedef file exists) + */ + private resolveTypdef() { + this.typedefFile = this.program.getFileByPathAbsolute(this.typedefKey); + this.hasTypedef = !!this.typedefFile; + } + + /** + * Attach the file to the dependency graph so it can monitor changes. + * Also notify the dependency graph of our current dependencies so other dependents can be notified. + */ + public attachDependencyGraph(dependencyGraph: DependencyGraph) { + if (this.unsubscribeFromDependencyGraph) { + this.unsubscribeFromDependencyGraph(); + } + + //event that fires anytime a dependency changes + this.unsubscribeFromDependencyGraph = this.program.dependencyGraph.onchange(this.dependencyGraphKey, () => { + this.resolveTypdef(); + + //if there is no typedef file, and this file hasn't been parsed yet, parse it now + //(solves issue when typedef gets deleted and this file had skipped parsing) + if (!this.hasTypedef && this.wasParseSkipped) { + this.parseDeferred = new Deferred(); + this.parse(this.fileContents); + } + }); + + const dependencies = this.ownScriptImports.filter(x => !!x.pkgPath).map(x => x.pkgPath.toLowerCase()); + + //if this is a .brs file, watch for typedef changes + if (this.extension === '.brs') { + dependencies.push( + util.getTypedefPath(this.pkgPath) + ); + } + dependencyGraph.addOrReplace(this.dependencyGraphKey, dependencies); + } + + /** + * Was parsing skipped because the file has a typedef? + */ + private wasParseSkipped = false; + /** * Calculate the AST for this file * @param fileContents @@ -137,6 +224,13 @@ export class BrsFile { throw new Error(`File was already processed. Create a new instance of BrsFile instead. ${this.pathAbsolute}`); } + //if we have a typedef file, skip parsing this file + if (this.hasTypedef) { + this.wasParseSkipped = true; + this.parseDeferred.resolve(); + return; + } + //tokenize the input file let lexer = this.program.logger.time(LogLevel.debug, ['lexer.lex', chalk.green(this.pathAbsolute)], () => { return Lexer.scan(fileContents, { @@ -165,10 +259,9 @@ export class BrsFile { //if the preprocessor generated tokens, use them. let tokens = preprocessor.processedTokens.length > 0 ? preprocessor.processedTokens : lexer.tokens; - this.parser = new Parser(); this.program.logger.time(LogLevel.debug, ['parser.parse', chalk.green(this.pathAbsolute)], () => { this.parser.parse(tokens, { - mode: this.extension === '.brs' ? ParseMode.BrightScript : ParseMode.BrighterScript, + mode: this.parseMode, logger: this.program.logger }); }); @@ -203,6 +296,7 @@ export class BrsFile { ...DiagnosticMessages.genericParserMessage('Critical error parsing file: ' + JSON.stringify(serializeError(e))) }); } + this.wasParseSkipped = false; this.parseDeferred.resolve(); } @@ -992,6 +1086,12 @@ export class BrsFile { } } + public getTypedef() { + const state = new TranspileState(this); + const programNode = new SourceNode(null, null, this.pathAbsolute, this.ast.getTypedef(state)); + return programNode.toString(); + } + public dispose() { this.parser?.dispose(); } diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts index e95ef6147..51f324bd0 100644 --- a/src/files/XmlFile.spec.ts +++ b/src/files/XmlFile.spec.ts @@ -332,19 +332,17 @@ describe('XmlFile', () => { }); }); - describe('getAllScriptImports', () => { + describe('getAllDependencies', () => { it('returns own imports', async () => { - file = await program.addOrReplaceFile({ - src: `${rootDir}/components/comp1.xml`, - dest: `components/comp1.xml` - }, ` + file = await program.addOrReplaceFile('components/comp1.xml', `