Repository: rokucommunity/brighterscript
Pull request #1289 (2024-08-20 18:22:16)

Merges `ExitForStatement` and `ExitWhileStatement`
There's a bit of weirdness here because `exitwhile` is an accepted statement, but `exitfor` is not.

Addresses #1285


Patch:
diff --git a/src/astUtils/reflection.spec.ts b/src/astUtils/reflection.spec.ts
index 104570d5..eb5b8f6b 100644
--- a/src/astUtils/reflection.spec.ts
+++ b/src/astUtils/reflection.spec.ts
@@ -1,10 +1,10 @@
 /* eslint-disable no-multi-spaces */
 import { expect } from '../chai-config.spec';
-import { PrintStatement, Block, Body, AssignmentStatement, ExitForStatement, ExitWhileStatement, ExpressionStatement, FunctionStatement, IfStatement, IncrementStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, EmptyStatement, TryCatchStatement, CatchStatement, ThrowStatement } from '../parser/Statement';
+import { PrintStatement, Block, Body, AssignmentStatement, ExpressionStatement, FunctionStatement, IfStatement, IncrementStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, EmptyStatement, TryCatchStatement, CatchStatement, ThrowStatement, ExitStatement } from '../parser/Statement';
 import { FunctionExpression, BinaryExpression, CallExpression, DottedGetExpression, IndexedGetExpression, GroupingExpression, EscapedCharCodeLiteralExpression, ArrayLiteralExpression, AALiteralExpression, UnaryExpression, VariableExpression, SourceLiteralExpression, NewExpression, CallfuncExpression, TemplateStringQuasiExpression, XmlAttributeGetExpression, TemplateStringExpression, TaggedTemplateStringExpression, AnnotationExpression } from '../parser/Expression';
 import type { Token } from '../lexer/Token';
 import { TokenKind } from '../lexer/TokenKind';
-import { isPrintStatement, isIfStatement, isBody, isAssignmentStatement, isBlock, isExpressionStatement, isExitForStatement, isExitWhileStatement, isFunctionStatement, isIncrementStatement, isGotoStatement, isLabelStatement, isReturnStatement, isEndStatement, isStopStatement, isForStatement, isForEachStatement, isWhileStatement, isDottedSetStatement, isIndexedSetStatement, isLibraryStatement, isNamespaceStatement, isImportStatement, isExpression, isBinaryExpression, isCallExpression, isFunctionExpression, isDottedGetExpression, isXmlAttributeGetExpression, isIndexedGetExpression, isGroupingExpression, isLiteralExpression, isEscapedCharCodeLiteralExpression, isArrayLiteralExpression, isAALiteralExpression, isUnaryExpression, isVariableExpression, isSourceLiteralExpression, isNewExpression, isCallfuncExpression, isTemplateStringQuasiExpression, isTemplateStringExpression, isTaggedTemplateStringExpression, isBrsFile, isXmlFile, isClassStatement, isStatement, isAnnotationExpression, isTryCatchStatement, isCatchStatement, isThrowStatement, isLiteralInvalid, isLiteralBoolean, isLiteralNumber, isLiteralInteger, isLiteralLongInteger, isLiteralFloat, isLiteralDouble } from './reflection';
+import { isPrintStatement, isIfStatement, isBody, isAssignmentStatement, isBlock, isExpressionStatement, isFunctionStatement, isIncrementStatement, isGotoStatement, isLabelStatement, isReturnStatement, isEndStatement, isStopStatement, isForStatement, isForEachStatement, isWhileStatement, isDottedSetStatement, isIndexedSetStatement, isLibraryStatement, isNamespaceStatement, isImportStatement, isExpression, isBinaryExpression, isCallExpression, isFunctionExpression, isDottedGetExpression, isXmlAttributeGetExpression, isIndexedGetExpression, isGroupingExpression, isLiteralExpression, isEscapedCharCodeLiteralExpression, isArrayLiteralExpression, isAALiteralExpression, isUnaryExpression, isVariableExpression, isSourceLiteralExpression, isNewExpression, isCallfuncExpression, isTemplateStringQuasiExpression, isTemplateStringExpression, isTaggedTemplateStringExpression, isBrsFile, isXmlFile, isClassStatement, isStatement, isAnnotationExpression, isTryCatchStatement, isCatchStatement, isThrowStatement, isLiteralInvalid, isLiteralBoolean, isLiteralNumber, isLiteralInteger, isLiteralLongInteger, isLiteralFloat, isLiteralDouble, isExitStatement } from './reflection';
 import { createToken, createStringLiteral, createInvalidLiteral, createBooleanLiteral, createIntegerLiteral, createVariableExpression, createFloatLiteral, createDoubleLiteral, createLongIntegerLiteral } from './creators';
 import { Program } from '../Program';
 import { BrsFile } from '../files/BrsFile';
@@ -31,8 +31,7 @@ describe('reflection', () => {
         const assignment = new AssignmentStatement({ equals: undefined, name: ident, value: expr });
         const block = new Block({ statements: [] });
         const expression = new ExpressionStatement({ expression: expr });
-        const exitFor = new ExitForStatement({ exitFor: token });
-        const exitWhile = new ExitWhileStatement({ exitWhile: token });
+        const exit = new ExitStatement({ exit: token, loopType: token });
         const funs = new FunctionStatement({
             name: ident,
             func: new FunctionExpression({
@@ -92,13 +91,9 @@ describe('reflection', () => {
             expect(isExpressionStatement(expression)).to.be.true;
             expect(isExpressionStatement(body)).to.be.false;
         });
-        it('isExitForStatement', () => {
-            expect(isExitForStatement(exitFor)).to.be.true;
-            expect(isExitForStatement(body)).to.be.false;
-        });
-        it('isExitWhileStatement', () => {
-            expect(isExitWhileStatement(exitWhile)).to.be.true;
-            expect(isExitWhileStatement(body)).to.be.false;
+        it('isExitStatement', () => {
+            expect(isExitStatement(exit)).to.be.true;
+            expect(isExitStatement(body)).to.be.false;
         });
         it('isFunctionStatement', () => {
             expect(isFunctionStatement(funs)).to.be.true;
diff --git a/src/astUtils/reflection.ts b/src/astUtils/reflection.ts
index 7de72937..11abe630 100644
--- a/src/astUtils/reflection.ts
+++ b/src/astUtils/reflection.ts
@@ -1,4 +1,4 @@
-import type { Body, AssignmentStatement, Block, ExpressionStatement, ExitForStatement, ExitWhileStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, InterfaceFieldStatement, InterfaceMethodStatement, InterfaceStatement, EnumStatement, EnumMemberStatement, TryCatchStatement, CatchStatement, ThrowStatement, MethodStatement, FieldStatement, ConstStatement, ContinueStatement, TypecastStatement, AliasStatement, ConditionalCompileStatement, ConditionalCompileConstStatement, ConditionalCompileErrorStatement, AugmentedAssignmentStatement } from '../parser/Statement';
+import type { Body, AssignmentStatement, Block, ExpressionStatement, ExitStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, InterfaceFieldStatement, InterfaceMethodStatement, InterfaceStatement, EnumStatement, EnumMemberStatement, TryCatchStatement, CatchStatement, ThrowStatement, MethodStatement, FieldStatement, ConstStatement, ContinueStatement, TypecastStatement, AliasStatement, ConditionalCompileStatement, ConditionalCompileConstStatement, ConditionalCompileErrorStatement, AugmentedAssignmentStatement } from '../parser/Statement';
 import type { LiteralExpression, BinaryExpression, CallExpression, FunctionExpression, DottedGetExpression, XmlAttributeGetExpression, IndexedGetExpression, GroupingExpression, EscapedCharCodeLiteralExpression, ArrayLiteralExpression, AALiteralExpression, UnaryExpression, VariableExpression, SourceLiteralExpression, NewExpression, CallfuncExpression, TemplateStringQuasiExpression, TemplateStringExpression, TaggedTemplateStringExpression, AnnotationExpression, FunctionParameterExpression, AAMemberExpression, TypecastExpression, TypeExpression, TypedArrayExpression } from '../parser/Expression';
 import type { BrsFile } from '../files/BrsFile';
 import type { XmlFile } from '../files/XmlFile';
@@ -85,11 +85,8 @@ export function isBlock(element: AstNode | undefined): element is Block {
 export function isExpressionStatement(element: AstNode | undefined): element is ExpressionStatement {
     return element?.kind === AstNodeKind.ExpressionStatement;
 }
-export function isExitForStatement(element: AstNode | undefined): element is ExitForStatement {
-    return element?.kind === AstNodeKind.ExitForStatement;
-}
-export function isExitWhileStatement(element: AstNode | undefined): element is ExitWhileStatement {
-    return element?.kind === AstNodeKind.ExitWhileStatement;
+export function isExitStatement(element: AstNode | undefined): element is ExitStatement {
+    return element?.kind === AstNodeKind.ExitStatement;
 }
 export function isFunctionStatement(element: AstNode | undefined): element is FunctionStatement {
     return element?.kind === AstNodeKind.FunctionStatement;
diff --git a/src/astUtils/visitors.ts b/src/astUtils/visitors.ts
index c4e38d2a..af92d64c 100644
--- a/src/astUtils/visitors.ts
+++ b/src/astUtils/visitors.ts
@@ -1,6 +1,6 @@
 /* eslint-disable no-bitwise */
 import type { CancellationToken } from 'vscode-languageserver';
-import type { Body, AssignmentStatement, Block, ExpressionStatement, ExitForStatement, ExitWhileStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, EnumStatement, EnumMemberStatement, DimStatement, TryCatchStatement, CatchStatement, ThrowStatement, InterfaceStatement, InterfaceFieldStatement, InterfaceMethodStatement, FieldStatement, MethodStatement, ConstStatement, ContinueStatement, TypecastStatement, AliasStatement, ConditionalCompileStatement, ConditionalCompileErrorStatement, ConditionalCompileConstStatement, AugmentedAssignmentStatement } from '../parser/Statement';
+import type { Body, AssignmentStatement, Block, ExpressionStatement, FunctionStatement, IfStatement, IncrementStatement, PrintStatement, GotoStatement, LabelStatement, ReturnStatement, EndStatement, StopStatement, ForStatement, ForEachStatement, WhileStatement, DottedSetStatement, IndexedSetStatement, LibraryStatement, NamespaceStatement, ImportStatement, ClassStatement, EnumStatement, EnumMemberStatement, DimStatement, TryCatchStatement, CatchStatement, ThrowStatement, InterfaceStatement, InterfaceFieldStatement, InterfaceMethodStatement, FieldStatement, MethodStatement, ConstStatement, ContinueStatement, TypecastStatement, AliasStatement, ConditionalCompileStatement, ConditionalCompileErrorStatement, ConditionalCompileConstStatement, AugmentedAssignmentStatement, ExitStatement } from '../parser/Statement';
 import type { AALiteralExpression, AAMemberExpression, AnnotationExpression, ArrayLiteralExpression, BinaryExpression, CallExpression, CallfuncExpression, DottedGetExpression, EscapedCharCodeLiteralExpression, FunctionExpression, FunctionParameterExpression, GroupingExpression, IndexedGetExpression, LiteralExpression, NewExpression, NullCoalescingExpression, RegexLiteralExpression, SourceLiteralExpression, TaggedTemplateStringExpression, TemplateStringExpression, TemplateStringQuasiExpression, TernaryExpression, TypecastExpression, TypeExpression, UnaryExpression, VariableExpression, XmlAttributeGetExpression } from '../parser/Expression';
 import { isExpression, isStatement } from './reflection';
 import type { Editor } from './Editor';
@@ -116,8 +116,7 @@ export function createVisitor(
         AssignmentStatement?: (statement: AssignmentStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
         Block?: (statement: Block, parent?: Statement, owner?: any, key?: any) => Statement | void;
         ExpressionStatement?: (statement: ExpressionStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
-        ExitForStatement?: (statement: ExitForStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
-        ExitWhileStatement?: (statement: ExitWhileStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
+        ExitStatement?: (statement: ExitStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
         FunctionStatement?: (statement: FunctionStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
         IfStatement?: (statement: IfStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
         IncrementStatement?: (statement: IncrementStatement, parent?: Statement, owner?: any, key?: any) => Statement | void;
diff --git a/src/files/BrsFile.spec.ts b/src/files/BrsFile.spec.ts
index 5cb1365e..b1db5d7f 100644
--- a/src/files/BrsFile.spec.ts
+++ b/src/files/BrsFile.spec.ts
@@ -2953,6 +2953,55 @@ describe('BrsFile', () => {
             expect(location.column).eql(4);
         });
 
+        describe('jump statements', () => {
+            it('handles exit for', async () => {
+                await testTranspile(`
+                    sub main()
+                        for i = 1 to 10
+                            exit for
+                        end for
+                    end sub
+                `);
+            });
+
+            it('handles exit while', async () => {
+                await testTranspile(`
+                    sub main()
+                        while true
+                            exit while
+                        end while
+                    end sub
+                `);
+            });
+
+            it('handles exitWhile (one word)', async () => {
+                await testTranspile(`
+                    sub main()
+                        while true
+                            exitWhile
+                        end while
+                    end sub
+                `);
+            });
+
+            it('transpiles case correctly', async () => {
+                await testTranspile(`
+                    sub main()
+                        for i = 1 to 10
+                            eXiT fOr
+                        end for
+                        while true
+                            exIt whILE
+                        end while
+                        while true
+                            eXitWhile
+                        end while
+                    end sub
+                `);
+            });
+        });
+
+
         describe('sourcemap validation', () => {
             it('computes correct source and position in sourcemap', async () => {
                 program.options.sourceMap = true;
diff --git a/src/lexer/Lexer.spec.ts b/src/lexer/Lexer.spec.ts
index 6d86e34f..f83e3676 100644
--- a/src/lexer/Lexer.spec.ts
+++ b/src/lexer/Lexer.spec.ts
@@ -932,7 +932,7 @@ describe('lexer', () => {
         it('matches single-word keywords', () => {
             // test just a sample of single-word reserved words for now.
             // if we find any that we've missed
-            let { tokens } = Lexer.scan('and then or if else endif return true false line_num');
+            let { tokens } = Lexer.scan('and then or if else endif return true false line_num exitwhile');
             expect(tokens.map(w => w.kind)).to.deep.equal([
                 TokenKind.And,
                 TokenKind.Then,
@@ -944,6 +944,7 @@ describe('lexer', () => {
                 TokenKind.True,
                 TokenKind.False,
                 TokenKind.LineNumLiteral,
+                TokenKind.ExitWhile,
                 TokenKind.Eof
             ]);
         });
@@ -955,7 +956,8 @@ describe('lexer', () => {
                 TokenKind.EndWhile,
                 TokenKind.EndSub,
                 TokenKind.EndFunction,
-                TokenKind.ExitWhile,
+                TokenKind.Exit,
+                TokenKind.While,
                 TokenKind.Eof
             ]);
         });
@@ -963,7 +965,8 @@ describe('lexer', () => {
         it('accepts \'exit for\' but not \'exitfor\'', () => {
             let { tokens } = Lexer.scan('exit for exitfor');
             expect(tokens.map(w => w.kind)).to.deep.equal([
-                TokenKind.ExitFor,
+                TokenKind.Exit,
+                TokenKind.For,
                 TokenKind.Identifier,
                 TokenKind.Eof
             ]);
diff --git a/src/lexer/TokenKind.ts b/src/lexer/TokenKind.ts
index 03256ee0..7976604b 100644
--- a/src/lexer/TokenKind.ts
+++ b/src/lexer/TokenKind.ts
@@ -108,7 +108,6 @@ export enum TokenKind {
     EndWhile = 'EndWhile',
     Eval = 'Eval',
     Exit = 'Exit',
-    ExitFor = 'ExitFor', // not technically a reserved word, but definitely a tokenKind
     ExitWhile = 'ExitWhile',
     False = 'False',
     For = 'For',
@@ -269,9 +268,7 @@ export const Keywords: Record<string, TokenKind> = {
     endwhile: TokenKind.EndWhile,
     'end while': TokenKind.EndWhile,
     exit: TokenKind.Exit,
-    'exit for': TokenKind.ExitFor, // note: 'exitfor' (no space) is *not* a keyword
     exitwhile: TokenKind.ExitWhile,
-    'exit while': TokenKind.ExitWhile,
     false: TokenKind.False,
     for: TokenKind.For,
     'for each': TokenKind.ForEach, // note: 'foreach' (no space) is *not* a keyword
@@ -400,7 +397,6 @@ export const AllowedProperties = [
     TokenKind.EndWhile,
     TokenKind.Eval,
     TokenKind.Exit,
-    TokenKind.ExitFor,
     TokenKind.ExitWhile,
     TokenKind.False,
     TokenKind.For,
@@ -476,7 +472,6 @@ export const AllowedProperties = [
 /** List of TokenKind that are allowed as local var identifiers. */
 export const AllowedLocalIdentifiers = [
     TokenKind.EndFor,
-    TokenKind.ExitFor,
     TokenKind.ForEach,
     TokenKind.Void,
     TokenKind.Boolean,
diff --git a/src/parser/AstNode.ts b/src/parser/AstNode.ts
index 2a89b50a..0b7dd04a 100644
--- a/src/parser/AstNode.ts
+++ b/src/parser/AstNode.ts
@@ -247,8 +247,7 @@ export enum AstNodeKind {
     EmptyStatement = 'EmptyStatement',
     AssignmentStatement = 'AssignmentStatement',
     ExpressionStatement = 'ExpressionStatement',
-    ExitForStatement = 'ExitForStatement',
-    ExitWhileStatement = 'ExitWhileStatement',
+    ExitStatement = 'ExitStatement',
     FunctionStatement = 'FunctionStatement',
     IfStatement = 'IfStatement',
     IncrementStatement = 'IncrementStatement',
diff --git a/src/parser/Parser.spec.ts b/src/parser/Parser.spec.ts
index 320cc7d4..f75c915e 100644
--- a/src/parser/Parser.spec.ts
+++ b/src/parser/Parser.spec.ts
@@ -4,11 +4,11 @@ import { ReservedWords, TokenKind } from '../lexer/TokenKind';
 import type { AAMemberExpression, BinaryExpression, LiteralExpression, TypecastExpression, UnaryExpression } from './Expression';
 import { TernaryExpression, NewExpression, IndexedGetExpression, DottedGetExpression, XmlAttributeGetExpression, CallfuncExpression, AnnotationExpression, CallExpression, FunctionExpression, VariableExpression } from './Expression';
 import { Parser, ParseMode } from './Parser';
-import type { AliasStatement, AssignmentStatement, Block, ClassStatement, ConditionalCompileConstStatement, ConditionalCompileErrorStatement, ConditionalCompileStatement, InterfaceStatement, ReturnStatement, TypecastStatement } from './Statement';
+import type { AliasStatement, AssignmentStatement, Block, ClassStatement, ConditionalCompileConstStatement, ConditionalCompileErrorStatement, ConditionalCompileStatement, ExitStatement, ForStatement, InterfaceStatement, ReturnStatement, TypecastStatement } from './Statement';
 import { PrintStatement, FunctionStatement, NamespaceStatement, ImportStatement } from './Statement';
 import { Range } from 'vscode-languageserver';
 import { DiagnosticMessages } from '../DiagnosticMessages';
-import { isAliasStatement, isAssignmentStatement, isBinaryExpression, isBlock, isBody, isCallExpression, isClassStatement, isConditionalCompileConstStatement, isConditionalCompileErrorStatement, isConditionalCompileStatement, isDottedGetExpression, isExpression, isExpressionStatement, isFunctionStatement, isGroupingExpression, isIfStatement, isIndexedGetExpression, isInterfaceStatement, isLiteralExpression, isNamespaceStatement, isPrintStatement, isTypecastExpression, isTypecastStatement, isUnaryExpression, isVariableExpression } from '../astUtils/reflection';
+import { isAliasStatement, isAssignmentStatement, isBinaryExpression, isBlock, isBody, isCallExpression, isClassStatement, isConditionalCompileConstStatement, isConditionalCompileErrorStatement, isConditionalCompileStatement, isDottedGetExpression, isExitStatement, isExpression, isExpressionStatement, isFunctionStatement, isGroupingExpression, isIfStatement, isIndexedGetExpression, isInterfaceStatement, isLiteralExpression, isNamespaceStatement, isPrintStatement, isTypecastExpression, isTypecastStatement, isUnaryExpression, isVariableExpression } from '../astUtils/reflection';
 import { expectDiagnostics, expectDiagnosticsIncludes, expectTypeToBe, expectZeroDiagnostics, rootDir } from '../testHelpers.spec';
 import { createVisitor, WalkMode } from '../astUtils/visitors';
 import type { Expression, Statement } from './AstNode';
@@ -2292,6 +2292,73 @@ describe('parser', () => {
             expect(((ast.statements[0] as FunctionStatement).func.body.statements[0] as AssignmentStatement).tokens.name.text).to.eq('alias');
         });
     });
+
+    describe('jump statements', () => {
+        it('should recognize `exit for`', () => {
+            let { ast, diagnostics } = parse(`
+                sub main()
+                    for i = 1 to 10
+                        exit for
+                    end for
+                end sub
+            `);
+            expectZeroDiagnostics(diagnostics);
+            let loop = (ast.statements[0] as FunctionStatement).func.body.statements[0] as ForStatement;
+            let exitStmt = loop.body.statements[0] as ExitStatement;
+            expect(isExitStatement(exitStmt)).to.be.true;
+            expect(exitStmt.tokens.loopType.text).to.eq('for');
+        });
+
+        it('should recognize `exit while`', () => {
+            let { ast, diagnostics } = parse(`
+                sub main(i)
+                    while i < 10
+                        exit while
+                        i++
+                    end while
+                end sub
+            `);
+            expectZeroDiagnostics(diagnostics);
+            let loop = (ast.statements[0] as FunctionStatement).func.body.statements[0] as ForStatement;
+            let exitStmt = loop.body.statements[0] as ExitStatement;
+            expect(isExitStatement(exitStmt)).to.be.true;
+            expect(exitStmt.tokens.loopType.text).to.eq('while');
+        });
+
+        it('should recognize `exitwhile` (one word)', () => {
+            let { ast, diagnostics } = parse(`
+                sub main(i)
+                    while i < 10
+                        exitwhile
+                        i++
+                    end while
+                end sub
+            `);
+            expectZeroDiagnostics(diagnostics);
+            let loop = (ast.statements[0] as FunctionStatement).func.body.statements[0] as ForStatement;
+            let exitStmt = loop.body.statements[0] as ExitStatement;
+            expect(isExitStatement(exitStmt)).to.be.true;
+            expect(exitStmt.tokens.loopType.text).to.eq('while');
+        });
+
+        it('should allow identifiers named `exitfor` (one word)', () => {
+            let { ast, diagnostics } = parse(`
+                sub main()
+                    for i = 1 to 10
+                        exitfor = 1
+                        exit for
+                    end for
+                end sub
+            `);
+            expectZeroDiagnostics(diagnostics);
+            let loop = (ast.statements[0] as FunctionStatement).func.body.statements[0] as ForStatement;
+            let assignment = loop.body.statements[0] as AssignmentStatement;
+            expect(assignment.tokens.name.text).to.eq('exitfor');
+            let exitStmt = loop.body.statements[1] as ExitStatement;
+            expect(isExitStatement(exitStmt)).to.be.true;
+            expect(exitStmt.tokens.loopType.text).to.eq('for');
+        });
+    });
 });
 
 export function parse(text: string, mode?: ParseMode, bsConsts: Record<string, boolean> = {}) {
diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts
index aad056db..f657fca7 100644
--- a/src/parser/Parser.ts
+++ b/src/parser/Parser.ts
@@ -35,8 +35,7 @@ import {
     EndStatement,
     EnumMemberStatement,
     EnumStatement,
-    ExitForStatement,
-    ExitWhileStatement,
+    ExitStatement,
     ExpressionStatement,
     ForEachStatement,
     FieldStatement,
@@ -1108,8 +1107,8 @@ export class Parser {
             return this.whileStatement();
         }
 
-        if (this.check(TokenKind.ExitWhile)) {
-            return this.exitWhile();
+        if (this.checkAny(TokenKind.Exit, TokenKind.ExitWhile)) {
+            return this.exitStatement();
         }
 
         if (this.check(TokenKind.For)) {
@@ -1120,10 +1119,6 @@ export class Parser {
             return this.forEachStatement();
         }
 
-        if (this.check(TokenKind.ExitFor)) {
-            return this.exitFor();
-        }
-
         if (this.check(TokenKind.End)) {
             return this.endStatement();
         }
@@ -1235,10 +1230,43 @@ export class Parser {
         });
     }
 
-    private exitWhile(): ExitWhileStatement {
-        let keyword = this.advance();
+    private exitStatement(): ExitStatement {
+        let exitToken = this.advance();
+        if (exitToken.kind === TokenKind.ExitWhile) {
+            // `exitwhile` is allowed in code, and means `exit while`
+            // use an ExitStatement that is nicer to work with by breaking the `exit` and `while` tokens apart
+
+            const exitText = exitToken.text.substring(0, 4);
+            const whileText = exitToken.text.substring(4);
+            const originalRange = exitToken.location.range;
+            const originalStart = originalRange.start;
+
+            const exitRange = util.createRange(
+                originalStart.line,
+                originalStart.character,
+                originalStart.line,
+                originalStart.character + 4);
+            const whileRange = util.createRange(
+                originalStart.line,
+                originalStart.character + 4,
+                originalStart.line,
+                originalStart.character + exitToken.text.length);
+
+            exitToken = createToken(TokenKind.Exit, exitText, util.createLocationFromRange(exitToken.location.uri, exitRange));
+            this.tokens[this.current - 1] = exitToken;
+            const newLoopToken = createToken(TokenKind.While, whileText, util.createLocationFromRange(exitToken.location.uri, whileRange));
+            this.tokens.splice(this.current, 0, newLoopToken);
+        }
+
+        const loopTypeToken = this.tryConsume(
+            DiagnosticMessages.expectedToken(TokenKind.While, TokenKind.For),
+            TokenKind.While, TokenKind.For
+        );
 
-        return new ExitWhileStatement({ exitWhile: keyword });
+        return new ExitStatement({
+            exit: exitToken,
+            loopType: loopTypeToken
+        });
     }
 
     private forStatement(): ForStatement {
@@ -1337,12 +1365,6 @@ export class Parser {
         });
     }
 
-    private exitFor(): ExitForStatement {
-        let keyword = this.advance();
-
-        return new ExitForStatement({ exitFor: keyword });
-    }
-
     private namespaceStatement(): NamespaceStatement | undefined {
         this.warnIfNotBrighterScriptMode('namespace');
         let keyword = this.advance();
diff --git a/src/parser/Statement.spec.ts b/src/parser/Statement.spec.ts
index a4218b36..0edb52dd 100644
--- a/src/parser/Statement.spec.ts
+++ b/src/parser/Statement.spec.ts
@@ -1,6 +1,6 @@
 import { expect } from '../chai-config.spec';
 import { NamespaceStatement, ClassStatement } from './Statement';
-import { AssignmentStatement, Block, Body, CatchStatement, DottedSetStatement, EmptyStatement, EndStatement, ExitForStatement, ExitWhileStatement, ExpressionStatement, ForEachStatement, ForStatement, FunctionStatement, GotoStatement, IfStatement, ImportStatement, IncrementStatement, IndexedSetStatement, LabelStatement, LibraryStatement, PrintStatement, ReturnStatement, StopStatement, ThrowStatement, TryCatchStatement, WhileStatement } from './Statement';
+import { AssignmentStatement, Block, Body, CatchStatement, DottedSetStatement, EmptyStatement, EndStatement, ExitStatement, ExpressionStatement, ForEachStatement, ForStatement, FunctionStatement, GotoStatement, IfStatement, ImportStatement, IncrementStatement, IndexedSetStatement, LabelStatement, LibraryStatement, PrintStatement, ReturnStatement, StopStatement, ThrowStatement, TryCatchStatement, WhileStatement } from './Statement';
 import { ParseMode, Parser } from './Parser';
 import { WalkMode } from '../astUtils/visitors';
 import { isClassStatement, isNamespaceStatement } from '../astUtils/reflection';
@@ -128,8 +128,8 @@ describe('Statement', () => {
             const assignment = new AssignmentStatement({ equals: undefined, name: ident, value: expr });
             const block = new Block({ statements: [] });
             const expression = new ExpressionStatement({ expression: expr });
-            const exitFor = new ExitForStatement({ exitFor: token });
-            const exitWhile = new ExitWhileStatement({ exitWhile: token });
+            const exitFor = new ExitStatement({ exit: token, loopType: token });
+            const exitWhile = new ExitStatement({ exit: token, loopType: token });
             const funs = new FunctionStatement({
                 name: ident,
                 func: new FunctionExpression({
diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts
index 4305fa82..c828974e 100644
--- a/src/parser/Statement.ts
+++ b/src/parser/Statement.ts
@@ -436,61 +436,37 @@ export class ExpressionStatement extends Statement {
     }
 }
 
-
-export class ExitForStatement extends Statement {
-    constructor(options?: {
-        exitFor?: Token;
-    }) {
-        super();
-        this.tokens = {
-            exitFor: options?.exitFor
-        };
-        this.location = this.tokens.exitFor?.location;
-    }
-
-    public readonly tokens: {
-        readonly exitFor?: Token;
-    };
-
-    public readonly kind = AstNodeKind.ExitForStatement;
-
-    public readonly location?: Location;
-
-    transpile(state: BrsTranspileState) {
-        return this.tokens.exitFor ? state.transpileToken(this.tokens.exitFor) : ['exit for'];
-    }
-
-    walk(visitor: WalkVisitor, options: WalkOptions) {
-        //nothing to walk
-    }
-
-    get leadingTrivia(): Token[] {
-        return this.tokens.exitFor?.leadingTrivia;
-    }
-
-}
-
-export class ExitWhileStatement extends Statement {
+export class ExitStatement extends Statement {
     constructor(options?: {
-        exitWhile?: Token;
+        exit?: Token;
+        loopType: Token;
     }) {
         super();
         this.tokens = {
-            exitWhile: options?.exitWhile
+            exit: options?.exit,
+            loopType: options.loopType
         };
-        this.location = this.tokens.exitWhile?.location;
+        this.location = util.createBoundingLocation(
+            this.tokens.exit,
+            this.tokens.loopType
+        );
     }
 
     public readonly tokens: {
-        readonly exitWhile?: Token;
+        readonly exit: Token;
+        readonly loopType?: Token;
     };
 
-    public readonly kind = AstNodeKind.ExitWhileStatement;
+    public readonly kind = AstNodeKind.ExitStatement;
 
     public readonly location?: Location;
 
     transpile(state: BrsTranspileState) {
-        return this.tokens.exitWhile ? state.transpileToken(this.tokens.exitWhile) : ['exit while'];
+        return [
+            state.transpileToken(this.tokens.exit, 'exit'),
+            this.tokens.loopType?.leadingWhitespace ?? ' ',
+            state.transpileToken(this.tokens.loopType)
+        ];
     }
 
     walk(visitor: WalkVisitor, options: WalkOptions) {
@@ -498,7 +474,7 @@ export class ExitWhileStatement extends Statement {
     }
 
     get leadingTrivia(): Token[] {
-        return this.tokens.exitWhile?.leadingTrivia;
+        return this.tokens.exit?.leadingTrivia;
     }
 }
 


Test patch:
diff --git a/src/parser/tests/statement/Misc.spec.ts b/src/parser/tests/statement/Misc.spec.ts
index b46b94f0..d8a64576 100644
--- a/src/parser/tests/statement/Misc.spec.ts
+++ b/src/parser/tests/statement/Misc.spec.ts
@@ -118,7 +118,6 @@ describe('parser', () => {
             TokenKind.EndWhile,
             TokenKind.Eval,
             TokenKind.Exit,
-            TokenKind.ExitFor,
             TokenKind.ExitWhile,
             TokenKind.False,
             TokenKind.For,
diff --git a/src/testHelpers.spec.ts b/src/testHelpers.spec.ts
index cfbfecce..2bfe14a3 100644
--- a/src/testHelpers.spec.ts
+++ b/src/testHelpers.spec.ts
@@ -7,7 +7,7 @@ import { expect } from './chai-config.spec';
 import type { CodeActionShorthand } from './CodeActionUtil';
 import { codeActionUtil } from './CodeActionUtil';
 import type { BrsFile } from './files/BrsFile';
-import type { Program } from './Program';
+import { Program } from './Program';
 import { standardizePath as s } from './util';
 import { getDiagnosticLine } from './diagnosticUtils';
 import { firstBy } from 'thenby';
@@ -175,11 +175,13 @@ export function expectZeroDiagnostics(arg: DiagnosticCollection) {
             diagnostic.message = diagnostic.message.replace(/\r/g, '\\r').replace(/\n/g, '\\n');
             message += `\n        • bs${diagnostic.code} "${diagnostic.message}" at ${diagnostic.location?.uri ?? ''}#(${diagnostic.location.range?.start.line}:${diagnostic.location.range?.start.character})-(${diagnostic.location.range?.end.line}:${diagnostic.location.range?.end.character})`;
             //print the line containing the error (if we can find it)srcPath
-            const file = (arg as any).getFile(diagnostic.location.uri);
+            if (arg instanceof Program) {
+                const file = arg.getFile(diagnostic.location.uri);
 
-            const line = (file as BrsFile)?.fileContents?.split(/\r?\n/g)?.[diagnostic.location.range?.start.line];
-            if (line) {
-                message += '\n' + getDiagnosticLine(diagnostic, line, chalk.red);
+                const line = (file as BrsFile)?.fileContents?.split(/\r?\n/g)?.[diagnostic.location.range?.start.line];
+                if (line) {
+                    message += '\n' + getDiagnosticLine(diagnostic, line, chalk.red);
+                }
             }
         }
         assert.fail(message);