diff --git a/docs/annotations.md b/docs/annotations.md index 8ae916254..831dd131b 100644 --- a/docs/annotations.md +++ b/docs/annotations.md @@ -11,7 +11,7 @@ A statement can have multiple annotations. The name of the annotation should be a valid identifier and can not be a keyword (e.g. `for`, `while`, `else`...). -Annotations can have parameters - these parameters should be a list of valid BrighterScript expressions separated by commas. +Annotations can have parameters - these parameters should be a list of valid BrighterScript literal expressions separated by commas. ``` @[(parameters)] @@ -21,6 +21,18 @@ Annotations can have parameters - these parameters should be a list of valid Bri @[(parameters)] [more annotations] ``` +## Annotation Arguments + +Annotations can only take literal values as arguments. That includes literal strings, numbers, arrays with literal values and associative arrays with literal values. + +Examples: + + - literal numbers: `123`, `3.14`, `0`, `&HFF` + - literal strings: `"hello"`, `""`, `"any string with quotes"` + - literal arrays: `[1, 2, 3]`, `["array", "of", "strings"]`, `[1, {letter: "A"}, "mixed"]` + - literal associative arrays: `{key: "value"}`, `{translation: [200, 300], fields: {title: "Star Wars", description: "A long time ago in a galaxy far, far away..."}}` + + ## Examples ```brighterscript diff --git a/docs/plugins.md b/docs/plugins.md index 9a2e37ef3..30976bb86 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -50,6 +50,7 @@ While there are no restrictions on plugin names, it helps others to find your pl Full compiler lifecycle: +- `onPluginConfigure` - `beforeProgramCreate` - `afterProgramCreate` - `afterScopeCreate` ("source" scope) @@ -75,13 +76,13 @@ Full compiler lifecycle: - `afterProgramValidate` - `beforePrepublish` - `afterPrepublish` -- `beforePublish` - - `beforeProgramTranspile` +- `beforeSerializeProgram` + - `beforeBuildProgram` - For each file: - - `beforeFileTranspile` - - `afterFileTranspile` - - `afterProgramTranspile` -- `afterPublish` + - `beforePrepareFile` + - `afterPrepareFile` + - `afterBuildProgram` +- `afterSerializeProgram` - `beforeProgramDispose` ### Language server @@ -90,15 +91,15 @@ Once the program has been validated, the language server runs a special loop - i When a file is removed: -- `beforeFileDispose` +- `beforeFileRemove` - `beforeScopeDispose` (component scope) - `afterScopeDispose` (component scope) -- `afterFileDispose` +- `afterFileRemove` When a file is added: -- `beforeFileParse` -- `afterFileParse` +- `beforeProvideFile` +- `afterProvideFile` - `afterScopeCreate` (component scope) - `afterFileValidate` @@ -157,10 +158,21 @@ The top level object is the `ProgramBuilder` which runs the overall process: pre Here are some important interfaces. You can view them in the code at [this link](https://github.com/rokucommunity/brighterscript/blob/ddcb7b2cd219bd9fecec93d52fbbe7f9b972816b/src/interfaces.ts#L190:~:text=export%20interface%20CompilerPlugin%20%7B). ```typescript -export type CompilerPluginFactory = () => CompilierPlugin; +export type CompilerPluginFactory = () => CompilerPlugin; export interface CompilerPlugin { name: string; + + /** + * A list of function declarations of allowed annotations + */ + annotations?: Array; + + /** + * Called when plugin is initially loaded + */ + onPluginConfigure?(event: OnPluginConfigureEvent): any; + /** * Called before a new program is created */ @@ -240,7 +252,6 @@ export interface CompilerPlugin { afterScopeDispose?(event: AfterScopeDisposeEvent): any; beforeScopeValidate?(event: BeforeScopeValidateEvent): any; - /** * Called before the `provideDefinition` hook */ @@ -256,7 +267,6 @@ export interface CompilerPlugin { */ afterProvideDefinition?(event: AfterProvideDefinitionEvent): any; - /** * Called before the `provideReferences` hook */ @@ -304,8 +314,6 @@ export interface CompilerPlugin { */ afterProvideWorkspaceSymbols?(event: AfterProvideWorkspaceSymbolsEvent): any; - - onGetSemanticTokens?: PluginHandler; //scope events onScopeValidate?(event: OnScopeValidateEvent): any; afterScopeValidate?(event: BeforeScopeValidateEvent): any; @@ -554,7 +562,7 @@ export default function () { ## Modifying code Sometimes plugins will want to modify code before the project is transpiled. While you can technically edit the AST directly at any point in the file's lifecycle, this is not recommended as those changes will remain changed as long as that file exists in memory and could cause issues with file validation if the plugin is used in a language-server context (i.e. inside vscode). -Instead, we provide an instace of an `Editor` class in the `beforeFileTranspile` event that allows you to modify AST before the file is transpiled, and then those modifications are undone `afterFileTranspile`. +Instead, we provide an instance of an `Editor` class in the `beforeBuildProgram` and `beforePrepareFile` events that allows you to modify AST before the file is transpiled, and then those modifications are undone after the `afterBuildProgram` event. For example, consider the following brightscript code: ```brightscript @@ -566,14 +574,14 @@ end sub Here's the plugin: ```typescript -import { CompilerPlugin, BeforeFileTranspileEvent, isBrsFile, WalkMode, createVisitor, TokenKind } from 'brighterscript'; +import { CompilerPlugin, BeforePrepareFileEvent, isBrsFile, WalkMode, createVisitor, TokenKind } from 'brighterscript'; // plugin factory export default function () { return { name: 'replacePlaceholders', // transform AST before transpilation - beforeFileTranspile: (event: BeforeFileTranspileEvent) => { + beforePrepareFile: (event: BeforePrepareFileEvent) => { if (isBrsFile(event.file)) { event.file.ast.walk(createVisitor({ LiteralExpression: (literal) => { @@ -600,12 +608,12 @@ Another common use case is to remove print statements and comments. Here's a plu Note: Comments are not regular nodes in the AST. They're considered "trivia". To access them, you need to ask each AstNode for its trivia. to help with this, we've included the `AstNode` visitor method. Here's how you'd do that: ```typescript -import { isBrsFile, createVisitor, WalkMode, BeforeFileTranspileEvent, CompilerPlugin } from 'brighterscript'; +import { isBrsFile, createVisitor, WalkMode, BeforePrepareFileEvent, CompilerPlugin } from 'brighterscript'; export default function plugin() { return { name: 'removeCommentAndPrintStatements', - beforeFileTranspile: (event: BeforeFileTranspileEvent) => { + beforePrepareFile: (event: BeforePrepareFileEvent) => { if (isBrsFile(event.file)) { // visit functions bodies event.file.ast.walk(createVisitor({ @@ -632,6 +640,78 @@ export default function plugin() { } ``` +## Providing Annotations via a plugin + +Plugins may provide [annotations](annotations.md) that can be used to add metadata to any statement in the code. + +Plugins must declare the annotations they support, so they can be validated properly. To declare an annotation, it must be listed in the `annotations` property - a list of instances of `TypedFunctionType`, or, if you want to include a description as well, use `AnnotationDeclaration`. + +For example: + +```typescript + this.annotations = [ + new TypedFunctionType(VoidType.instance).setName('inline'), + { + description: 'Add a log message whenever this function is called', + type: new TypedFunctionType(VoidType.instance) + .setName('log') + .addParameter('prefix', StringType.instance) + .addParameter('addLineNumbers', BooleanType.instance, true) + } + ]; +``` + +Annotations that do not require any arguments are listed as functions with no parameters. Annotations that require arguments may have their parameters types listed as well. + +Here's an example plugin that provides the `log` annotation above: + +```typescript +import { isBrsFile, createVisitor, WalkMode, BeforePrepareFileEvent, CompilerPlugin, FunctionStatement, PrintStatement, createStringLiteral, VariableExpression, createToken, TokenKind, Identifier } from 'brighterscript'; + +export default function plugin() { + return { + name: 'addLogging', + annotations: [{ + description: ` + Add a log message whenever this function is called + @param {string} prefix Words that appear before the regular log message + @param {boolean} [addLineNumbers=false] optional param to include line numbers + ` + type: new TypedFunctionType(VoidType.instance) + .setName('log') + .addParameter('prefix', StringType.instance) + .addParameter('addLineNumbers', BooleanType.instance, true) + }], + beforePrepareFile: (event: BeforePrepareFileEvent) => { + if (isBrsFile(event.file)) { + event.file.ast.walk(createVisitor({ + FunctionStatement: (funcStmt: FunctionStatement, _parent, owner, key) => { + const logAnnotation = funcStmt.annotations?.find(anno => anno.name === 'log'); + if (logAnnotation) { + const args = logAnnotation.getArguments(); + const logPrintStmt = new PrintStatement({ + print: createToken(TokenKind.Print), + expressions:[ + createStringLiteral(args[0].toString()), // prefix, + createStringLiteral(funcStmt.tokens.name.text) // function name + ] + }); + if(args[1]) { // add line num + logPrintStmt.expressions.unshift(new VariableExpression({ name: createToken(TokenKind.SourceLineNumLiteral) as Identifier })) + } + event.editor.arrayUnshift(funcStmt.func.body.statements, logPrintStmt) + } + } + }), { + walkMode: WalkMode.visitStatements + }); + } + } + } as CompilerPlugin; +} +``` + + ## Modifying `bsconfig.json` via a plugin In some cases you may want to modify the project's configuration via a plugin, such as to change settings based on environment variables or to dynamically modify the project's `files` array. Plugins may do so in the `beforeProgramCreate` step. For example, here's a plugin which adds an additional file to the build: diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 7ed5b0f84..f39e135cb 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -1042,6 +1042,19 @@ export let DiagnosticMessages = { }, severity: DiagnosticSeverity.Error, code: 'cannot-find-callfunc' + }), + cannotFindAnnotation: (name: string) => ({ + message: `Cannot find annotation '${name}' `, + data: { + name: name + }, + severity: DiagnosticSeverity.Error, + code: 'cannot-find-annotation' + }), + expectedLiteralValue: (context: string, value: string) => ({ + message: `Expected literal value ${context}, but found '${value}' `, + severity: DiagnosticSeverity.Error, + code: 'expected-literal-value' }) }; export const defaultMaximumTruncationLength = 160; diff --git a/src/PluginInterface.ts b/src/PluginInterface.ts index f9cddf326..b6a6bb4a9 100644 --- a/src/PluginInterface.ts +++ b/src/PluginInterface.ts @@ -1,5 +1,6 @@ -import type { CompilerPlugin } from './interfaces'; +import type { AnnotationDeclaration, CompilerPlugin } from './interfaces'; import { LogLevel, createLogger, type Logger } from './logging'; +import type { TypedFunctionType } from './types/TypedFunctionType'; /* * we use `Required` everywhere here because we expect that the methods on plugin objects will * be optional, and we don't want to deal with `undefined`. @@ -75,7 +76,7 @@ export default class PluginInterface /** * Call `event` on plugins, but allow the plugins to return promises that will be awaited before the next plugin is notified */ - public async emitAsync & string>(event: K, ...args: PluginEventArgs[K]): Promise< PluginEventArgs[K][0]> { + public async emitAsync & string>(event: K, ...args: PluginEventArgs[K]): Promise[K][0]> { this.logger.debug(`Emitting async plugin event: ${event}`); for (let plugin of this.plugins) { if ((plugin as any)[event]) { @@ -171,4 +172,20 @@ export default class PluginInterface public clear() { this.plugins = []; } + + + private annotationMap: Map>; + + public getAnnotationMap() { + if (this.annotationMap) { + return this.annotationMap; + } + this.annotationMap = new Map>(); + for (let plugin of this.plugins) { + if (plugin.annotations?.length > 0) { + this.annotationMap.set(plugin.name, plugin.annotations); + } + } + return this.annotationMap; + } } diff --git a/src/Program.ts b/src/Program.ts index 6b1ab04c1..78fce6a60 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -16,7 +16,7 @@ import { globalCallables, globalFile } from './globalCallables'; import { parseManifest, getBsConst } from './preprocessor/Manifest'; import { URI } from 'vscode-uri'; import PluginInterface from './PluginInterface'; -import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement, isReferenceType } from './astUtils/reflection'; +import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement, isReferenceType, isTypedFunctionType, isAnnotationDeclaration } from './astUtils/reflection'; import type { FunctionStatement, MethodStatement, NamespaceStatement } from './parser/Statement'; import { BscPlugin } from './bscPlugin/BscPlugin'; import { Editor } from './astUtils/Editor'; @@ -55,7 +55,8 @@ import { ProgramValidatorDiagnosticsTag } from './bscPlugin/validation/ProgramVa import type { ProvidedSymbolInfo, BrsFile } from './files/BrsFile'; import type { XmlFile } from './files/XmlFile'; import { SymbolTable } from './SymbolTable'; -import { ReferenceType } from './types'; +import type { TypedFunctionType } from './types/TypedFunctionType'; +import { ReferenceType } from './types/ReferenceType'; const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`; const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`; @@ -126,6 +127,9 @@ export class Program { //TODO we might need to fix this because the isValidated clears stuff now (this.globalScope as any).isValidated = true; + + // Get declarations for all annotations from all plugins + this.populateAnnotationSymbolTable(); } @@ -235,6 +239,33 @@ export class Program { */ public plugins: PluginInterface; + public pluginAnnotationTable = new SymbolTable('Plugin Annotations', () => this.globalScope?.symbolTable); + + private populateAnnotationSymbolTable() { + for (const [pluginName, annotations] of this.plugins.getAnnotationMap().entries()) { + for (const annotation of annotations) { + if (isTypedFunctionType(annotation) && annotation.name) { + this.addAnnotationSymbol(annotation.name, annotation, { pluginName: pluginName }); + } else if (isAnnotationDeclaration(annotation)) { + const annoType = annotation.type; + let description = (typeof annotation.description === 'string') ? annotation.description : undefined; + this.addAnnotationSymbol(annoType.name, annoType, { pluginName: pluginName, description: description }); + } else if (typeof annotation === 'string') { + // TODO: Do we need to parse this? + } + } + } + } + + public addAnnotationSymbol(name: string, annoType: TypedFunctionType, extraData: ExtraSymbolData = {}) { + if (name && annoType) { + annoType.setName(name); + const pluginName = extraData?.pluginName ?? ''; + this.logger.info(`Adding annotation '${name}' (${pluginName})`); + this.pluginAnnotationTable.addSymbol(name, extraData, annoType, SymbolTypeFlag.annotation); + } + } + private fileSymbolInformation = new Map(); private currentScopeValidationOptions: ScopeValidationOptions; diff --git a/src/ProgramBuilder.spec.ts b/src/ProgramBuilder.spec.ts index 041bdcf02..8e2b307dd 100644 --- a/src/ProgramBuilder.spec.ts +++ b/src/ProgramBuilder.spec.ts @@ -9,12 +9,14 @@ import { LogLevel, createLogger } from './logging'; import * as diagnosticUtils from './diagnosticUtils'; import { DiagnosticSeverity } from 'vscode-languageserver'; import { BrsFile } from './files/BrsFile'; -import { expectZeroDiagnostics } from './testHelpers.spec'; +import { expectTypeToBe, expectZeroDiagnostics } from './testHelpers.spec'; import type { BsConfig } from './BsConfig'; import type { BscFile } from './files/BscFile'; import { tempDir, rootDir, stagingDir } from './testHelpers.spec'; import { Deferred } from './deferred'; -import type { AfterProgramCreateEvent, BsDiagnostic } from './interfaces'; +import type { AfterProgramCreateEvent, BsDiagnostic, CompilerPlugin, ExtraSymbolData } from './interfaces'; +import { StringType, TypedFunctionType, VoidType } from './types'; +import { SymbolTypeFlag } from './SymbolTypeFlag'; describe('ProgramBuilder', () => { @@ -312,6 +314,31 @@ describe('ProgramBuilder', () => { }); + describe('plugins', () => { + it('adds annotations defined in a plugin to the annotation symbol table', async () => { + builder = new ProgramBuilder(); + + const plugin: CompilerPlugin = { + name: 'test', + annotations: [{ + type: new TypedFunctionType(VoidType.instance) + .setName('myAnnotation') + .addParameter('id', StringType.instance, false), + description: 'Extra description' + }] + }; + builder.plugins.add(plugin); + await builder.load({}); + + const extraData: ExtraSymbolData = {}; + const foundAnnotation = builder.program.pluginAnnotationTable.getSymbolType('myAnnotation', { flags: SymbolTypeFlag.annotation, data: extraData }); + + expectTypeToBe(foundAnnotation, TypedFunctionType); + expect(extraData.pluginName).to.eql('test'); + expect(extraData.description).to.eql('Extra description'); + }); + }); + describe('printDiagnostics', () => { it('does not crash when a diagnostic is missing range informtaion', () => { diff --git a/src/ProgramBuilder.ts b/src/ProgramBuilder.ts index 8269d949c..c96ad76cd 100644 --- a/src/ProgramBuilder.ts +++ b/src/ProgramBuilder.ts @@ -163,6 +163,9 @@ export class ProgramBuilder { for (let plugin of plugins) { this.plugins.add(plugin); } + this.plugins.emit('onPluginConfigure', { + builder: this + }); this.plugins.emit('beforeProgramCreate', { builder: this diff --git a/src/SymbolTable.ts b/src/SymbolTable.ts index 7b62623b7..6f70b8117 100644 --- a/src/SymbolTable.ts +++ b/src/SymbolTable.ts @@ -237,6 +237,7 @@ export class SymbolTable implements SymbolTypeGetter { options.data.isFromDocComment = data?.isFromDocComment; options.data.isBuiltIn = data?.isBuiltIn; options.data.isFromCallFunc = data?.isFromCallFunc; + options.data.pluginName = data?.pluginName; } return resolvedType; } diff --git a/src/SymbolTypeFlag.ts b/src/SymbolTypeFlag.ts index cdb962992..a65c05fb5 100644 --- a/src/SymbolTypeFlag.ts +++ b/src/SymbolTypeFlag.ts @@ -6,5 +6,6 @@ export const enum SymbolTypeFlag { private = 8, protected = 16, postTranspile = 32, - deprecated = 64 + deprecated = 64, + annotation = 128 } diff --git a/src/astUtils/reflection.ts b/src/astUtils/reflection.ts index 360121cef..99c8b84f7 100644 --- a/src/astUtils/reflection.ts +++ b/src/astUtils/reflection.ts @@ -2,7 +2,7 @@ import type { Body, AssignmentStatement, Block, ExpressionStatement, ExitStateme import type { LiteralExpression, BinaryExpression, CallExpression, FunctionExpression, DottedGetExpression, XmlAttributeGetExpression, IndexedGetExpression, GroupingExpression, EscapedCharCodeLiteralExpression, ArrayLiteralExpression, AALiteralExpression, UnaryExpression, VariableExpression, SourceLiteralExpression, NewExpression, CallfuncExpression, TemplateStringQuasiExpression, TemplateStringExpression, TaggedTemplateStringExpression, AnnotationExpression, FunctionParameterExpression, AAMemberExpression, TypecastExpression, TypeExpression, TypedArrayExpression, TernaryExpression, NullCoalescingExpression, PrintSeparatorExpression } from '../parser/Expression'; import type { BrsFile } from '../files/BrsFile'; import type { XmlFile } from '../files/XmlFile'; -import type { BsDiagnostic, TypedefProvider } from '../interfaces'; +import type { AnnotationDeclaration, BsDiagnostic, TypedefProvider } from '../interfaces'; import type { InvalidType } from '../types/InvalidType'; import type { VoidType } from '../types/VoidType'; import { InternalWalkMode } from './visitors'; @@ -465,3 +465,10 @@ export function isLiteralDouble(value: any): value is LiteralExpression & { type export function isBsDiagnostic(value: any): value is BsDiagnostic { return value.message; } + + +// Plugins + +export function isAnnotationDeclaration(value: any): value is AnnotationDeclaration { + return isTypedFunctionType(value.type); +} diff --git a/src/bscPlugin/hover/HoverProcessor.ts b/src/bscPlugin/hover/HoverProcessor.ts index 5285bdf7f..b64905816 100644 --- a/src/bscPlugin/hover/HoverProcessor.ts +++ b/src/bscPlugin/hover/HoverProcessor.ts @@ -133,6 +133,9 @@ export class HoverProcessor { return null; } const expression = file.getClosestExpression(this.event.position); + if (!expression) { + return null; + } const hoverContents: string[] = []; for (let scope of this.event.scopes) { try { diff --git a/src/bscPlugin/validation/BrsFileValidator.spec.ts b/src/bscPlugin/validation/BrsFileValidator.spec.ts index abbf6059c..3705da7a5 100644 --- a/src/bscPlugin/validation/BrsFileValidator.spec.ts +++ b/src/bscPlugin/validation/BrsFileValidator.spec.ts @@ -17,6 +17,7 @@ import { StringType } from '../../types/StringType'; import { ArrayType } from '../../types/ArrayType'; import { DynamicType } from '../../types/DynamicType'; import { TypedFunctionType } from '../../types/TypedFunctionType'; +import { VoidType } from '../../types/VoidType'; import { ParseMode } from '../../parser/Parser'; import type { ExtraSymbolData } from '../../interfaces'; import { AssociativeArrayType } from '../../types/AssociativeArrayType'; @@ -1412,4 +1413,201 @@ describe('BrsFileValidator', () => { expectDiagnostics(program, []); }); }); + + describe('annotations', () => { + it('validates when unknown annotation is used', () => { + program.setFile('source/main.bs', ` + @unknownAnnotation + sub someFunc() + print "hello" + end sub + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.cannotFindAnnotation('unknownAnnotation') + ]); + }); + + it('allows known annotations', () => { + program.addAnnotationSymbol('knownAnnotation', new TypedFunctionType(VoidType.instance)); + + program.setFile('source/main.bs', ` + @knownAnnotation + sub someFunc() + print "hello" + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + + it('checks annotation function arg count', () => { + program.addAnnotationSymbol('takesOneOrTwo', + new TypedFunctionType(VoidType.instance) + .setName('takesOneOrTwo') + .addParameter('x', DynamicType.instance) + .addParameter('y', DynamicType.instance, true), + { pluginName: 'Test' }); + + program.setFile('source/main.bs', ` + @takesOneOrTwo + sub someFunc() + print "hello" + end sub + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.mismatchArgumentCount('1-2', 0) + ]); + }); + + it('allows valid arg counts', () => { + program.addAnnotationSymbol('takesOneOrTwo', + new TypedFunctionType(VoidType.instance) + .setName('takesOneOrTwo') + .addParameter('x', DynamicType.instance) + .addParameter('y', DynamicType.instance, true)); + + program.setFile('source/main.bs', ` + @takesOneOrTwo(1) + sub someFunc() + end sub + + @takesOneOrTwo(1, "test") + sub otherFunc() + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + + it('allows valid arg counts for variadic functions', () => { + program.addAnnotationSymbol('takesOneOrMore', + new TypedFunctionType(VoidType.instance) + .setName('takesOneOrMore') + .addParameter('x', DynamicType.instance) + .setVariadic(true)); + + program.setFile('source/main.bs', ` + @takesOneOrMore(1) + sub someFunc() + end sub + + @takesOneOrMore(1, "test", {test: 1}, [1,2,3], "more", "args") + sub otherFunc() + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + + it('ignores when multiple annotations of same name are added', () => { + program.addAnnotationSymbol('usedTwice', + new TypedFunctionType(VoidType.instance) + .setName('usedTwice') + .addParameter('x', IntegerType.instance)); + + program.addAnnotationSymbol('usedTwice', + new TypedFunctionType(VoidType.instance) + .setName('usedTwice')); + + program.setFile('source/main.bs', ` + @usedTwice(1) + sub someFunc() + end sub + + @usedTwice + sub otherFunc() + end sub + + @usedTwice("TODO: this shouldn't be accepted", "Because there is no matching annotation") + sub yetAnotherFunc() + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + + it('validates arg types', () => { + program.addAnnotationSymbol('annoStringInt', + new TypedFunctionType(VoidType.instance) + .addParameter('str', StringType.instance) + .addParameter('int', IntegerType.instance)); + + program.setFile('source/main.bs', ` + @annoStringInt(3.14, "test") + sub someFunc() + end sub + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.argumentTypeMismatch('float', 'string').message, + DiagnosticMessages.argumentTypeMismatch('string', 'integer').message + ]); + }); + + + it('allows valid arg types', () => { + program.addAnnotationSymbol('annoStringInt', + new TypedFunctionType(VoidType.instance) + .addParameter('str', StringType.instance) + .addParameter('int', IntegerType.instance)); + + program.setFile('source/main.bs', ` + @annoStringInt("test", 123) + sub someFunc() + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + + it('validates uninitialized values', () => { + program.addAnnotationSymbol('annoString', + new TypedFunctionType(VoidType.instance) + .addParameter('str', StringType.instance)); + + program.setFile('source/main.bs', ` + @annoString(someVar) + sub someFunc() + end sub + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.expectedLiteralValue('in annotation argument', 'someVar').message + ]); + }); + + it('validates uninitialized values', () => { + program.addAnnotationSymbol('annoString', + new TypedFunctionType(VoidType.instance) + .addParameter('str', StringType.instance)); + + program.setFile('source/main.bs', ` + + const myConst = "hello" + @annoString(myConst) + sub someFunc() + end sub + `); + program.validate(); + expectDiagnostics(program, [ + DiagnosticMessages.expectedLiteralValue('in annotation argument', 'myConst').message + ]); + }); + + it('allows associative arrays', () => { + program.addAnnotationSymbol('annoAA', + new TypedFunctionType(VoidType.instance) + .addParameter('aa', new AssociativeArrayType())); + + program.setFile('source/main.bs', ` + @annoAA({name: "John Doe", age: 25, ids: [1, 2, 3, 4]}) + sub someFunc() + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + }); }); diff --git a/src/bscPlugin/validation/BrsFileValidator.ts b/src/bscPlugin/validation/BrsFileValidator.ts index 8a6b98845..6088791f3 100644 --- a/src/bscPlugin/validation/BrsFileValidator.ts +++ b/src/bscPlugin/validation/BrsFileValidator.ts @@ -1,11 +1,12 @@ -import { isAliasStatement, isArrayType, isBlock, isBody, isClassStatement, isConditionalCompileConstStatement, isConditionalCompileErrorStatement, isConditionalCompileStatement, isConstStatement, isDottedGetExpression, isDottedSetStatement, isEnumStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isImportStatement, isIndexedGetExpression, isIndexedSetStatement, isInterfaceStatement, isInvalidType, isLibraryStatement, isLiteralExpression, isMethodStatement, isNamespaceStatement, isTypecastExpression, isTypecastStatement, isUnaryExpression, isVariableExpression, isVoidType, isWhileStatement } from '../../astUtils/reflection'; +import { isAliasStatement, isArrayType, isBlock, isBody, isCallableType, isClassStatement, isClassType, isConditionalCompileConstStatement, isConditionalCompileErrorStatement, isConditionalCompileStatement, isConstStatement, isDottedGetExpression, isDottedSetStatement, isEnumStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isImportStatement, isIndexedGetExpression, isIndexedSetStatement, isInterfaceStatement, isInvalidType, isLibraryStatement, isLiteralExpression, isMethodStatement, isNamespaceStatement, isStatement, isTypecastExpression, isTypecastStatement, isTypedFunctionType, isUnaryExpression, isVariableExpression, isVoidType, isWhileStatement } from '../../astUtils/reflection'; import { createVisitor, WalkMode } from '../../astUtils/visitors'; import { DiagnosticMessages } from '../../DiagnosticMessages'; import type { BrsFile } from '../../files/BrsFile'; -import type { ExtraSymbolData, OnFileValidateEvent } from '../../interfaces'; +import type { ExtraSymbolData, OnFileValidateEvent, TypeCompatibilityData } from '../../interfaces'; import { TokenKind } from '../../lexer/TokenKind'; import type { AstNode, Expression, Statement } from '../../parser/AstNode'; -import { CallExpression, type FunctionExpression, type LiteralExpression } from '../../parser/Expression'; +import type { FunctionExpression, LiteralExpression } from '../../parser/Expression'; +import { CallExpression } from '../../parser/Expression'; import { ParseMode } from '../../parser/Parser'; import type { ContinueStatement, EnumMemberStatement, EnumStatement, ForEachStatement, ForStatement, ImportStatement, LibraryStatement, Body, WhileStatement, TypecastStatement, Block, AliasStatement } from '../../parser/Statement'; import { SymbolTypeFlag } from '../../SymbolTypeFlag'; @@ -17,6 +18,7 @@ import type { Range } from 'vscode-languageserver'; import type { Token } from '../../lexer/Token'; import type { BrightScriptDoc } from '../../parser/BrightScriptDocParser'; import brsDocParser from '../../parser/BrightScriptDocParser'; +import { UninitializedType } from '../../types'; export class BrsFileValidator { constructor( @@ -278,23 +280,10 @@ export class BrsFileValidator { }, 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); + if (isStatement(node)) { + this.validateAnnotations(node); } + this.handleDocTags(node); } }); @@ -305,6 +294,27 @@ export class BrsFileValidator { }); } + + private handleDocTags(node: AstNode) { + //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); + } + } + private processDocTagsInFunction(doc: BrightScriptDoc, node: AstNode, funcExpr: FunctionExpression) { //TODO: Handle doc tags that influence the function they're in @@ -664,4 +674,75 @@ export class BrsFileValidator { } } } + + private validateAnnotations(statement: Statement) { + if (!statement.annotations || statement.annotations.length < 1) { + return; + } + + const symbolTable = this.event.program.pluginAnnotationTable; + const extraData: ExtraSymbolData = {}; + + for (const annotation of statement.annotations) { + const annotationType = symbolTable.getSymbolType(annotation.name, { flags: SymbolTypeFlag.annotation, data: extraData }); + + if (!annotationType || !annotationType?.isResolvable()) { + this.event.program.diagnostics.register({ + ...DiagnosticMessages.cannotFindAnnotation(annotation.name), + location: brsDocParser.getTypeLocationFromToken(annotation.tokens.name) ?? annotation.location + }); + continue; + } + if (!isTypedFunctionType(annotationType)) { + // TODO: handle multiple function definitions - in that case this would be a UnionType + continue; + } + const { minParams, maxParams } = annotationType.getMinMaxParamCount(); + let expCallArgCount = annotation.call?.args.length ?? 0; + if (expCallArgCount > maxParams || expCallArgCount < minParams) { + let minMaxParamsText = minParams === maxParams ? maxParams : `${minParams}-${maxParams}`; + this.event.program.diagnostics.register({ + ...DiagnosticMessages.mismatchArgumentCount(minMaxParamsText, expCallArgCount), + location: annotation.location + }); + } + + // validate the arg types - very similar to code in ScopeValidator + let paramIndex = 0; + for (let arg of annotation.call?.args ?? []) { + const data = {} as ExtraSymbolData; + let argType = arg.getType({ flags: SymbolTypeFlag.runtime, data: data, onlyAllowLiterals: true }); + + if (!argType || !argType.isResolvable()) { + this.event.program.diagnostics.register({ + ...DiagnosticMessages.expectedLiteralValue('in annotation argument', util.getAllDottedGetPartsAsString(arg)), + location: arg.location + }); + break; + } + let paramType = annotationType.params[paramIndex]?.type; + if (!paramType) { + // unable to find a paramType -- maybe there are more args than params + break; + } + + if (isCallableType(paramType) && isClassType(argType) && isClassStatement(data.definingNode)) { + argType = data.definingNode?.getConstructorType(); + } + + const compatibilityData: TypeCompatibilityData = {}; + if (!argType || !argType.isResolvable() || !paramType?.isTypeCompatible(argType, compatibilityData)) { + + const argTypeStr = argType?.toString() ?? UninitializedType.instance.toString(); + + this.event.program.diagnostics.register({ + ...DiagnosticMessages.argumentTypeMismatch(argTypeStr, paramType.toString(), compatibilityData), + location: arg.location + }); + } + paramIndex++; + } + } + } + } diff --git a/src/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts index 35731f768..673d8d0db 100644 --- a/src/bscPlugin/validation/ScopeValidator.ts +++ b/src/bscPlugin/validation/ScopeValidator.ts @@ -13,8 +13,7 @@ import type { Token } from '../../lexer/Token'; import { AstNodeKind } from '../../parser/AstNode'; import type { AstNode } from '../../parser/AstNode'; import type { Expression } from '../../parser/AstNode'; -import type { VariableExpression, DottedGetExpression, BinaryExpression, UnaryExpression, NewExpression, LiteralExpression, FunctionExpression, CallfuncExpression } from '../../parser/Expression'; -import { CallExpression } from '../../parser/Expression'; +import type { VariableExpression, DottedGetExpression, BinaryExpression, UnaryExpression, NewExpression, LiteralExpression, FunctionExpression, CallExpression, CallfuncExpression } from '../../parser/Expression'; import { createVisitor, WalkMode } from '../../astUtils/visitors'; import type { BscType } from '../../types/BscType'; import type { BscFile } from '../../files/BscFile'; @@ -441,20 +440,7 @@ export class ScopeValidator { } //get min/max parameter count for callable - let minParams = 0; - let maxParams = 0; - for (let param of funcType.params) { - maxParams++; - //optional parameters must come last, so we can assume that minParams won't increase once we hit - //the first isOptional - if (param.isOptional !== true) { - minParams++; - } - } - if (funcType.isVariadic) { - // function accepts variable number of arguments - maxParams = CallExpression.MaximumArguments; - } + const { minParams, maxParams } = funcType.getMinMaxParamCount(); const argsForCall = argOffset < 1 ? args : args.slice(argOffset); let expCallArgCount = argsForCall.length; @@ -477,7 +463,7 @@ export class ScopeValidator { } if (isCallableType(paramType) && isClassType(argType) && isClassStatement(data.definingNode)) { - argType = data.definingNode.getConstructorType(); + argType = data.definingNode?.getConstructorType(); } const compatibilityData: TypeCompatibilityData = {}; diff --git a/src/files/BrsFile.spec.ts b/src/files/BrsFile.spec.ts index 7dfda8ccd..f7dbb6687 100644 --- a/src/files/BrsFile.spec.ts +++ b/src/files/BrsFile.spec.ts @@ -21,7 +21,7 @@ import * as fsExtra from 'fs-extra'; import undent from 'undent'; import { tempDir, rootDir } from '../testHelpers.spec'; import { SymbolTypeFlag } from '../SymbolTypeFlag'; -import { ClassType, EnumType, FloatType, InterfaceType } from '../types'; +import { ClassType, EnumType, FloatType, InterfaceType, VoidType } from '../types'; import type { StandardizedFileEntry } from 'roku-deploy'; import * as fileUrl from 'file-url'; import { isAALiteralExpression, isBlock, isFunctionExpression } from '../astUtils/reflection'; @@ -1867,6 +1867,7 @@ describe('BrsFile', () => { }); it('works for bs content', () => { + program.addAnnotationSymbol('annotation', new TypedFunctionType(VoidType.instance)); program.setFile('source/lib.bs', ``); doTest(` import "pkg:/source/lib.bs" @@ -3022,6 +3023,7 @@ describe('BrsFile', () => { }); it('includes annotation comments for class', async () => { + program.addAnnotationSymbol('annotation', new TypedFunctionType(VoidType.instance)); await testTranspile(` 'comment1 @annotation @@ -3049,6 +3051,7 @@ describe('BrsFile', () => { }); it('includes annotation comments for function', async () => { + program.addAnnotationSymbol('annotation', new TypedFunctionType(VoidType.instance)); await testTranspile(` 'comment1 @annotation @@ -3067,6 +3070,7 @@ describe('BrsFile', () => { }); it('includes annotation comments for enum', async () => { + program.addAnnotationSymbol('annotation', new TypedFunctionType(VoidType.instance)); await testTranspile(` 'comment1 @annotation @@ -3084,6 +3088,7 @@ describe('BrsFile', () => { }); it('includes annotation comments for const', async () => { + program.addAnnotationSymbol('annotation', new TypedFunctionType(VoidType.instance)); await testTranspile(` 'comment1 @annotation @@ -3099,6 +3104,7 @@ describe('BrsFile', () => { }); it('includes annotation comments for empty namespaces', async () => { + program.addAnnotationSymbol('annotation', new TypedFunctionType(VoidType.instance)); await testTranspile(` 'comment1 @annotation @@ -4405,6 +4411,10 @@ describe('BrsFile', () => { }); it('includes annotations', () => { + program.addAnnotationSymbol('an', new TypedFunctionType(VoidType.instance)); + program.addAnnotationSymbol('anfunc', new TypedFunctionType(VoidType.instance).addParameter('name', StringType.instance, true)); + program.addAnnotationSymbol('anmember', new TypedFunctionType(VoidType.instance).addParameter('name', StringType.instance, true)); + testTypedef(` namespace test @an diff --git a/src/interfaces.ts b/src/interfaces.ts index 8e3aca4f4..c8e8c04f5 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -219,8 +219,24 @@ export interface CommentFlag { export type CompilerPluginFactory = () => CompilerPlugin; +export interface AnnotationDeclaration { + description?: string; + type: TypedFunctionType; +} + export interface CompilerPlugin { name: string; + + /** + * A list of function declarations of allowed annotations + */ + annotations?: Array; + + /** + * Called when plugin is initially loaded + */ + onPluginConfigure?(event: OnPluginConfigureEvent): any; + /** * Called before a new program is created */ @@ -506,6 +522,9 @@ export interface OnGetCodeActionsEvent { codeActions: CodeAction[]; } +export interface OnPluginConfigureEvent { + builder: ProgramBuilder; +} export interface BeforeProgramCreateEvent { builder: ProgramBuilder; } @@ -990,6 +1009,10 @@ export interface ExtraSymbolData { * Was this a result of a callfunc? */ isFromCallFunc?: boolean; + /** + * Name of plugin that defined this symbol + */ + pluginName?: string; } export interface GetTypeOptions { @@ -1001,6 +1024,7 @@ export interface GetTypeOptions { ignoreCacheForRetrieval?: boolean; isExistenceTest?: boolean; preferDocType?: boolean; + onlyAllowLiterals?: boolean; } export class TypeChainEntry { diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts index 5aa20c514..398e1d26e 100644 --- a/src/parser/Expression.ts +++ b/src/parser/Expression.ts @@ -1441,7 +1441,7 @@ export class VariableExpression extends Expression { getType(options: GetTypeOptions) { let resultType: BscType = util.tokenToBscType(this.tokens.name); const nameKey = this.getName(); - if (!resultType) { + if (!resultType && !options?.onlyAllowLiterals) { const symbolTable = this.getSymbolTable(); resultType = symbolTable?.getSymbolType(nameKey, { ...options, fullName: nameKey, tableProvider: () => this.getSymbolTable() }); diff --git a/src/parser/tests/statement/InterfaceStatement.spec.ts b/src/parser/tests/statement/InterfaceStatement.spec.ts index d50ea62c1..81f847902 100644 --- a/src/parser/tests/statement/InterfaceStatement.spec.ts +++ b/src/parser/tests/statement/InterfaceStatement.spec.ts @@ -1,6 +1,8 @@ import { expectZeroDiagnostics, getTestGetTypedef, getTestTranspile } from '../../../testHelpers.spec'; import { rootDir } from '../../../testHelpers.spec'; import { Program } from '../../../Program'; +import { TypedFunctionType } from '../../../types/TypedFunctionType'; +import { VoidType } from '../../../types/VoidType'; describe('InterfaceStatement', () => { let program: Program; @@ -50,6 +52,9 @@ describe('InterfaceStatement', () => { }); it('includes annotations', async () => { + program.addAnnotationSymbol('IFace', new TypedFunctionType(VoidType.instance)); + program.addAnnotationSymbol('Method', new TypedFunctionType(VoidType.instance)); + program.addAnnotationSymbol('Field', new TypedFunctionType(VoidType.instance)); await testGetTypedef(` @IFace interface Person diff --git a/src/types/TypedFunctionType.ts b/src/types/TypedFunctionType.ts index 89b3b7fd5..9bbc897db 100644 --- a/src/types/TypedFunctionType.ts +++ b/src/types/TypedFunctionType.ts @@ -5,6 +5,7 @@ import { BscTypeKind } from './BscTypeKind'; import { isUnionTypeCompatible } from './helpers'; import { BuiltInInterfaceAdder } from './BuiltInInterfaceAdder'; import type { TypeCompatibilityData } from '../interfaces'; +import { CallExpression } from '../parser/Expression'; export class TypedFunctionType extends BaseFunctionType { constructor( @@ -37,7 +38,7 @@ export class TypedFunctionType extends BaseFunctionType { return this; } - public addParameter(name: string, type: BscType, isOptional: boolean) { + public addParameter(name: string, type: BscType, isOptional = false) { this.params.push({ name: name, type: type, @@ -46,6 +47,11 @@ export class TypedFunctionType extends BaseFunctionType { return this; } + public setVariadic(variadic: boolean) { + this.isVariadic = variadic; + return this; + } + public isTypeCompatible(targetType: BscType, data: TypeCompatibilityData = {}) { if ( isDynamicType(targetType) || @@ -122,6 +128,25 @@ export class TypedFunctionType extends BaseFunctionType { //made it here, all params and return type pass predicate return true; } + + public getMinMaxParamCount(): { minParams: number; maxParams: number } { + //get min/max parameter count for callable + let minParams = 0; + let maxParams = 0; + for (let param of this.params) { + maxParams++; + //optional parameters must come last, so we can assume that minParams won't increase once we hit + //the first isOptional + if (param.isOptional !== true) { + minParams++; + } + } + if (this.isVariadic) { + // function accepts variable number of arguments + maxParams = CallExpression.MaximumArguments; + } + return { minParams: minParams, maxParams: maxParams }; + } } BuiltInInterfaceAdder.typedFunctionFactory = (returnType: BscType) => { diff --git a/src/util.ts b/src/util.ts index 9be9277a5..2e301e8b9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2381,6 +2381,9 @@ export class Util { public isInTypeExpression(expression: AstNode): boolean { + if (!expression) { + return false; + } //TODO: this is much faster than node.findAncestor(), but may need to be updated for "complicated" type expressions if (isTypeExpression(expression) || isTypeExpression(expression?.parent) ||