diff --git a/docs/arrow-functions.md b/docs/arrow-functions.md
new file mode 100644
index 000000000..b665cf23c
--- /dev/null
+++ b/docs/arrow-functions.md
@@ -0,0 +1,124 @@
+# Arrow Functions
+
+Arrow functions are a short way to write a function whose body is a single expression, modeled on the `=>` syntax from TypeScript/JavaScript. They are most useful for small callbacks passed to other functions.
+
+```brighterscript
+adults = utils.filter(people, (p) => p.age >= 18)
+```
+
+transpiles to
+
+```brightscript
+adults = utils_filter(people, function(p)
+ return p.age >= 18
+end function)
+```
+
+## Syntax
+
+An arrow function is a parenthesized parameter list, an optional return type, `=>`, and a single expression that becomes the function's return value. Parameters use the same syntax as regular functions, so types, optional parameters and default values all work.
+
+```brighterscript
+' no params
+getZero = () => 0
+
+' single param (parens are required, even for one param)
+double = (x) => x * 2
+
+' multiple params
+add = (a, b) => a + b
+
+' typed params and return type
+add = (a as integer, b as integer) as integer => a + b
+
+' optional params with default values
+greet = (name = "world") => "hello " + name
+
+' passed as an argument
+names = map(people, (p) => p.name)
+
+' returning an associative array literal (wrap it in parens)
+makePoint = (x, y) => ({ x: x, y: y })
+```
+
+The body extends as far to the right as possible, so `(a) => a ? 1 : 2` returns the whole ternary expression.
+
+## Transpilation
+
+An arrow function compiles to an ordinary anonymous function that returns its expression:
+
+```brighterscript
+add = (a as integer, b as integer) as integer => a + b
+```
+
+```brightscript
+add = function(a as integer, b as integer) as integer
+ return a + b
+end function
+```
+
+When the body is a call to something that returns nothing (a `sub`, or a built-in method like `push`), the arrow function compiles to a `sub` whose body is a plain statement, since `return list.push(item)` is not valid on device:
+
+```brighterscript
+addItem = (list as integer[], item as integer) => list.push(item)
+```
+
+```brightscript
+addItem = sub(list as dynamic, item as integer)
+ list.push(item)
+end sub
+```
+
+## Type checking
+
+Arrow functions are type-checked exactly like anonymous functions. They can be used anywhere a [function type](function-types.md) is expected, and their arguments and return values are validated.
+
+Without an explicit return type, the return type is inferred from the body expression:
+
+```brighterscript
+sub apply(callback as function(x as integer) as integer)
+end sub
+
+' ok
+apply((x as integer) => x * 2)
+
+' error: "function (x as string) as string" is not compatible with "function (x as integer) as integer"
+apply((x as string) => x + "!")
+```
+
+## No closures (yet)
+
+Arrow functions do **not** capture variables from the function they are written in. They follow the same scoping rules as regular anonymous functions: they can see parameters, file-level functions, namespaces, classes and globals, but not the locals of the enclosing function. Referencing one is a compile error:
+
+```brighterscript
+sub main()
+ factor = 2
+ ' error: Arrow functions cannot use variable 'factor' from the enclosing function
+ scale = (x) => x * factor
+
+ ' pass it in as a parameter instead
+ scale = (x, by) => x * by
+ print scale(10, factor)
+end sub
+```
+
+`m` is also not allowed inside an arrow function. Pass the value you need in as an argument instead:
+
+```brighterscript
+' error: 'm' cannot be used inside an arrow function
+render = (data) => m.view.setData(data)
+
+' ok
+render = (data, view) => view.setData(data)
+render(data, m.view)
+```
+
+Both restrictions are deliberate. Closures are being designed separately (see [#530](https://github.com/rokucommunity/brighterscript/issues/530)) and will most likely reuse the `=>` syntax, so what "capturing a variable" and `m` mean inside an arrow function is left open until then. Code that works today will keep working unchanged once closures exist.
+
+An arrow function that captures nothing and does not use `m` is a plain BrightScript function. It can be passed anywhere a function can: parameters typed `as function` or with a function type, untyped (`dynamic`) parameters, and compiled `.brs` libraries that simply call `callback(x)`.
+
+## Limitations
+
+- The body must be a single expression. For multiple statements, use a regular `function ... end function`.
+- Parameters must be in parentheses, even when there is only one: `(x) => x * 2`, not `x => x * 2`.
+- Arrow functions are a BrighterScript feature and are not available in `.brs` files.
diff --git a/docs/readme.md b/docs/readme.md
index 7cb65d8fc..d4a13179b 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -14,6 +14,13 @@ sub doSomething()
end sub
```
+## [Arrow Functions](arrow-functions.md)
+
+```brighterscript
+' a short way to write a single-expression callback
+adults = filter(people, (p) => p.age >= 18)
+```
+
## [Assignments](assigments.md)
```brighterscript
diff --git a/src/CrossScopeValidator.ts b/src/CrossScopeValidator.ts
index cb80fb5b2..4a7a5a37c 100644
--- a/src/CrossScopeValidator.ts
+++ b/src/CrossScopeValidator.ts
@@ -988,6 +988,11 @@ export class CrossScopeValidator {
}
}
+ //an arrow function reaching for a local of its enclosing function gets a diagnostic that says why the name is unavailable
+ const arrowFunctionDiagnostic = util.getArrowFunctionCaptureDiagnostic(typeChainResult.astNode);
+ if (arrowFunctionDiagnostic) {
+ return arrowFunctionDiagnostic;
+ }
if (isCallExpression(typeChainResult.astNode?.parent) && typeChainResult.astNode?.parent.callee === typeChainResult.astNode) {
return DiagnosticMessages.cannotFindFunction(typeChainResult.itemName, typeChainResult.fullNameOfItem, typeChainResult.itemParentTypeName, parentDescriptor);
}
diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts
index b0291d526..40d74b21b 100644
--- a/src/DiagnosticMessages.ts
+++ b/src/DiagnosticMessages.ts
@@ -1401,6 +1401,30 @@ export let DiagnosticMessages = {
message: `Function has ${count} labels used by goto, max is ${max}.`,
severity: DiagnosticSeverity.Error,
code: 'exceeds-max-goto-label-count'
+ }),
+ /**
+ * An arrow function references a local variable of the function it is written inside. Arrow functions are plain
+ * functions (closures are not supported yet, see #530), so that variable is not visible to them
+ * @param name the name of the variable being referenced
+ */
+ arrowFunctionCannotCaptureVariable: (name: string) => ({
+ message: `Arrow functions cannot use variable '${name}' from the enclosing function. Capturing outer variables is not supported yet, so pass it in as a parameter instead`,
+ severity: DiagnosticSeverity.Error,
+ code: 'arrow-function-cannot-capture-variable'
+ }),
+ /**
+ * `m` is not allowed inside arrow functions: what it should mean there (BrightScript's call-site `m` or a captured outer `m`)
+ * is left open until closures are designed, so allowing either now would lock that decision in
+ */
+ arrowFunctionCannotUseM: () => ({
+ message: `'m' cannot be used inside an arrow function. Pass the value in as a parameter instead`,
+ severity: DiagnosticSeverity.Error,
+ code: 'arrow-function-cannot-use-m'
+ }),
+ expectedExpressionAfterArrow: () => ({
+ message: `Expected an expression after '=>'`,
+ severity: DiagnosticSeverity.Error,
+ code: 'expected-expression-after-arrow'
})
};
export const defaultMaximumTruncationLength = 160;
diff --git a/src/bscPlugin/validation/BrsFileValidator.ts b/src/bscPlugin/validation/BrsFileValidator.ts
index 854025816..be577769e 100644
--- a/src/bscPlugin/validation/BrsFileValidator.ts
+++ b/src/bscPlugin/validation/BrsFileValidator.ts
@@ -366,7 +366,8 @@ export class BrsFileValidator {
// symbol table should not include any symbols from parent func
funcSymbolTable.pushParentProvider(() => node.findAncestor
(isBody).getSymbolTable());
}
- if (!funcSymbolTable?.hasSymbol('m', SymbolTypeFlag.runtime) || isInlineFunc) {
+ //arrow functions don't get an `m`: what it should mean there is left open until closures are designed (see #1929)
+ if (!node.isArrowFunction && (!funcSymbolTable?.hasSymbol('m', SymbolTypeFlag.runtime) || isInlineFunc)) {
if (!isTypecastStatement(node.body?.statements?.[0])) {
// if this is an inline function, or if the function body does not start with a typecast statement, add the `m` symbol to the function scope. If the function body starts with a typecast statement, the `m` symbol will be added in the visitor for TypecastStatement, and it will be typed as the type from the typecast statement
funcSymbolTable?.addSymbol('m', { isInstance: true }, new AssociativeArrayType(), SymbolTypeFlag.runtime);
@@ -377,6 +378,9 @@ export class BrsFileValidator {
this.collectFunctionLiterals(node);
this.validateFunctionGotoLabelCount(node);
this.flagControlFlowUnreachableCode(node);
+ if (node.isArrowFunction) {
+ this.validateArrowFunctionDoesNotUseM(node);
+ }
},
FunctionParameterExpression: (node) => {
if (isTypedFunctionTypeExpression(node.parent)) {
@@ -459,6 +463,11 @@ export class BrsFileValidator {
},
ReturnStatement: (node) => {
const func = node.findAncestor(isFunctionExpression);
+ if (func?.isArrowFunction) {
+ //an arrow function's `return` is synthesized from its body expression so it always has a value;
+ //whether that value fits the return type is checked by ScopeValidator.validateReturnStatement
+ return;
+ }
//these situations cannot have a value next to `return`
if (
//`function as void`, `sub as void`
@@ -753,6 +762,29 @@ export class BrsFileValidator {
}
}
+ /**
+ * `m` is not allowed inside arrow functions: what it should mean there (BrightScript's call-site `m` or a captured outer `m`)
+ * is left open until closures are designed (see #1929). Checked per arrow function rather than for every `m` in the file, so
+ * only arrow function bodies pay for it. Does not step into nested functions: a nested arrow function is checked on its own
+ * visit, and a nested regular function may use `m` as usual
+ */
+ private validateArrowFunctionDoesNotUseM(arrow: FunctionExpression) {
+ const visitor = createVisitor({
+ VariableExpression: (node) => {
+ if (node.tokens.name?.text?.toLowerCase() === 'm') {
+ this.event.program.diagnostics.register({
+ ...DiagnosticMessages.arrowFunctionCannotUseM(),
+ location: node.tokens.name.location
+ });
+ }
+ }
+ });
+ for (const param of arrow.parameters) {
+ param.walk(visitor, { walkMode: WalkMode.visitExpressions });
+ }
+ arrow.body?.walk(visitor, { walkMode: WalkMode.visitExpressions });
+ }
+
private validateFunctionParameterCount(func: FunctionExpression) {
if (func.parameters.length > CallExpression.MaximumArguments) {
//flag every parameter over the limit
diff --git a/src/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts
index 1a2404b66..35f906235 100644
--- a/src/bscPlugin/validation/ScopeValidator.ts
+++ b/src/bscPlugin/validation/ScopeValidator.ts
@@ -891,7 +891,13 @@ export class ScopeValidator {
private validateReturnStatement(file: BrsFile, returnStmt: ReturnStatement) {
const data: ExtraSymbolData = {};
const getTypeOptions = { flags: SymbolTypeFlag.runtime, data: data };
- let funcType = returnStmt.findAncestor(isFunctionExpression)?.getType({ flags: SymbolTypeFlag.typetime });
+ const func = returnStmt.findAncestor(isFunctionExpression);
+ let funcType = func?.getType({ flags: SymbolTypeFlag.typetime });
+ if (func?.isArrowFunction && isTypedFunctionType(funcType) && isVoidType(funcType.returnType) && isCallExpression(returnStmt.value)) {
+ //a void arrow function (`(x) => list.push(x)`) transpiles to a `sub` that runs its body as a plain statement,
+ //so there is no return value to check
+ return;
+ }
if (isTypedFunctionType(funcType)) {
let actualReturnType = returnStmt?.value
? this.getNodeTypeWrapper(file, returnStmt?.value, getTypeOptions)
@@ -1271,7 +1277,13 @@ export class ScopeValidator {
// only show "cannot find... " errors if the type is not defined from a doc comment
const typeChainScan = util.processTypeChain(typeChain);
const circularReferenceInfo = this.getCircularReference(exprType);
- if (isCallExpression(typeChainScan.astNode.parent) && typeChainScan.astNode.parent.callee === expression) {
+ const arrowFunctionDiagnostic = util.getArrowFunctionCaptureDiagnostic(typeChainScan.astNode);
+ if (arrowFunctionDiagnostic) {
+ this.addMultiScopeDiagnostic({
+ ...arrowFunctionDiagnostic,
+ location: typeChainScan?.location
+ });
+ } else if (isCallExpression(typeChainScan.astNode.parent) && typeChainScan.astNode.parent.callee === expression) {
this.addMultiScopeDiagnostic({
...DiagnosticMessages.cannotFindFunction(typeChainScan.itemName, typeChainScan.fullNameOfItem, typeChainScan.itemParentTypeName, this.getParentTypeDescriptor(typeChainScan)),
location: typeChainScan?.location
diff --git a/src/lexer/Lexer.spec.ts b/src/lexer/Lexer.spec.ts
index 730886fc3..36ddc54ba 100644
--- a/src/lexer/Lexer.spec.ts
+++ b/src/lexer/Lexer.spec.ts
@@ -42,6 +42,36 @@ describe('lexer', () => {
]);
});
+ it('recognizes the arrow operator without disturbing equals and greater-than', () => {
+ expectKinds('=> = > >= = >', [
+ TokenKind.Arrow,
+ TokenKind.Equal,
+ TokenKind.Greater,
+ TokenKind.GreaterEqual,
+ TokenKind.Equal,
+ TokenKind.Greater
+ ]);
+ });
+
+ it('recognizes the arrow operator without surrounding whitespace', () => {
+ expectKinds('(x)=>x*2', [
+ TokenKind.LeftParen,
+ TokenKind.Identifier,
+ TokenKind.RightParen,
+ TokenKind.Arrow,
+ TokenKind.Identifier,
+ TokenKind.Star,
+ TokenKind.IntegerLiteral
+ ]);
+ });
+
+ it('gives the arrow token its text and location', () => {
+ const tokens = Lexer.scan('a => b').tokens;
+ const arrow = tokens.find(x => x.kind === TokenKind.Arrow);
+ expect(arrow.text).to.eql('=>');
+ expect(arrow.location.range).to.eql(Range.create(0, 2, 0, 4));
+ });
+
it('separates optional chain characters and LeftSquare when found at beginning of statement locations', () => {
//a statement starting with a question mark is actually a print statement, so we need to keep the ? separate from [
expectKinds(`?[ ?[ : ?[ ?[`, [
diff --git a/src/lexer/Lexer.ts b/src/lexer/Lexer.ts
index 548ac002b..00a0d716e 100644
--- a/src/lexer/Lexer.ts
+++ b/src/lexer/Lexer.ts
@@ -275,6 +275,14 @@ export class Lexer {
break;
}
},
+ '=': function (this: Lexer) {
+ if (this.peek() === '>') {
+ this.advance();
+ this.addToken(TokenKind.Arrow);
+ } else {
+ this.addToken(TokenKind.Equal);
+ }
+ },
'>': function (this: Lexer) {
switch (this.peek()) {
case '=':
@@ -348,7 +356,6 @@ export class Lexer {
private static tokenKindMap = {
'(': TokenKind.LeftParen,
')': TokenKind.RightParen,
- '=': TokenKind.Equal,
',': TokenKind.Comma,
'{': TokenKind.LeftCurlyBrace,
'}': TokenKind.RightCurlyBrace,
diff --git a/src/lexer/TokenKind.ts b/src/lexer/TokenKind.ts
index 5e0b8e060..3dc83af3b 100644
--- a/src/lexer/TokenKind.ts
+++ b/src/lexer/TokenKind.ts
@@ -42,6 +42,7 @@ export enum TokenKind {
GreaterEqual = 'GreaterEqual', // >=
Equal = 'Equal', // =
LessGreater = 'LessGreater', // BrightScript uses `<>` for "not equal"
+ Arrow = 'Arrow', // => (arrow function)
// literals
Identifier = 'Identifier',
@@ -836,6 +837,7 @@ export const FixedTokenText: Partial> = {
[TokenKind.GreaterEqual]: '>=',
[TokenKind.Equal]: '=',
[TokenKind.LessGreater]: '<>',
+ [TokenKind.Arrow]: '=>',
[TokenKind.Dot]: '.',
[TokenKind.DotDotDot]: '...',
[TokenKind.Comma]: ',',
diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts
index 1a083ee73..6a8fe01a4 100644
--- a/src/parser/Expression.ts
+++ b/src/parser/Expression.ts
@@ -10,7 +10,7 @@ import { ParseMode } from './Parser';
import type { WalkOptions, WalkVisitor } from '../astUtils/visitors';
import { WalkMode } from '../astUtils/visitors';
import { walk, InternalWalkMode, walkArray } from '../astUtils/visitors';
-import { isAAIndexedMemberExpression, isAALiteralExpression, isAAMemberExpression, isArrayLiteralExpression, isArrayType, isAssociativeArrayType, isCallableType, isCallExpression, isCallfuncExpression, isClassType, isDottedGetExpression, isEnumType, isEscapedCharCodeLiteralExpression, isFunctionExpression, isFunctionStatement, isIntegerType, isInterfaceMethodStatement, isInterfaceType, isInvalidType, isLiteralBoolean, isLiteralExpression, isLiteralNumber, isLiteralString, isLongIntegerType, isMethodStatement, isNamespaceStatement, isNativeType, isNewExpression, isPrimitiveType, isReferenceType, isSpreadExpression, isStringType, isTemplateStringExpression, isTypecastExpression, isTypeStatementType, isUnaryExpression, isVariableExpression, isVoidType } from '../astUtils/reflection';
+import { isAAIndexedMemberExpression, isAALiteralExpression, isAAMemberExpression, isArrayLiteralExpression, isArrayType, isAssociativeArrayType, isCallableType, isCallExpression, isCallfuncExpression, isClassType, isDottedGetExpression, isEnumType, isEscapedCharCodeLiteralExpression, isFunctionExpression, isFunctionStatement, isIntegerType, isInterfaceMethodStatement, isInterfaceType, isInvalidType, isLiteralBoolean, isLiteralExpression, isLiteralNumber, isLiteralString, isLongIntegerType, isMethodStatement, isNamespaceStatement, isNativeType, isNewExpression, isPrimitiveType, isReferenceType, isReturnStatement, isSpreadExpression, isStringType, isTemplateStringExpression, isTypecastExpression, isTypedFunctionType, isTypeStatementType, isUnaryExpression, isUninitializedType, isVariableExpression, isVoidType } from '../astUtils/reflection';
import type { GetTypeOptions, TranspileResult, TypedefProvider, ExtraSymbolData } from '../interfaces';
import { TypeChainEntry } from '../interfaces';
import { VoidType } from '../types/VoidType';
@@ -278,6 +278,11 @@ export class FunctionExpression extends Expression implements TypedefProvider {
returnTypeExpression?: TypeExpression;
body: Block;
endFunctionType?: Token;
+ /**
+ * The `=>` of an arrow function (`(a, b) => a + b`). When set, this is an arrow function: it has no `function`/`end function`
+ * keywords and its body is a single synthesized `return` statement holding the body expression
+ */
+ arrow?: Token;
}) {
super();
this.tokens = {
@@ -285,7 +290,8 @@ export class FunctionExpression extends Expression implements TypedefProvider {
leftParen: options.leftParen,
rightParen: options.rightParen,
as: options.as,
- endFunctionType: options.endFunctionType
+ endFunctionType: options.endFunctionType,
+ arrow: options.arrow
};
this.parameters = options.parameters ?? [];
this.body = options.body;
@@ -309,10 +315,63 @@ export class FunctionExpression extends Expression implements TypedefProvider {
readonly leftParen?: Token;
readonly rightParen?: Token;
readonly as?: Token;
+ readonly arrow?: Token;
};
+ /**
+ * Is this an arrow function (`(a, b) => a + b`)? Arrow functions are ordinary functions whose body is a single expression,
+ * stored as a synthesized `return` statement so hover, validation and transpile treat them like any other function
+ */
+ public get isArrowFunction() {
+ return !!this.tokens.arrow;
+ }
+
+ /**
+ * The expression that forms the body of an arrow function (the `a + b` in `(a, b) => a + b`). Undefined for regular functions
+ */
+ public get arrowBody(): Expression | undefined {
+ const statement = this.isArrowFunction ? this.body?.statements?.[0] : undefined;
+ return isReturnStatement(statement) ? statement.value : undefined;
+ }
+
+ /**
+ * For an arrow function with no declared return type: the type its body expression produces, or `void` when the body
+ * is a call to something that returns nothing (a `sub`, or a built-in like `push`). Undefined for regular functions
+ * and for arrow functions with a declared return type
+ */
+ private getInferredArrowReturnType(): BscType | undefined {
+ const body = this.arrowBody;
+ if (!body || this.returnTypeExpression) {
+ return undefined;
+ }
+ //a call to a user-defined sub evaluates to `invalid` at runtime (so that's its type), but it still produces nothing worth returning
+ if (isCallExpression(body)) {
+ const calleeType = body.callee.getType({ flags: SymbolTypeFlag.runtime });
+ if (isTypedFunctionType(calleeType) && (calleeType.isSub || isVoidType(calleeType.returnType))) {
+ return VoidType.instance;
+ }
+ }
+ const bodyType = body.getType({ flags: SymbolTypeFlag.runtime });
+ return isUninitializedType(bodyType) ? VoidType.instance : bodyType;
+ }
+
+ /**
+ * Does this arrow function produce no value? True when its return type is explicitly `void`, or when it has no declared
+ * return type and its body is a call to something that returns nothing (i.e. `(x) => list.push(x)`).
+ * These transpile to a `sub` whose body is a plain statement, since `return list.push(x)` fails on device
+ */
+ private get isVoidArrowFunction() {
+ if (!this.isArrowFunction) {
+ return false;
+ }
+ const returnType = this.returnTypeExpression
+ ? this.returnTypeExpression.getType({ flags: SymbolTypeFlag.typetime })
+ : this.getInferredArrowReturnType();
+ return isVoidType(returnType);
+ }
+
public get leadingTrivia(): Token[] {
- return this.tokens.functionType?.leadingTrivia;
+ return this.tokens.functionType?.leadingTrivia ?? this.tokens.leftParen?.leadingTrivia;
}
public get endTrivia(): Token[] {
@@ -321,7 +380,7 @@ export class FunctionExpression extends Expression implements TypedefProvider {
/**
* The range of the function, starting at the 'f' in function or 's' in sub (or the open paren if the keyword is missing),
- * and ending with the last n' in 'end function' or 'b' in 'end sub'
+ * and ending with the last n' in 'end function' or 'b' in 'end sub' (or the end of the body expression for an arrow function)
*/
public get location(): Location {
return util.createBoundingLocation(
@@ -331,15 +390,22 @@ export class FunctionExpression extends Expression implements TypedefProvider {
this.tokens.rightParen,
this.tokens.as,
this.returnTypeExpression,
+ this.tokens.arrow,
+ this.isArrowFunction ? this.body : undefined,
this.tokens.endFunctionType
);
}
transpile(state: BrsTranspileState, name?: Identifier, includeBody = true) {
let results = [] as TranspileResult;
+ let functionTypeText = this.tokens.functionType?.text;
+ if (this.isArrowFunction) {
+ //an arrow function has no keyword of its own, so pick one based on whether its body produces a value
+ functionTypeText = this.isVoidArrowFunction ? 'sub' : 'function';
+ }
//'function'|'sub'
results.push(
- state.transpileToken(this.tokens.functionType, 'function', false, state.skipLeadingComments)
+ state.transpileToken(this.tokens.functionType, functionTypeText ?? 'function', false, state.skipLeadingComments)
);
//functionName?
if (name) {
@@ -381,7 +447,15 @@ export class FunctionExpression extends Expression implements TypedefProvider {
let hasBody = false;
if (includeBody) {
state.lineage.unshift(this);
- let body = this.body.transpile(state);
+ let body: TranspileResult;
+ if (this.isArrowFunction && functionTypeText === 'sub') {
+ //a `sub` can't `return` a value, so emit the body expression as a plain statement
+ state.blockDepth++;
+ body = [state.newline, state.indent(), ...this.arrowBody.transpile(state)];
+ state.blockDepth--;
+ } else {
+ body = this.body.transpile(state);
+ }
hasBody = body.length > 0;
state.lineage.shift();
results.push(...body);
@@ -389,7 +463,7 @@ export class FunctionExpression extends Expression implements TypedefProvider {
const lastLocatable = hasBody ? this.body : this.returnTypeExpression ?? this.tokens.leftParen ?? this.tokens.functionType;
results.push(
- ...state.transpileEndBlockToken(lastLocatable, this.tokens.endFunctionType, `end ${this.tokens.functionType ?? 'function'}`)
+ ...state.transpileEndBlockToken(lastLocatable, this.tokens.endFunctionType, `end ${functionTypeText ?? 'function'}`)
);
return results;
}
@@ -455,7 +529,14 @@ export class FunctionExpression extends Expression implements TypedefProvider {
options
);
- const isSub = this.tokens.functionType?.kind === TokenKind.Sub;
+ let isSub = this.tokens.functionType?.kind === TokenKind.Sub;
+ if (this.isArrowFunction && !returnType) {
+ //an arrow function with no declared return type returns whatever its body expression evaluates to
+ returnType = this.getInferredArrowReturnType();
+ if (isVoidType(returnType)) {
+ isSub = true;
+ }
+ }
//if we don't have a return type and this is a sub, set the return type to `void`. else use `dynamic`
if (!returnType) {
returnType = isSub ? VoidType.instance : DynamicType.instance;
@@ -530,7 +611,8 @@ export class FunctionExpression extends Expression implements TypedefProvider {
leftParen: util.cloneToken(this.tokens.leftParen),
rightParen: util.cloneToken(this.tokens.rightParen),
as: util.cloneToken(this.tokens.as),
- returnTypeExpression: this.returnTypeExpression?.clone()
+ returnTypeExpression: this.returnTypeExpression?.clone(),
+ arrow: util.cloneToken(this.tokens.arrow)
}),
['body', 'returnTypeExpression']
);
diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts
index 1b8e4f40a..66758d57b 100644
--- a/src/parser/Parser.ts
+++ b/src/parser/Parser.ts
@@ -1002,16 +1002,7 @@ export class Parser {
[asToken, typeExpression] = this.consumeAsTokenAndTypeExpression();
}
- params.reduce((haveFoundOptional: boolean, param: FunctionParameterExpression) => {
- if (haveFoundOptional && !param.defaultValue) {
- this.diagnostics.push({
- ...DiagnosticMessages.requiredParameterMayNotFollowOptionalParameter(param.tokens.name.text),
- location: param.location
- });
- }
-
- return haveFoundOptional || !!param.defaultValue;
- }, false);
+ this.validateParameterOrder(params);
this.consumeStatementSeparators(true);
@@ -1113,6 +1104,22 @@ export class Parser {
});
}
+ /**
+ * Flag required parameters that follow optional ones (i.e. `sub foo(a = 1, b)`), which is not allowed
+ */
+ private validateParameterOrder(params: FunctionParameterExpression[]) {
+ params.reduce((haveFoundOptional: boolean, param: FunctionParameterExpression) => {
+ if (haveFoundOptional && !param.defaultValue) {
+ this.diagnostics.push({
+ ...DiagnosticMessages.requiredParameterMayNotFollowOptionalParameter(param.tokens.name.text),
+ location: param.location
+ });
+ }
+
+ return haveFoundOptional || !!param.defaultValue;
+ }, false);
+ }
+
private assignment(allowTypedAssignment = false): AssignmentStatement {
let name = this.advance() as Identifier;
//add diagnostic if name is a reserved word that cannot be used as an identifier
@@ -3162,6 +3169,132 @@ export class Parser {
}
}
+ /**
+ * Tokens that end the line an arrow function's parameter list is on. `(` and `=>` must be on the same line, so hitting
+ * one of these while scanning means the parens are not an arrow function's
+ */
+ private static arrowScanTerminators = new Set([TokenKind.Newline, TokenKind.Colon, TokenKind.Comment, TokenKind.Eof]);
+
+ /**
+ * Does an arrow function (`(a, b) => a + b`) start at the current `(`? Decided by scanning tokens rather than by
+ * speculatively parsing, since `=>` is a single token that never appears inside a parameter list or a type expression
+ * and has to be on the same line as the closing `)`: find the matching `)`, then look for `=>` either right after it
+ * or after an `as ` clause. Costs a handful of token reads and no allocations, so every grouping expression
+ * (`(a + b)`, `(a as integer)`) can afford it
+ */
+ private isArrowFunctionAhead(): boolean {
+ //find the `)` that matches the `(` at the current position
+ let index = this.current;
+ let depth = 0;
+ for (; index < this.tokens.length; index++) {
+ const kind = this.tokens[index].kind;
+ if (kind === TokenKind.LeftParen || kind === TokenKind.QuestionLeftParen) {
+ depth++;
+ } else if (kind === TokenKind.RightParen) {
+ depth--;
+ if (depth === 0) {
+ break;
+ }
+ } else if (Parser.arrowScanTerminators.has(kind)) {
+ return false;
+ }
+ }
+ if (depth !== 0) {
+ return false;
+ }
+ index++;
+ if (this.tokens[index]?.kind === TokenKind.Arrow) {
+ return true;
+ }
+ if (this.tokens[index]?.kind !== TokenKind.As) {
+ return false;
+ }
+ //`) as =>`: the return type may contain brackets of its own (function types, typed arrays, inline interfaces),
+ //so only a `=>` outside of them counts, and a `,` or an unmatched closing bracket outside of them ends the type
+ depth = 0;
+ for (index++; index < this.tokens.length; index++) {
+ const kind = this.tokens[index].kind;
+ if (kind === TokenKind.LeftParen || kind === TokenKind.LeftSquareBracket || kind === TokenKind.LeftCurlyBrace) {
+ depth++;
+ } else if (kind === TokenKind.RightParen || kind === TokenKind.RightSquareBracket || kind === TokenKind.RightCurlyBrace) {
+ if (depth === 0) {
+ return false;
+ }
+ depth--;
+ } else if (depth === 0 && kind === TokenKind.Arrow) {
+ return true;
+ } else if ((depth === 0 && kind === TokenKind.Comma) || Parser.arrowScanTerminators.has(kind)) {
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Parse an arrow function (`(a, b) => a + b`) when one starts at the current `(`, otherwise return undefined without
+ * consuming anything so the caller parses the parens as a grouping expression (`(a + b)`, `(a as integer)`) instead
+ */
+ private tryArrowFunction(): FunctionExpression | undefined {
+ if (!this.isArrowFunctionAhead()) {
+ return undefined;
+ }
+ //like `functionDeclaration`, an arrow function is its own world for call tracking and nesting depth
+ const previousCallExpressions = this.callExpressions;
+ this.callExpressions = [];
+ this.namespaceAndFunctionDepth++;
+ try {
+ const leftParen = this.advance();
+ const params = [] as FunctionParameterExpression[];
+ if (!this.check(TokenKind.RightParen)) {
+ do {
+ params.push(this.functionParameter());
+ } while (this.match(TokenKind.Comma));
+ }
+ const rightParen = this.consume(
+ DiagnosticMessages.unmatchedLeftToken(leftParen.text, 'function parameter list'),
+ TokenKind.RightParen
+ );
+ let asToken: Token;
+ let returnTypeExpression: TypeExpression;
+ if (this.check(TokenKind.As)) {
+ [asToken, returnTypeExpression] = this.consumeAsTokenAndTypeExpression();
+ }
+ const arrow = this.consume(DiagnosticMessages.expectedToken(TokenKind.Arrow), TokenKind.Arrow);
+ if (this.options.mode !== ParseMode.BrighterScript) {
+ this.diagnostics.push({
+ ...DiagnosticMessages.bsFeatureNotSupportedInBrsFiles('arrow functions'),
+ location: arrow.location
+ });
+ }
+ this.validateParameterOrder(params);
+
+ const value = this.checkEndOfStatement() ? undefined : this.expression();
+ if (!value) {
+ this.diagnostics.push({
+ ...DiagnosticMessages.expectedExpressionAfterArrow(),
+ location: arrow.location
+ });
+ throw this.lastDiagnosticAsError();
+ }
+ //store the body as a `return` statement so the rest of the compiler treats this like any other function
+ const body = new Block({
+ statements: [new ReturnStatement({ value: value })]
+ });
+ return new FunctionExpression({
+ leftParen: leftParen,
+ parameters: params,
+ rightParen: rightParen,
+ as: asToken,
+ returnTypeExpression: returnTypeExpression,
+ arrow: arrow,
+ body: body
+ });
+ } finally {
+ this.namespaceAndFunctionDepth--;
+ this.callExpressions = previousCallExpressions;
+ }
+ }
+
private boolean(): Expression {
let expr = this.relational();
@@ -3731,6 +3864,13 @@ export class Parser {
}
private primary(): Expression {
+ //`(` might start an arrow function (`(a, b) => a + b`) rather than a grouping expression
+ if (this.check(TokenKind.LeftParen)) {
+ const arrowFunction = this.tryArrowFunction();
+ if (arrowFunction) {
+ return arrowFunction;
+ }
+ }
switch (true) {
case this.matchAny(
TokenKind.False,
diff --git a/src/parser/tests/expression/ArrowFunction.spec.ts b/src/parser/tests/expression/ArrowFunction.spec.ts
new file mode 100644
index 000000000..41397ac63
--- /dev/null
+++ b/src/parser/tests/expression/ArrowFunction.spec.ts
@@ -0,0 +1,1738 @@
+import { expect } from '../../../chai-config.spec';
+import { Range } from 'vscode-languageserver';
+import { DiagnosticMessages } from '../../../DiagnosticMessages';
+import { Program } from '../../../Program';
+import { expectDiagnostics, expectZeroDiagnostics, getTestGetTypedef, getTestTranspile, rootDir } from '../../../testHelpers.spec';
+import { ParseMode, Parser } from '../../Parser';
+import { isAALiteralExpression, isArrayLiteralExpression, isBinaryExpression, isCallExpression, isFunctionExpression, isGroupingExpression, isLiteralExpression, isPrintStatement, isReturnStatement, isTernaryExpression, isTypecastExpression, isVoidType } from '../../../astUtils/reflection';
+import { WalkMode } from '../../../astUtils/visitors';
+import type { AssignmentStatement, FunctionStatement, PrintStatement, ReturnStatement } from '../../Statement';
+import type { AALiteralExpression, AAMemberExpression, ArrayLiteralExpression, BinaryExpression, CallExpression, FunctionExpression, GroupingExpression } from '../../Expression';
+import type { AstNode } from '../../AstNode';
+import { SymbolTypeFlag } from '../../../SymbolTypeFlag';
+import type { BrsFile } from '../../../files/BrsFile';
+import { TokenKind } from '../../../lexer/TokenKind';
+import { util } from '../../../util';
+
+describe('arrow functions', () => {
+ /**
+ * Parse `code` as the body of a function and return the parsed statements
+ */
+ function parseBody(code: string, mode = ParseMode.BrighterScript) {
+ const parser = Parser.parse(`function main()\n ${code}\nend function`, { mode: mode });
+ expectZeroDiagnostics(parser.diagnostics);
+ return (parser.ast.statements[0] as FunctionStatement).func.body.statements;
+ }
+
+ /**
+ * Parse `code` as the only statement of a function body and return the value of its assignment
+ */
+ function parseValue(code: string) {
+ const assignment = parseBody(code)[0] as AssignmentStatement;
+ return assignment.value;
+ }
+
+ function expectArrow(value: AstNode) {
+ expect(isFunctionExpression(value), 'expected an arrow function').to.be.true;
+ const func = value as FunctionExpression;
+ expect(func.isArrowFunction).to.be.true;
+ return func;
+ }
+
+ function parseArrow(code: string) {
+ return expectArrow(parseValue(code));
+ }
+
+ describe('parser', () => {
+ describe('forms', () => {
+ it('parses an arrow function with no params', () => {
+ const func = parseArrow('getZero = () => 0');
+ expect(func.parameters).to.be.empty;
+ expect(isLiteralExpression(func.arrowBody)).to.be.true;
+ //the body is stored as a `return` statement, and there are no function keywords
+ expect(func.body.statements).to.have.lengthOf(1);
+ expect(isReturnStatement(func.body.statements[0])).to.be.true;
+ expect(func.tokens.functionType).to.be.undefined;
+ expect(func.tokens.endFunctionType).to.be.undefined;
+ expect(func.tokens.arrow.kind).to.eql(TokenKind.Arrow);
+ });
+
+ it('parses a single param', () => {
+ const func = parseArrow('twice = (x) => x * 2');
+ expect(func.parameters.map(p => p.tokens.name.text)).to.eql(['x']);
+ expect(isBinaryExpression(func.arrowBody)).to.be.true;
+ });
+
+ it('parses multiple untyped params', () => {
+ const func = parseArrow('add = (a, b, c) => a + b + c');
+ expect(func.parameters.map(p => p.tokens.name.text)).to.eql(['a', 'b', 'c']);
+ });
+
+ it('parses typed params and a return type', () => {
+ const func = parseArrow('add = (a as integer, b as integer) as integer => a + b');
+ expect(func.parameters.map(p => p.tokens.name.text)).to.eql(['a', 'b']);
+ expect(func.parameters[0].typeExpression.getType({ flags: SymbolTypeFlag.typetime }).toString()).to.eql('integer');
+ expect(func.tokens.as).to.exist;
+ expect(func.returnTypeExpression.getType({ flags: SymbolTypeFlag.typetime }).toString()).to.eql('integer');
+ });
+
+ it('parses a mix of typed and untyped params', () => {
+ const func = parseArrow('f = (a, b as string, c) => a');
+ expect(func.parameters[0].typeExpression).to.be.undefined;
+ expect(func.parameters[1].typeExpression).to.exist;
+ expect(func.parameters[2].typeExpression).to.be.undefined;
+ });
+
+ it('parses typed array params and union return types', () => {
+ const func = parseArrow('first = (items as integer[]) as integer or string => items[0]');
+ expect(func.parameters[0].typeExpression.getType({ flags: SymbolTypeFlag.typetime }).toString()).to.eql('Array');
+ expect(func.returnTypeExpression.getType({ flags: SymbolTypeFlag.typetime }).toString()).to.eql('integer or string');
+ });
+
+ it('parses function-typed params', () => {
+ const func = parseArrow('apply = (callback as function(x as integer) as integer) => callback(1)');
+ expect(func.parameters[0].typeExpression.getType({ flags: SymbolTypeFlag.typetime }).toString()).to.eql('function (x as integer) as integer');
+ });
+
+ it('parses a void return type', () => {
+ const func = parseArrow('logIt = (message as string) as void => doLog(message)');
+ expect(isVoidType(func.returnTypeExpression.getType({ flags: SymbolTypeFlag.typetime }))).to.be.true;
+ });
+
+ it('parses default param values', () => {
+ const func = parseArrow('greet = (name = "world") => "hello " + name');
+ expect(func.parameters[0].defaultValue).to.exist;
+ expect(func.parameters[0].tokens.equals).to.exist;
+ });
+
+ it('parses a default value that refers to an earlier param', () => {
+ const func = parseArrow('f = (a, b = a) => a + b');
+ expect(func.parameters[1].defaultValue).to.exist;
+ });
+
+ it('parses an arrow function as a default param value', () => {
+ const func = parseArrow('f = (callback = (x) => x) => callback(1)');
+ expectArrow(func.parameters[0].defaultValue);
+ });
+
+ it('parses without any whitespace', () => {
+ const func = parseArrow('f=(x)=>x*2');
+ expect(func.parameters.map(p => p.tokens.name.text)).to.eql(['x']);
+ expect(isBinaryExpression(func.arrowBody)).to.be.true;
+ });
+
+ it('parses with extra whitespace', () => {
+ const func = parseArrow('f = ( x , y ) => x');
+ expect(func.parameters.map(p => p.tokens.name.text)).to.eql(['x', 'y']);
+ });
+
+ it('stops the body at a colon statement separator', () => {
+ const statements = parseBody('f = (x) => x : print f(1)');
+ expect(statements).to.have.lengthOf(2);
+ expectArrow((statements[0] as AssignmentStatement).value);
+ expect(isPrintStatement(statements[1])).to.be.true;
+ });
+ });
+
+ describe('body', () => {
+ it('parses a parenthesized AA literal body', () => {
+ const func = parseArrow('makePoint = (x, y) => ({ x: x, y: y })');
+ expect(isGroupingExpression(func.arrowBody)).to.be.true;
+ expect(isAALiteralExpression((func.arrowBody as GroupingExpression).expression)).to.be.true;
+ });
+
+ it('parses an array literal body', () => {
+ const func = parseArrow('pair = (x, y) => [x, y]');
+ expect(isArrayLiteralExpression(func.arrowBody)).to.be.true;
+ });
+
+ it('parses a body that starts with a grouping expression', () => {
+ const func = parseArrow('f = (x) => (x + 1) * 2');
+ expect(isBinaryExpression(func.arrowBody)).to.be.true;
+ expect(isGroupingExpression((func.arrowBody as BinaryExpression).left)).to.be.true;
+ });
+
+ it('extends the body as far right as possible', () => {
+ const func = parseArrow('pick = (a) => a ? 1 : 2');
+ expect(isTernaryExpression(func.arrowBody)).to.be.true;
+ });
+
+ it('parses a call body', () => {
+ const func = parseArrow('f = (x) => doThing(x, 1)');
+ expect(isCallExpression(func.arrowBody)).to.be.true;
+ });
+
+ it('parses a typecast body', () => {
+ const func = parseArrow('f = (x) => x as string');
+ expect(isTypecastExpression(func.arrowBody)).to.be.true;
+ });
+
+ it('parses nested arrow functions', () => {
+ const outer = parseArrow('f = (a) => (b) => b');
+ const inner = expectArrow(outer.arrowBody);
+ expect(inner.parameters.map(p => p.tokens.name.text)).to.eql(['b']);
+ });
+
+ it('parses a body that immediately invokes another arrow function', () => {
+ const func = parseArrow('f = (x) => ((y) => y * 2)(x)');
+ expect(isCallExpression(func.arrowBody)).to.be.true;
+ const callee = (func.arrowBody as CallExpression).callee;
+ expect(isGroupingExpression(callee)).to.be.true;
+ expectArrow((callee as GroupingExpression).expression);
+ });
+ });
+
+ describe('contexts', () => {
+ it('parses an arrow function as a call argument', () => {
+ const value = parseValue('result = filter(items, (i) => i.rating > 3.5)');
+ expect(isCallExpression(value)).to.be.true;
+ expectArrow((value as CallExpression).args[1]);
+ });
+
+ it('parses several arrow functions in one call', () => {
+ const value = parseValue('result = combine((a) => a, (b) => b, (c) => c)') as CallExpression;
+ expect(value.args).to.have.lengthOf(3);
+ for (const arg of value.args) {
+ expectArrow(arg);
+ }
+ });
+
+ it('parses an immediately-invoked arrow function', () => {
+ const value = parseValue('y = ((a, b) => a + b)(1, 2)');
+ expect(isCallExpression(value)).to.be.true;
+ const callee = (value as CallExpression).callee;
+ expect(isGroupingExpression(callee)).to.be.true;
+ expectArrow((callee as GroupingExpression).expression);
+ expect((value as CallExpression).args).to.have.lengthOf(2);
+ });
+
+ it('parses arrow functions inside an array literal', () => {
+ const value = parseValue('arr = [(x) => x, (y) => y * 2]') as ArrayLiteralExpression;
+ expect(value.elements).to.have.lengthOf(2);
+ expectArrow(value.elements[0]);
+ expectArrow(value.elements[1]);
+ });
+
+ it('parses an arrow function as an AA literal value', () => {
+ const value = parseValue('aa = { callback: (x) => x }') as AALiteralExpression;
+ expectArrow((value.elements[0] as AAMemberExpression).value);
+ });
+
+ it('parses an arrow function in a return statement', () => {
+ const statement = parseBody('return (z) => z')[0] as ReturnStatement;
+ expect(isReturnStatement(statement)).to.be.true;
+ expectArrow(statement.value);
+ });
+
+ it('parses an immediately-invoked arrow function in a print statement', () => {
+ const statement = parseBody('print ((x) => x * 2)(4)')[0] as PrintStatement;
+ expect(isPrintStatement(statement)).to.be.true;
+ expect(isCallExpression(statement.expressions[0])).to.be.true;
+ });
+
+ it('parses an arrow function as a class field initializer', () => {
+ const parser = Parser.parse(`
+ class Person
+ callback = (x as integer) => x
+ end class
+ `, { mode: ParseMode.BrighterScript });
+ expectZeroDiagnostics(parser.diagnostics);
+ const field = (parser.ast.statements[0] as any).fields[0];
+ expectArrow(field.initialValue);
+ });
+
+ it('parses an arrow function inside a namespace', () => {
+ const parser = Parser.parse(`
+ namespace alpha
+ sub main()
+ f = (x) => beta(x)
+ end sub
+ end namespace
+ `, { mode: ParseMode.BrighterScript });
+ expectZeroDiagnostics(parser.diagnostics);
+ const func = parser.ast.findChild((node) => isFunctionExpression(node) && node.isArrowFunction, { walkMode: WalkMode.visitAllRecursive });
+ expect(func).to.exist;
+ });
+ });
+
+ describe('does not disturb existing syntax', () => {
+ it('still parses grouping expressions', () => {
+ const value = parseValue('x = (a + b) * 2');
+ expect(isBinaryExpression(value)).to.be.true;
+ expect(isGroupingExpression((value as BinaryExpression).left)).to.be.true;
+ });
+
+ it('still parses a parenthesized variable', () => {
+ const value = parseValue('x = (a)');
+ expect(isGroupingExpression(value)).to.be.true;
+ });
+
+ it('still parses a parenthesized typecast', () => {
+ const value = parseValue('x = (a as integer)');
+ expect(isGroupingExpression(value)).to.be.true;
+ expect(isTypecastExpression((value as GroupingExpression).expression)).to.be.true;
+ });
+
+ it('still parses a parenthesized typecast followed by more expression', () => {
+ const value = parseValue('x = (a as integer) + 1');
+ expect(isBinaryExpression(value)).to.be.true;
+ expect(isGroupingExpression((value as BinaryExpression).left)).to.be.true;
+ });
+
+ it('still parses a parenthesized comparison', () => {
+ const value = parseValue('x = (a = 1)');
+ expect(isGroupingExpression(value)).to.be.true;
+ expect(isBinaryExpression((value as GroupingExpression).expression)).to.be.true;
+ });
+
+ it('still parses a parenthesized call', () => {
+ const value = parseValue('x = (a(1))');
+ expect(isGroupingExpression(value)).to.be.true;
+ expect(isCallExpression((value as GroupingExpression).expression)).to.be.true;
+ });
+
+ it('still parses a parenthesized condition in an if statement', () => {
+ const statements = parseBody('if (a) then print 1');
+ expect(statements).to.have.lengthOf(1);
+ });
+
+ it('still parses a greater-than-or-equal comparison', () => {
+ const value = parseValue('x = a >= b') as BinaryExpression;
+ expect(isBinaryExpression(value)).to.be.true;
+ expect(value.tokens.operator.kind).to.eql(TokenKind.GreaterEqual);
+ });
+
+ it('still parses regular anonymous functions', () => {
+ const statements = parseBody('f = function(x)\n return x\n end function');
+ const func = (statements[0] as AssignmentStatement).value as FunctionExpression;
+ expect(isFunctionExpression(func)).to.be.true;
+ expect(func.isArrowFunction).to.be.false;
+ expect(func.arrowBody).to.be.undefined;
+ });
+ });
+
+ describe('locations', () => {
+ it('spans from the open paren to the end of the body', () => {
+ const func = parseArrow('twice = (x) => x * 2');
+ // twice = (x) => x * 2
+ expect(func.location.range).to.eql(Range.create(1, 12, 1, 24));
+ });
+
+ it('spans a typed arrow function', () => {
+ const func = parseArrow('add = (a as integer, b as integer) as integer => a + b');
+ // add = (a as integer, b as integer) as integer => a + b
+ expect(func.location.range).to.eql(Range.create(1, 10, 1, 58));
+ });
+
+ it('locates the arrow token', () => {
+ const func = parseArrow('twice = (x) => x * 2');
+ expect(func.tokens.arrow.text).to.eql('=>');
+ expect(func.tokens.arrow.location.range).to.eql(Range.create(1, 16, 1, 18));
+ });
+
+ it('locates the body expression', () => {
+ const func = parseArrow('twice = (x) => x * 2');
+ expect(func.arrowBody.location.range).to.eql(Range.create(1, 19, 1, 24));
+ //the synthesized return statement has the same range as the body (there is no `return` token)
+ expect(func.body.statements[0].location.range).to.eql(Range.create(1, 19, 1, 24));
+ });
+ });
+
+ describe('ast', () => {
+ it('links parents', () => {
+ const func = parseArrow('twice = (x) => x * 2');
+ expect(func.body.parent).to.equal(func);
+ expect(func.body.statements[0].parent).to.equal(func.body);
+ expect(func.arrowBody.parent).to.equal(func.body.statements[0]);
+ expect(func.parameters[0].parent).to.equal(func);
+ });
+
+ it('is found by recursive walks, including its synthesized return statement', () => {
+ const parser = Parser.parse(`sub main()\n f = (x) => x\nend sub`, { mode: ParseMode.BrighterScript });
+ const arrows = parser.ast.findChildren((node) => isFunctionExpression(node) && node.isArrowFunction, { walkMode: WalkMode.visitAllRecursive });
+ expect(arrows).to.have.lengthOf(1);
+ const returns = parser.ast.findChildren(isReturnStatement, { walkMode: WalkMode.visitAllRecursive });
+ expect(returns).to.have.lengthOf(1);
+ });
+
+ it('clones', () => {
+ const func = parseArrow('twice = (x) => x * 2');
+ const clone = func.clone();
+ expect(clone.isArrowFunction).to.be.true;
+ expect(clone.arrowBody).to.exist;
+ expect(clone.arrowBody).not.to.equal(func.arrowBody);
+ expect(clone.tokens.arrow).not.to.equal(func.tokens.arrow);
+ expect(clone.tokens.arrow.text).to.eql('=>');
+ expect(clone.body.parent).to.equal(clone);
+ expect(clone.location.range).to.eql(func.location.range);
+ });
+ });
+
+ describe('diagnostics', () => {
+ it('flags a missing body before a newline', () => {
+ const parser = Parser.parse(`sub main()\n x = (a) =>\nend sub`, { mode: ParseMode.BrighterScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.expectedExpressionAfterArrow()
+ ]);
+ });
+
+ it('flags a missing body before a colon', () => {
+ const parser = Parser.parse(`sub main()\n x = (a) => : print 1\nend sub`, { mode: ParseMode.BrighterScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.expectedExpressionAfterArrow()
+ ]);
+ });
+
+ it('flags a missing body at the end of the file', () => {
+ const parser = Parser.parse(`x = (a) =>`, { mode: ParseMode.BrighterScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.expectedExpressionAfterArrow()
+ ]);
+ });
+
+ it('flags required params after optional ones', () => {
+ const parser = Parser.parse(`sub main()\n f = (a = 1, b) => a + b\nend sub`, { mode: ParseMode.BrighterScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.requiredParameterMayNotFollowOptionalParameter('b')
+ ]);
+ });
+
+ it('flags a missing type after "as" but still parses the arrow function', () => {
+ const parser = Parser.parse(`sub main()\n f = (a as) => a\nend sub`, { mode: ParseMode.BrighterScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.expectedIdentifier('as')
+ ]);
+ const func = parser.ast.findChild((node) => isFunctionExpression(node) && node.isArrowFunction, { walkMode: WalkMode.visitAllRecursive });
+ expect(func).to.exist;
+ });
+
+ it('reports an unmatched paren the same as a grouping expression would', () => {
+ const parser = Parser.parse(`sub main()\n f = (a, b => a\nend sub`, { mode: ParseMode.BrighterScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.unmatchedLeftToken('(', 'expression')
+ ]);
+ });
+
+ it('flags a standalone arrow function statement', () => {
+ const parser = Parser.parse(`sub main()\n (x) => x\nend sub`, { mode: ParseMode.BrighterScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.expectedStatement()
+ ]);
+ });
+
+ it('is not allowed in brightscript mode', () => {
+ const parser = Parser.parse(`sub main()\n f = (a) => a\nend sub`, { mode: ParseMode.BrightScript });
+ expectDiagnostics(parser.diagnostics, [
+ DiagnosticMessages.bsFeatureNotSupportedInBrsFiles('arrow functions')
+ ]);
+ });
+
+ it('still parses groupings in brightscript mode without diagnostics', () => {
+ const parser = Parser.parse(`sub main()\n x = (a + b) * 2\n y = (a)\nend sub`, { mode: ParseMode.BrightScript });
+ expectZeroDiagnostics(parser.diagnostics);
+ });
+ });
+ });
+
+ describe('program', () => {
+ let program: Program;
+ const testTranspile = getTestTranspile(() => [program, rootDir]);
+ const testGetTypedef = getTestGetTypedef(() => [program, rootDir]);
+
+ beforeEach(() => {
+ program = new Program({ rootDir: rootDir, sourceMap: true });
+ });
+ afterEach(() => {
+ program.dispose();
+ });
+
+ /**
+ * Validate `source/main.bs` containing `code` (expecting no diagnostics) and return the first arrow function in it
+ */
+ function getArrowFunction(code: string) {
+ const file = program.setFile('source/main.bs', code);
+ program.validate();
+ expectZeroDiagnostics(program);
+ return file.ast.findChild((node) => isFunctionExpression(node) && node.isArrowFunction, { walkMode: WalkMode.visitAllRecursive });
+ }
+
+ /**
+ * Validate `source/main.bs` containing `code` and return the codes of the resulting diagnostics
+ */
+ function getDiagnosticCodes(code: string) {
+ program.setFile('source/main.bs', code);
+ program.validate();
+ return program.getDiagnostics().map(x => x.code);
+ }
+
+ describe('transpile', () => {
+ it('transpiles to an anonymous function that returns the expression', async () => {
+ await testTranspile(`
+ sub main()
+ twice = (x) => x * 2
+ end sub
+ `, `
+ sub main()
+ twice = function(x)
+ return x * 2
+ end function
+ end sub
+ `);
+ });
+
+ it('transpiles an arrow function with no params', async () => {
+ await testTranspile(`
+ sub main()
+ getZero = () => 0
+ end sub
+ `, `
+ sub main()
+ getZero = function()
+ return 0
+ end function
+ end sub
+ `);
+ });
+
+ it('keeps param and return types', async () => {
+ await testTranspile(`
+ sub main()
+ add = (a as integer, b as integer) as integer => a + b
+ end sub
+ `, `
+ sub main()
+ add = function(a as integer, b as integer) as integer
+ return a + b
+ end function
+ end sub
+ `);
+ });
+
+ it('removes param and return types when removeParameterTypes is set', async () => {
+ program.options.removeParameterTypes = true;
+ await testTranspile(`
+ sub main()
+ add = (a as integer, b as integer) as integer => a + b
+ end sub
+ `, `
+ sub main()
+ add = function(a, b)
+ return a + b
+ end function
+ end sub
+ `);
+ });
+
+ it('converts brighterscript-only param types', async () => {
+ await testTranspile(`
+ class Person
+ name as string
+ end class
+ sub main()
+ getName = (p as Person, others as Person[]) => p.name
+ end sub
+ `, `
+ sub __Person_method_new()
+ m.name = invalid
+ end sub
+ function __Person_builder()
+ instance = {}
+ instance.new = __Person_method_new
+ return instance
+ end function
+ function Person()
+ instance = __Person_builder()
+ instance.new()
+ return instance
+ end function
+
+ sub main()
+ getName = function(p as dynamic, others as dynamic)
+ return p.name
+ end function
+ end sub
+ `);
+ });
+
+ it('keeps default param values', async () => {
+ await testTranspile(`
+ sub main()
+ greet = (name = "world") => "hello " + name
+ end sub
+ `, `
+ sub main()
+ greet = function(name = "world")
+ return "hello " + name
+ end function
+ end sub
+ `);
+ });
+
+ it('keeps a default value that refers to an earlier param', async () => {
+ await testTranspile(`
+ sub main()
+ f = (a, b = a) => a + b
+ end sub
+ `, `
+ sub main()
+ f = function(a, b = a)
+ return a + b
+ end function
+ end sub
+ `);
+ });
+
+ it('transpiles as a call argument', async () => {
+ await testTranspile(`
+ sub main()
+ result = apply((i) => i > 1)
+ end sub
+ function apply(predicate)
+ return predicate(2)
+ end function
+ `, `
+ sub main()
+ result = apply(function(i)
+ return i > 1
+ end function)
+ end sub
+
+ function apply(predicate)
+ return predicate(2)
+ end function
+ `);
+ });
+
+ it('transpiles several arrow functions in one call', async () => {
+ await testTranspile(`
+ sub main()
+ result = combine((a) => a, (b) => b)
+ end sub
+ function combine(first, second)
+ return first(1) + second(2)
+ end function
+ `, `
+ sub main()
+ result = combine(function(a)
+ return a
+ end function, function(b)
+ return b
+ end function)
+ end sub
+
+ function combine(first, second)
+ return first(1) + second(2)
+ end function
+ `);
+ });
+
+ it('transpiles nested arrow functions', async () => {
+ await testTranspile(`
+ sub main()
+ f = (a) => (b) => b
+ end sub
+ `, `
+ sub main()
+ f = function(a)
+ return function(b)
+ return b
+ end function
+ end function
+ end sub
+ `);
+ });
+
+ it('transpiles immediately-invoked arrow functions', async () => {
+ await testTranspile(`
+ sub main()
+ print ((x) => x * 2)(4)
+ y = ((a, b) => a + b)(1, 2)
+ end sub
+ `, `
+ sub main()
+ print (function(x)
+ return x * 2
+ end function)(4)
+ y = (function(a, b)
+ return a + b
+ end function)(1, 2)
+ end sub
+ `);
+ });
+
+ it('transpiles a parenthesized AA literal body', async () => {
+ await testTranspile(`
+ sub main()
+ makePoint = (x, y) => ({ x: x, y: y })
+ end sub
+ `, `
+ sub main()
+ makePoint = function(x, y)
+ return ({
+ x: x
+ y: y
+ })
+ end function
+ end sub
+ `);
+ });
+
+ it('transpiles a template string body', async () => {
+ await testTranspile(`
+ sub main()
+ greet = (name) => \`hi \${name}\`
+ end sub
+ `, `
+ sub main()
+ greet = function(name)
+ return ("hi " + bslib_toString(name))
+ end function
+ end sub
+ `);
+ });
+
+ it('transpiles a null-coalescing body', async () => {
+ await testTranspile(`
+ sub main()
+ orOne = (x) => x ?? 1
+ end sub
+ `, `
+ sub main()
+ orOne = function(x)
+ return bslib_coalesce(x, 1)
+ end function
+ end sub
+ `);
+ });
+
+ it('transpiles a ternary body', async () => {
+ await testTranspile(`
+ sub main()
+ size = (x) => x > 1 ? "big" : "small"
+ end sub
+ `, `
+ sub main()
+ size = function(x)
+ return bslib_ternary(x > 1, "big", "small")
+ end function
+ end sub
+ `);
+ });
+
+ it('transpiles arrow functions as ternary branches', async () => {
+ await testTranspile(`
+ sub main(useA)
+ callback = useA ? (a) => a : (b) => b
+ end sub
+ `, `
+ sub main(useA)
+ if useA then
+ callback = function(a)
+ return a
+ end function
+ else
+ callback = function(b)
+ return b
+ end function
+ end if
+ end sub
+ `);
+ });
+
+ it('transpiles an arrow function as a null-coalescing fallback', async () => {
+ await testTranspile(`
+ sub main(handler)
+ callback = handler ?? ((x) => x)
+ end sub
+ `, `
+ sub main(handler)
+ callback = bslib_coalesce(handler, (function(x)
+ return x
+ end function))
+ end sub
+ `);
+ });
+
+ it('transpiles arrow functions inside array literals, AA literals and return statements', async () => {
+ await testTranspile(`
+ function main()
+ arr = [(x) => x, (y) => y * 2]
+ aa = { callback: (x) => x }
+ return (z) => z
+ end function
+ `, `
+ function main()
+ arr = [
+ function(x)
+ return x
+ end function
+ function(y)
+ return y * 2
+ end function
+ ]
+ aa = {
+ callback: function(x)
+ return x
+ end function
+ }
+ return function(z)
+ return z
+ end function
+ end function
+ `);
+ });
+
+ it('transpiles an arrow function as a callfunc argument', async () => {
+ await testTranspile(`
+ sub main(node as roSGNode)
+ node@.doIt((x) => x)
+ end sub
+ `, `
+ sub main(node as dynamic)
+ node.callfunc("doIt", function(x)
+ return x
+ end function)
+ end sub
+ `);
+ });
+
+ it('resolves namespace-relative references inside the body', async () => {
+ await testTranspile(`
+ namespace alpha
+ function beta(x)
+ return x
+ end function
+ sub main()
+ f = (x) => beta(x)
+ g = (x) => alpha.beta(x)
+ end sub
+ end namespace
+ `, `
+ function alpha_beta(x)
+ return x
+ end function
+
+ sub alpha_main()
+ f = function(x)
+ return alpha_beta(x)
+ end function
+ g = function(x)
+ return alpha_beta(x)
+ end function
+ end sub
+ `);
+ });
+
+ it('inlines enums and consts used inside the body', async () => {
+ program.setFile('source/main.bs', `
+ enum Direction
+ up = 1
+ end enum
+ const MAX = 10
+ sub main()
+ f = (x) => x + Direction.up + MAX
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ const result = await program.getTranspiledFileContents('source/main.bs');
+ expect(result.code).to.include('return x + 1 + 10');
+ });
+
+ it('transpiles class constructors called inside the body', async () => {
+ program.setFile('source/main.bs', `
+ class Person
+ end class
+ sub main()
+ make = () => new Person()
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ const result = await program.getTranspiledFileContents('source/main.bs');
+ expect(result.code).to.include('make = function()\n return Person()\n end function');
+ });
+
+ it('transpiles an arrow function as a class field initializer', async () => {
+ program.setFile('source/main.bs', `
+ class Person
+ callback = (x as integer) => x
+ end class
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ const result = await program.getTranspiledFileContents('source/main.bs');
+ expect(result.code).to.include('m.callback = function(x as integer)\n return x\n end function');
+ });
+
+ it('types an arrow function class field as Function in typedefs', async () => {
+ await testGetTypedef(`
+ class Person
+ callback = (x as integer) => x
+ end class
+ `, `
+ class Person
+ sub new()
+ end sub
+ public callback as Function
+ end class
+ `);
+ });
+
+ it('transpiles arrow functions inside conditional compile blocks', async () => {
+ await testTranspile(`
+ sub main()
+ #if true
+ f = (x) => x
+ #else
+ f = (x) => x * 2
+ #end if
+ end sub
+ `, `
+ sub main()
+ #if true
+ f = function(x)
+ return x
+ end function
+ #else
+ f = function(x)
+ return x * 2
+ end function
+ #end if
+ end sub
+ `, 'trim', 'source/main.bs', false);
+ });
+
+ it('keeps a trailing comment', async () => {
+ await testTranspile(`
+ sub main()
+ twice = (x) => x * 2 ' double it
+ end sub
+ `, `
+ sub main()
+ twice = function(x)
+ return x * 2
+ end function ' double it
+ end sub
+ `);
+ });
+
+ it('becomes a sub when the body calls a built-in that returns nothing', async () => {
+ await testTranspile(`
+ sub main()
+ addItem = (list as integer[], item as integer) => list.push(item)
+ end sub
+ `, `
+ sub main()
+ addItem = sub(list as dynamic, item as integer)
+ list.push(item)
+ end sub
+ end sub
+ `);
+ });
+
+ it('becomes a sub when the body calls a sub', async () => {
+ await testTranspile(`
+ sub main()
+ logIt = (message as string) => doLog(message)
+ end sub
+ sub doLog(message as string)
+ end sub
+ `, `
+ sub main()
+ logIt = sub(message as string)
+ doLog(message)
+ end sub
+ end sub
+
+ sub doLog(message as string)
+ end sub
+ `);
+ });
+
+ it('becomes a sub when the body calls a function declared as void', async () => {
+ await testTranspile(`
+ sub main()
+ logIt = (message as string) => doLog(message)
+ end sub
+ function doLog(message as string) as void
+ end function
+ `, `
+ sub main()
+ logIt = sub(message as string)
+ doLog(message)
+ end sub
+ end sub
+
+ function doLog(message as string) as void
+ end function
+ `);
+ });
+
+ it('becomes a sub when the body calls a namespaced sub', async () => {
+ await testTranspile(`
+ namespace alpha
+ sub doLog(message as string)
+ end sub
+ end namespace
+ sub main()
+ logIt = (message as string) => alpha.doLog(message)
+ end sub
+ `, `
+ sub alpha_doLog(message as string)
+ end sub
+
+ sub main()
+ logIt = sub(message as string)
+ alpha_doLog(message)
+ end sub
+ end sub
+ `);
+ });
+
+ it('becomes a sub when the return type is void', async () => {
+ await testTranspile(`
+ sub main()
+ logIt = (message as string) as void => doLog(message)
+ end sub
+ sub doLog(message as string)
+ end sub
+ `, `
+ sub main()
+ logIt = sub(message as string) as void
+ doLog(message)
+ end sub
+ end sub
+
+ sub doLog(message as string)
+ end sub
+ `);
+ });
+
+ it('stays a function when the body calls a function that returns a value', async () => {
+ await testTranspile(`
+ sub main()
+ upper = (text as string) => ucase(text)
+ twice = (x as integer) => doubleIt(x)
+ end sub
+ function doubleIt(x as integer) as integer
+ return x * 2
+ end function
+ `, `
+ sub main()
+ upper = function(text as string)
+ return ucase(text)
+ end function
+ twice = function(x as integer)
+ return doubleIt(x)
+ end function
+ end sub
+
+ function doubleIt(x as integer) as integer
+ return x * 2
+ end function
+ `);
+ });
+
+ it('stays a function when the body calls something of unknown type', async () => {
+ await testTranspile(`
+ sub main(callback)
+ run = (x) => callback(x)
+ end sub
+ `, `
+ sub main(callback)
+ run = function(x)
+ return callback(x)
+ end function
+ end sub
+ `, 'trim', 'source/main.bs', false);
+ });
+ });
+
+ describe('types', () => {
+ it('infers the return type from the body', () => {
+ const func = getArrowFunction(`
+ sub main()
+ twice = (x as integer) => x * 2
+ print twice(2)
+ end sub
+ `);
+ expect(func.getType({ flags: SymbolTypeFlag.runtime }).toString()).to.eql('function (x as integer) as integer');
+ });
+
+ it('infers string, boolean and class return types', () => {
+ program.setFile('source/main.bs', `
+ class Person
+ end class
+ sub main()
+ concat = (a as string, b as string) => a + b
+ isBig = (a as integer) => a > 1
+ make = () => new Person()
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ const file = program.getFile('source/main.bs');
+ const arrows = file.ast.findChildren((node) => isFunctionExpression(node) && node.isArrowFunction, { walkMode: WalkMode.visitAllRecursive });
+ const returnTypes = arrows.map(x => x.getType({ flags: SymbolTypeFlag.runtime }).returnType.toString());
+ expect(returnTypes).to.eql(['string', 'boolean', 'Person']);
+ });
+
+ it('uses the declared return type when there is one', () => {
+ const func = getArrowFunction(`
+ sub main()
+ f = (x as integer) as dynamic => x
+ end sub
+ `);
+ expect(func.getType({ flags: SymbolTypeFlag.runtime }).toString()).to.eql('function (x as integer) as dynamic');
+ });
+
+ it('treats untyped params as dynamic', () => {
+ const func = getArrowFunction(`
+ sub main()
+ f = (x) => x
+ end sub
+ `);
+ expect(func.getType({ flags: SymbolTypeFlag.runtime }).toString()).to.eql('function (x as dynamic) as dynamic');
+ });
+
+ it('treats a body that calls a sub as void', () => {
+ const func = getArrowFunction(`
+ sub main()
+ logIt = (message as string) => doLog(message)
+ end sub
+ sub doLog(message as string)
+ end sub
+ `);
+ const type = func.getType({ flags: SymbolTypeFlag.runtime });
+ expect(type.isSub).to.be.true;
+ expect(isVoidType(type.returnType)).to.be.true;
+ });
+
+ it('types a nested arrow function as a function returning a function', () => {
+ const func = getArrowFunction(`
+ sub main()
+ f = (a as integer) => (b as integer) => b
+ end sub
+ `);
+ //the inner arrow function is a plain function value, so this is a function that returns a function
+ expect(func.getType({ flags: SymbolTypeFlag.runtime }).toString()).to.eql('function (a as integer) as function (b as integer) as integer');
+ });
+ });
+
+ describe('validation', () => {
+ describe('captured variables', () => {
+ it('flags a captured local variable', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ factor = 2
+ scale = (x) => x * factor
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('factor')
+ ]);
+ });
+
+ it('flags a captured parameter', () => {
+ program.setFile('source/main.bs', `
+ sub main(factor)
+ scale = (x) => x * factor
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('factor')
+ ]);
+ });
+
+ it('flags a captured variable that is called as a function', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ callback = function(x)
+ return x
+ end function
+ f = (x) => callback(x)
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('callback')
+ ]);
+ });
+
+ it('flags a captured variable used through a member access', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ config = { factor: 2 }
+ scale = (x) => x * config.factor
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('config')
+ ]);
+ });
+
+ it('flags a variable captured from an enclosing arrow function', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (a) => (b) => a + b
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('a')
+ ]);
+ });
+
+ it('flags a variable captured from an enclosing anonymous function', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ g = function()
+ inner = 1
+ f = (x) => x + inner
+ return f
+ end function
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('inner')
+ ]);
+ });
+
+ it('flags a local declared after the arrow function exactly once', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (x) => x + later
+ later = 1
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('later')
+ ]);
+ });
+
+ it('flags a captured loop variable', () => {
+ program.setFile('source/main.bs', `
+ sub main(items)
+ for each item in items
+ f = (x) => item
+ end for
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('item')
+ ]);
+ });
+
+ it('flags a captured exception variable', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ try
+ print 1
+ catch e
+ f = () => e
+ end try
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('e')
+ ]);
+ });
+
+ it('flags a captured variable used in a default param value', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ factor = 2
+ f = (x = factor) => x
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('factor')
+ ]);
+ });
+
+ it('flags each captured variable', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ a = 1
+ b = 2
+ f = () => a + b
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('a'),
+ DiagnosticMessages.arrowFunctionCannotCaptureVariable('b')
+ ]);
+ });
+
+ it('still reports unknown names as cannot-find-name', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (x) => x + unknownThing
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.cannotFindName('unknownThing')
+ ]);
+ });
+
+ it('allows a param that shadows an enclosing local', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ factor = 2
+ f = (factor) => factor * 2
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('allows a default value that refers to an earlier param', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (a, b = a) => a + b
+ print f(1)
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('allows a regular anonymous function inside an arrow function to use the arrow params', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (x) => function(y)
+ return y
+ end function
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+ });
+
+ describe('m', () => {
+ it('flags m', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ render = (data) => m.view.setData(data)
+ getM = () => m
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotUseM(),
+ DiagnosticMessages.arrowFunctionCannotUseM()
+ ]);
+ });
+
+ it('flags m regardless of case', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ getM = () => M
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotUseM()
+ ]);
+ });
+
+ it('flags m inside an arrow function in a class', () => {
+ program.setFile('source/main.bs', `
+ class Person
+ name as string
+ callback = () => m.name
+ sub test()
+ f = () => m.name
+ end sub
+ end class
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotUseM(),
+ DiagnosticMessages.arrowFunctionCannotUseM()
+ ]);
+ });
+
+ it('flags m inside a nested arrow function', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (x) => (y) => m.value
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.arrowFunctionCannotUseM()
+ ]);
+ });
+
+ it('allows m inside a regular anonymous function nested in an arrow function', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (x) => function()
+ return m.value
+ end function
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('allows m in the enclosing function around an arrow function', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ m.callback = (x) => x
+ print m.callback(1)
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('allows a param named like a member of m to be passed in instead', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ render = (data, view) => view.setData(data)
+ render({}, m.view)
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+ });
+
+ describe('allowed references', () => {
+ it('allows file-level functions, namespaces and globals', () => {
+ program.setFile('source/main.bs', `
+ namespace alpha
+ function beta(x)
+ return x
+ end function
+ sub main()
+ f = (x) => beta(x)
+ g = (x) => alpha.beta(x)
+ h = (x) => lcase(x)
+ end sub
+ end namespace
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('allows functions from other files in the same scope', () => {
+ program.setFile('source/lib.bs', `
+ function helper(x)
+ return x
+ end function
+ `);
+ program.setFile('source/main.bs', `
+ sub main()
+ f = (x) => helper(x)
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('allows enums, consts, classes and interfaces', () => {
+ program.setFile('source/main.bs', `
+ enum Direction
+ up = 1
+ end enum
+ const MAX = 10
+ interface Movie
+ title as string
+ end interface
+ class Person
+ name as string
+ end class
+ sub main()
+ f = (x as integer) => x + Direction.up + MAX
+ g = (movie as Movie) => movie.title
+ h = () => new Person()
+ i = (p as Person) => p.name
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('validates member access on typed params', () => {
+ expect(getDiagnosticCodes(`
+ class Person
+ name as string
+ end class
+ sub main()
+ f = (p as Person) => p.age
+ end sub
+ `)).to.eql(['cannot-find-name']);
+ });
+ });
+
+ describe('type checking', () => {
+ it('validates against function types', () => {
+ program.setFile('source/main.bs', `
+ sub apply(callback as function(x as integer) as integer)
+ end sub
+ sub main()
+ apply((x as integer) => x * 2)
+ apply((x as string) => x + "!")
+ end sub
+ `);
+ program.validate();
+ expect(program.getDiagnostics().map(x => x.code)).to.eql(['argument-type-mismatch']);
+ });
+
+ it('validates the param count against function types', () => {
+ expect(getDiagnosticCodes(`
+ sub apply(callback as function(x as integer) as integer)
+ end sub
+ sub main()
+ apply((a as integer, b as integer) => a + b)
+ end sub
+ `)).to.eql(['argument-type-mismatch']);
+ });
+
+ it('rejects a void arrow function where a value-returning function is expected', () => {
+ expect(getDiagnosticCodes(`
+ sub apply(callback as function(x as integer) as integer)
+ end sub
+ sub doLog(x)
+ end sub
+ sub main()
+ apply((x as integer) => doLog(x))
+ end sub
+ `)).to.eql(['argument-type-mismatch']);
+ });
+
+ it('accepts a void arrow function where a void function is expected', () => {
+ program.setFile('source/main.bs', `
+ sub applySub(callback as sub(x as integer) as void)
+ end sub
+ sub applyFunction(callback as function(x as integer) as void)
+ end sub
+ sub doLog(x)
+ end sub
+ sub main()
+ applySub((x as integer) => doLog(x))
+ applyFunction((x as integer) => doLog(x))
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('validates calls to an arrow function', () => {
+ expect(getDiagnosticCodes(`
+ sub main()
+ twice = (x as integer) => x * 2
+ print twice("a")
+ print twice()
+ print twice(1, 2)
+ end sub
+ `)).to.eql(['argument-type-mismatch', 'incorrect-argument-count', 'incorrect-argument-count']);
+ });
+
+ it('validates the arrow function type in a typed assignment', () => {
+ expect(getDiagnosticCodes(`
+ sub main()
+ good as function(x as integer) as integer = (x as integer) => x
+ bad as function(x as string) as string = (x as integer) => x
+ end sub
+ `)).to.eql(['assignment-type-mismatch']);
+ });
+
+ it('validates an arrow function returned from a function', () => {
+ expect(getDiagnosticCodes(`
+ function make() as function(x as integer) as integer
+ return (x as integer) => x
+ end function
+ function makeBad() as function(x as integer) as integer
+ return (x as string) => x
+ end function
+ `)).to.eql(['return-type-mismatch']);
+ });
+
+ it('flags a body that does not match the declared return type', () => {
+ expect(getDiagnosticCodes(`
+ sub main()
+ f = (x as integer) as string => x
+ end sub
+ `)).to.eql(['return-type-mismatch']);
+ });
+
+ it('flags a void return type with a body that produces a value', () => {
+ expect(getDiagnosticCodes(`
+ sub main()
+ f = (x as integer) as void => x
+ end sub
+ `)).to.eql(['return-type-mismatch']);
+ });
+
+ it('uses the inferred return type at call sites', () => {
+ expect(getDiagnosticCodes(`
+ sub main()
+ twice = (x as integer) => x * 2
+ name as string = twice(1)
+ end sub
+ `)).to.eql(['assignment-type-mismatch']);
+ });
+
+ it('can be passed to untyped and plain function params', () => {
+ program.setFile('source/main.bs', `
+ sub takesAnything(callback)
+ end sub
+ sub takesDynamic(callback as dynamic)
+ end sub
+ sub takesObject(callback as object)
+ end sub
+ sub takesFunction(callback as function)
+ end sub
+ sub main()
+ takesAnything((x) => x)
+ takesDynamic((x) => x)
+ takesObject((x) => x)
+ takesFunction((x) => x)
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('can be stored and called through an AA', () => {
+ program.setFile('source/main.bs', `
+ sub main()
+ handlers = { twice: (x as integer) => x * 2 }
+ print handlers.twice(2)
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+ });
+
+ describe('files', () => {
+ it('is not allowed in brs files', () => {
+ program.setFile('source/main.brs', `
+ sub main()
+ f = (x) => x
+ end sub
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ DiagnosticMessages.bsFeatureNotSupportedInBrsFiles('arrow functions')
+ ]);
+ });
+
+ it('works in component scopes', () => {
+ program.setFile('components/Widget.xml', [
+ '',
+ '',
+ ' ',
+ ''
+ ].join('\n'));
+ program.setFile('components/Widget.bs', `
+ sub init()
+ twice = (x as integer) => x * 2
+ print twice(2)
+ end sub
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+ });
+ });
+
+ describe('editor features', () => {
+ it('provides hover for the arrow function, its call site and its params', () => {
+ const file = program.setFile('source/main.bs', [
+ 'sub main()',
+ ' twice = (count as integer) => count * 2',
+ ' print twice(4)',
+ 'end sub'
+ ].join('\n'));
+ program.validate();
+ expectZeroDiagnostics(program);
+ const hoverText = (line: number, character: number) => {
+ return program.getHover(file.srcPath, util.createPosition(line, character)).map(x => x.contents).flat().join('\n');
+ };
+ expect(hoverText(1, 6)).to.include('function (count as integer) as integer');
+ expect(hoverText(2, 11)).to.include('function twice(count as integer) as integer');
+ expect(hoverText(1, 15)).to.include('count as integer');
+ expect(hoverText(1, 36)).to.include('count as integer');
+ });
+
+ it('offers params but not enclosing locals in completions inside the body', () => {
+ const file = program.setFile('source/main.bs', [
+ 'sub main()',
+ ' factor = 2',
+ ' twice = (count as integer) => count * count',
+ 'end sub',
+ 'function helper()',
+ 'end function'
+ ].join('\n'));
+ program.validate();
+ //inside the second `count`
+ const labels = program.getCompletions(file.srcPath, util.createPosition(2, 44)).map(x => x.label);
+ expect(labels).to.include('count');
+ expect(labels).to.include('helper');
+ expect(labels).not.to.include('factor');
+ expect(labels).not.to.include('twice');
+ });
+
+ it('offers member completions on typed params', () => {
+ const file = program.setFile('source/main.bs', [
+ 'class Person',
+ ' name as string',
+ 'end class',
+ 'sub main()',
+ ' getName = (p as Person) => p.',
+ 'end sub'
+ ].join('\n'));
+ program.validate();
+ const labels = program.getCompletions(file.srcPath, util.createPosition(4, 33)).map(x => x.label);
+ expect(labels).to.include('name');
+ });
+
+ it('goes to the definition of a param and of the arrow function', () => {
+ const file = program.setFile('source/main.bs', [
+ 'sub main()',
+ ' twice = (count as integer) => count * 2',
+ ' print twice(4)',
+ 'end sub'
+ ].join('\n'));
+ program.validate();
+ expectZeroDiagnostics(program);
+ //`count` usage in the body -> the param
+ let definitions = program.getDefinition(file.srcPath, util.createPosition(1, 36));
+ expect(definitions.map(x => (x as any).range ?? (x as any).targetRange)).to.eql([Range.create(1, 13, 1, 18)]);
+ //`twice` at the call site -> the assignment
+ definitions = program.getDefinition(file.srcPath, util.createPosition(2, 11));
+ expect(definitions.map(x => (x as any).range ?? (x as any).targetRange)).to.eql([Range.create(1, 4, 1, 9)]);
+ });
+
+ it('marks params as parameters in semantic tokens', () => {
+ const file = program.setFile('source/main.bs', [
+ 'sub main()',
+ ' twice = (count as integer) => count * 2',
+ 'end sub'
+ ].join('\n'));
+ program.validate();
+ const tokens = program.getSemanticTokens(file.srcPath) ?? [];
+ const paramToken = tokens.find(x => x.range.start.line === 1 && x.range.start.character === 13);
+ expect(paramToken?.tokenType).to.eql('parameter');
+ });
+ });
+ });
+});
diff --git a/src/util.ts b/src/util.ts
index 645fe69d0..29b115947 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -2541,6 +2541,29 @@ export class Util {
}
}
+ /**
+ * Arrow functions can't capture locals from the function they're written in (closures aren't supported yet, see #530).
+ * When an unresolved variable inside an arrow function is such a local, return the arrow-specific diagnostic so the user
+ * learns *why* the name can't be found instead of getting a bare `cannot-find-name`. Returns undefined in every other case.
+ * (`m` inside an arrow function is flagged by BrsFileValidator, since `m` always resolves through the scope)
+ */
+ public getArrowFunctionCaptureDiagnostic(node: AstNode) {
+ if (!isVariableExpression(node)) {
+ return undefined;
+ }
+ const func = node.findAncestor(isFunctionExpression);
+ if (!func?.isArrowFunction) {
+ return undefined;
+ }
+ const name = node.tokens.name?.text;
+ //an arrow function sees the same file-level and global names as the function around it, so a name that only
+ //the enclosing function can resolve must be one of its locals or parameters
+ if (name && func.parent?.getSymbolTable()?.hasSymbol(name, SymbolTypeFlag.runtime)) {
+ return DiagnosticMessages.arrowFunctionCannotCaptureVariable(name);
+ }
+ return undefined;
+ }
+
public processTypeChain(typeChain: TypeChainEntry[]): TypeChainProcessResult {
let fullChainName = '';
let fullErrorName = '';