diff --git a/docs/type-checking-without-transpile.md b/docs/type-checking-without-transpile.md new file mode 100644 index 000000000..613045843 --- /dev/null +++ b/docs/type-checking-without-transpile.md @@ -0,0 +1,50 @@ +# Typechecking without transpiling +If you want the benefits of BrighterScript's type system but don't want to use the transpiler, you can use comments to declare variable types to get much richer editor experience and type validation. + +## Params +Declare the type of a specific parameter: + +```brighterscript +'@param {roSGNodeRectangle} node +function resize(node, width as integer, height as string) + 'yay, we know that `node` is a `roSGNodeRectangle` type + node.width = width + node.height = height +end function +``` + +## Return type + +Declare the return type of a function + +```brighterscript +'@return {roSGNodeRectangle} +function createRectangle() + return createObject("roSGNode", "Rectangle") +end function + +function test() + 'yay, we know that `rectangle` is a `roSGNodeRectangle` type + rectangle = createRectangle() +end function +``` + +## Inline variable type +You can override the type of a variable in brs files by starting a comment with `@type` . This will cast the variable below with the given type. + +```brighterscript +' @type {integer} +runtime = getRuntimeFromServer() +``` + +## TODO: Function-level variable type +Sometimes you need to declare a variable at more than one location in your code. In this situation, you can declare the variable type in the body of the function before the variable usage, and that variable will then be treated as that type. + +```brighterscript +' @type {integer} runtime +if m.top.hasRuntime + runtime = m.top.runtime +else + runtime = 12345 +end if +``` diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 5d481f59e..43d089dd2 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -809,6 +809,14 @@ export let DiagnosticMessages = { message: `Unsafe unmatched terminator '${terminator}' in conditional compilation block`, code: 1153, severity: DiagnosticSeverity.Error + }), + cannotFindTypeInCommentDoc: (name: string) => ({ + message: `Cannot find type '${name}' in doc comment`, + code: 1154, + data: { + name: name + }, + severity: DiagnosticSeverity.Error }) }; export const defaultMaximumTruncationLength = 160; diff --git a/src/Scope.spec.ts b/src/Scope.spec.ts index f7536d782..2287ae5e9 100644 --- a/src/Scope.spec.ts +++ b/src/Scope.spec.ts @@ -2813,6 +2813,27 @@ describe('Scope', () => { expectZeroDiagnostics(program); }); + it('should allow access to underscored version of namespaced class constructor in different file', () => { + program.setFile('source/main.bs', ` + sub printPi() + print alpha_util_SomeKlass().value + end sub + `); + program.setFile('source/util.bs', ` + namespace alpha.util + class SomeKlass + value as float + + sub new() + value = 3.14 + end sub + end class + end namespace + `); + program.validate(); + expectZeroDiagnostics(program); + }); + it('resolves deep namespaces defined in different locations', () => { program.setFile(`source/main.bs`, ` sub main() diff --git a/src/SymbolTable.ts b/src/SymbolTable.ts index eca994458..4457d26ca 100644 --- a/src/SymbolTable.ts +++ b/src/SymbolTable.ts @@ -232,6 +232,7 @@ export class SymbolTable implements SymbolTypeGetter { options.data.doNotMerge = data?.doNotMerge; options.data.isAlias = data?.isAlias; options.data.isInstance = data?.isInstance; + options.data.isFromDocComment = data?.isFromDocComment; } return resolvedType; } diff --git a/src/bscPlugin/validation/BrsFileValidator.spec.ts b/src/bscPlugin/validation/BrsFileValidator.spec.ts index 94bb068c5..cbb16bb0f 100644 --- a/src/bscPlugin/validation/BrsFileValidator.spec.ts +++ b/src/bscPlugin/validation/BrsFileValidator.spec.ts @@ -5,7 +5,7 @@ import type { AssignmentStatement, ClassStatement, FunctionStatement, NamespaceS import { DiagnosticMessages } from '../../DiagnosticMessages'; import { expectDiagnostics, expectHasDiagnostics, expectTypeToBe, expectZeroDiagnostics } from '../../testHelpers.spec'; import { Program } from '../../Program'; -import { isClassStatement, isFunctionExpression, isNamespaceStatement } from '../../astUtils/reflection'; +import { isAssignmentStatement, isClassStatement, isFunctionExpression, isFunctionParameterExpression, isFunctionStatement, isNamespaceStatement, isPrintStatement, isReturnStatement } from '../../astUtils/reflection'; import util from '../../util'; import { WalkMode, createVisitor } from '../../astUtils/visitors'; import { SymbolTypeFlag } from '../../SymbolTypeFlag'; @@ -14,7 +14,7 @@ import { FloatType } from '../../types/FloatType'; import { IntegerType } from '../../types/IntegerType'; import { InterfaceType } from '../../types/InterfaceType'; import { StringType } from '../../types/StringType'; -import { TypedFunctionType } from '../../types'; +import { DynamicType, TypedFunctionType } from '../../types'; import { ParseMode } from '../../parser/Parser'; import type { ExtraSymbolData } from '../../interfaces'; @@ -851,4 +851,367 @@ describe('BrsFileValidator', () => { }); }); + describe('types in comments', () => { + + describe('@param', () => { + it('uses @param type in brs file', () => { + const file = program.setFile('source/main.brs', ` + ' @param {string} name + function sayHello(name) + print "Hello " + name + end function + `); + program.validate(); + expectZeroDiagnostics(program); + let data = {} as ExtraSymbolData; + expectTypeToBe( + file.ast.findChild(isFunctionParameterExpression).getType({ + flags: SymbolTypeFlag.runtime, data: data + }), + StringType + ); + data = {}; + const printSymbolTable = file.ast.findChild(isPrintStatement).getSymbolTable(); + expectTypeToBe( + printSymbolTable.getSymbolType('name', { + flags: SymbolTypeFlag.runtime, data: data + }), + StringType + ); + expect(data.isFromDocComment).to.be.true; + }); + + it('handles no type in @param tag', () => { + const file = program.setFile('source/main.brs', ` + ' @param name + function sayHello(name) + print "Hello " + name + end function + `); + program.validate(); + expectZeroDiagnostics(program); + let data = {} as ExtraSymbolData; + expectTypeToBe( + file.ast.findChild(isFunctionParameterExpression).getType({ + flags: SymbolTypeFlag.runtime, data: data + }), + DynamicType + ); + data = {}; + const printSymbolTable = file.ast.findChild(isPrintStatement).getSymbolTable(); + expectTypeToBe( + printSymbolTable.getSymbolType('name', { + flags: SymbolTypeFlag.runtime, data: data + }), + DynamicType + ); + }); + + it('uses @param type in brs file that can refer to a custom type', () => { + const file = program.setFile('source/main.brs', ` + ' @param {Klass} myClass + function sayHello(myClass) + print "Hello " + myClass.name + end function + `); + program.setFile('source/klass.bs', ` + class Klass + name as string + end class + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + const funcParamExpr = file.ast.findChild(isFunctionParameterExpression); + expectTypeToBe( + funcParamExpr.getType({ + flags: SymbolTypeFlag.typetime, data: data + }), + ClassType + ); + const myClassType = file.ast.findChild(isPrintStatement).getSymbolTable().getSymbolType('myClass', { + flags: SymbolTypeFlag.runtime, data: data + }); + expectTypeToBe(myClassType, ClassType); + expectTypeToBe(myClassType.getMemberType('name', { flags: SymbolTypeFlag.runtime }), StringType); + expect(data.isFromDocComment).to.be.true; + }); + + it('uses @param type in brs file that can refer to a built in type', () => { + const file = program.setFile('source/main.brs', ` + ' @param {roDeviceInfo} info + function sayHello(info) + print "Hello " + info.getModel() + end function + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + expectTypeToBe( + file.ast.findChild(isFunctionParameterExpression).getType({ + flags: SymbolTypeFlag.typetime, data: data + }), + InterfaceType + ); + const infoType = file.ast.findChild(isPrintStatement).getSymbolTable().getSymbolType('info', { + flags: SymbolTypeFlag.runtime, data: data + }); + expectTypeToBe(infoType, InterfaceType); + expectTypeToBe(infoType.getMemberType('getModel', { flags: SymbolTypeFlag.runtime }), TypedFunctionType); + expect(data.isFromDocComment).to.be.true; + }); + + it('allows jsdoc comment style /** prefix', () => { + const file = program.setFile('source/main.brs', ` + ' /** + ' * @param {string} info + ' */ + function sayHello(info) + print "Hello " + info + end function + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + expectTypeToBe( + file.ast.findChild(isFunctionParameterExpression).getType({ + flags: SymbolTypeFlag.runtime, data: data + }), + StringType + ); + const infoType = file.ast.findChild(isPrintStatement).getSymbolTable().getSymbolType('info', { + flags: SymbolTypeFlag.runtime, data: data + }); + expectTypeToBe(infoType, StringType); + expect(data.isFromDocComment).to.be.true; + }); + + it('ignores types it cannot find', () => { + const file = program.setFile('source/main.brs', ` + ' @param {TypeNotThere} info + function sayHello(info) + print "Hello " + info.prop + end function + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.cannotFindTypeInCommentDoc('TypeNotThere').message + ]); + const data = {} as ExtraSymbolData; + expectTypeToBe( + file.ast.findChild(isFunctionParameterExpression).getType({ + flags: SymbolTypeFlag.runtime, data: data + }), + DynamicType + ); + const infoType = file.ast.findChild(isPrintStatement).getSymbolTable().getSymbolType('info', { + flags: SymbolTypeFlag.runtime, data: data + }); + expectTypeToBe(infoType, DynamicType); + expect(data.isFromDocComment).to.be.true; + }); + }); + + describe('@return', () => { + it('uses @return type in brs file', () => { + const file = program.setFile('source/main.brs', ` + ' @return {string} + function getPie() + return "pumpkin" + end function + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + const funcStmt = file.ast.findChild(isFunctionStatement); + const funcType = funcStmt.getType({ flags: SymbolTypeFlag.runtime, data: data }); + expectTypeToBe(funcType, TypedFunctionType); + const returnType = (funcType as TypedFunctionType).returnType; + expectTypeToBe(returnType, StringType); + }); + + it('allows unknown type when using @return tag', () => { + const file = program.setFile('source/main.brs', ` + ' @return {TypeNotThere} + function getPie() + return "pumpkin" + end function + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.cannotFindTypeInCommentDoc('TypeNotThere').message + ]); + const data = {} as ExtraSymbolData; + const funcStmt = file.ast.findChild(isFunctionStatement); + const funcType = funcStmt.getType({ flags: SymbolTypeFlag.runtime, data: data }); + expectTypeToBe(funcType, TypedFunctionType); + const returnType = (funcType as TypedFunctionType).returnType; + expectTypeToBe(returnType, DynamicType); + }); + + it('validates return statements against @return tag with valid type', () => { + const file = program.setFile('source/main.brs', ` + ' @return {integer} + function getPie() + return "pumpkin" + end function + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.returnTypeMismatch('string', 'integer').message + ]); + const data = {} as ExtraSymbolData; + const funcStmt = file.ast.findChild(isFunctionStatement); + const funcType = funcStmt.getType({ flags: SymbolTypeFlag.runtime, data: data }); + expectTypeToBe(funcType, TypedFunctionType); + const returnType = (funcType as TypedFunctionType).returnType; + expectTypeToBe(returnType, IntegerType); + }); + + it('checks return statements against @return tag with valid custom type', () => { + const file = program.setFile('source/main.brs', ` + ' @return {alpha.Klass} + function getPie() + return alpha_Klass() + end function + `); + program.setFile('source/klass.bs', ` + namespace alpha + class Klass + name as string + end class + end namespace + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + const funcStmt = file.ast.findChild(isFunctionStatement); + const funcType = funcStmt.getType({ flags: SymbolTypeFlag.typetime, data: data }); + expectTypeToBe(funcType, TypedFunctionType); + const returnType = (funcType as TypedFunctionType).returnType; + expectTypeToBe(returnType, ClassType); + }); + + it('validates return statements against @return tag with valid custom type', () => { + program.setFile('source/main.brs', ` + ' @return {alpha.Klass} + function getPie() + return "foo" + end function + `); + program.setFile('source/klass.bs', ` + namespace alpha + class Klass + name as string + end class + end namespace + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.returnTypeMismatch('string', 'alpha.Klass').message + ]); + }); + }); + + describe('@type', () => { + it('uses @type type in brs file', () => { + const file = program.setFile('source/main.brs', ` + function getPie() as string + ' @type {string} + pieType = getFruit() + return pieType + end function + + function getFruit() + return "apple" + end function + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + const funcStmt = file.ast.findChild(isFunctionStatement); + const returnStmt = funcStmt.findChild(isReturnStatement); + const varType = returnStmt.getSymbolTable().getSymbolType('pieType', { flags: SymbolTypeFlag.runtime, data: data }); + expectTypeToBe(varType, StringType); + }); + + it('allows unknown type when using @type tag', () => { + const file = program.setFile('source/main.brs', ` + + function getValue() + ' @type {unknown} + something = {} + return something + end function + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.cannotFindTypeInCommentDoc('unknown').message + ]); + const data = {} as ExtraSymbolData; + const funcStmt = file.ast.findChild(isFunctionStatement); + const funcType = funcStmt.getType({ flags: SymbolTypeFlag.runtime, data: data }); + expectTypeToBe(funcType, TypedFunctionType); + const returnType = (funcType as TypedFunctionType).returnType; + expectTypeToBe(returnType, DynamicType); + }); + + it('treats variable as type given in @type', () => { + const file = program.setFile('source/main.brs', ` + function getModelName() + ' @type {roDeviceInfo} + info = getData() + return info.getModel() + end function + + function getData() + return {} + end function + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + const assignStmt = file.ast.findChild(isAssignmentStatement); + const infoType = assignStmt.getSymbolTable().getSymbolType('info', { flags: SymbolTypeFlag.runtime, data: data }); + expectTypeToBe(infoType, InterfaceType); + expect(infoType.toString()).to.eq('roDeviceInfo'); + expect(data.isFromDocComment).to.be.true; + }); + + }); + + // Skipped until we can figure out how to handle @var tags + describe.skip('@var', () => { + it('uses @var type in brs file to define types of variables', () => { + const file = program.setFile('source/main.brs', ` + function getPie() as string + ' @var {string} someDate + if m.top.isTrue + someDate = getDate() + else + someDate = m.date2 + end if + + if m.someProp + someDate = m.someProp.date + end if + + return someDate + end function + + function getDate() + return "Dec 25" + end function + `); + program.validate(); + expectZeroDiagnostics(program); + const data = {} as ExtraSymbolData; + const funcStmt = file.ast.findChild(isFunctionStatement); + const returnStmt = funcStmt.findChild(isReturnStatement); + const varType = returnStmt.getSymbolTable().getSymbolType('someDate', { flags: SymbolTypeFlag.runtime, data: data }); + expectTypeToBe(varType, StringType); + }); + + }); + }); }); diff --git a/src/bscPlugin/validation/BrsFileValidator.ts b/src/bscPlugin/validation/BrsFileValidator.ts index 6270a3c83..6d7ead3ab 100644 --- a/src/bscPlugin/validation/BrsFileValidator.ts +++ b/src/bscPlugin/validation/BrsFileValidator.ts @@ -15,6 +15,8 @@ import { DynamicType } from '../../types/DynamicType'; import util from '../../util'; import type { Range } from 'vscode-languageserver'; import type { Token } from '../../lexer/Token'; +import type { BrightScriptDoc } from '../../parser/BrightScriptDocParser'; +import brsDocParser from '../../parser/BrightScriptDocParser'; export class BrsFileValidator { constructor( @@ -89,11 +91,25 @@ export class BrsFileValidator { node.getSymbolTable().addSymbol('m', { definingNode: node, isInstance: true }, nodeType, SymbolTypeFlag.runtime); // eslint-disable-next-line no-bitwise node.parent.getSymbolTable()?.addSymbol(node.tokens.name?.text, { definingNode: node }, nodeType, SymbolTypeFlag.typetime | SymbolTypeFlag.runtime); + + if (node.findAncestor(isNamespaceStatement)) { + //add the transpiled name for namespaced constructors to the root symbol table + const transpiledClassConstructor = node.getName(ParseMode.BrightScript); + + this.event.file.parser.ast.symbolTable.addSymbol( + transpiledClassConstructor, + { definingNode: node }, + node.getConstructorType(), + // eslint-disable-next-line no-bitwise + SymbolTypeFlag.runtime | SymbolTypeFlag.postTranspile + ); + } }, AssignmentStatement: (node) => { + const data: ExtraSymbolData = {}; //register this variable - const nodeType = node.getType({ flags: SymbolTypeFlag.runtime }); - node.parent.getSymbolTable()?.addSymbol(node.tokens.name.text, { definingNode: node, isInstance: true }, nodeType, SymbolTypeFlag.runtime); + const nodeType = node.getType({ flags: SymbolTypeFlag.runtime, data: data }); + node.parent.getSymbolTable()?.addSymbol(node.tokens.name.text, { definingNode: node, isInstance: true, isFromDocComment: data.isFromDocComment }, nodeType, SymbolTypeFlag.runtime); }, DottedSetStatement: (node) => { this.validateNoOptionalChainingInVarSet(node, [node.obj]); @@ -160,14 +176,15 @@ export class BrsFileValidator { }, FunctionParameterExpression: (node) => { const paramName = node.tokens.name?.text; - const nodeType = node.getType({ flags: SymbolTypeFlag.typetime }); + const data: ExtraSymbolData = {}; + const nodeType = node.getType({ flags: SymbolTypeFlag.typetime, data: data }); // add param symbol at expression level, so it can be used as default value in other params const funcExpr = node.findAncestor(isFunctionExpression); const funcSymbolTable = funcExpr?.getSymbolTable(); - funcSymbolTable?.addSymbol(paramName, { definingNode: node, isInstance: true }, nodeType, SymbolTypeFlag.runtime); + funcSymbolTable?.addSymbol(paramName, { definingNode: node, isInstance: true, isFromDocComment: data.isFromDocComment }, nodeType, SymbolTypeFlag.runtime); //also add param symbol at block level, as it may be redefined, and if so, should show a union - funcExpr.body.getSymbolTable()?.addSymbol(paramName, { definingNode: node, isInstance: true }, nodeType, SymbolTypeFlag.runtime); + funcExpr.body.getSymbolTable()?.addSymbol(paramName, { definingNode: node, isInstance: true, isFromDocComment: data.isFromDocComment }, nodeType, SymbolTypeFlag.runtime); }, InterfaceStatement: (node) => { this.validateDeclarationLocations(node, 'interface', () => util.createBoundingRange(node.tokens.interface, node.tokens.name)); @@ -223,6 +240,25 @@ export class BrsFileValidator { // eslint-disable-next-line no-bitwise node.parent.getSymbolTable().addSymbol(node.tokens.name.text, { definingNode: node, doNotMerge: true, isAlias: true }, targetType, SymbolTypeFlag.runtime | SymbolTypeFlag.typetime); + }, + AstNode: (node) => { + //check for doc comments + if (!node.leadingTrivia || node.leadingTrivia.length === 0) { + return; + } + const doc = brsDocParser.parseNode(node); + if (doc.tags.length === 0) { + return; + } + + let funcExpr = node.findAncestor(isFunctionExpression); + if (funcExpr) { + // handle comment tags inside a function expression + this.processDocTagsInFunction(doc, node, funcExpr); + } else { + //handle comment tags outside of a function expression + this.processDocTagsAtTopLevel(doc, node); + } } }); @@ -233,6 +269,33 @@ export class BrsFileValidator { }); } + private processDocTagsInFunction(doc: BrightScriptDoc, node: AstNode, funcExpr: FunctionExpression) { + //TODO: Handle doc tags that influence the function they're in + + // For example, declaring variable types: + // const symbolTable = funcExpr.body.getSymbolTable(); + + // for (const varTag of doc.getAllTags(BrsDocTagKind.Var)) { + // const varName = (varTag as BrsDocParamTag).name; + // const varTypeStr = (varTag as BrsDocParamTag).type; + // const data: ExtraSymbolData = {}; + // const type = doc.getTypeFromContext(varTypeStr, node, { flags: SymbolTypeFlag.typetime, fullName: varTypeStr, data: data, tableProvider: () => symbolTable }); + // if (type) { + // symbolTable.addSymbol(varName, { ...data, isFromDocComment: true }, type, SymbolTypeFlag.runtime); + // } + // } + } + + private processDocTagsAtTopLevel(doc: BrightScriptDoc, node: AstNode) { + //TODO: + // - handle import statements? + // - handle library statements? + // - handle typecast statements? + // - handle alias statements? + // - handle const statements? + // - allow interface definitions? + } + /** * Validate that a statement is defined in one of these specific locations * - the root of the AST diff --git a/src/bscPlugin/validation/ScopeValidator.spec.ts b/src/bscPlugin/validation/ScopeValidator.spec.ts index 5c5126e7b..3609c3d32 100644 --- a/src/bscPlugin/validation/ScopeValidator.spec.ts +++ b/src/bscPlugin/validation/ScopeValidator.spec.ts @@ -2972,6 +2972,48 @@ describe('ScopeValidator', () => { }); + describe('cannotFindTypeInDocComment', () => { + it('validates types it cannot find in @param', () => { + program.setFile('source/main.brs', ` + ' @param {TypeNotThere} info + function sayHello(info) + print "Hello " + info.prop + end function + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.cannotFindTypeInCommentDoc('TypeNotThere').message + ]); + }); + + it('validates types it cannot find in @return', () => { + program.setFile('source/main.brs', ` + ' @return {TypeNotThere} info + function sayHello(info) + return {data: info.prop} + end function + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.cannotFindTypeInCommentDoc('TypeNotThere').message + ]); + }); + + it('validates types it cannot find in @type', () => { + program.setFile('source/main.brs', ` + function sayHello(info) + ' @type {TypeNotThere} + value = info.prop + end function + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.cannotFindTypeInCommentDoc('TypeNotThere').message + ]); + }); + + }); + describe('revalidation', () => { it('revalidates when a enum defined in a different namespace changes', () => { diff --git a/src/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts index f82878818..6507f11a8 100644 --- a/src/bscPlugin/validation/ScopeValidator.ts +++ b/src/bscPlugin/validation/ScopeValidator.ts @@ -28,6 +28,8 @@ import type { XmlFile } from '../../files/XmlFile'; import { SGFieldTypes } from '../../parser/SGTypes'; import { DynamicType } from '../../types'; import { BscTypeKind } from '../../types/BscTypeKind'; +import type { BrsDocWithType } from '../../parser/BrightScriptDocParser'; +import brsDocParser from '../../parser/BrightScriptDocParser'; /** * The lower-case names of all platform-included scenegraph nodes @@ -179,6 +181,15 @@ export class ScopeValidator { type: this.getNodeTypeWrapper(file, funcParam, { flags: SymbolTypeFlag.runtime }), nameRange: funcParam.tokens.name.location?.range }); + }, + AstNode: (node) => { + //check for doc comments + if (!node.leadingTrivia || node.leadingTrivia.filter(triviaToken => triviaToken.kind === TokenKind.Comment).length === 0) { + return; + } + + this.validateDocComments(node); + } }); // validate only what's needed in the file @@ -404,13 +415,14 @@ export class ScopeValidator { * Detect return statements with incompatible types vs. declared return type */ private validateReturnStatement(file: BrsFile, returnStmt: ReturnStatement) { - const getTypeOptions = { flags: SymbolTypeFlag.runtime }; + const data: ExtraSymbolData = {}; + const getTypeOptions = { flags: SymbolTypeFlag.runtime, data: data }; let funcType = returnStmt.findAncestor(isFunctionExpression).getType({ flags: SymbolTypeFlag.typetime }); if (isTypedFunctionType(funcType)) { const actualReturnType = this.getNodeTypeWrapper(file, returnStmt?.value, getTypeOptions); const compatibilityData: TypeCompatibilityData = {}; - if (actualReturnType && !funcType.returnType.isTypeCompatible(actualReturnType, compatibilityData)) { + if (funcType.returnType.isResolvable() && actualReturnType && !funcType.returnType.isTypeCompatible(actualReturnType, compatibilityData)) { this.addMultiScopeDiagnostic({ ...DiagnosticMessages.returnTypeMismatch(actualReturnType.toString(), funcType.returnType.toString(), compatibilityData), location: returnStmt.value.location @@ -695,7 +707,8 @@ export class ScopeValidator { } } - } else { + } else if (!typeData?.isFromDocComment) { + // only show "cannot find... " errors if the type is not defined from a doc comment const typeChainScan = util.processTypeChain(typeChain); if (isCallExpression(typeChainScan.astNode.parent) && typeChainScan.astNode.parent.callee === expression) { this.addMultiScopeDiagnostic({ @@ -1155,6 +1168,23 @@ export class ScopeValidator { } } + private validateDocComments(node: AstNode) { + const doc = brsDocParser.parseNode(node); + for (const docTag of doc.tags) { + const docTypeTag = docTag as BrsDocWithType; + if (!docTypeTag.typeExpression || !docTypeTag.location) { + continue; + } + const foundType = docTypeTag.typeExpression?.getType({ flags: SymbolTypeFlag.typetime }); + if (!foundType?.isResolvable()) { + this.addMultiScopeDiagnostic({ + ...DiagnosticMessages.cannotFindTypeInCommentDoc(docTypeTag.typeString), + location: brsDocParser.getTypeLocationFromToken(docTypeTag.token) ?? docTypeTag.location + }); + } + } + } + /** * Detect when a child has imported a script that an ancestor also imported */ diff --git a/src/index.ts b/src/index.ts index 5202ac6c4..d1664134f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export * from './parser/Parser'; export * from './parser/AstNode'; export * from './parser/Expression'; export * from './parser/Statement'; +export * from './parser/BrightScriptDocParser'; export * from './BsConfig'; export * from './deferred'; // convenience re-export from vscode diff --git a/src/interfaces.ts b/src/interfaces.ts index a62785a16..a9483aeae 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -973,6 +973,10 @@ export interface ExtraSymbolData { * so check for `=== true` or `!== true` */ isInstance?: boolean; + /** + * Is this type as defined in a doc comment? + */ + isFromDocComment?: boolean; } export interface GetTypeOptions { @@ -983,6 +987,7 @@ export interface GetTypeOptions { onlyCacheResolvedTypes?: boolean; ignoreCacheForRetrieval?: boolean; isExistenceTest?: boolean; + preferDocType?: boolean; } export class TypeChainEntry { diff --git a/src/lexer/TokenKind.ts b/src/lexer/TokenKind.ts index 7976604ba..45e300a99 100644 --- a/src/lexer/TokenKind.ts +++ b/src/lexer/TokenKind.ts @@ -699,3 +699,27 @@ export const AllowedTriviaTokens: ReadonlyArray = [ TokenKind.Comment, TokenKind.Colon ]; + + +/** + * The tokens that may be in a binary expression + */ +export const BinaryExpressionOperatorTokens: ReadonlyArray = [ + TokenKind.Equal, + TokenKind.LessGreater, + TokenKind.Greater, + TokenKind.GreaterEqual, + TokenKind.Less, + TokenKind.LessEqual, + TokenKind.And, + TokenKind.Or, + TokenKind.Plus, + TokenKind.Minus, + TokenKind.Star, + TokenKind.RightShift, + TokenKind.LeftShift, + TokenKind.Forwardslash, + TokenKind.Mod, + TokenKind.Backslash, + TokenKind.Caret +]; diff --git a/src/parser/BrightScriptDocParser.spec.ts b/src/parser/BrightScriptDocParser.spec.ts new file mode 100644 index 000000000..a34167673 --- /dev/null +++ b/src/parser/BrightScriptDocParser.spec.ts @@ -0,0 +1,358 @@ +import { expect } from 'chai'; +import { brsDocParser } from './BrightScriptDocParser'; +import { Parser } from './Parser'; +import { expectTypeToBe } from '../testHelpers.spec'; +import { SymbolTypeFlag } from '../SymbolTypeFlag'; +import { IntegerType } from '../types/IntegerType'; +import { UnionType } from '../types/UnionType'; +import { isReferenceType } from '../astUtils/reflection'; +import { createToken } from '../astUtils/creators'; +import { TokenKind } from '../lexer/TokenKind'; +import util from '../util'; + +describe('BrightScriptDocParser', () => { + + + it('should get a comment', () => { + const doc = brsDocParser.parse('this is a comment'); + expect(doc.description).to.equal('this is a comment'); + }); + + it('should get a tag', () => { + const doc = brsDocParser.parse(` + this is a comment + @sometag here is the rest + `); + expect(doc.description).to.equal('this is a comment'); + expect(doc.tags.length).to.equal(1); + expect(doc.tags[0].tagName).to.equal('sometag'); + expect(doc.tags[0].detail).to.equal('here is the rest'); + expect(doc.getTag('sometag').detail).to.equal('here is the rest'); + }); + + it('ignores leading apostrophes ', () => { + const doc = brsDocParser.parse(` + ' this is a comment + ' @sometag here is the rest + `); + expect(doc.description).to.equal('this is a comment'); + expect(doc.tags.length).to.equal(1); + expect(doc.tags[0].tagName).to.equal('sometag'); + expect(doc.tags[0].detail).to.equal('here is the rest'); + expect(doc.getTag('sometag').detail).to.equal('here is the rest'); + }); + + it('should get a multiline comment', () => { + const doc = brsDocParser.parse(` + this is a comment + this is some more of a comment + `); + expect(doc.description).to.equal('this is a comment\nthis is some more of a comment'); + }); + + describe('parseParam', () => { + + it('should find @param tags of various types', () => { + const doc = brsDocParser.parse(` + this is a comment + @param p1 + @param p2 description of p2 + @param {some.type} p3 + @param {some.type} p4 description of p4 + @param [p5] optional p5 + @param {some.type} [p6] optional with type p6 + @param p7 multi line description + of p7 + @param p8 + description of p8 + `); + + expect(doc.getAllTags('param').length).to.equal(8); + + expect(doc.getParam('p1').description).to.equal(''); + expect(doc.getParam('p1').typeString).to.equal(''); + + expect(doc.getParam('p2').description).to.equal('description of p2'); + expect(doc.getParam('p2').typeString).to.equal(''); + + expect(doc.getParam('p3').description).to.equal(''); + expect(doc.getParam('p3').typeString).to.equal('some.type'); + + expect(doc.getParam('p4').description).to.equal('description of p4'); + expect(doc.getParam('p4').typeString).to.equal('some.type'); + + expect(doc.getParam('p5').description).to.equal('optional p5'); + expect(doc.getParam('p5').typeString).to.be.equal(''); + expect(doc.getParam('p5').optional).to.be.true; + + expect(doc.getParam('p6').description).to.equal('optional with type p6'); + expect(doc.getParam('p6').typeString).to.be.equal('some.type'); + expect(doc.getParam('p6').optional).to.be.true; + + expect(doc.getParam('p7').description).to.equal('multi line description\nof p7'); + expect(doc.getParam('p7').typeString).to.be.equal(''); + expect(doc.getParam('p7').optional).to.be.false; + + expect(doc.getParam('p8').description).to.equal('description of p8'); + expect(doc.getParam('p8').typeString).to.equal(''); + }); + }); + + it('includes the @description tag in the description', () => { + const doc = brsDocParser.parse(` + this is a comment + @description this is a description + `); + expect(doc.description).to.equal('this is a comment\nthis is a description'); + }); + + it('includes the @description tag in the description when multiline', () => { + const doc = brsDocParser.parse(` + this is a comment + + above space intentionally blank + @description this is a description + + above space intentionally blank again + @param whatever + this will be the description of whatever + + `); + expect(doc.description).to.equal('this is a comment\n\nabove space intentionally blank\nthis is a description\n\nabove space intentionally blank again'); + }); + + it('includes the @return tag', () => { + const doc = brsDocParser.parse(` + this is a comment + @return this is a return + `); + expect(doc.getReturn().description).to.equal('this is a return'); + }); + + it('includes the @return tag when it has a type', () => { + const doc = brsDocParser.parse(` + this is a comment + @return {some.thing.here} this is a return + `); + expect(doc.getReturn().description).to.equal('this is a return'); + expect(doc.getReturn().typeString).to.equal('some.thing.here'); + }); + + it('includes the @return tag when it only has a type', () => { + const doc = brsDocParser.parse(` + this is a comment + @return {some.thing.here} + `); + expect(doc.getReturn().description).to.equal(''); + expect(doc.getReturn().typeString).to.equal('some.thing.here'); + }); + + it('allows the @returns (with an s)', () => { + const doc = brsDocParser.parse(` + this is a comment + @returns {some.thing.here} this is a returns + `); + expect(doc.getReturn().description).to.equal('this is a returns'); + expect(doc.getReturn().typeString).to.equal('some.thing.here'); + }); + + + it('finds the type tag', () => { + const doc = brsDocParser.parse(` + @type {integer} + `); + expect(doc.getTypeTag().typeString).to.equal('integer'); + }); + + it('is case INSENSITIVE for param names', () => { + const doc = brsDocParser.parse(` + @param {integer} ALLCAPS + `); + expect(doc.getParam('allcaps').typeString).to.equal('integer'); + }); + + it('is case SENSITIVE for tag names', () => { + const doc = brsDocParser.parse(` + @ALLCAPS + `); + expect(doc.getTag('allcaps')).to.be.undefined; + }); + + describe('nodes', () => { + const parser = new Parser(); + + it('should get documentation from an ast node', () => { + let { ast } = parser.parse(` + ' this is a comment + sub foo() + end sub + `); + + const doc = brsDocParser.parseNode(ast.statements[0]); + expect(doc.description).to.equal('this is a comment'); + }); + + it('should get documentation from a function', () => { + let { ast } = parser.parse(` + ' My description + ' of this function + ' @param p1 this is p1 + ' @param p2 this is p2 + ' @return {integer} sum of p1 and p2 + function foo(p1, p2) + return p1 + p2 + end function + `); + + const doc = brsDocParser.parseNode(ast.statements[0]); + expect(doc.description).to.equal('My description\nof this function'); + expect(doc.getAllTags('param').length).to.equal(2); + expect(doc.getParam('p1').description).to.equal('this is p1'); + expect(doc.getParam('p2').description).to.equal('this is p2'); + expect(doc.getReturn().description).to.equal('sum of p1 and p2'); + expect(doc.getReturn().typeString).to.equal('integer'); + }); + + it('should get documentation when it is wrapped in jsdoc /** */', () => { + let { ast } = parser.parse(` + ' /** + ' * My description + ' * of this function + ' * @param p1 this is p1 + ' * @param p2 this is p2 + ' * @return {integer} sum of p1 and p2 + ' */ + function foo(p1, p2) + return p1 + p2 + end function + `); + + const doc = brsDocParser.parseNode(ast.statements[0]); + expect(doc.description).to.equal('My description\nof this function'); + expect(doc.getAllTags('param').length).to.equal(2); + expect(doc.getParam('p1').description).to.equal('this is p1'); + expect(doc.getParam('p2').description).to.equal('this is p2'); + expect(doc.getReturn().description).to.equal('sum of p1 and p2'); + expect(doc.getReturn().typeString).to.equal('integer'); + }); + + it('should get types from the context of the node with teh documentation', () => { + let { ast } = parser.parse(` + ' @param {List} p1 this is p1 + ' @param {integer} p2 this is p2 + ' @return {integer} sum of p1 and p2 + function foo(p1, p2) + return p1.next.value + p2 + end function + + class List + next as Bar + value as integer + end class + `); + + const doc = brsDocParser.parseNode(ast.statements[0]); + expect(doc.getAllTags('param').length).to.equal(2); + expect(isReferenceType(doc.getParamBscType('p1', { flags: SymbolTypeFlag.typetime }))).to.be.true; + }); + + }); + + describe('Types', () => { + const parser = new Parser(); + + it('should get a type expression from a tag with a type', () => { + const doc = brsDocParser.parse(` + @type {integer} + `); + expect(doc.getTypeTag().typeExpression).to.be.ok; + }); + + it('should get a BscType from a tag with a type', () => { + const doc = brsDocParser.parse(` + @param {integer} test + `); + expectTypeToBe(doc.getParamBscType('test', { flags: SymbolTypeFlag.typetime }), IntegerType); + }); + + it('should get a union type from a tag with a types with "or"', () => { + const doc = brsDocParser.parse(` + @param {integer or String} test + `); + expectTypeToBe(doc.getParamBscType('test', { flags: SymbolTypeFlag.typetime }), UnionType); + }); + + + it('should get documentation from a function', () => { + let { ast } = parser.parse(` + ' My description + ' of this function + ' @param p1 this is p1 + ' @param p2 this is p2 + ' @return {integer} sum of p1 and p2 + function foo(p1, p2) + return p1 + p2 + end function + `); + + const doc = brsDocParser.parseNode(ast.statements[0]); + expect(doc.description).to.equal('My description\nof this function'); + expect(doc.getAllTags('param').length).to.equal(2); + expect(doc.getParam('p1').description).to.equal('this is p1'); + expect(doc.getParam('p2').description).to.equal('this is p2'); + expect(doc.getReturn().description).to.equal('sum of p1 and p2'); + expect(doc.getReturn().typeString).to.equal('integer'); + }); + + it('should get documentation when it is wrapped in jsdoc /** */', () => { + let { ast } = parser.parse(` + ' /** + ' * My description + ' * of this function + ' * @param p1 this is p1 + ' * @param p2 this is p2 + ' * @return {integer} sum of p1 and p2 + ' */ + function foo(p1, p2) + return p1 + p2 + end function + `); + + const doc = brsDocParser.parseNode(ast.statements[0]); + expect(doc.description).to.equal('My description\nof this function'); + expect(doc.getAllTags('param').length).to.equal(2); + expect(doc.getParam('p1').description).to.equal('this is p1'); + expect(doc.getParam('p2').description).to.equal('this is p2'); + expect(doc.getReturn().description).to.equal('sum of p1 and p2'); + expect(doc.getReturn().typeString).to.equal('integer'); + }); + + }); + + describe('getTypeExpressionFromTypeString', () => { + it('should get the location of the type', () => { + const text = '\' @param {integer} test'; + const commentToken = createToken(TokenKind.Comment, text, util.createLocation(1, 0, 1, text.length)); + const typeLoc = brsDocParser.getTypeLocationFromToken(commentToken); + expect(typeLoc.range.start.character).to.equal(10); + expect(typeLoc.range.end.character).to.equal(17); + }); + + it('should return undefined if no type found', () => { + const texts = [ + '\' @param test', + '\' @type oijp oisjoisj oi', + '\' @param {start curly brace', + '\' @param integer}', + '\' @param }integer{', + '\' @param }integer{ }}' + ]; + for (const text of texts) { + const commentToken = createToken(TokenKind.Comment, text, util.createLocation(1, 0, 1, text.length)); + const typeLoc = brsDocParser.getTypeLocationFromToken(commentToken); + expect(typeLoc).to.be.undefined; + } + + }); + }); +}); diff --git a/src/parser/BrightScriptDocParser.ts b/src/parser/BrightScriptDocParser.ts new file mode 100644 index 000000000..cde2e4843 --- /dev/null +++ b/src/parser/BrightScriptDocParser.ts @@ -0,0 +1,339 @@ +import type { GetSymbolTypeOptions } from '../SymbolTable'; +import util from '../util'; +import type { AstNode, Expression } from './AstNode'; +import type { Location } from 'vscode-languageserver'; +import { Parser } from './Parser'; +import type { ExpressionStatement } from './Statement'; +import { isExpressionStatement } from '../astUtils/reflection'; +import { SymbolTypeFlag } from '../SymbolTypeFlag'; +import type { Token } from '../lexer/Token'; + +const tagRegex = /@(\w+)(?:\s+(.*))?/; +const paramRegex = /(?:{([^}]*)}\s+)?(?:(\[?\w+\]?))\s*(.*)/; +const returnRegex = /(?:{([^}]*)})?\s*(.*)/; +const typeTagRegex = /(?:{([^}]*)})?/; + +export enum BrsDocTagKind { + Description = 'description', + Param = 'param', + Return = 'return', + Type = 'type' +} + +export class BrightScriptDocParser { + + public parseNode(node: AstNode) { + const commentTokens: Token[] = []; + const result = this.parse( + util.getNodeDocumentation(node, { + prettyPrint: false, + commentTokens: commentTokens + }), + commentTokens); + for (const tag of result.tags) { + if ((tag as BrsDocWithType).typeExpression) { + (tag as BrsDocWithType).typeExpression.symbolTable = node.getSymbolTable(); + } + } + return result; + } + + public parse(documentation: string, matchingTokens: Token[] = []) { + const brsDoc = new BrightScriptDoc(documentation); + if (!documentation) { + return brsDoc; + } + const lines = documentation.split('\n'); + const blockLines = [] as { line: string; token?: Token }[]; + const descriptionLines = [] as { line: string; token?: Token }[]; + let lastTag: BrsDocTag; + let haveMatchingTokens = false; + if (lines.length === matchingTokens.length) { + // We locations for each line, so we can add Locations + haveMatchingTokens = true; + } + function augmentLastTagWithBlockLines() { + if (blockLines.length > 0 && lastTag) { + // add to the description or details to previous tag + if (typeof (lastTag as BrsDocWithDescription).description !== 'undefined') { + (lastTag as BrsDocWithDescription).description += '\n' + blockLines.map(obj => obj.line).join('\n'); + (lastTag as BrsDocWithDescription).description = (lastTag as BrsDocWithDescription).description.trim(); + } + if (typeof lastTag.detail !== 'undefined') { + lastTag.detail += '\n' + blockLines.map(obj => obj.line).join('\n'); + lastTag.detail = lastTag.detail.trim(); + } + if (haveMatchingTokens) { + lastTag.location = util.createBoundingLocation(lastTag.location, blockLines[blockLines.length - 1].token.location); + } + } + blockLines.length = 0; + } + for (let line of lines) { + let token = haveMatchingTokens ? matchingTokens.shift() : undefined; + line = line.trim(); + while (line.startsWith('\'')) { + // remove leading apostrophes + line = line.substring(1).trim(); + } + if (!line.startsWith('@')) { + if (lastTag) { + + blockLines.push({ line: line, token: token }); + } else if (descriptionLines.length > 0 || line) { + // add a line to the list if it's not empty + descriptionLines.push({ line: line, token: token }); + } + } else { + augmentLastTagWithBlockLines(); + const newTag = this.parseLine(line, token); + lastTag = newTag; + if (newTag) { + brsDoc.tags.push(newTag); + } + } + } + augmentLastTagWithBlockLines(); + brsDoc.description = descriptionLines.map(obj => obj.line).join('\n').trim(); + return brsDoc; + } + + public getTypeLocationFromToken(token: Token): Location { + if (!token?.location) { + return undefined; + } + const startCurly = token.text.indexOf('{'); + const endCurly = token.text.indexOf('}'); + if (startCurly === -1 || endCurly === -1 || endCurly <= startCurly) { + return undefined; + } + return { + uri: token.location.uri, + range: { + start: { + line: token.location.range.start.line, + character: token.location.range.start.character + startCurly + 1 + }, + end: { + line: token.location.range.start.line, + character: token.location.range.start.character + endCurly + } + } + }; + } + + private parseLine(line: string, token?: Token) { + line = line.trim(); + const match = tagRegex.exec(line); + if (!match) { + return; + } + const tagName = match[1]; + const detail = match[2] ?? ''; + + let result: BrsDocTag = { + tagName: tagName, + detail: detail + }; + + switch (tagName) { + case BrsDocTagKind.Param: + result = this.parseParam(detail); + break; + case BrsDocTagKind.Return: + case 'returns': + result = this.parseReturn(detail); + break; + case BrsDocTagKind.Type: + result = this.parseType(detail); + break; + } + return { + ...result, + token: token, + location: token?.location + }; + } + + private parseParam(detail: string): BrsDocParamTag { + let type = ''; + let description = ''; + let optional = false; + let paramName = ''; + let match = paramRegex.exec(detail); + if (match) { + type = match[1] ?? ''; + paramName = match[2] ?? ''; + description = match[3] ?? ''; + } else { + paramName = detail.trim(); + } + if (paramName) { + optional = paramName.startsWith('[') && paramName.endsWith(']'); + paramName = paramName.replace(/\[|\]/g, '').trim(); + } + return { + tagName: BrsDocTagKind.Param, + name: paramName, + typeString: type, + typeExpression: this.getTypeExpressionFromTypeString(type), + description: description, + optional: optional, + detail: detail + }; + } + + private parseReturn(detail: string): BrsDocWithDescription { + let match = returnRegex.exec(detail); + let type = ''; + let description = ''; + if (match) { + type = match[1] ?? ''; + description = match[2] ?? ''; + } + return { + tagName: BrsDocTagKind.Return, + typeString: type, + typeExpression: this.getTypeExpressionFromTypeString(type), + description: description, + detail: detail + }; + } + + private parseType(detail: string): BrsDocWithType { + let match = typeTagRegex.exec(detail); + let type = ''; + if (match) { + if (match[1]) { + type = match[1] ?? ''; + } + } + return { + tagName: BrsDocTagKind.Type, + typeString: type, + typeExpression: this.getTypeExpressionFromTypeString(type), + detail: detail + }; + } + + private getTypeExpressionFromTypeString(typeString: string) { + if (!typeString) { + return undefined; + } + let result: Expression; + try { + let { ast } = Parser.parse(typeString); + if (isExpressionStatement(ast?.statements?.[0])) { + result = (ast.statements[0] as ExpressionStatement).expression; + } + } catch (e) { + //ignore + } + return result; + } +} + +export class BrightScriptDoc { + + protected _description: string; + + public tags = [] as BrsDocTag[]; + + constructor( + public readonly documentation: string + ) { + } + + set description(value: string) { + this._description = value; + } + + get description() { + const descTag = this.tags.find((tag) => { + return tag.tagName === BrsDocTagKind.Description; + }); + + let result = this._description ?? ''; + if (descTag) { + const descTagDetail = descTag.detail; + result = result ? result + '\n' + descTagDetail : descTagDetail; + } + return result.trim(); + } + + getParam(name: string) { + const lowerName = name.toLowerCase(); + return this.tags.find((tag) => { + return tag.tagName === BrsDocTagKind.Param && (tag as BrsDocParamTag).name.toLowerCase() === lowerName; + }) as BrsDocParamTag; + } + + getReturn() { + return this.tags.find((tag) => { + return tag.tagName === BrsDocTagKind.Return || tag.tagName === 'returns'; + }) as BrsDocWithDescription; + } + + getTypeTag() { + return this.tags.find((tag) => { + return tag.tagName === BrsDocTagKind.Type; + }) as BrsDocWithType; + } + + getTypeTagByName(name: string) { + const lowerName = name.toLowerCase(); + return this.tags.find((tag) => { + return tag.tagName === BrsDocTagKind.Type && (tag as BrsDocParamTag).name.toLowerCase() === lowerName; + }) as BrsDocWithType; + } + + getTag(tagName: string) { + return this.tags.find((tag) => { + return tag.tagName === tagName; + }); + } + + getAllTags(tagName: string) { + return this.tags.filter((tag) => { + return tag.tagName === tagName; + }); + } + + getParamBscType(name: string, options: GetSymbolTypeOptions = { flags: SymbolTypeFlag.typetime }) { + const param = this.getParam(name); + return param?.typeExpression?.getType({ ...options, flags: SymbolTypeFlag.typetime }); + } + + getReturnBscType(options: GetSymbolTypeOptions = { flags: SymbolTypeFlag.typetime }) { + const retTag = this.getReturn(); + return retTag?.typeExpression?.getType({ ...options, flags: SymbolTypeFlag.typetime }); + } + + getTypeTagBscType(options: GetSymbolTypeOptions = { flags: SymbolTypeFlag.typetime }) { + const typeTag = this.getTypeTag(); + return typeTag?.typeExpression?.getType({ ...options, flags: SymbolTypeFlag.typetime }); + } +} + +export interface BrsDocTag { + tagName: string; + detail?: string; + location?: Location; + token?: Token; +} +export interface BrsDocWithType extends BrsDocTag { + typeString?: string; + typeExpression?: Expression; +} + +export interface BrsDocWithDescription extends BrsDocWithType { + description?: string; +} + +export interface BrsDocParamTag extends BrsDocWithDescription { + name: string; + optional?: boolean; +} + +export let brsDocParser = new BrightScriptDocParser(); +export default brsDocParser; diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts index b8ef34bd5..1da1d3106 100644 --- a/src/parser/Expression.ts +++ b/src/parser/Expression.ts @@ -32,6 +32,7 @@ import { TypedFunctionType } from '../types'; import { SymbolTypeFlag } from '../SymbolTypeFlag'; import { FunctionType } from '../types/FunctionType'; import type { BaseFunctionType } from '../types/BaseFunctionType'; +import { brsDocParser } from './BrightScriptDocParser'; export type ExpressionVisitor = (expression: Expression, parent: Expression) => void; @@ -385,7 +386,16 @@ export class FunctionExpression extends Expression implements TypedefProvider { public getType(options: GetTypeOptions): TypedFunctionType { //if there's a defined return type, use that - let returnType = this.returnTypeExpression?.getType({ ...options, typeChain: undefined }); + let returnType: BscType; + + const docs = brsDocParser.parseNode(this.findAncestor(isFunctionStatement)); + + returnType = util.chooseTypeFromCodeOrDocComment( + this.returnTypeExpression?.getType({ ...options, typeChain: undefined }), + docs.getReturnBscType({ ...options, tableProvider: () => this.getSymbolTable() }), + options + ); + const isSub = this.tokens.functionType?.kind === TokenKind.Sub; //if we don't have a return type and this is a sub, set the return type to `void`. else use `dynamic` if (!returnType) { @@ -446,10 +456,15 @@ export class FunctionParameterExpression extends Expression { public readonly typeExpression?: TypeExpression; public getType(options: GetTypeOptions) { - const paramType = this.typeExpression?.getType({ ...options, flags: SymbolTypeFlag.typetime, typeChain: undefined }) ?? - this.defaultValue?.getType({ ...options, flags: SymbolTypeFlag.runtime, typeChain: undefined }) ?? - DynamicType.instance; - options.typeChain?.push(new TypeChainEntry({ name: this.tokens.name.text, type: paramType, data: options.data, astNode: this })); + const docs = brsDocParser.parseNode(this.findAncestor(isFunctionStatement)); + const paramName = this.tokens.name.text; + + const paramTypeFromCode = this.typeExpression?.getType({ ...options, flags: SymbolTypeFlag.typetime, typeChain: undefined }) ?? + this.defaultValue?.getType({ ...options, flags: SymbolTypeFlag.runtime, typeChain: undefined }); + const paramTypeFromDoc = docs.getParamBscType(paramName, { ...options, fullName: paramName, tableProvider: () => this.getSymbolTable() }); + + let paramType = util.chooseTypeFromCodeOrDocComment(paramTypeFromCode, paramTypeFromDoc, options) ?? DynamicType.instance; + options.typeChain?.push(new TypeChainEntry({ name: paramName, type: paramType, data: options.data, astNode: this })); return paramType; } diff --git a/src/parser/Parser.spec.ts b/src/parser/Parser.spec.ts index f75c915e7..12e8f4ee6 100644 --- a/src/parser/Parser.spec.ts +++ b/src/parser/Parser.spec.ts @@ -567,6 +567,23 @@ describe('parser', () => { expect(parser.diagnostics[0]?.message).to.exist; expect(parser.ast.statements[0]).to.be.instanceof(FunctionStatement); }); + + it('adds binary expressions to the ast', () => { + let { tokens } = Lexer.scan(` + function a(x, y) + 1 or 2 + x * y + x + y + x - y + end function + `); + let { ast, diagnostics } = Parser.parse(tokens) as any; + expectDiagnosticsIncludes(diagnostics, DiagnosticMessages.expectedStatementOrFunctionCallButReceivedExpression()); + for (const stmt of ast.statements[0].func.body.statements) { + expect(isExpressionStatement(stmt)).to.be.true; + expect(isBinaryExpression((stmt).expression)).to.be.true; + } + }); }); describe('comments', () => { diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index f657fca71..e25ede199 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -14,7 +14,8 @@ import { TokenKind, BlockTerminators, ReservedWords, - CompoundAssignmentOperators + CompoundAssignmentOperators, + BinaryExpressionOperatorTokens } from '../lexer/TokenKind'; import type { PrintSeparatorSpace, @@ -2372,6 +2373,10 @@ export class Parser { return new ExpressionStatement({ expression: expr }); } + if (this.checkAny(...BinaryExpressionOperatorTokens)) { + expr = new BinaryExpression({ left: expr, operator: this.advance(), right: this.expression() }); + } + //at this point, it's probably an error. However, we recover a little more gracefully by creating an inclosing ExpressionStatement this.diagnostics.push({ ...DiagnosticMessages.expectedStatementOrFunctionCallButReceivedExpression(), diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts index c828974e5..12a7c0c90 100644 --- a/src/parser/Statement.ts +++ b/src/parser/Statement.ts @@ -26,6 +26,7 @@ import { VoidType } from '../types/VoidType'; import { TypedFunctionType } from '../types/TypedFunctionType'; import { ArrayType } from '../types/ArrayType'; import { SymbolTypeFlag } from '../SymbolTypeFlag'; +import brsDocParser from './BrightScriptDocParser'; export class EmptyStatement extends Statement { constructor(options?: { range?: Location } @@ -176,7 +177,10 @@ export class AssignmentStatement extends Statement { } getType(options: GetTypeOptions) { - const variableType = this.typeExpression?.getType({ ...options, typeChain: undefined }) ?? this.value.getType({ ...options, typeChain: undefined }); + const variableTypeFromCode = this.typeExpression?.getType({ ...options, typeChain: undefined }); + const docs = brsDocParser.parseNode(this); + const variableTypeFromDocs = docs?.getTypeTagBscType(options); + const variableType = util.chooseTypeFromCodeOrDocComment(variableTypeFromCode, variableTypeFromDocs, options) ?? this.value.getType({ ...options, typeChain: undefined }); // Note: compound assignments (eg. +=) are internally dealt with via the RHS being a BinaryExpression // so this.value will be a BinaryExpression, and BinaryExpressions can figure out their own types diff --git a/src/util.ts b/src/util.ts index 2d9dfd97e..0120836a2 100644 --- a/src/util.ts +++ b/src/util.ts @@ -644,11 +644,17 @@ export class Util { /** * Combine all the documentation for a node - uses the AstNode's leadingTrivia property + * @param node the node to get the documentation for + * @param options extra options + * @param options.prettyPrint if true, will format the comment text for markdown + * @param options.commentTokens out Array of tokens that match the comment lines */ - public getNodeDocumentation(node: AstNode) { + public getNodeDocumentation(node: AstNode, options: { prettyPrint?: boolean; commentTokens?: Token[] } = { prettyPrint: true }) { if (!node) { return ''; } + options = options ?? { prettyPrint: true }; + options.commentTokens = options.commentTokens ?? []; const nodeTrivia = node.leadingTrivia ?? []; const leadingTrivia = isStatement(node) ? [...(node.annotations?.map(anno => anno.leadingTrivia ?? []).flat() ?? []), ...nodeTrivia] @@ -678,36 +684,39 @@ export class Util { } const jsDocCommentBlockLine = /(\/\*{2,}|\*{1,}\/)/i; let usesjsDocCommentBlock = false; - if (comments.length > 0) { - return comments.reverse() - .map(x => x.text.replace(/^('|rem)/i, '').trim()) - .filter(line => { - if (jsDocCommentBlockLine.exec(line)) { - usesjsDocCommentBlock = true; - return false; - } - return true; - }).map(line => { - if (usesjsDocCommentBlock) { - if (line.startsWith('*')) { - //remove jsDoc leading '*' - line = line.slice(1).trim(); - } + if (comments.length === 0) { + return ''; + } + return comments.reverse() + .map(x => ({ line: x.text.replace(/^('|rem)/i, '').trim(), token: x })) + .filter(({ line }) => { + if (jsDocCommentBlockLine.exec(line)) { + usesjsDocCommentBlock = true; + return false; + } + return true; + }).map(({ line, token }) => { + if (usesjsDocCommentBlock) { + if (line.startsWith('*')) { + //remove jsDoc leading '*' + line = line.slice(1).trim(); } - if (line.startsWith('@')) { - // Handle jsdoc/brightscriptdoc tags specially - // make sure they are on their own markdown line, and add italics - const firstSpaceIndex = line.indexOf(' '); - if (firstSpaceIndex === -1) { - return `\n_${line}_`; - } - const firstWord = line.substring(0, firstSpaceIndex); - return `\n_${firstWord}_ ${line.substring(firstSpaceIndex + 1)}`; + } + if (options.prettyPrint && line.startsWith('@')) { + // Handle jsdoc/brightscriptdoc tags specially + // make sure they are on their own markdown line, and add italics + const firstSpaceIndex = line.indexOf(' '); + if (firstSpaceIndex === -1) { + return `\n_${line}_`; } - return line; - }).join('\n'); - } - return ''; + const firstWord = line.substring(0, firstSpaceIndex); + return `\n_${firstWord}_ ${line.substring(firstSpaceIndex + 1)}`; + } + if (options.commentTokens) { + options.commentTokens.push(token); + } + return line; + }).join('\n'); } /** @@ -2404,6 +2413,25 @@ export class Util { return false; } + + public chooseTypeFromCodeOrDocComment(codeType: BscType, docType: BscType, options: GetTypeOptions) { + let returnType: BscType; + if (options.preferDocType && docType) { + returnType = docType; + if (options.data) { + options.data.isFromDocComment = true; + } + } else { + returnType = codeType; + if (!returnType && docType) { + returnType = docType; + if (options.data) { + options.data.isFromDocComment = true; + } + } + } + return returnType; + } } /**