Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

```
@<annotation_name>[(parameters)]
Expand All @@ -21,6 +21,18 @@ Annotations can have parameters - these parameters should be a list of valid Bri
@<annotation_name>[(parameters)] [more annotations] <statement>
```

## 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
Expand Down
120 changes: 100 additions & 20 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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`

Expand Down Expand Up @@ -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<TypedFunctionType | AnnotationDeclaration>;

/**
* Called when plugin is initially loaded
*/
onPluginConfigure?(event: OnPluginConfigureEvent): any;

/**
* Called before a new program is created
*/
Expand Down Expand Up @@ -240,7 +252,6 @@ export interface CompilerPlugin {
afterScopeDispose?(event: AfterScopeDisposeEvent): any;

beforeScopeValidate?(event: BeforeScopeValidateEvent): any;

/**
* Called before the `provideDefinition` hook
*/
Expand All @@ -256,7 +267,6 @@ export interface CompilerPlugin {
*/
afterProvideDefinition?(event: AfterProvideDefinitionEvent): any;


/**
* Called before the `provideReferences` hook
*/
Expand Down Expand Up @@ -304,8 +314,6 @@ export interface CompilerPlugin {
*/
afterProvideWorkspaceSymbols?(event: AfterProvideWorkspaceSymbolsEvent): any;


onGetSemanticTokens?: PluginHandler<OnGetSemanticTokensEvent>;
//scope events
onScopeValidate?(event: OnScopeValidateEvent): any;
afterScopeValidate?(event: BeforeScopeValidateEvent): any;
Expand Down Expand Up @@ -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
Expand All @@ -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) => {
Expand All @@ -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({
Expand All @@ -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 = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be really nice to enforce which statement types this annotation could be declared on. For example, rooibos @suite() only works when attached to classes. Adding @suite() to a namespace should be an error. BrighterScript could provide consistent validations for these. I envision configuring it something like this:

this.annotations.push({
    restrictTo: ['class'], //this would be a list of all types the annotation maybe declared above
    func:  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)
        }
    

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:
Expand Down
13 changes: 13 additions & 0 deletions src/DiagnosticMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 19 additions & 2 deletions src/PluginInterface.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -75,7 +76,7 @@ export default class PluginInterface<T extends CompilerPlugin = CompilerPlugin>
/**
* Call `event` on plugins, but allow the plugins to return promises that will be awaited before the next plugin is notified
*/
public async emitAsync<K extends keyof PluginEventArgs<T> & string>(event: K, ...args: PluginEventArgs<T>[K]): Promise< PluginEventArgs<T>[K][0]> {
public async emitAsync<K extends keyof PluginEventArgs<T> & string>(event: K, ...args: PluginEventArgs<T>[K]): Promise<PluginEventArgs<T>[K][0]> {
this.logger.debug(`Emitting async plugin event: ${event}`);
for (let plugin of this.plugins) {
if ((plugin as any)[event]) {
Expand Down Expand Up @@ -171,4 +172,20 @@ export default class PluginInterface<T extends CompilerPlugin = CompilerPlugin>
public clear() {
this.plugins = [];
}


private annotationMap: Map<string, Array<string | TypedFunctionType | AnnotationDeclaration>>;

public getAnnotationMap() {
if (this.annotationMap) {
return this.annotationMap;
}
this.annotationMap = new Map<string, Array<string | TypedFunctionType | AnnotationDeclaration>>();
for (let plugin of this.plugins) {
if (plugin.annotations?.length > 0) {
this.annotationMap.set(plugin.name, plugin.annotations);
}
}
return this.annotationMap;
}
}
35 changes: 33 additions & 2 deletions src/Program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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();
}


Expand Down Expand Up @@ -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<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();

private currentScopeValidationOptions: ScopeValidationOptions;
Expand Down
Loading