diff --git a/src/files/BrsFile.Class.spec.ts b/src/files/BrsFile.Class.spec.ts index 6e5c4a7cf..8b7d4f39e 100644 --- a/src/files/BrsFile.Class.spec.ts +++ b/src/files/BrsFile.Class.spec.ts @@ -2151,4 +2151,158 @@ describe('BrsFile BrighterScript classes', () => { DiagnosticMessages.mismatchArgumentCount(1, 0) ]); }); + + describe('conditional compile', () => { + it('transpiles methods inside conditional compile blocks', async () => { + await testTranspile(` + #const DEBUG = true + class Animal + sub speak() + print "speak" + end sub + #if DEBUG + sub debugSpeak() + print "debug" + end sub + #end if + end class + `, ` + #const DEBUG = true + sub __Animal_method_new() + end sub + sub __Animal_method_speak() + print "speak" + end sub + #if DEBUG + sub __Animal_method_debugSpeak() + print "debug" + end sub + #end if + function __Animal_builder() + instance = {} + instance.new = __Animal_method_new + instance.speak = __Animal_method_speak + #if DEBUG + instance.debugSpeak = __Animal_method_debugSpeak + #end if + return instance + end function + function Animal() + instance = __Animal_builder() + instance.new() + return instance + end function + `, 'trim', 'source/main.bs'); + }); + + it('transpiles fields inside conditional compile blocks as conditional initializers', async () => { + await testTranspile(` + #const DEBUG = true + class Animal + name = "generic" + #if DEBUG + logLevel = 4 + #else + logLevel = 0 + #end if + end class + `, ` + #const DEBUG = true + sub __Animal_method_new() + m.name = "generic" + #if DEBUG + m.logLevel = 4 + #else + m.logLevel = 0 + #end if + end sub + function __Animal_builder() + instance = {} + instance.new = __Animal_method_new + return instance + end function + function Animal() + instance = __Animal_builder() + instance.new() + return instance + end function + `, 'trim', 'source/main.bs'); + }); + + it('transpiles #else if chains of methods', async () => { + await testTranspile(` + #const DEBUG = true + #const BETA = false + class Animal + #if DEBUG + sub speak() + print "debug" + end sub + #else if BETA + sub speak() + print "beta" + end sub + #else + sub speak() + print "prod" + end sub + #end if + end class + `, ` + #const DEBUG = true + #const BETA = false + sub __Animal_method_new() + end sub + #if DEBUG + sub __Animal_method_speak() + print "debug" + end sub + #else if BETA + sub __Animal_method_speak() + print "beta" + end sub + #else + sub __Animal_method_speak() + print "prod" + end sub + #end if + function __Animal_builder() + instance = {} + instance.new = __Animal_method_new + #if DEBUG + instance.speak = __Animal_method_speak + #else if BETA + instance.speak = __Animal_method_speak + #else + instance.speak = __Animal_method_speak + #end if + return instance + end function + function Animal() + instance = __Animal_builder() + instance.new() + return instance + end function + `, 'trim', 'source/main.bs'); + }); + + it('does not produce diagnostics for conditional members used within the class', () => { + program.setFile('source/main.bs', ` + #const DEBUG = true + class Animal + #if DEBUG + logLevel = 4 + sub debugSpeak() + print m.logLevel + end sub + #end if + sub speak() + m.debugSpeak() + end sub + end class + `); + program.validate(); + expectZeroDiagnostics(program); + }); + }); }); diff --git a/src/parser/Parser.Class.spec.ts b/src/parser/Parser.Class.spec.ts index 1b3eab8b8..76f642814 100644 --- a/src/parser/Parser.Class.spec.ts +++ b/src/parser/Parser.Class.spec.ts @@ -3,11 +3,11 @@ import { DiagnosticMessages } from '../DiagnosticMessages'; import { TokenKind, AllowedLocalIdentifiers, AllowedProperties } from '../lexer/TokenKind'; import { Lexer } from '../lexer/Lexer'; import { Parser, ParseMode } from './Parser'; -import type { FunctionStatement, AssignmentStatement, FieldStatement } from './Statement'; +import type { FunctionStatement, AssignmentStatement, FieldStatement, ConditionalCompileStatement, MethodStatement, Block } from './Statement'; import { ClassStatement } from './Statement'; import { NewExpression } from './Expression'; import { expectDiagnostics, expectDiagnosticsIncludes, expectZeroDiagnostics } from '../testHelpers.spec'; -import { isClassStatement } from '../astUtils/reflection'; +import { isClassStatement, isConditionalCompileStatement, isMethodStatement } from '../astUtils/reflection'; import { StringType } from '../types/StringType'; import { SymbolTypeFlag } from '../SymbolTypeFlag'; import util from '../util'; @@ -561,4 +561,152 @@ describe('parser class', () => { klassMembers.forEach(sym => expect(sym.flags & SymbolTypeFlag.optional).to.eq(SymbolTypeFlag.optional)); }); }); + + describe('conditional compile', () => { + it('allows methods inside conditional compile blocks', () => { + let { ast, diagnostics } = Parser.parse(` + class Person + sub speak() + end sub + + #if DEBUG + sub debugSpeak() + print "debug" + end sub + #end if + end class + `, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(diagnostics); + const klass = ast.statements[0] as ClassStatement; + expect(isClassStatement(klass)).to.be.true; + expect(isConditionalCompileStatement(klass.body[1])).to.be.true; + const cc = klass.body[1] as ConditionalCompileStatement; + expect(isMethodStatement(cc.thenBranch.statements[0])).to.be.true; + //both methods are registered on the class + expect(klass.methods.map(x => x.tokens.name.text)).to.eql(['speak', 'debugSpeak']); + expect(klass.memberMap['debugspeak']).to.exist; + }); + + it('allows fields inside conditional compile blocks', () => { + let { ast, diagnostics } = Parser.parse(` + class Person + name as string + #if DEBUG + debugName as string + #end if + end class + `, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(diagnostics); + const klass = ast.statements[0] as ClassStatement; + expect(klass.fields.map(x => x.tokens.name.text)).to.eql(['name', 'debugName']); + }); + + it('allows #else and #else if branches with members', () => { + let { ast, diagnostics } = Parser.parse(` + class Person + #if DEBUG + sub speak() + print "debug" + end sub + #else if BETA + sub speak() + print "beta" + end sub + #else + sub speak() + print "prod" + end sub + #end if + end class + `, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(diagnostics); + const klass = ast.statements[0] as ClassStatement; + const cc = klass.body[0] as ConditionalCompileStatement; + expect(isConditionalCompileStatement(cc)).to.be.true; + expect(isMethodStatement(cc.thenBranch.statements[0])).to.be.true; + const elseIf = cc.elseBranch as ConditionalCompileStatement; + expect(isConditionalCompileStatement(elseIf)).to.be.true; + expect(elseIf.tokens.condition.text).to.eq('BETA'); + expect(isMethodStatement(elseIf.thenBranch.statements[0])).to.be.true; + expect(isMethodStatement((elseIf.elseBranch as Block).statements[0])).to.be.true; + //all three speak() methods are registered + expect(klass.methods).to.be.lengthOf(3); + }); + + it('allows nested conditional compile blocks', () => { + let { ast, diagnostics } = Parser.parse(` + class Person + #if DEBUG + #if BETA + sub speak() + end sub + #end if + #end if + end class + `, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(diagnostics); + const klass = ast.statements[0] as ClassStatement; + const outer = klass.body[0] as ConditionalCompileStatement; + expect(isConditionalCompileStatement(outer)).to.be.true; + const inner = outer.thenBranch.statements[0] as ConditionalCompileStatement; + expect(isConditionalCompileStatement(inner)).to.be.true; + expect(isMethodStatement(inner.thenBranch.statements[0])).to.be.true; + expect(klass.methods).to.be.lengthOf(1); + }); + + it('allows empty conditional compile blocks', () => { + let { ast, diagnostics } = Parser.parse(` + class Person + #if DEBUG + #end if + end class + `, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(diagnostics); + const klass = ast.statements[0] as ClassStatement; + expect(isConditionalCompileStatement(klass.body[0])).to.be.true; + }); + + it('allows annotations on members inside conditional compile blocks', () => { + let { ast, diagnostics } = Parser.parse(` + class Person + #if DEBUG + @it("does something") + sub speak() + end sub + #end if + end class + `, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(diagnostics); + const klass = ast.statements[0] as ClassStatement; + const cc = klass.body[0] as ConditionalCompileStatement; + const method = cc.thenBranch.statements[0] as MethodStatement; + expect(method.annotations?.[0]?.name).to.eq('it'); + }); + + it('includes conditional members in the class type', () => { + let { ast, diagnostics } = Parser.parse(` + class Person + #if DEBUG + sub speak() + end sub + #end if + end class + `, { mode: ParseMode.BrighterScript }); + expectZeroDiagnostics(diagnostics); + const klass = ast.statements[0] as ClassStatement; + const klassType = klass.getType({ flags: SymbolTypeFlag.typetime }); + expect(klassType.getMemberTable().getSymbol('speak', SymbolTypeFlag.runtime)).to.exist; + }); + + it('flags unterminated conditional compile blocks in class bodies', () => { + let { diagnostics } = Parser.parse(` + class Person + #if DEBUG + sub speak() + end sub + end class + `, { mode: ParseMode.BrighterScript }); + expect(diagnostics.length).to.be.greaterThan(0); + }); + }); }); diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index 3d3153e9a..3e8b5fcce 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -686,60 +686,10 @@ export class Parser { //gather up all class members (Fields, Methods) let body = [] as Statement[]; - while (this.checkAny(TokenKind.Public, TokenKind.Protected, TokenKind.Private, TokenKind.Function, TokenKind.Sub, TokenKind.Comment, TokenKind.Identifier, TokenKind.At, ...AllowedProperties)) { + while (this.checkAny(TokenKind.Public, TokenKind.Protected, TokenKind.Private, TokenKind.Function, TokenKind.Sub, TokenKind.Comment, TokenKind.Identifier, TokenKind.At, TokenKind.HashIf, ...AllowedProperties)) { try { - let decl: Statement; - let accessModifier: Token; - - if (this.check(TokenKind.At)) { - this.annotationExpression(); - } - - if (this.checkAny(TokenKind.Public, TokenKind.Protected, TokenKind.Private)) { - //use actual access modifier - accessModifier = this.advance(); - } - - let overrideKeyword: Token; - if (this.peek().text.toLowerCase() === 'override') { - overrideKeyword = this.advance(); - } - //methods (function/sub keyword OR identifier followed by opening paren) - if (this.checkAny(TokenKind.Function, TokenKind.Sub) || (this.checkAny(TokenKind.Identifier, ...AllowedProperties) && this.checkNext(TokenKind.LeftParen))) { - const funcDeclaration = this.functionDeclaration(false, false); - - //if we have an overrides keyword AND this method is called 'new', that's not allowed - if (overrideKeyword && funcDeclaration.tokens.name.text.toLowerCase() === 'new') { - this.diagnostics.push({ - ...DiagnosticMessages.cannotUseOverrideKeywordOnConstructorFunction(), - location: overrideKeyword.location - }); - } - - decl = new MethodStatement({ - modifiers: accessModifier, - name: funcDeclaration.tokens.name, - func: funcDeclaration.func, - override: overrideKeyword - }); - - //fields - } else if (this.checkAny(TokenKind.Identifier, ...AllowedProperties)) { - - decl = this.fieldDeclaration(accessModifier); - - //class fields cannot be overridden - if (overrideKeyword) { - this.diagnostics.push({ - ...DiagnosticMessages.classFieldCannotBeOverridden(), - location: overrideKeyword.location - }); - } - - } - + let decl = this.classMemberDeclaration(); if (decl) { - this.consumePendingAnnotations(decl); body.push(decl); } } catch (e) { @@ -772,6 +722,121 @@ export class Parser { return result; } + /** + * Parse a single member (method, field, annotation, or conditional compile block) of a class body + */ + private classMemberDeclaration(): Statement | undefined { + //conditional compile blocks can wrap class members + if (this.check(TokenKind.HashIf)) { + return this.conditionalCompileStatement(() => this.classBodyConditionalCompileBlock()); + } + + let decl: Statement; + let accessModifier: Token; + + if (this.check(TokenKind.At)) { + this.annotationExpression(); + } + + if (this.checkAny(TokenKind.Public, TokenKind.Protected, TokenKind.Private)) { + //use actual access modifier + accessModifier = this.advance(); + } + + let overrideKeyword: Token; + if (this.peek().text.toLowerCase() === 'override') { + overrideKeyword = this.advance(); + } + //methods (function/sub keyword OR identifier followed by opening paren) + if (this.checkAny(TokenKind.Function, TokenKind.Sub) || (this.checkAny(TokenKind.Identifier, ...AllowedProperties) && this.checkNext(TokenKind.LeftParen))) { + const funcDeclaration = this.functionDeclaration(false, false); + + //if we have an overrides keyword AND this method is called 'new', that's not allowed + if (overrideKeyword && funcDeclaration.tokens.name.text.toLowerCase() === 'new') { + this.diagnostics.push({ + ...DiagnosticMessages.cannotUseOverrideKeywordOnConstructorFunction(), + location: overrideKeyword.location + }); + } + + decl = new MethodStatement({ + modifiers: accessModifier, + name: funcDeclaration.tokens.name, + func: funcDeclaration.func, + override: overrideKeyword + }); + + //fields + } else if (this.checkAny(TokenKind.Identifier, ...AllowedProperties)) { + + decl = this.fieldDeclaration(accessModifier); + + //class fields cannot be overridden + if (overrideKeyword) { + this.diagnostics.push({ + ...DiagnosticMessages.classFieldCannotBeOverridden(), + location: overrideKeyword.location + }); + } + + } + + if (decl) { + this.consumePendingAnnotations(decl); + } + return decl; + } + + /** + * Parse the contents of a conditional compile branch inside a class body (class members only). + * Follows the same token-position contract as `conditionalCompileBlock`: when a branch terminator + * (`#else if`, `#else`, `#end if`) is found, the parser is rewound to the newline that precedes it. + */ + private classBodyConditionalCompileBlock(): Block | undefined { + const parentAnnotations = this.enterAnnotationBlock(); + + this.consumeStatementSeparators(true); + const conditionalEndTokens = [TokenKind.HashElse, TokenKind.HashElseIf, TokenKind.HashEndIf]; + this.globalTerminators.push(conditionalEndTokens); + const statements: Statement[] = []; + while (this.checkAny(TokenKind.Public, TokenKind.Protected, TokenKind.Private, TokenKind.Function, TokenKind.Sub, TokenKind.Comment, TokenKind.Identifier, TokenKind.At, TokenKind.HashIf, ...AllowedProperties)) { + try { + let decl = this.classMemberDeclaration(); + if (decl) { + statements.push(decl); + } + } catch (e) { + //throw out any failed members and move on to the next line + this.flagUntil(TokenKind.Newline, TokenKind.Colon, TokenKind.Eof); + } + + //ensure statement separator + this.consumeStatementSeparators(); + } + this.globalTerminators.pop(); + + if (this.isAtEnd()) { + this.exitAnnotationBlock(parentAnnotations); + return undefined; + } + + if (this.checkAny(...conditionalEndTokens)) { + if (this.previous().kind === TokenKind.Newline) { + //rewind to the preceding newline (callers expect to consume it themselves) + this.current--; + } + this.exitAnnotationBlock(parentAnnotations); + return new Block({ statements: statements }); + } + + //found something that is neither a class member nor a conditional compile terminator (i.e. `end class`) + this.diagnostics.push({ + ...DiagnosticMessages.unsafeUnmatchedTerminatorInConditionalCompileBlock(this.peek().text), + location: this.peek().location + }); + throw this.lastDiagnosticAsError(); + } + private fieldDeclaration(accessModifier: Token | null) { let optionalKeyword = this.consumeTokenIf(TokenKind.Optional); @@ -2154,7 +2219,13 @@ export class Parser { return branch; } - private conditionalCompileStatement(): ConditionalCompileStatement { + /** + * Parse a `#if` statement + * @param branchBlockParser optional function used to parse the contents of each branch. When omitted, + * branches are parsed as regular statement blocks. (Class bodies pass a parser + * that only allows class members.) + */ + private conditionalCompileStatement(branchBlockParser?: () => Block | undefined): ConditionalCompileStatement { const hashIfToken = this.advance(); let notToken: Token | undefined; @@ -2182,7 +2253,7 @@ export class Parser { //if this is `#if false` remove all diagnostics. let diagnosticsLengthBeforeBlock = this.diagnostics.length; - thenBranch = this.blockConditionalCompileBranch(hashIfToken); + thenBranch = this.blockConditionalCompileBranch(hashIfToken, branchBlockParser); const conditionTextLower = condition.text.toLowerCase(); if (!this.options.bsConsts?.get(conditionTextLower) || conditionTextLower === 'false') { //throw out any new diagnostics created as a result of a false block @@ -2195,13 +2266,13 @@ export class Parser { //else branch if (this.check(TokenKind.HashElseIf)) { // recurse-read `#else if` - elseBranch = this.conditionalCompileStatement(); + elseBranch = this.conditionalCompileStatement(branchBlockParser); this.ensureNewLine(); } else if (this.check(TokenKind.HashElse)) { hashElseToken = this.advance(); let diagnosticsLengthBeforeBlock = this.diagnostics.length; - elseBranch = this.blockConditionalCompileBranch(hashIfToken); + elseBranch = this.blockConditionalCompileBranch(hashIfToken, branchBlockParser); if (condition.text.toLowerCase() === 'true') { //throw out any new diagnostics created as a result of a false block @@ -2237,13 +2308,13 @@ export class Parser { } //consume a conditional compile branch block of an `#if` statement - private blockConditionalCompileBranch(hashIfToken: Token) { + private blockConditionalCompileBranch(hashIfToken: Token, branchBlockParser?: () => Block | undefined) { //keep track of the current error count, because if the then branch fails, //we will trash them in favor of a single error on if let diagnosticsLengthBeforeBlock = this.diagnostics.length; //parsing until trailing "#end if", "#else", "#else if" - let branch = this.conditionalCompileBlock(); + let branch = branchBlockParser ? branchBlockParser() : this.conditionalCompileBlock(); if (!branch) { //throw out any new diagnostics created as a result of a `then` block parse failure. diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts index b91b6063e..64d348627 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 { isCallExpression, isCatchStatement, isConditionalCompileStatement, isEnumMemberStatement, isEnumStatement, isExpressionStatement, isFieldStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isIfStatement, isInterfaceFieldStatement, isInterfaceMethodStatement, isInvalidType, isLiteralExpression, isMethodStatement, isNamespaceStatement, isPrintSeparatorExpression, isTryCatchStatement, isTypedefProvider, isUnaryExpression, isUninitializedType, isVoidType, isWhileStatement } from '../astUtils/reflection'; +import { isBlock, isCallExpression, isCatchStatement, isConditionalCompileStatement, isEnumMemberStatement, isEnumStatement, isExpressionStatement, isFieldStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isIfStatement, isInterfaceFieldStatement, isInterfaceMethodStatement, isInvalidType, isLiteralExpression, isMethodStatement, isNamespaceStatement, isPrintSeparatorExpression, isTryCatchStatement, isTypedefProvider, isUnaryExpression, isUninitializedType, isVoidType, isWhileStatement } from '../astUtils/reflection'; import type { GetTypeOptions } from '../interfaces'; import { TypeChainEntry, type TranspileResult, type TypedefProvider } from '../interfaces'; import { createIdentifier, createInvalidLiteral, createMethodStatement, createToken } from '../astUtils/creators'; @@ -2629,15 +2629,7 @@ export class ClassStatement extends Statement implements TypedefProvider { this.parentClassName = options.parentClassName; this.symbolTable = new SymbolTable(`ClassStatement: '${this.tokens.name?.text}'`, () => this.parent?.getSymbolTable()); - for (let statement of this.body) { - if (isMethodStatement(statement)) { - this.methods.push(statement); - this.memberMap[statement?.tokens.name?.text.toLowerCase()] = statement; - } else if (isFieldStatement(statement)) { - this.fields.push(statement); - this.memberMap[statement?.tokens.name?.text.toLowerCase()] = statement; - } - } + this.registerMembers(this.body); this.location = util.createBoundingLocation( this.parentClassName, @@ -2693,6 +2685,29 @@ export class ClassStatement extends Statement implements TypedefProvider { public readonly location: Location | undefined; + /** + * Register all members (methods and fields) found in the given statements, + * descending into conditional compile blocks + */ + private registerMembers(statements: Statement[]) { + for (let statement of statements) { + if (isMethodStatement(statement)) { + this.methods.push(statement); + this.memberMap[statement?.tokens.name?.text.toLowerCase()] = statement; + } else if (isFieldStatement(statement)) { + this.fields.push(statement); + this.memberMap[statement?.tokens.name?.text.toLowerCase()] = statement; + } else if (isConditionalCompileStatement(statement)) { + this.registerMembers(statement.thenBranch?.statements ?? []); + if (isConditionalCompileStatement(statement.elseBranch)) { + this.registerMembers([statement.elseBranch]); + } else if (isBlock(statement.elseBranch)) { + this.registerMembers(statement.elseBranch.statements); + } + } + } + } + transpile(state: BrsTranspileState) { let result = [] as TranspileResult; @@ -2910,57 +2925,66 @@ export class ClassStatement extends Statement implements TypedefProvider { ); let parentClassIndex = this.getParentClassIndex(state); - for (let statement of body) { - //is field statement - if (isFieldStatement(statement)) { - //do nothing with class fields in this situation, they are handled elsewhere - continue; - - //methods - } else if (isMethodStatement(statement)) { - - //store overridden parent methods as super{parentIndex}_{methodName} - if ( - //is override method - statement.tokens.override || - //is constructor function in child class - (statement.tokens.name.text.toLowerCase() === 'new' && ancestors[0]) - ) { - result.push( - `instance.super${parentClassIndex}_${statement.tokens.name.text} = instance.${statement.tokens.name.text}`, + const transpileMemberAssignments = (statements: Statement[]) => { + let memberResults = [] as TranspileResult; + for (let statement of statements) { + //is field statement + if (isFieldStatement(statement)) { + //do nothing with class fields in this situation, they are handled elsewhere + continue; + + //methods + } else if (isMethodStatement(statement)) { + + //store overridden parent methods as super{parentIndex}_{methodName} + if ( + //is override method + statement.tokens.override || + //is constructor function in child class + (statement.tokens.name.text.toLowerCase() === 'new' && ancestors[0]) + ) { + memberResults.push( + `instance.super${parentClassIndex}_${statement.tokens.name.text} = instance.${statement.tokens.name.text}`, + state.newline, + state.indent() + ); + } + + state.classStatement = this; + state.skipLeadingComments = true; + //add leading comments + if ((statement.leadingTrivia?.filter(token => token.kind === TokenKind.Comment) ?? []).length > 0) { + memberResults.push( + ...state.transpileComments(statement.leadingTrivia), + state.indent() + ); + } + memberResults.push( + 'instance.', + state.transpileToken(statement.tokens.name), + ' = ', + state.transpileToken(this.getMethodIdentifier(transpiledClassName, statement)), state.newline, state.indent() ); - } - - state.classStatement = this; - state.skipLeadingComments = true; - //add leading comments - if ((statement.leadingTrivia?.filter(token => token.kind === TokenKind.Comment) ?? []).length > 0) { - result.push( - ...state.transpileComments(statement.leadingTrivia), + state.skipLeadingComments = false; + delete state.classStatement; + } else if (isConditionalCompileStatement(statement)) { + memberResults.push( + ...this.getTranspiledConditionalCompileMembers(state, statement, transpileMemberAssignments) + ); + } else { + //other random statements (probably just comments) + memberResults.push( + ...statement.transpile(state), + state.newline, state.indent() ); } - result.push( - 'instance.', - state.transpileToken(statement.tokens.name), - ' = ', - state.transpileToken(this.getMethodIdentifier(transpiledClassName, statement)), - state.newline, - state.indent() - ); - state.skipLeadingComments = false; - delete state.classStatement; - } else { - //other random statements (probably just comments) - result.push( - ...statement.transpile(state), - state.newline, - state.indent() - ); } - } + return memberResults; + }; + result.push(...transpileMemberAssignments(body)); //return the instance result.push('return instance\n'); state.blockDepth--; @@ -3030,11 +3054,76 @@ export class ClassStatement extends Statement implements TypedefProvider { state.indent() ); delete state.classStatement; + } else if (isConditionalCompileStatement(statement)) { + result.push( + ...this.getTranspiledConditionalCompileMembers(state, statement, (branchStatements) => { + return this.getTranspiledMethods(state, transpiledClassName, branchStatements); + }) + ); } } return result; } + /** + * Transpile a conditional compile statement found in a class body, emitting the `#if`/`#else if`/`#else`/`#end if` + * directives and delegating the transpilation of the members in each branch to the given callback. + */ + private getTranspiledConditionalCompileMembers(state: BrsTranspileState, statement: ConditionalCompileStatement, transpileBranchMembers: (statements: Statement[]) => TranspileResult) { + let result = [] as TranspileResult; + let hasContent = false; + + const transpileBranchBody = (branchStatements: Statement[]) => { + state.blockDepth++; + const leadingIndent = state.indent(); + const body = transpileBranchMembers(branchStatements); + state.blockDepth--; + if (body.length === 0) { + //nothing to emit for this branch - just move to the next line + return [state.newline, state.indent()] as TranspileResult; + } + hasContent = true; + //remove the trailing indent from the final member (it was emitted at the deeper block depth) + body.pop(); + return [state.newline, leadingIndent, ...body, state.indent()] as TranspileResult; + }; + + result.push('#if '); + if (statement.tokens.not) { + result.push('not '); + } + result.push(state.transpileToken(statement.tokens.condition)); + result.push(...transpileBranchBody(statement.thenBranch?.statements ?? [])); + + let elseBranch = statement.elseBranch; + while (elseBranch) { + if (isConditionalCompileStatement(elseBranch)) { + result.push('#else if '); + if (elseBranch.tokens.not) { + result.push('not '); + } + result.push(state.transpileToken(elseBranch.tokens.condition)); + result.push(...transpileBranchBody(elseBranch.thenBranch?.statements ?? [])); + elseBranch = elseBranch.elseBranch; + } else { + result.push('#else'); + result.push(...transpileBranchBody(elseBranch.statements)); + elseBranch = undefined; + } + } + result.push( + '#end if', + state.newline, + state.indent() + ); + + //if no branch produced any output, skip this conditional compile statement entirely + if (!hasContent) { + return [] as TranspileResult; + } + return result; + } + /** * The class function is the function with the same name as the class. This is the function that * consumers should call to create a new instance of that class. @@ -3345,14 +3434,14 @@ export class MethodStatement extends FunctionStatement { } /** - * Inject field initializers at the top of the `new` function (after any present `super()` call) + * Inject field initializers at the top of the `new` function (after any present `super()` call). + * Fields declared inside conditional compile blocks have their initializers wrapped in an equivalent + * conditional compile statement. */ private injectFieldInitializersForConstructor(state: BrsTranspileState) { let startingIndex = state.classStatement!.hasParentClass() ? 1 : 0; - let newStatements = [] as Statement[]; - //insert the field initializers in order - for (let field of state.classStatement!.fields) { + const buildFieldAssignment = (field: FieldStatement) => { let thisQualifiedName = { ...field.tokens.name }; thisQualifiedName.text = 'm.' + field.tokens.name?.text; const fieldAssignment = field.initialValue @@ -3369,8 +3458,56 @@ export class MethodStatement extends FunctionStatement { }); // Add parent so namespace lookups work fieldAssignment.parent = state.classStatement; - newStatements.push(fieldAssignment); - } + return fieldAssignment; + }; + + //build a conditional compile statement containing the field initializers of the original's branches, + //or undefined if there are no fields in any branch + const buildConditionalCompile = (statement: ConditionalCompileStatement): ConditionalCompileStatement | undefined => { + const thenStatements = buildInitializers(statement.thenBranch?.statements ?? []); + let elseBranch: ConditionalCompileStatement | Block | undefined; + if (isConditionalCompileStatement(statement.elseBranch)) { + elseBranch = buildConditionalCompile(statement.elseBranch); + } else if (isBlock(statement.elseBranch)) { + const elseStatements = buildInitializers(statement.elseBranch.statements); + if (elseStatements.length > 0) { + elseBranch = new Block({ statements: elseStatements }); + } + } + if (thenStatements.length === 0 && !elseBranch) { + //no fields in any branch - nothing to initialize + return undefined; + } + const conditionalCompile = new ConditionalCompileStatement({ + hashIf: util.cloneToken(statement.tokens.hashIf), + not: util.cloneToken(statement.tokens.not), + condition: util.cloneToken(statement.tokens.condition), + hashElse: elseBranch ? util.cloneToken(statement.tokens.hashElse) : undefined, + hashEndIf: util.cloneToken(statement.tokens.hashEndIf), + thenBranch: new Block({ statements: thenStatements }), + elseBranch: elseBranch + }); + conditionalCompile.parent = state.classStatement; + return conditionalCompile; + }; + + const buildInitializers = (statements: Statement[]): Statement[] => { + const result = [] as Statement[]; + for (const statement of statements) { + if (isFieldStatement(statement)) { + result.push(buildFieldAssignment(statement)); + } else if (isConditionalCompileStatement(statement)) { + const conditionalCompile = buildConditionalCompile(statement); + if (conditionalCompile) { + result.push(conditionalCompile); + } + } + } + return result; + }; + + //insert the field initializers in order + let newStatements = buildInitializers(state.classStatement!.body); state.editor.arraySplice(this.func.body.statements, startingIndex, 0, ...newStatements); }