From e7a5f0295524f3f8000771f23c6f54ec0c8414f6 Mon Sep 17 00:00:00 2001 From: Tyler Smith Date: Fri, 16 Oct 2026 19:37:36 -0300 Subject: [PATCH 1/3] Add arrow function expressions (#1929) Support `(a, b) => a + b` as sugar for an anonymous function that returns its expression. Arrows are FunctionExpressions with a synthesized return, so hover, validation and transpile work unchanged. Capturing enclosing locals and using `m` are errors for now, pending closures (#530). --- docs/arrow-functions.md | 124 +++++ docs/readme.md | 7 + src/DiagnosticMessages.ts | 24 + src/bscPlugin/validation/BrsFileValidator.ts | 15 +- src/bscPlugin/validation/ScopeValidator.ts | 39 +- src/lexer/Lexer.spec.ts | 11 + src/lexer/Lexer.ts | 9 +- src/lexer/TokenKind.ts | 2 + src/parser/Expression.ts | 100 +++- src/parser/Parser.ts | 131 ++++- .../tests/expression/ArrowFunction.spec.ts | 457 ++++++++++++++++++ 11 files changed, 896 insertions(+), 23 deletions(-) create mode 100644 docs/arrow-functions.md create mode 100644 src/parser/tests/expression/ArrowFunction.spec.ts 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/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..ad5dfbdc4 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); @@ -459,6 +460,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` @@ -601,6 +607,13 @@ export class BrsFileValidator { location: node.tokens.name.location }); } + //`m` is not allowed inside arrow functions: what it should mean there is left open until closures are designed (see #1929) + if (name?.toLowerCase() === 'm' && node.findAncestor(isFunctionExpression)?.isArrowFunction) { + this.event.program.diagnostics.register({ + ...DiagnosticMessages.arrowFunctionCannotUseM(), + location: node.tokens.name.location + }); + } }, AstNode: (node) => { //check for doc comments diff --git a/src/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts index 1a2404b66..da598e86a 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) @@ -1183,6 +1189,29 @@ export class ScopeValidator { } + /** + * Arrow functions can't capture locals from the function they're written in (closures aren't supported yet, see #530). + * When an unresolved name 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`. + * (`m` inside an arrow function is flagged by BrsFileValidator, since `m` always resolves through the scope) + */ + private getArrowFunctionNameDiagnostic(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; + } + validateVariableAndDottedGetExpressions(file: BrsFile, expression: VariableExpression | DottedGetExpression) { if (isDottedGetExpression(expression.parent)) { // We validate dottedGetExpressions at the top-most level @@ -1271,7 +1300,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 = this.getArrowFunctionNameDiagnostic(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..859756ac5 100644 --- a/src/lexer/Lexer.spec.ts +++ b/src/lexer/Lexer.spec.ts @@ -42,6 +42,17 @@ 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('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..2e8f02b83 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,103 @@ export class Parser { } } + /** + * Try to parse an arrow function (`(a, b) => a + b`) when the current token is `(`. + * Returns undefined, with the parser position and diagnostics untouched, when the parens turn out to be a + * grouping expression (`(a + b)`) or a parenthesized typecast (`(a as integer)`) instead + */ + private tryArrowFunction(): FunctionExpression | undefined { + //cheap pre-check so ordinary groupings never pay for a backtrack: an arrow function's parens are either empty, + //or start with a parameter name followed by `,`, `)`, `=` or `as` + const first = this.peekNext(); + if (first?.kind !== TokenKind.RightParen) { + const isParamName = first?.kind === TokenKind.Identifier || this.allowedLocalIdentifiers.includes(first?.kind); + const afterFirst = this.tokens[this.current + 2]?.kind; + if (!isParamName || ![TokenKind.Comma, TokenKind.RightParen, TokenKind.Equal, TokenKind.As].includes(afterFirst)) { + return undefined; + } + } + + const backtrack = this.current; + const diagnosticCount = this.diagnostics.length; + //`functionParameter` rewrites the name token's kind, so remember it in case this isn't an arrow function after all + const firstKind = first?.kind; + const abandon = () => { + this.current = backtrack; + this.diagnostics.splice(diagnosticCount); + if (first) { + first.kind = firstKind; + } + 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++; + let arrow: Token; + 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(); + } + if (!this.check(TokenKind.Arrow)) { + return abandon(); + } + arrow = this.advance(); + 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 + }); + } catch (e) { + if (arrow) { + //this is definitely an arrow function and its body failed to parse: let the statement be scrapped as usual + throw e; + } + return abandon(); + } finally { + this.namespaceAndFunctionDepth--; + this.callExpressions = previousCallExpressions; + } + } + private boolean(): Expression { let expr = this.relational(); @@ -3731,6 +3835,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..3f475a651 --- /dev/null +++ b/src/parser/tests/expression/ArrowFunction.spec.ts @@ -0,0 +1,457 @@ +import { expect } from '../../../chai-config.spec'; +import { Range } from 'vscode-languageserver'; +import { DiagnosticMessages } from '../../../DiagnosticMessages'; +import { Program } from '../../../Program'; +import { expectDiagnostics, expectZeroDiagnostics, getTestTranspile, rootDir } from '../../../testHelpers.spec'; +import { ParseMode, Parser } from '../../Parser'; +import { isAALiteralExpression, isBinaryExpression, isCallExpression, isFunctionExpression, isGroupingExpression, isLiteralExpression, isReturnStatement, isTernaryExpression, isTypecastExpression, isVoidType } from '../../../astUtils/reflection'; +import { WalkMode } from '../../../astUtils/visitors'; +import type { AssignmentStatement, FunctionStatement } from '../../Statement'; +import type { BinaryExpression, CallExpression, FunctionExpression, GroupingExpression } from '../../Expression'; +import { SymbolTypeFlag } from '../../../SymbolTypeFlag'; +import type { BrsFile } from '../../../files/BrsFile'; + +describe('arrow functions', () => { + /** + * Parse `code` as the only statement of a function body and return the value of its assignment + */ + function parseValue(code: string) { + const parser = Parser.parse(`sub main()\n ${code}\nend sub`, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(parser.diagnostics); + const assignment = (parser.ast.statements[0] as FunctionStatement).func.body.statements[0] as AssignmentStatement; + return assignment.value; + } + + function parseArrow(code: string) { + const value = parseValue(code); + expect(isFunctionExpression(value), 'expected an arrow function').to.be.true; + const func = value as FunctionExpression; + expect(func.isArrowFunction).to.be.true; + return func; + } + + describe('parser', () => { + 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; + }); + + 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 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.returnTypeExpression.getType({ flags: SymbolTypeFlag.typetime }).toString()).to.eql('integer'); + }); + + it('parses default param values', () => { + const func = parseArrow('greet = (name = "world") => "hello " + name'); + expect(func.parameters[0].defaultValue).to.exist; + }); + + 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; + const arg = (value as CallExpression).args[1]; + expect(isFunctionExpression(arg) && arg.isArrowFunction).to.be.true; + }); + + 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('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 nested arrow functions', () => { + const outer = parseArrow('f = (a) => (b) => b'); + expect(isFunctionExpression(outer.arrowBody) && outer.arrowBody.isArrowFunction).to.be.true; + }); + + 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('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 comparison', () => { + const value = parseValue('x = (a = 1)'); + expect(isGroupingExpression(value)).to.be.true; + expect(isBinaryExpression((value as GroupingExpression).expression)).to.be.true; + }); + + it('flags a missing body', () => { + const parser = Parser.parse(`sub main()\n x = (a) =>\nend sub`, { 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('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('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.tokens.arrow).not.to.equal(func.tokens.arrow); + }); + }); + + describe('program', () => { + let program: Program; + const testTranspile = getTestTranspile(() => [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 }); + } + + 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('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('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('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 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('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 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 + `); + }); + }); + + describe('validation', () => { + 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('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('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 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 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('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('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('flags a body that does not match the declared return type', () => { + program.setFile('source/main.bs', ` + sub main() + f = (x as integer) as string => x + end sub + `); + program.validate(); + expect(program.getDiagnostics().map(x => x.code)).to.eql(['return-type-mismatch']); + }); + + it('can be passed to untyped and plain function params', () => { + program.setFile('source/main.bs', ` + sub takesAnything(callback) + end sub + sub takesFunction(callback as function) + end sub + sub main() + takesAnything((x) => x) + takesFunction((x) => x) + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + + 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') + ]); + }); + }); + }); +}); From 7a5e0335efb52e4f0afcb4917c1640e6875e20f8 Mon Sep 17 00:00:00 2001 From: Tyler Smith Date: Fri, 16 Oct 2026 20:51:03 -0300 Subject: [PATCH 2/3] Expand arrow function tests and dedupe the capture diagnostic (#1929) A local declared after an arrow function (`f = (x) => x + later` then `later = 1`) was reported twice: CrossScopeValidator emitted `cannot-find-name` while ScopeValidator emitted `arrow-function-cannot-capture-variable`. Move the capture check into `util.getArrowFunctionCaptureDiagnostic()` and use it from both validators so they produce the same diagnostic and it collapses to one, matching how plain anonymous functions already behave. Grow the arrow function spec from 36 to 140 cases: all parameter and body forms, every expression context, non-regression for groupings and parenthesized typecasts, locations and AST links, parser diagnostics, full transpile output for each lowering (including `sub` lowering for sub/void/namespaced callees and the parenthesized-AA, template-string, `??` and ternary bodies), return type inference, capture and `m` validation across nesting, classes and loops, function-type compatibility, and editor features (hover, completions, definition, semantic tokens). Add lexer cases for `=>` without whitespace and its token location. --- src/CrossScopeValidator.ts | 5 + src/bscPlugin/validation/ScopeValidator.ts | 25 +- src/lexer/Lexer.spec.ts | 19 + .../tests/expression/ArrowFunction.spec.ts | 1691 +++++++++++++++-- src/util.ts | 23 + 5 files changed, 1534 insertions(+), 229 deletions(-) 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/bscPlugin/validation/ScopeValidator.ts b/src/bscPlugin/validation/ScopeValidator.ts index da598e86a..35f906235 100644 --- a/src/bscPlugin/validation/ScopeValidator.ts +++ b/src/bscPlugin/validation/ScopeValidator.ts @@ -1189,29 +1189,6 @@ export class ScopeValidator { } - /** - * Arrow functions can't capture locals from the function they're written in (closures aren't supported yet, see #530). - * When an unresolved name 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`. - * (`m` inside an arrow function is flagged by BrsFileValidator, since `m` always resolves through the scope) - */ - private getArrowFunctionNameDiagnostic(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; - } - validateVariableAndDottedGetExpressions(file: BrsFile, expression: VariableExpression | DottedGetExpression) { if (isDottedGetExpression(expression.parent)) { // We validate dottedGetExpressions at the top-most level @@ -1300,7 +1277,7 @@ 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); - const arrowFunctionDiagnostic = this.getArrowFunctionNameDiagnostic(typeChainScan.astNode); + const arrowFunctionDiagnostic = util.getArrowFunctionCaptureDiagnostic(typeChainScan.astNode); if (arrowFunctionDiagnostic) { this.addMultiScopeDiagnostic({ ...arrowFunctionDiagnostic, diff --git a/src/lexer/Lexer.spec.ts b/src/lexer/Lexer.spec.ts index 859756ac5..36ddc54ba 100644 --- a/src/lexer/Lexer.spec.ts +++ b/src/lexer/Lexer.spec.ts @@ -53,6 +53,25 @@ describe('lexer', () => { ]); }); + 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/parser/tests/expression/ArrowFunction.spec.ts b/src/parser/tests/expression/ArrowFunction.spec.ts index 3f475a651..41397ac63 100644 --- a/src/parser/tests/expression/ArrowFunction.spec.ts +++ b/src/parser/tests/expression/ArrowFunction.spec.ts @@ -2,149 +2,442 @@ import { expect } from '../../../chai-config.spec'; import { Range } from 'vscode-languageserver'; import { DiagnosticMessages } from '../../../DiagnosticMessages'; import { Program } from '../../../Program'; -import { expectDiagnostics, expectZeroDiagnostics, getTestTranspile, rootDir } from '../../../testHelpers.spec'; +import { expectDiagnostics, expectZeroDiagnostics, getTestGetTypedef, getTestTranspile, rootDir } from '../../../testHelpers.spec'; import { ParseMode, Parser } from '../../Parser'; -import { isAALiteralExpression, isBinaryExpression, isCallExpression, isFunctionExpression, isGroupingExpression, isLiteralExpression, isReturnStatement, isTernaryExpression, isTypecastExpression, isVoidType } from '../../../astUtils/reflection'; +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 } from '../../Statement'; -import type { BinaryExpression, CallExpression, FunctionExpression, GroupingExpression } from '../../Expression'; +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 parser = Parser.parse(`sub main()\n ${code}\nend sub`, { mode: ParseMode.BrighterScript }); - expectZeroDiagnostics(parser.diagnostics); - const assignment = (parser.ast.statements[0] as FunctionStatement).func.body.statements[0] as AssignmentStatement; + const assignment = parseBody(code)[0] as AssignmentStatement; return assignment.value; } - function parseArrow(code: string) { - const value = parseValue(code); + 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', () => { - 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; - }); + 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 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 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.returnTypeExpression.getType({ flags: SymbolTypeFlag.typetime }).toString()).to.eql('integer'); - }); + 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 default param values', () => { - const func = parseArrow('greet = (name = "world") => "hello " + name'); - expect(func.parameters[0].defaultValue).to.exist; - }); + 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 an arrow function as a call argument', () => { - const value = parseValue('result = filter(items, (i) => i.rating > 3.5)'); - expect(isCallExpression(value)).to.be.true; - const arg = (value as CallExpression).args[1]; - expect(isFunctionExpression(arg) && arg.isArrowFunction).to.be.true; - }); + 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 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 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('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 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 nested arrow functions', () => { - const outer = parseArrow('f = (a) => (b) => b'); - expect(isFunctionExpression(outer.arrowBody) && outer.arrowBody.isArrowFunction).to.be.true; - }); + 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('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('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('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('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('still parses a parenthesized variable', () => { - const value = parseValue('x = (a)'); - expect(isGroupingExpression(value)).to.be.true; + 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; + }); }); - 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; + 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); + }); }); - 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; + 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; + }); }); - it('flags a missing body', () => { - const parser = Parser.parse(`sub main()\n x = (a) =>\nend sub`, { mode: ParseMode.BrighterScript }); - expectDiagnostics(parser.diagnostics, [ - DiagnosticMessages.expectedExpressionAfterArrow() - ]); + 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; + }); }); - 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') - ]); + 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)); + }); }); - 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') - ]); + 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); + }); }); - 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.tokens.arrow).not.to.equal(func.tokens.arrow); + 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 }); @@ -163,6 +456,15 @@ describe('arrow functions', () => { 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(` @@ -178,6 +480,20 @@ describe('arrow functions', () => { `); }); + 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() @@ -192,6 +508,52 @@ describe('arrow functions', () => { `); }); + 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() @@ -206,6 +568,20 @@ describe('arrow functions', () => { `); }); + 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() @@ -227,6 +603,29 @@ describe('arrow functions', () => { `); }); + 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() @@ -243,214 +642,1096 @@ describe('arrow functions', () => { `); }); - it('becomes a sub when the body calls a built-in that returns nothing', async () => { + it('transpiles immediately-invoked arrow functions', async () => { await testTranspile(` sub main() - addItem = (list as integer[], item as integer) => list.push(item) + print ((x) => x * 2)(4) + y = ((a, b) => a + b)(1, 2) end sub `, ` sub main() - addItem = sub(list as dynamic, item as integer) - list.push(item) - end sub + print (function(x) + return x * 2 + end function)(4) + y = (function(a, b) + return a + b + end function)(1, 2) end sub `); }); - it('becomes a sub when the body calls a sub', async () => { + it('transpiles a parenthesized AA literal body', async () => { await testTranspile(` sub main() - logIt = (message as string) => doLog(message) - end sub - sub doLog(message as string) + makePoint = (x, y) => ({ x: x, y: y }) end sub `, ` sub main() - logIt = sub(message as string) - doLog(message) - end sub - end sub - - sub doLog(message as string) + makePoint = function(x, y) + return ({ + x: x + y: y + }) + end function end sub `); }); - it('becomes a sub when the return type is void', async () => { + it('transpiles a template string body', async () => { await testTranspile(` sub main() - logIt = (message as string) as void => doLog(message) - end sub - sub doLog(message as string) + greet = (name) => \`hi \${name}\` end sub `, ` sub main() - logIt = sub(message as string) as void - doLog(message) - end sub - end sub - - sub doLog(message as string) + greet = function(name) + return ("hi " + bslib_toString(name)) + end function end sub `); }); - }); - describe('validation', () => { - it('infers the return type from the body', () => { - const func = getArrowFunction(` + it('transpiles a null-coalescing body', async () => { + await testTranspile(` sub main() - twice = (x as integer) => x * 2 - print twice(2) + orOne = (x) => x ?? 1 + end sub + `, ` + sub main() + orOne = function(x) + return bslib_coalesce(x, 1) + end function end sub `); - expect(func.getType({ flags: SymbolTypeFlag.runtime }).toString()).to.eql('function (x as integer) as integer'); }); - it('treats a body that calls a sub as void', () => { - const func = getArrowFunction(` + it('transpiles a ternary body', async () => { + await testTranspile(` sub main() - logIt = (message as string) => doLog(message) + size = (x) => x > 1 ? "big" : "small" end sub - sub doLog(message as string) + `, ` + sub main() + size = function(x) + return bslib_ternary(x > 1, "big", "small") + end function end sub `); - const type = func.getType({ flags: SymbolTypeFlag.runtime }); - expect(type.isSub).to.be.true; - expect(isVoidType(type.returnType)).to.be.true; }); - it('flags a captured local variable', () => { - program.setFile('source/main.bs', ` - sub main() - factor = 2 - scale = (x) => x * factor + 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 `); - program.validate(); - expectDiagnostics(program, [ - DiagnosticMessages.arrowFunctionCannotCaptureVariable('factor') - ]); }); - it('flags a captured parameter', () => { - program.setFile('source/main.bs', ` - sub main(factor) - scale = (x) => x * factor + 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 `); - 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) + 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 - f = (x) => callback(x) + 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 `); - program.validate(); - expectDiagnostics(program, [ - DiagnosticMessages.arrowFunctionCannotCaptureVariable('callback') - ]); }); - it('flags a variable captured from an enclosing arrow function', () => { + 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 = (a) => (b) => a + b + f = (x) => x + Direction.up + MAX end sub `); program.validate(); - expectDiagnostics(program, [ - DiagnosticMessages.arrowFunctionCannotCaptureVariable('a') - ]); + expectZeroDiagnostics(program); + const result = await program.getTranspiledFileContents('source/main.bs'); + expect(result.code).to.include('return x + 1 + 10'); }); - it('flags m', () => { + it('transpiles class constructors called inside the body', async () => { program.setFile('source/main.bs', ` + class Person + end class sub main() - render = (data) => m.view.setData(data) - getM = () => m + make = () => new Person() end sub `); program.validate(); - expectDiagnostics(program, [ - DiagnosticMessages.arrowFunctionCannotUseM(), - DiagnosticMessages.arrowFunctionCannotUseM() - ]); + expectZeroDiagnostics(program); + const result = await program.getTranspiledFileContents('source/main.bs'); + expect(result.code).to.include('make = function()\n return Person()\n end function'); }); - it('allows file-level functions, namespaces and globals', () => { + 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 - function beta(x) - return x - end function - sub main() - f = (x) => beta(x) - g = (x) => alpha.beta(x) - h = (x) => lcase(x) + 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 `); - program.validate(); - expectZeroDiagnostics(program); }); - it('validates against function types', () => { - program.setFile('source/main.bs', ` - sub apply(callback as function(x as integer) as integer) + 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() - apply((x as integer) => x * 2) - apply((x as string) => x + "!") + logIt = sub(message as string) as void + doLog(message) + end sub + end sub + + sub doLog(message as string) end sub `); - program.validate(); - expect(program.getDiagnostics().map(x => x.code)).to.eql(['argument-type-mismatch']); }); - it('flags a body that does not match the declared return type', () => { - program.setFile('source/main.bs', ` + 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() - f = (x as integer) as string => x + 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 `); - program.validate(); - expect(program.getDiagnostics().map(x => x.code)).to.eql(['return-type-mismatch']); }); - it('can be passed to untyped and plain function params', () => { - program.setFile('source/main.bs', ` - sub takesAnything(callback) + 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 takesFunction(callback as function) + `, ` + 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() - takesAnything((x) => x) - takesFunction((x) => x) + 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('is not allowed in brs files', () => { - program.setFile('source/main.brs', ` + 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', [ + '', + '', + '