From dbf6b98128b86091d7c514140e3e2d0bed7e0561 Mon Sep 17 00:00:00 2001 From: Mark Pearce Date: Thu, 30 Jul 2026 13:47:23 -0300 Subject: [PATCH 1/2] Fix transpile crash synthesizing implicit constructor with a class-typed param A subclass with no explicit constructor gets one synthesized from its parent's constructor signature. The synthesized parameter list was built by deep-cloning the parent's FunctionParameterExpression nodes, but the clone is detached from the AST (no parent), so a class/enum-typed parameter's TypeExpression can no longer resolve its type by symbol lookup at transpile time - crashing in TypeExpression.transpile() on `exprType.toTypeString()` when exprType comes back undefined. This happens even for a same-namespace parent/subclass, not just across a namespace boundary as originally suspected - any custom type on an inherited, implicitly-constructed parameter crashes, since primitive types are unaffected only because they short-circuit before ever calling getType(). Resolve each such parameter's type once, while the original (still attached) parameter can still resolve it, and bake the fully-qualified name and resolved type directly into the clone via a small ResolvedTypeExpression that bypasses symbol-table resolution entirely. Fixes #1767 Co-Authored-By: Claude Sonnet 5 --- src/files/BrsFile.Class.spec.ts | 126 ++++++++++++++++++++++++++++++++ src/parser/Statement.ts | 52 +++++++++++-- 2 files changed, 173 insertions(+), 5 deletions(-) diff --git a/src/files/BrsFile.Class.spec.ts b/src/files/BrsFile.Class.spec.ts index 6e5c4a7cf..fe1b9cd20 100644 --- a/src/files/BrsFile.Class.spec.ts +++ b/src/files/BrsFile.Class.spec.ts @@ -586,6 +586,132 @@ describe('BrsFile BrighterScript classes', () => { `, undefined, 'source/main.bs'); }); + it('does not crash synthesizing an implicit constructor whose inherited param is a class type declared in a different namespace', async () => { + await testTranspile(` + namespace Foo + class Thing + end class + + class Base + sub new(t as Thing) + m.thing = t + end sub + thing as Thing + end class + end namespace + + namespace Bar + class DerivedImplicitCtor extends Foo.Base + extra as integer = 2 + end class + end namespace + `, ` + sub __Foo_Thing_method_new() + end sub + function __Foo_Thing_builder() + instance = {} + instance.new = __Foo_Thing_method_new + return instance + end function + function Foo_Thing() + instance = __Foo_Thing_builder() + instance.new() + return instance + end function + sub __Foo_Base_method_new(t as dynamic) + m.thing = invalid + m.thing = t + end sub + function __Foo_Base_builder() + instance = {} + instance.new = __Foo_Base_method_new + return instance + end function + function Foo_Base(t as dynamic) + instance = __Foo_Base_builder() + instance.new(t) + return instance + end function + sub __Bar_DerivedImplicitCtor_method_new(t as dynamic) + m.super0_new(t) + m.extra = 2 + end sub + function __Bar_DerivedImplicitCtor_builder() + instance = __Foo_Base_builder() + instance.super0_new = instance.new + instance.new = __Bar_DerivedImplicitCtor_method_new + return instance + end function + function Bar_DerivedImplicitCtor(t as dynamic) + instance = __Bar_DerivedImplicitCtor_builder() + instance.new(t) + return instance + end function + `, undefined, 'source/main.bs'); + }); + + it('does not crash synthesizing an implicit constructor whose inherited param is a class type declared in the same namespace', async () => { + await testTranspile(` + namespace Foo + class Thing + end class + + class Base + sub new(t as Thing) + m.thing = t + end sub + thing as Thing + end class + + class DerivedSameNs extends Base + extra as integer = 2 + end class + end namespace + `, ` + sub __Foo_Thing_method_new() + end sub + function __Foo_Thing_builder() + instance = {} + instance.new = __Foo_Thing_method_new + return instance + end function + function Foo_Thing() + instance = __Foo_Thing_builder() + instance.new() + return instance + end function + sub __Foo_Base_method_new(t as dynamic) + m.thing = invalid + m.thing = t + end sub + function __Foo_Base_builder() + instance = {} + instance.new = __Foo_Base_method_new + return instance + end function + function Foo_Base(t as dynamic) + instance = __Foo_Base_builder() + instance.new(t) + return instance + end function + sub __Foo_DerivedSameNs_method_new(t as dynamic) + m.super0_new(t) + m.extra = 2 + end sub + function __Foo_DerivedSameNs_builder() + instance = __Foo_Base_builder() + instance.super0_new = instance.new + instance.new = __Foo_DerivedSameNs_method_new + return instance + end function + function Foo_DerivedSameNs(t as dynamic) + instance = __Foo_DerivedSameNs_builder() + instance.new(t) + return instance + end function + `, undefined, 'source/main.bs'); + }); + it('properly handles child class constructor override and super calls', async () => { await testTranspile(` class Animal diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts index ac79ac1ac..51d4e37c6 100644 --- a/src/parser/Statement.ts +++ b/src/parser/Statement.ts @@ -1,8 +1,8 @@ /* eslint-disable no-bitwise */ import type { Token, Identifier } from '../lexer/Token'; import { TokenKind } from '../lexer/TokenKind'; -import type { DottedGetExpression, FunctionParameterExpression, LiteralExpression, TypecastExpression, TypeExpression } from './Expression'; -import { FunctionExpression } from './Expression'; +import type { DottedGetExpression, LiteralExpression, TypecastExpression } from './Expression'; +import { FunctionExpression, FunctionParameterExpression, TypeExpression } from './Expression'; import { CallExpression, VariableExpression } from './Expression'; import { util } from '../util'; import type { Location } from 'vscode-languageserver'; @@ -10,10 +10,10 @@ 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 { isCallExpression, isCatchStatement, isClassType, isConditionalCompileStatement, isEnumMemberStatement, isEnumType, 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'; +import { createDottedIdentifier, createIdentifier, createInvalidLiteral, createMethodStatement, createToken, createVariableExpression } from '../astUtils/creators'; import { DynamicType } from '../types/DynamicType'; import type { BscType } from '../types/BscType'; import { SymbolTable } from '../SymbolTable'; @@ -2606,6 +2606,23 @@ export class InterfaceMethodStatement extends Statement implements TypedefProvid } } +/** + * A `TypeExpression` whose type is already known, bypassing symbol-table resolution. + * Used when synthesizing a subclass's implicit constructor parameters: the cloned + * parameter's type expression is detached from the AST (no parent, so no symbol table + * to resolve against), so we resolve its type once - using the original, still-attached + * parameter - and bake the result in directly rather than re-resolving it. + */ +class ResolvedTypeExpression extends TypeExpression { + constructor(private readonly resolvedType: BscType, expression: Expression) { + super({ expression: expression }); + } + + public getType(options: GetTypeOptions): BscType { + return this.resolvedType; + } +} + export class ClassStatement extends Statement implements TypedefProvider { constructor(options: { class?: Token; @@ -2867,6 +2884,31 @@ export class ClassStatement extends Statement implements TypedefProvider { return []; } + /** + * Clone a parent constructor's parameter for use in a synthesized subclass constructor. + * The clone is detached from the AST (no parent), so if the parameter's type is a class + * or enum, its type reference can't be re-resolved by symbol name once cloned - especially + * since the subclass may live in a different namespace than the one the parameter's type + * was originally declared in, where even a fully-qualified reference has no symbol table + * to resolve against. Resolve the type now (while the original is still properly attached), + * and bake the fully-qualified name/type directly into the clone. + */ + private cloneConstructorParam(param: FunctionParameterExpression): FunctionParameterExpression { + const exprType = param.typeExpression?.getType({ flags: SymbolTypeFlag.typetime }); + if (!isClassType(exprType) && !isEnumType(exprType)) { + return param.clone(); + } + const nameParts = exprType.toString().split('.'); + const qualifiedExpression = nameParts.length > 1 ? createDottedIdentifier(nameParts) : createVariableExpression(nameParts[0]); + return new FunctionParameterExpression({ + name: param.tokens.name, + equals: param.tokens.equals, + defaultValue: param.defaultValue?.clone(), + as: param.tokens.as, + typeExpression: new ResolvedTypeExpression(exprType, qualifiedExpression) + }); + } + /** * Determine if the specified field was declared in one of the ancestor classes */ @@ -2999,7 +3041,7 @@ export class ClassStatement extends Statement implements TypedefProvider { modifiers: [], name: createIdentifier('new'), func: new FunctionExpression({ - parameters: params.map(x => x.clone()), + parameters: params.map(x => this.cloneConstructorParam(x)), body: new Block({ statements: [call] }), functionType: createToken(TokenKind.Sub), endFunctionType: createToken(TokenKind.EndSub), From 6a074241eac89e31721a7d77dd3d5437050bed4b Mon Sep 17 00:00:00 2001 From: Mark Pearce Date: Thu, 30 Jul 2026 18:16:00 -0300 Subject: [PATCH 2/2] Replace ResolvedTypeExpression subclass with a resolvedType option on TypeExpression A dedicated TypeExpression subclass whose sole purpose was overriding getType() didn't fit alongside the other Statement.ts classes, and reads oddly as an AST node when it's really just a resolved-type override. Add an optional resolvedType to TypeExpression itself instead - when set, getType() returns it directly instead of resolving `expression` via symbol table lookup. Same behavior, no new class. Co-Authored-By: Claude Sonnet 5 --- src/parser/Expression.ts | 18 ++++++++++++++++-- src/parser/Statement.ts | 19 +------------------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts index 43ac53c3f..de41f6e94 100644 --- a/src/parser/Expression.ts +++ b/src/parser/Expression.ts @@ -2693,9 +2693,16 @@ export class TypeExpression extends Expression implements TypedefProvider { * The standard AST expression that represents the type for this TypeExpression. */ expression: Expression; + /** + * An already-known type for this TypeExpression, bypassing resolution of `expression` + * via symbol table lookup. Useful when `expression` is not attached to (or can't resolve + * against) a real symbol table - e.g. a type reference synthesized for a detached AST node. + */ + resolvedType?: BscType; }) { super(); this.expression = options.expression; + this.resolvedType = options.resolvedType; this.location = util.cloneLocation(this.expression?.location); } @@ -2706,6 +2713,12 @@ export class TypeExpression extends Expression implements TypedefProvider { */ public readonly expression: Expression; + /** + * An already-known type for this TypeExpression. When set, `getType()` returns this + * directly instead of resolving `expression` via symbol table lookup. + */ + public readonly resolvedType?: BscType; + public readonly location: Location; public transpile(state: BrsTranspileState): TranspileResult { @@ -2729,7 +2742,7 @@ export class TypeExpression extends Expression implements TypedefProvider { } public getType(options: GetTypeOptions): BscType { - return this.expression.getType({ ...options, flags: SymbolTypeFlag.typetime }); + return this.resolvedType ?? this.expression.getType({ ...options, flags: SymbolTypeFlag.typetime }); } getTypedef(state: TranspileState): TranspileResult { @@ -2760,7 +2773,8 @@ export class TypeExpression extends Expression implements TypedefProvider { public clone() { return this.finalizeClone( new TypeExpression({ - expression: this.expression?.clone() + expression: this.expression?.clone(), + resolvedType: this.resolvedType }), ['expression'] ); diff --git a/src/parser/Statement.ts b/src/parser/Statement.ts index 51d4e37c6..113923ed6 100644 --- a/src/parser/Statement.ts +++ b/src/parser/Statement.ts @@ -2606,23 +2606,6 @@ export class InterfaceMethodStatement extends Statement implements TypedefProvid } } -/** - * A `TypeExpression` whose type is already known, bypassing symbol-table resolution. - * Used when synthesizing a subclass's implicit constructor parameters: the cloned - * parameter's type expression is detached from the AST (no parent, so no symbol table - * to resolve against), so we resolve its type once - using the original, still-attached - * parameter - and bake the result in directly rather than re-resolving it. - */ -class ResolvedTypeExpression extends TypeExpression { - constructor(private readonly resolvedType: BscType, expression: Expression) { - super({ expression: expression }); - } - - public getType(options: GetTypeOptions): BscType { - return this.resolvedType; - } -} - export class ClassStatement extends Statement implements TypedefProvider { constructor(options: { class?: Token; @@ -2905,7 +2888,7 @@ export class ClassStatement extends Statement implements TypedefProvider { equals: param.tokens.equals, defaultValue: param.defaultValue?.clone(), as: param.tokens.as, - typeExpression: new ResolvedTypeExpression(exprType, qualifiedExpression) + typeExpression: new TypeExpression({ expression: qualifiedExpression, resolvedType: exprType }) }); }