Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/alphatab/scripts/CloneEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ function generateClonePropertyStatements(

function generateCloneBody(
program: ts.Program,
input: ts.ClassDeclaration,
input: ts.ClassDeclaration | ts.InterfaceDeclaration,
importer: (name: string, module: string) => void
): ts.Block {
const propertiesToSerialize = input.members
Expand Down Expand Up @@ -299,7 +299,7 @@ function generateCloneBody(

function createCloneMethod(
program: ts.Program,
input: ts.ClassDeclaration,
input: ts.ClassDeclaration | ts.InterfaceDeclaration,
importer: (name: string, module: string) => void
) {
return ts.factory.createMethodDeclaration(
Expand Down
11 changes: 7 additions & 4 deletions packages/alphatab/scripts/EmitterBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export function generateFile(program: ts.Program, sourceFile: ts.SourceFile, fil

export function generateClass(
program: ts.Program,
classDeclaration: ts.ClassDeclaration,
generate: (program: ts.Program, classDeclaration: ts.ClassDeclaration) => ts.SourceFile
classDeclaration: ts.ClassDeclaration | ts.InterfaceDeclaration,
generate: (program: ts.Program, classDeclaration: ts.ClassDeclaration | ts.InterfaceDeclaration) => ts.SourceFile
) {
const sourceFileName = path.relative(
path.join(path.dirname(program.getCompilerOptions().configFilePath as string), 'src'),
Expand All @@ -83,11 +83,14 @@ export function generateClass(

export default function createEmitter(
jsDocMarker: string,
generate: (program: ts.Program, classDeclaration: ts.ClassDeclaration) => ts.SourceFile
generate: (program: ts.Program, classDeclaration: ts.ClassDeclaration | ts.InterfaceDeclaration) => ts.SourceFile
) {
function scanSourceFile(program: ts.Program, sourceFile: ts.SourceFile) {
for (const stmt of sourceFile.statements) {
if (ts.isClassDeclaration(stmt) && ts.getJSDocTags(stmt).some(t => t.tagName.text === jsDocMarker)) {
if (
(ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) &&
ts.getJSDocTags(stmt).some(t => t.tagName.text === jsDocMarker)
) {
generateClass(program, stmt, generate);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/alphatab/scripts/JsonDeclarationEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ function createJsonMember(

function createJsonMembers(
program: ts.Program,
input: ts.ClassDeclaration,
input: ts.ClassDeclaration | ts.InterfaceDeclaration,
importer: (name: string, module: string) => void
): ts.TypeElement[] {
const hasMatchingSetter = (name: string): boolean =>
Expand Down
4 changes: 2 additions & 2 deletions packages/alphatab/scripts/Serializer.fromJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as ts from 'typescript';
import type { TypeSchema } from './TypeSchema';

function generateFromJsonBody(
input: ts.ClassDeclaration,
input: ts.ClassDeclaration | ts.InterfaceDeclaration,
serializable: TypeSchema,
importer: (name: string, module: string) => void
) {
Expand Down Expand Up @@ -33,7 +33,7 @@ function generateFromJsonBody(
}

export function createFromJsonMethod(
input: ts.ClassDeclaration,
input: ts.ClassDeclaration | ts.InterfaceDeclaration,
serializable: TypeSchema,
importer: (name: string, module: string) => void
) {
Expand Down
52 changes: 35 additions & 17 deletions packages/alphatab/scripts/Serializer.setProperty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,17 @@ function generateSetPropertyBody(serializable: TypeSchema, importer: (name: stri
importer(itemSerializer, findSerializerModule(prop.type));

if (prop.type.isNullable || prop.type.isOptional) {
importer(prop.type.typeAsString, prop.type.modulePath);
if (!prop.type.isRecord) {
// @record types are constructed via `{}` object literal (not `new T()`), so no value-import is needed.
importer(prop.type.typeAsString, prop.type.modulePath);
}
const constructExpr = prop.type.isRecord ? '{}' : `new ${prop.type.typeAsString}()`;
caseStatements.push(
createNodeFromSource<ts.IfStatement>(
`
if (v) {
obj.${fieldName} = new ${prop.type.typeAsString}();
${itemSerializer}.fromJson(obj.${fieldName}, v);
if (v) {
obj.${fieldName} = ${constructExpr};
${itemSerializer}.fromJson(obj.${fieldName}, v);
} else {
obj.${fieldName} = ${prop.type.isNullable ? 'null' : 'undefined'}
}`,
Expand Down Expand Up @@ -379,7 +383,8 @@ function generateSetPropertyBody(serializable: TypeSchema, importer: (name: stri

const itemSerializer = `${prop.type.typeAsString}Serializer`;
importer(itemSerializer, findSerializerModule(prop.type));
if (prop.type.isNullable || prop.type.isOptional) {
if ((prop.type.isNullable || prop.type.isOptional) && !prop.type.isRecord) {
// @record types are constructed via `{}` object literal (not `new T()`), so no value-import is needed.
importer(prop.type.typeAsString, prop.type.modulePath);
}

Expand Down Expand Up @@ -427,11 +432,13 @@ function generateSetPropertyBody(serializable: TypeSchema, importer: (name: stri
ts.factory.createBlock(
[
assignField(
ts.factory.createNewExpression(
ts.factory.createIdentifier(prop.type.typeAsString),
undefined,
[]
)
prop.type.isRecord
? ts.factory.createObjectLiteralExpression([], false)
: ts.factory.createNewExpression(
ts.factory.createIdentifier(prop.type.typeAsString),
undefined,
[]
)
),
ts.factory.createExpressionStatement(
ts.factory.createCallExpression(
Expand All @@ -456,7 +463,16 @@ function generateSetPropertyBody(serializable: TypeSchema, importer: (name: stri
],
true
),
ts.factory.createBlock([assignField(ts.factory.createNull())], true)
ts.factory.createBlock(
[
assignField(
prop.type.isOptional
? ts.factory.createIdentifier('undefined')
: ts.factory.createNull()
)
],
true
)
),
ts.factory.createReturnStatement(ts.factory.createTrue())
],
Expand Down Expand Up @@ -509,11 +525,13 @@ function generateSetPropertyBody(serializable: TypeSchema, importer: (name: stri
),
ts.factory.createBlock([
assignField(
ts.factory.createNewExpression(
ts.factory.createIdentifier(prop.type.typeAsString),
[],
[]
)
prop.type.isRecord
? ts.factory.createObjectLiteralExpression([], false)
: ts.factory.createNewExpression(
ts.factory.createIdentifier(prop.type.typeAsString),
[],
[]
)
)
])
)),
Expand Down Expand Up @@ -604,7 +622,7 @@ function generateSetPropertyBody(serializable: TypeSchema, importer: (name: stri
}

export function createSetPropertyMethod(
input: ts.ClassDeclaration,
input: ts.ClassDeclaration | ts.InterfaceDeclaration,
serializable: TypeSchema,
importer: (name: string, module: string) => void
) {
Expand Down
36 changes: 26 additions & 10 deletions packages/alphatab/scripts/Serializer.toJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,30 @@ function generateToJsonBody(serializable: TypeSchema, importer: (name: string, m
} else {
const itemSerializer = `${prop.type.typeAsString}Serializer`;
importer(itemSerializer, findSerializerModule(prop.type));
propertyStatements.push(
createNodeFromSource<ts.ExpressionStatement>(
`
o.set(${JSON.stringify(jsonName)}, ${itemSerializer}.toJson(obj.${fieldName}));
`,
ts.SyntaxKind.ExpressionStatement
)
);
if (prop.type.isRecord && (prop.type.isNullable || prop.type.isOptional)) {
// @record-typed optional / nullable fields skip emission when unset, so the JSON
// output omits null entries rather than carrying a Map value the C# JS-style
// TypeOf shim would render as `undefined` (TypeHelper.cs:606+).
propertyStatements.push(
createNodeFromSource<ts.IfStatement>(
`
if(obj.${fieldName}) {
o.set(${JSON.stringify(jsonName)}, ${itemSerializer}.toJson(obj.${fieldName}));
}
`,
ts.SyntaxKind.IfStatement
)
);
} else {
propertyStatements.push(
createNodeFromSource<ts.ExpressionStatement>(
`
o.set(${JSON.stringify(jsonName)}, ${itemSerializer}.toJson(obj.${fieldName}));
`,
ts.SyntaxKind.ExpressionStatement
)
);
}
}

if (prop.target) {
Expand All @@ -314,13 +330,13 @@ function generateToJsonBody(serializable: TypeSchema, importer: (name: string, m
}

export function createToJsonMethod(
input: ts.ClassDeclaration,
input: ts.ClassDeclaration | ts.InterfaceDeclaration,
serializable: TypeSchema,
importer: (name: string, module: string) => void
) {
const methodDecl = createNodeFromSource<ts.MethodDeclaration>(
`public class Serializer {
public static toJson(obj: ${input.name!.text} | null): Map<string, unknown> | null {
public static toJson(obj: ${input.name!.text} | null | undefined): Map<string, unknown> | null {
}
}`,
ts.SyntaxKind.MethodDeclaration
Expand Down
51 changes: 36 additions & 15 deletions packages/alphatab/scripts/TypeSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type TypeWithNullableInfo = {
readonly modulePath: string;
readonly isCloneable: boolean;
readonly isJsonImmutable: boolean;
readonly isRecord: boolean;
readonly isNumberType: boolean;
readonly isMap: boolean;
readonly isSet: boolean;
Expand All @@ -28,23 +29,28 @@ export type TypeWithNullableInfo = {

type Writeable<T> = { -readonly [P in keyof T]: T[P] };

export function buildTypeSchema(program: ts.Program, input: ts.ClassDeclaration) {
export function buildTypeSchema(program: ts.Program, input: ts.ClassDeclaration | ts.InterfaceDeclaration) {
const schema: TypeSchema = {
properties: [],
isStrict: !!ts.getJSDocTags(input).find(t => t.tagName.text === 'json_strict'),
hasToJsonExtension: false,
hasSetPropertyExtension: false
hasSetPropertyExtension: false,
isRecord: !!ts.getJSDocTags(input).find(t => t.tagName.text === 'record')
};

const accessorHasSetter = (cls: ts.ClassDeclaration, name: string): boolean =>
const accessorHasSetter = (cls: ts.ClassDeclaration | ts.InterfaceDeclaration, name: string): boolean =>
cls.members.some(m => ts.isSetAccessorDeclaration(m) && (m.name as ts.Identifier).text === name);

const handleMember = (
cls: ts.ClassDeclaration,
member: ts.ClassDeclaration['members'][0],
cls: ts.ClassDeclaration | ts.InterfaceDeclaration,
member: ts.ClassElement | ts.TypeElement,
typeArgumentMapping: Map<string, ts.Type> | undefined
) => {
if (ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)) {
if (
ts.isPropertyDeclaration(member) ||
ts.isPropertySignature(member) ||
ts.isGetAccessorDeclaration(member)
) {
// Only the getter side of an accessor pair contributes a schema entry; the setter is
// handled implicitly via the assignment generated by the serializer. A getter without
// a matching setter is a computed read-only property and is skipped — it cannot be
Expand All @@ -55,11 +61,10 @@ export function buildTypeSchema(program: ts.Program, input: ts.ClassDeclaration)
) {
return;
}
if (
!member.modifiers?.find(
m => m.kind === ts.SyntaxKind.StaticKeyword || m.kind === ts.SyntaxKind.PrivateKeyword
)
) {
const isStaticOrPrivate = !!member.modifiers?.some(
m => m.kind === ts.SyntaxKind.StaticKeyword || m.kind === ts.SyntaxKind.PrivateKeyword
);
if (!isStaticOrPrivate) {
const jsonNames = [(member.name as ts.Identifier).text.toLowerCase()];
const jsDoc = ts.getJSDocTags(member);

Expand All @@ -71,7 +76,9 @@ export function buildTypeSchema(program: ts.Program, input: ts.ClassDeclaration)
const asRaw = !!jsDoc.find(t => t.tagName.text === 'json_raw');
const isReadonly = !!jsDoc.find(t => t.tagName.text === 'json_read_only');
const isAccessor = ts.isGetAccessorDeclaration(member);
const isOptional = ts.isPropertyDeclaration(member) && !!member.questionToken;
const isOptional =
(ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) &&
!!member.questionToken;

// Heuristic: a deprecated getter+setter pair is almost always a
// backwards-compat alias for a canonical property — round-tripping it via
Expand All @@ -96,7 +103,7 @@ export function buildTypeSchema(program: ts.Program, input: ts.ClassDeclaration)
isJsonReadOnly: isReadonly,
isReadOnly: isAccessor
? false
: member.modifiers!.some(m => m.kind == ts.SyntaxKind.ReadonlyKeyword),
: !!member.modifiers?.some(m => m.kind == ts.SyntaxKind.ReadonlyKeyword),
name: (member.name as ts.Identifier).text,
jsDocTags: jsDoc,
type: getTypeWithNullableInfo(
Expand All @@ -121,7 +128,7 @@ export function buildTypeSchema(program: ts.Program, input: ts.ClassDeclaration)
}
};

let hierarchy: ts.ClassDeclaration | undefined = input;
let hierarchy: ts.ClassDeclaration | ts.InterfaceDeclaration | undefined = input;
let typeArgumentMapping: Map<string, ts.Type> | undefined;
const checker = program.getTypeChecker();
while (hierarchy) {
Expand Down Expand Up @@ -179,6 +186,7 @@ export function getTypeWithNullableInfo(
typeAsString: '',
modulePath: '',
isJsonImmutable: false,
isRecord: false,
isNumberType: false,
isMap: false,
isSet: false,
Expand All @@ -191,7 +199,17 @@ export function getTypeWithNullableInfo(
let mainType: ts.Type | undefined;

const fillBaseInfoFrom = (tsType: ts.Type) => {
const valueDeclaration = tsType.symbol?.valueDeclaration;
// For interfaces (e.g. @record types), there is no valueDeclaration; fall back to
// the first declaration if it is an InterfaceDeclaration. We intentionally do NOT
// fall back for generic classes etc., because picking up arbitrary declarations
// would mis-resolve type parameters and break unrelated emitters.
let valueDeclaration: ts.Declaration | undefined = tsType.symbol?.valueDeclaration;
if (!valueDeclaration && tsType.symbol?.declarations) {
const interfaceDecl = tsType.symbol.declarations.find(d => ts.isInterfaceDeclaration(d));
if (interfaceDecl) {
valueDeclaration = interfaceDecl;
}
}
mainType = tsType;

typeInfo.typeAsString = checker.typeToString(tsType, undefined, undefined);
Expand All @@ -209,6 +227,7 @@ export function getTypeWithNullableInfo(
if (typeInfo.jsDocTags) {
typeInfo.isJsonImmutable = !!typeInfo.jsDocTags.find(t => t.tagName.text === 'json_immutable');
typeInfo.isCloneable = !!typeInfo.jsDocTags.find(t => t.tagName.text === 'cloneable');
typeInfo.isRecord = !!typeInfo.jsDocTags.find(t => t.tagName.text === 'record');
}

if (tsType.flags & ts.ObjectFlags.Reference) {
Expand Down Expand Up @@ -249,6 +268,7 @@ export function getTypeWithNullableInfo(
modulePath: '',
isCloneable: false,
isJsonImmutable: false,
isRecord: false,
isNumberType: true,
isMap: false,
isSet: false,
Expand Down Expand Up @@ -421,6 +441,7 @@ export interface TypeSchema {
isStrict: boolean;
hasToJsonExtension: boolean;
hasSetPropertyExtension: boolean;
isRecord: boolean;
properties: TypeProperty[];
}

Expand Down
Loading
Loading