diff --git a/docs/plugins.md b/docs/plugins.md index b60c1a1a9..2122a63f4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -606,6 +606,8 @@ Build the line/character `Location` on demand with `util.getLocation()`: const location = util.getLocation(funcStmt.tokens.name); // { uri, range } ``` +A node's bounds are computed once, when it's constructed, from the children and tokens it's given. They describe where the node came from in the original source, so they don't change if a plugin edits the AST afterwards (i.e. adding statements to a block doesn't move the block). + `util.getLocation()` returns `undefined` for synthetic tokens and nodes (ones that were created in code rather than parsed from a file), since they have no position. A synthetic node won't get a source map entry, and diagnostics can't point at it. A position describes where the code came from in the original source, so it's used as-is for source maps and diagnostics. Don't change the position of a parsed token or node. Only give one to a synthetic token or node you're creating: diff --git a/src/parser/AstNode.spec.ts b/src/parser/AstNode.spec.ts index 1ad9efd78..efe148ea8 100644 --- a/src/parser/AstNode.spec.ts +++ b/src/parser/AstNode.spec.ts @@ -7,12 +7,12 @@ import type { AAIndexedMemberExpression, AALiteralExpression, AAMemberExpression import { expectZeroDiagnostics } from '../testHelpers.spec'; import { tempDir, rootDir, outDir } from '../testHelpers.spec'; import { isAAIndexedMemberExpression, isAALiteralExpression, isAAMemberExpression, isAnnotationExpression, isArrayLiteralExpression, isAssignmentStatement, isBinaryExpression, isBlock, isCallExpression, isCallfuncExpression, isCatchStatement, isClassStatement, isConstStatement, isDimStatement, isDottedGetExpression, isDottedSetStatement, isEnumMemberStatement, isEnumStatement, isExpressionStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isGroupingExpression, isIfStatement, isIncrementStatement, isIndexedGetExpression, isIndexedSetStatement, isInterfaceFieldStatement, isInterfaceMethodStatement, isInterfaceStatement, isMethodStatement, isNamespaceStatement, isNewExpression, isNullCoalescingExpression, isPrintStatement, isReturnStatement, isTaggedTemplateStringExpression, isTemplateStringExpression, isTemplateStringQuasiExpression, isTernaryExpression, isThrowStatement, isTryCatchStatement, isTypecastExpression, isUnaryExpression, isVariableExpression, isLiteralExpression, isWhileStatement, isXmlAttributeGetExpression } from '../astUtils/reflection'; -import type { ClassStatement, FunctionStatement, InterfaceFieldStatement, InterfaceMethodStatement, MethodStatement, InterfaceStatement, CatchStatement, ThrowStatement, EnumStatement, EnumMemberStatement, ConstStatement, Block, PrintStatement, DimStatement, ForStatement, WhileStatement, IndexedSetStatement, NamespaceStatement, TryCatchStatement, DottedSetStatement, ExpressionStatement } from './Statement'; +import type { ConditionalCompileStatement, ClassStatement, FunctionStatement, InterfaceFieldStatement, InterfaceMethodStatement, MethodStatement, InterfaceStatement, CatchStatement, ThrowStatement, EnumStatement, EnumMemberStatement, ConstStatement, Block, PrintStatement, DimStatement, ForStatement, WhileStatement, IndexedSetStatement, NamespaceStatement, TryCatchStatement, DottedSetStatement, ExpressionStatement } from './Statement'; import { AssignmentStatement, EmptyStatement } from './Statement'; import { ParseMode, Parser } from './Parser'; import type { AstNode } from './AstNode'; import { WalkMode } from '../astUtils/visitors'; -import { isStatement } from '../astUtils/reflection'; +import { isConditionalCompileStatement, isStatement } from '../astUtils/reflection'; import { createAssignmentStatement, createBlock, createIfStatement, createToken, createVariableExpression } from '../astUtils/creators'; import { TokenKind } from '../lexer/TokenKind'; @@ -305,6 +305,115 @@ describe('AstNode', () => { }); }); + describe('bounds', () => { + it('keeps the bounds from construction when the AST is changed afterwards', () => { + const { ast } = Parser.parse('sub main()\n a = 1\nend sub'); + const block = ast.findChild(isBlock); + const bodyLocation = util.getLocation(ast); + const blockLocation = util.getLocation(block); + //a plugin moves a statement with a real position from elsewhere into this AST + const other = Parser.parse('\n\n\n\n\nsub other()\n b = 2\nend sub').ast; + ast.statements.push(other.statements[0]); + block.statements.push(other.findChild(isAssignmentStatement)); + expect(util.getLocation(ast)).to.eql(bodyLocation); + expect(util.getLocation(block)).to.eql(blockLocation); + }); + + it('computes the body bounds from its statements', () => { + const { ast } = Parser.parse('\n\nsub main()\nend sub\n\nsub other()\nend sub\n'); + expect(util.getLocation(ast).range).to.eql(util.createRange(2, 0, 6, 7)); + }); + + it('includes the annotation call in the annotation bounds', () => { + const { ast } = Parser.parse('@annotation(1, 2)\nsub main()\nend sub', { mode: ParseMode.BrighterScript }); + const annotation = (ast.statements[0] as FunctionStatement).annotations[0]; + expect(util.getLocation(annotation).range).to.eql(util.createRange(0, 0, 0, 17)); + }); + + describe('empty blocks', () => { + function getEmptyBlockRanges(code: string) { + const { ast } = Parser.parse(code, { mode: ParseMode.BrighterScript }); + return ast.findChildren(isBlock, { walkMode: WalkMode.visitAllRecursive }).map(x => util.getLocation(x)?.range); + } + + it('function', () => { + expect(getEmptyBlockRanges('sub main(a)\nend sub')).to.eql([ + util.createRange(0, 11, 1, 0) + ]); + }); + + it('if/else', () => { + expect(getEmptyBlockRanges('sub main()\n if true then\n else\n end if\nend sub')).to.eql([ + //the function body is not empty, so it's bounded by its statements + util.createRange(1, 4, 3, 10), + //then branch + util.createRange(1, 16, 2, 4), + //else branch + util.createRange(2, 8, 3, 4) + ]); + }); + + it('for', () => { + expect(getEmptyBlockRanges('sub main()\n for i = 0 to 1\n end for\nend sub')[1]).to.eql( + util.createRange(1, 18, 2, 4) + ); + }); + + it('for each', () => { + expect(getEmptyBlockRanges('sub main()\n for each x in y\n end for\nend sub')[1]).to.eql( + util.createRange(1, 19, 2, 4) + ); + }); + + it('while', () => { + expect(getEmptyBlockRanges('sub main()\n while true\n end while\nend sub')[1]).to.eql( + util.createRange(1, 14, 2, 4) + ); + }); + + it('try/catch', () => { + expect(getEmptyBlockRanges('sub main()\n try\n catch e\n end try\nend sub').slice(1)).to.eql([ + //try branch + util.createRange(1, 7, 2, 4), + //catch branch + util.createRange(2, 11, 3, 4) + ]); + }); + + it('#if/#else', () => { + //the walker skips inactive conditional compile branches, so read both branches off the statement directly + const { ast } = Parser.parse('#const DEBUG = true\n#if DEBUG\n#else\n#end if', { mode: ParseMode.BrighterScript }); + const statement = ast.findChild(isConditionalCompileStatement); + expect([statement.thenBranch, statement.elseBranch].map(x => util.getLocation(x)?.range)).to.eql([ + //then branch + util.createRange(1, 9, 2, 0), + //else branch + util.createRange(2, 5, 3, 0) + ]); + }); + + it('select case', () => { + expect(getEmptyBlockRanges('sub main()\n select case x\n case 1\n case else\n end select\nend sub').slice(1)).to.eql([ + //`case 1` body ends at the next case + util.createRange(2, 14, 3, 8), + //`case else` body ends at `end select` + util.createRange(3, 17, 4, 4) + ]); + }); + }); + + it('spans the gap between the directives for #if/#else branches that have statements', () => { + const { ast } = Parser.parse('#const DEBUG = true\n#if DEBUG\n print 1\n#else\n print 2\n#end if', { mode: ParseMode.BrighterScript }); + const statement = ast.findChild(isConditionalCompileStatement); + expect([statement.thenBranch, statement.elseBranch].map(x => util.getLocation(x)?.range)).to.eql([ + //then branch + util.createRange(1, 9, 3, 0), + //else branch + util.createRange(3, 5, 5, 0) + ]); + }); + }); + describe('findChild', () => { it('finds a child that matches the matcher', () => { const file = program.setFile('source/main.brs', ` diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts index 356bb042f..d9cfd47d4 100644 --- a/src/parser/Expression.ts +++ b/src/parser/Expression.ts @@ -292,15 +292,16 @@ export class FunctionExpression extends Expression implements TypedefProvider { this.body.parent = this; } this.symbolTable = new SymbolTable('FunctionExpression', () => this.parent?.getSymbolTable()); - util.setBounds(this, + const signature = util.setBounds({} as Locatable, this.tokens.functionType, this.tokens.leftParen, ...this.parameters ?? [], this.tokens.rightParen, this.tokens.as, - this.returnTypeExpression, - this.tokens.endFunctionType + this.returnTypeExpression ); + util.setBounds(this, signature, this.tokens.endFunctionType); + this.body?.setBoundsBetween(signature, this.tokens.endFunctionType); } public readonly kind = AstNodeKind.FunctionExpression; @@ -1053,6 +1054,7 @@ export class LiteralExpression extends Expression { this.tokens = { value: options.value }; + util.setBounds(this, this.tokens.value); } public readonly tokens: { @@ -1061,15 +1063,9 @@ export class LiteralExpression extends Expression { public readonly kind = AstNodeKind.LiteralExpression; - public get startPosition() { - return this.tokens.value.startPosition; - } - public get endPosition() { - return this.tokens.value.endPosition; - } - public get sourceInfo() { - return this.tokens.value.sourceInfo; - } + public readonly startPosition: number; + public readonly endPosition: number; + public readonly sourceInfo: SourceInfo | undefined; public getType(options?: GetTypeOptions) { return util.tokenToBscType(this.tokens.value); @@ -2441,6 +2437,8 @@ export class AnnotationExpression extends Expression { }; this.call = options.call; this.name = this.tokens.name.text; + //the parser assigns `call` after construction (since the call needs this annotation as its callee), and updates the bounds then + util.setBounds(this, this.tokens.at, this.tokens.name, this.call); } public readonly kind = AstNodeKind.AnnotationExpression; @@ -2450,19 +2448,9 @@ export class AnnotationExpression extends Expression { readonly name: Token; }; - public get startPosition() { - return this.getBounds().startPosition; - } - public get endPosition() { - return this.getBounds().endPosition; - } - public get sourceInfo() { - return this.getBounds().sourceInfo; - } - private getBounds() { - //this needs to be computed on demand because `call` is assigned after construction - return util.setBounds({} as Locatable, this.tokens.at, this.tokens.name, this.call); - } + public readonly startPosition: number; + public readonly endPosition: number; + public readonly sourceInfo: SourceInfo | undefined; public readonly name: string; @@ -2775,6 +2763,7 @@ export class RegexLiteralExpression extends Expression { this.tokens = { regexLiteral: options.regexLiteral }; + util.setBounds(this, this.tokens.regexLiteral); } public readonly kind = AstNodeKind.RegexLiteralExpression; @@ -2782,15 +2771,9 @@ export class RegexLiteralExpression extends Expression { readonly regexLiteral: Token; }; - public get startPosition() { - return this.tokens?.regexLiteral?.startPosition; - } - public get endPosition() { - return this.tokens?.regexLiteral?.endPosition; - } - public get sourceInfo() { - return this.tokens?.regexLiteral?.sourceInfo; - } + public readonly startPosition: number; + public readonly endPosition: number; + public readonly sourceInfo: SourceInfo | undefined; public transpile(state: BrsTranspileState): TranspileResult { let text = this.tokens.regexLiteral?.text ?? ''; diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index 1c828ac2f..c2f293886 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -256,7 +256,7 @@ export class Parser { private body() { const parentAnnotations = this.enterAnnotationBlock(); - let body = new Body({ statements: [] }); + const statements: Statement[] = []; if (this.tokens.length > 0) { this.consumeStatementSeparators(true); @@ -271,7 +271,7 @@ export class Parser { if (dec) { if (!isAnnotationExpression(dec)) { this.consumePendingAnnotations(dec); - body.statements.push(dec); + statements.push(dec); //ensure statement separator this.consumeStatementSeparators(false); } else { @@ -286,7 +286,8 @@ export class Parser { } this.exitAnnotationBlock(parentAnnotations); - return body; + //construct the body once all of its statements are known, so its bounds are computed from them + return new Body({ statements: statements }); } private sanitizeParseOptions(options: ParseOptions) { @@ -1796,6 +1797,8 @@ export class Parser { if (this.check(TokenKind.LeftParen)) { let leftParen = this.advance(); annotation.call = this.finishCall(leftParen, annotation, false); + //the call needs the annotation as its callee, so it can't be passed to the constructor. Include it in the bounds now that it exists + util.setBounds(annotation, annotation.tokens.at, annotation.tokens.name, annotation.call); } return annotation; } diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts index ac23ae616..127431825 100644 --- a/src/parser/Statement.ts +++ b/src/parser/Statement.ts @@ -10,7 +10,7 @@ import type { BrsTranspileState } from './BrsTranspileState'; import { ParseMode } from './Parser'; import type { WalkVisitor, WalkOptions } from '../astUtils/visitors'; import { InternalWalkMode, walk, createVisitor, WalkMode, walkArray } from '../astUtils/visitors'; -import { isBinaryExpression, isBlock, isCallExpression, isCaseStatement, isCatchStatement, isClassType, isConditionalCompileStatement, isLiteralBoolean, isEnumMemberStatement, isEnumType, isEnumStatement, isExitStatement, isExpressionStatement, isFieldStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isIfStatement, isInterfaceFieldStatement, isInterfaceMethodStatement, isInvalidType, isLiteralExpression, isMethodStatement, isNamespaceStatement, isPrintSeparatorExpression, isSelectCaseStatement, isTryCatchStatement, isTypecastExpression, isTypedefProvider, isUnaryExpression, isUninitializedType, isVariableExpression, isVoidType, isWhileStatement } from '../astUtils/reflection'; +import { isBinaryExpression, isBlock, isCallExpression, isCaseStatement, isClassType, isConditionalCompileStatement, isLiteralBoolean, isEnumMemberStatement, isEnumType, isEnumStatement, isExitStatement, isExpressionStatement, isFieldStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isIfStatement, isInterfaceFieldStatement, isInterfaceMethodStatement, isInvalidType, isLiteralExpression, isMethodStatement, isNamespaceStatement, isPrintSeparatorExpression, isSelectCaseStatement, isTypecastExpression, isTypedefProvider, isUnaryExpression, isUninitializedType, isVariableExpression, isVoidType, isWhileStatement } from '../astUtils/reflection'; import type { GetTypeOptions } from '../interfaces'; import { TypeChainEntry, type TranspileResult, type TypedefProvider } from '../interfaces'; import { createDottedIdentifier, createIdentifier, createInvalidLiteral, createMethodStatement, createToken, createVariableExpression } from '../astUtils/creators'; @@ -67,6 +67,7 @@ export class Body extends Statement implements TypedefProvider { }) { super(); this.statements = options?.statements ?? []; + util.setBounds(this, ...this.statements); } public readonly statements: Statement[] = []; @@ -74,23 +75,9 @@ export class Body extends Statement implements TypedefProvider { public readonly symbolTable = new SymbolTable('Body', () => this.parent?.getSymbolTable()); - public get startPosition() { - return this.getBounds().startPosition; - } - public get endPosition() { - return this.getBounds().endPosition; - } - public get sourceInfo() { - return this.getBounds().sourceInfo; - } - private getBounds() { - //this needs to be lazy because the body has its statements pushed to it after being constructed - if (!this._bounds?.sourceInfo) { - this._bounds = util.setBounds(this._bounds ?? {} as Locatable, ...(this.statements ?? [])); - } - return this._bounds; - } - private _bounds: Locatable; + public readonly startPosition: number; + public readonly endPosition: number; + public readonly sourceInfo: SourceInfo | undefined; transpile(state: BrsTranspileState) { let result: TranspileResult = state.transpileAnnotations(this); @@ -335,137 +322,38 @@ export class Block extends Statement { super(); this.statements = options.statements; this.symbolTable = new SymbolTable('Block', () => this.parent.getSymbolTable()); + util.setBounds(this, ...this.statements ?? []); } public readonly statements: Statement[]; public readonly kind = AstNodeKind.Block; - private buildBounds(): Locatable { - //a conditional compile branch always spans its directives below, so it covers blank lines, comments and code that did not parse too - if (this.statements?.length > 0 && !isConditionalCompileStatement(this.parent)) { - return util.setBounds({} as Locatable, ...this.statements ?? []); - } - let lastBitBefore: Locatable; - let firstBitAfter: Locatable; - - if (isFunctionExpression(this.parent)) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.tokens.functionType, - this.parent.tokens.leftParen, - ...(this.parent.parameters ?? []), - this.parent.tokens.rightParen, - this.parent.tokens.as, - this.parent.returnTypeExpression - ); - firstBitAfter = this.parent.tokens.endFunctionType; - } else if (isIfStatement(this.parent)) { - if (this.parent.thenBranch === this) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.tokens.then, - this.parent.condition - ); - firstBitAfter = util.setBounds({} as Locatable, - this.parent.tokens.else, - this.parent.elseBranch, - this.parent.tokens.endIf - ); - } else if (this.parent.elseBranch === this) { - lastBitBefore = this.parent.tokens.else; - firstBitAfter = this.parent.tokens.endIf; - } - } else if (isConditionalCompileStatement(this.parent)) { - if (this.parent.thenBranch === this) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.tokens.condition, - this.parent.tokens.not, - this.parent.tokens.hashIf - ); - firstBitAfter = util.setBounds({} as Locatable, - this.parent.tokens.hashElse, - this.parent.elseBranch, - this.parent.tokens.hashEndIf - ); - } else if (this.parent.elseBranch === this) { - lastBitBefore = this.parent.tokens.hashElse; - firstBitAfter = this.parent.tokens.hashEndIf; - } - } else if (isForStatement(this.parent)) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.increment, - this.parent.tokens.step, - this.parent.finalValue, - this.parent.tokens.to, - this.parent.counterDeclaration, - this.parent.tokens.for - ); - firstBitAfter = this.parent.tokens.endFor; - } else if (isForEachStatement(this.parent)) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.target, - this.parent.tokens.in, - this.parent.tokens.item, - this.parent.tokens.forEach - ); - firstBitAfter = this.parent.tokens.endFor; - } else if (isWhileStatement(this.parent)) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.condition, - this.parent.tokens.while - ); - firstBitAfter = this.parent.tokens.endWhile; - } else if (isTryCatchStatement(this.parent)) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.tokens.try - ); - firstBitAfter = util.setBounds({} as Locatable, - this.parent.tokens.endTry, - this.parent.catchStatement - ); - } else if (isCatchStatement(this.parent) && isTryCatchStatement(this.parent?.parent)) { - lastBitBefore = util.setBounds({} as Locatable, - this.parent.tokens.catch, - this.parent.exceptionVariableExpression - ); - firstBitAfter = this.parent.parent.tokens.endTry; - } else if (isCaseStatement(this.parent) && isSelectCaseStatement(this.parent?.parent)) { - lastBitBefore = this.parent.headerBounds; - const cases = this.parent.parent.cases; - const nextCase = cases[cases.indexOf(this.parent) + 1]; - firstBitAfter = nextCase?.tokens.case ?? this.parent.parent.tokens.endSelect; - } - //positions from different sources can't be compared, so both sides must come from the same source - if (lastBitBefore?.sourceInfo && lastBitBefore.sourceInfo === firstBitAfter?.sourceInfo) { - return { startPosition: lastBitBefore.endPosition, endPosition: firstBitAfter.startPosition, sourceInfo: lastBitBefore.sourceInfo }; - } - //a conditional compile branch whose directives have no location (such as one built from synthesized tokens) falls back to its statements - if (this.statements?.length > 0) { - return util.setBounds({} as Locatable, ...this.statements); - } - } + public readonly startPosition: number; + public readonly endPosition: number; + public readonly sourceInfo: SourceInfo | undefined; - public get startPosition() { - return this.getBounds()?.startPosition; - } - public get endPosition() { - return this.getBounds()?.endPosition; - } - public get sourceInfo() { - return this.getBounds()?.sourceInfo; - } - private getBounds() { - //this needs to be lazy because the body has its statements pushed to it after being constructed. - //the bounds can depend on the parent, so they're only kept once the block has one - if (!this._bounds?.sourceInfo) { - const bounds = this.buildBounds(); - if (!this.parent) { - return bounds; - } - this._bounds = bounds; + /** + * An empty block has no statements to get its bounds from, so its parent calls this (from the parent's constructor) + * to set the bounds to the gap between the parent's code on either side of the block. Does nothing if the block has statements, + * unless `options.evenIfNotEmpty` is true. If either side has no position, the block keeps the bounds of its statements. + * @param before the parent's code just before this block (i.e. a function's signature) + * @param after the parent's code just after this block (i.e. `end function`) + * @param options extra options + * @param options.evenIfNotEmpty span the gap even when the block has statements (i.e. a conditional compile branch, which also covers blank lines, comments and code that did not parse) + */ + public setBoundsBetween(before: Locatable | undefined, after: Locatable | undefined, options?: { evenIfNotEmpty?: boolean }) { + if (this.statements?.length > 0 && !options?.evenIfNotEmpty) { + return; + } + //offsets from different sources can't be compared, so both sides must come from the same source + if (before?.sourceInfo && before.sourceInfo === after?.sourceInfo) { + const self = this as Locatable; + self.startPosition = util.getContentEnd(before); + self.endPosition = after.startPosition; + self.sourceInfo = before.sourceInfo; } - return this._bounds; } - private _bounds: Locatable; transpile(state: BrsTranspileState): TranspileResult { state.blockDepth++; @@ -775,6 +663,15 @@ export class IfStatement extends Statement { endIf: options.endIf }; + //give empty branches the bounds of the gap they occupy (the else branch first, since the then branch ends where it starts) + if (isBlock(this.elseBranch)) { + this.elseBranch.setBoundsBetween(this.tokens.else, this.tokens.endIf); + } + this.thenBranch?.setBoundsBetween( + util.setBounds({} as Locatable, this.tokens.then, this.condition), + util.setBounds({} as Locatable, this.tokens.else, this.elseBranch, this.tokens.endIf) + ); + util.setBounds(this, ...Object.values(this.tokens), this.condition, @@ -1434,6 +1331,10 @@ export class ForStatement extends Statement { this.body = options.body; this.increment = options.increment; + this.body?.setBoundsBetween( + util.setBounds({} as Locatable, this.increment, this.tokens.step, this.finalValue, this.tokens.to, this.counterDeclaration, this.tokens.for), + this.tokens.endFor + ); util.setBounds(this, this.tokens.for, this.counterDeclaration, @@ -1566,6 +1467,10 @@ export class ForEachStatement extends Statement { this.target = options.target; this.typeExpression = options.typeExpression; + this.body?.setBoundsBetween( + util.setBounds({} as Locatable, this.target, this.tokens.in, this.typeExpression, this.tokens.as, this.tokens.item, this.tokens.forEach), + this.tokens.endFor + ); util.setBounds(this, this.tokens.forEach, this.tokens.item, @@ -1691,6 +1596,10 @@ export class WhileStatement extends Statement { }; this.body = options.body; this.condition = options.condition; + this.body?.setBoundsBetween( + util.setBounds({} as Locatable, this.condition, this.tokens.while), + this.tokens.endWhile + ); util.setBounds(this, this.tokens.while, this.condition, @@ -3914,6 +3823,15 @@ export class TryCatchStatement extends Statement { }; this.tryBranch = options.tryBranch; this.catchStatement = options.catchStatement; + this.tryBranch?.setBoundsBetween( + this.tokens.try, + util.setBounds({} as Locatable, this.tokens.endTry, this.catchStatement) + ); + //the catch statement doesn't know where the `end try` is, so the try/catch sets the bounds of its (empty) branch + this.catchStatement?.catchBranch?.setBoundsBetween( + util.setBounds({} as Locatable, this.catchStatement.tokens.catch, this.catchStatement.exceptionVariableExpression), + this.tokens.endTry + ); util.setBounds(this, this.tokens.try, this.tryBranch, @@ -4822,6 +4740,17 @@ export class ConditionalCompileStatement extends Statement { hashEndIf: options.hashEndIf }; + //a branch always spans the gap between its directives (even when it has statements), so it covers blank lines, comments and code that did not parse too. + //the else branch goes first, since the then branch ends where it starts + if (isBlock(this.elseBranch)) { + this.elseBranch.setBoundsBetween(this.tokens.hashElse, this.tokens.hashEndIf, { evenIfNotEmpty: true }); + } + this.thenBranch?.setBoundsBetween( + util.setBounds({} as Locatable, this.tokens.condition, this.tokens.not, this.tokens.hashIf), + util.setBounds({} as Locatable, this.tokens.hashElse, this.elseBranch, this.tokens.hashEndIf), + { evenIfNotEmpty: true } + ); + util.setBounds(this, ...Object.values(this.tokens), this.thenBranch, @@ -5218,6 +5147,14 @@ export class SelectCaseStatement extends Statement { this.subject = options.subject; this.leadingStatements = options.leadingStatements; this.cases = options.cases ?? []; + //a case doesn't know where the next case (or `end select`) is, so the select sets the bounds of its (empty) body + for (let i = 0; i < this.cases.length; i++) { + const caseStatement = this.cases[i]; + caseStatement?.body?.setBoundsBetween( + caseStatement.headerBounds, + this.cases[i + 1]?.tokens.case ?? this.tokens.endSelect + ); + } util.setBounds(this, this.tokens.select, this.tokens.case,