Files
GDevelop/GDJS/scripts/codemod-file-to-ts.js

332 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const shell = require('shelljs');
const fs = require('fs').promises;
const path = require('path');
const { transform } = require('lebab');
const args = require('minimist')(process.argv.slice(2), {
string: ['file'],
});
const sourcePath = path.resolve(__dirname, args['file'] || '');
if (path.extname(sourcePath) !== '.js') {
shell.echo(`❌ Source file (--file) should be a .js file`);
shell.exit(1);
}
const outPath = path.join(
path.dirname(sourcePath),
path.basename(sourcePath, '.js') + '.ts'
);
const nthIndexOf = (str, character, n) => {
var i = -1;
while (n-- && i++ < str.length) {
i = str.indexOf(character, i);
if (i < 0) break;
}
return i;
};
const translateTypedefs = (code) => {
let transformedCode = code;
while (true) {
const objectTypedefRegex = /\/\*\s+\* @typedef \{Object\} (\w+) *(.*)/;
const typedefStartPosition = transformedCode.search(objectTypedefRegex);
if (typedefStartPosition === -1) break;
const endMarker = '*/';
const typedefEndPosition = transformedCode.indexOf(
endMarker,
typedefStartPosition
);
if (typedefEndPosition === -1) {
throw new Error('Could not find a typedef ending');
}
transformedCode =
transformedCode.slice(0, typedefEndPosition) +
'};' +
transformedCode.slice(typedefEndPosition + endMarker.length);
transformedCode = transformedCode.replace(
objectTypedefRegex,
'/** $2 */ \nexport type $1 = {'
);
while (true) {
const nextPropertyRegex = /\*\s+@property\s+\{([^\}]+)\}\s+(\[)?(\w+)(\])?\s*(.*)/;
const nextPropertyPosition = transformedCode.search(nextPropertyRegex);
if (
nextPropertyPosition === -1 ||
nextPropertyPosition > typedefEndPosition
)
break;
// TODO: handle optional property
transformedCode = transformedCode
.replace(nextPropertyRegex, '/** $5 */\n $3: $1;')
.replace(/(\w+)\: (\w+)\=\;\n/, '$1?: $2;\n');
}
}
transformedCode = transformedCode.replace(
/\/\*[\s\*]+ @typedef \{(.*)\} (\w+)[\s\*]+\*\//,
'export type $2 = $1;\n'
);
return transformedCode;
};
const identifyClassNames = (code) => {
var classNameRegex = /gdjs\.(\w+) = function ?\(/g;
const classNames = [];
let match;
const isClass = (name) => {
return name && name[0] === name[0].toUpperCase();
}
do {
match = classNameRegex.exec(code);
if (match && isClass(match[1])) {
classNames.push(match[1]);
}
} while (match);
return classNames;
};
(async () => {
const fileContent = await fs.readFile(sourcePath, 'utf-8');
let modifiedFileContent = fileContent;
const classNames = identifyClassNames(fileContent);
shell.echo(`️ Found classes: ${classNames.join(', ') || 'none'}`);
// Replace "namespaced" gdjs class names so that they can be transformed
classNames.forEach((className) => {
modifiedFileContent = modifiedFileContent
// Fix the constructor
.replace(
new RegExp(`gdjs\\.${className} = function`),
`var ${className} = function`
)
// Fix the methods/statics/any property
.replace(new RegExp(`gdjs\\.${className}\\.`, 'g'), className + '.');
});
// Run lebab to transform the classes
let { code, warnings } = transform(modifiedFileContent, ['class', 'let']);
console.log('️ Warnings:', warnings);
// Identify the parameters of the constructor and move them to the constructor function
// (not done by lebab).
const constructorParametersRegex = new RegExp(
/(?<parameters>( \* @param \{.*\}.*\s)+)[\S\s]+\*\/\s+class/,
''
);
const constructorParametersMatches = code.match(constructorParametersRegex);
if (constructorParametersMatches) {
const parameters = constructorParametersMatches.groups.parameters;
shell.echo(`️ Found constructor parameters:\n` + parameters);
code = code
.replace(parameters, '')
.replace(' constructor(', `/**\n${parameters} */\n constructor(`);
} else {
shell.echo(`️ Could not find any constructor parameters`);
}
// Change from [] to Array<> as it's not recognized properly otherwise by gents:
code = code.replace(/ \{([\w.]+)\[\]\} /g, ' {Array<$1>} ');
// Save typedefs from being erased when running gents:
code = code.replace(/\/\*\*\s+\* @typedef /g, '/*\n * @typedef ');
await fs.writeFile(outPath, code, 'utf-8');
// Run gents to convert from JSDoc typings to TypeScript annotations:
const fileName = path.basename(outPath);
const output = shell.exec(
`/Users/florian/Projects/F/clutz/build/install/clutz/bin/gents --convert "${fileName}"`,
{
cwd: path.dirname(outPath),
silent: true,
}
);
console.warn(output.stderr);
// Skip the header:
const typedCode = output.stdout;
const headerEndPosition = nthIndexOf(typedCode, '\n', 3);
if (headerEndPosition === -1) {
shell.echo(
`❌ Unable to find the header to skip in transformed ${sourcePath}.`
);
return;
}
const typedCodeWithoutHeader = output.stdout.slice(headerEndPosition + 1);
// Export the classes
let typedCodeWithExports = typedCodeWithoutHeader;
classNames.forEach((className) => {
typedCodeWithExports = typedCodeWithExports.replace(
new RegExp(`\\nclass ${className}`),
`\nexport class ${className}`
);
});
// Add known types:
let cleanedTypedCode = typedCodeWithExports
.replace('// @ts-check', '')
// Start with specific typings:
.replace(/(runtimeScene): any/gi, '$1: gdjs.RuntimeScene')
.replace(/(runtimeGame): any/gi, '$1: gdjs.RuntimeGame')
.replace(/(runtimeBehavior): any/gi, '$1: gdjs.RuntimeBehavior')
.replace(/(followBaseLayerCamera): any/gi, '$1: boolean')
.replace(/(hotReloader): any/gi, '$1: gdjs.HotReloader')
.replace(/Promise\<undefined\>/g, 'Promise<void>')
// Fix wrong typings:
.replace(/getClearColor\(\): number/g, 'getClearColor(): Array<integer>')
// Fix (or add) getter function return types. Search for two spaces
// at the beginning to avoid confusing with function calls.
.replace(/( get.*Name\(.*\))(: any)? {/g, '$1: string {')
.replace(/( get.*ZOrder\(.*\))(: (any|number))? {/gi, '$1: float {')
.replace(/( get.*TimeScale\(.*\))(: (any|number))? {/gi, '$1: float {')
.replace(/( get.*ElapsedTime\(.*\))(: (any|number))? {/gi, '$1: float {')
.replace(/( get.*ZoomFactor\(.*\))(: (any|number))? {/gi, '$1: float {')
.replace(/( get.*Zoom\(.*\))(: (any|number))? {/gi, '$1: float {')
.replace(/( get.*Rotation\(.*\))(: (any|number))? {/gi, '$1: float {')
.replace(/( get.*Angle\(.*\))(: (any|number))? {/gi, '$1: float {')
.replace(/( get.*Width\(.*\))(: (any|number))? {/g, '$1: float {')
.replace(/( get.*Height\(.*\))(: (any|number))? {/g, '$1: float {')
.replace(/( get.*Count\(.*\))(: (any|number))? {/g, '$1: integer {')
.replace(/( get.*X\(.*\))(: (any|number))? {/g, '$1: float {')
.replace(/( get.*Y\(.*\))(: (any|number))? {/g, '$1: float {')
.replace(/( get.*Id\(.*\))(: (any|number))? {/g, '$1: integer {')
.replace(/( get\w*String\(.*\))(: any)? {/g, '$1: string {')
.replace(/( get\w*Time\(.*\))(: (any|number))? {/g, '$1: float {')
.replace(/( return.*Variable\(.*\))(: any)? {/g, '$1: gdjs.Variable {')
.replace(/( is\w*\(.*\))(: any)? {/g, '$1: boolean {')
.replace(/( has\w*\(.*\))(: any)? {/g, '$1: boolean {')
// Fix (or add) setter function return types. Search for two spaces
// at the beginning to avoid confusing with function calls.
.replace(/( set\w*ZOrder\(.*\))(: any)? {/g, '$1: void {')
.replace(/( set\w*Width\(.*\))(: any)? {/g, '$1: void {')
.replace(/( set\w*Height\(.*\))(: any)? {/g, '$1: void {')
.replace(/( set\w*Angle\(.*\))(: any)? {/g, '$1: void {')
.replace(/( set\w*X\(.*\))(: any)? {/g, '$1: void {')
.replace(/( set\w*Y\(.*\))(: any)? {/g, '$1: void {')
.replace(/( set\w*String\(.*\))(: any)? {/g, '$1: void {')
.replace(/( set\w*Time\(.*\))(: any)? {/g, '$1: void {')
.replace(/( updateFromBehaviorData\(.*\))(: any)? {/g, '$1: boolean {')
.replace(/( updateFromObjectData\(.*\))(: any)? {/g, '$1: boolean {')
.replace(/( update.*\(.*\))(: any)? {/g, '$1: void {')
.replace(/( show\w*\(.*\))(: any)? {/g, '$1: void {')
.replace(/( hide\w*\(.*\))(: any)? {/g, '$1: void {')
.replace(/( reset\w*\(.*\))(: any)? {/g, '$1: void {')
.replace(/( deleteFromScene\w*\(.*\))(: any)? {/g, '$1: void {')
.replace(/( onDeletedFromScene\w*\(.*\))(: any)? {/g, '$1: void {')
.replace(/( enable\w*\(.*\))(: any)? {/g, '$1: void {')
// Members:
.replace(/(\w*): any = (\d)/g, '$1: number = $2')
.replace(/(\w*): any = (true|false)/g, '$1: boolean = $2')
// More generic parameters:
.replace(/hidden: any/g, 'hidden: boolean')
.replace(/enable: any/g, 'enable: boolean')
.replace(/type: any/g, 'type: string')
.replace(/nameId: any/g, 'nameId: integer')
.replace(/(cameraId\??): (number|any)/gi, '$1: integer')
.replace(/(clearColor): any/gi, '$1: Array<integer>')
.replace(/(zOrder): (any|number)/gi, '$1: integer')
.replace(/(timeScale): (any|number)/gi, '$1: float')
.replace(/(elapsedTime): (any|number)/gi, '$1: float')
.replace(/(zoomFactor): (any|number)/gi, '$1: float')
.replace(/(zoom): (any|number)/gi, '$1: float')
.replace(/(rotation): (any|number)/gi, '$1: float')
.replace(/(angle): (any|number)/gi, '$1: float')
.replace(/(resolutionWidth): (any|number)/gi, '$1: integer')
.replace(/(resolutionHeight): (any|number)/gi, '$1: integer')
.replace(/(cameraX): (any|number)/gi, '$1: float')
.replace(/(cameraY): (any|number)/gi, '$1: float')
.replace(/(r|g|b): number/g, '$1: integer')
// The "less precise" replacements are done at the end:
.replace(/(_\w*EffectsData): (any)/gi, '$1: Array<EffectsData>')
.replace(/(width): (any|number)/gi, '$1: float')
.replace(/(height): (any|number)/gi, '$1: float')
.replace(/(time): (any|number)/gi, '$1: float')
.replace(/(name): any/gi, '$1: string')
.replace(/(_is.*): any/gi, '$1: boolean')
.replace(/x: number/g, 'x: float')
.replace(/y: number/g, 'y: float')
.replace(/: String(( {)|,|\))/g, ': string$1')
.replace(/(value): number/gi, '$1: float')
// Add ": void" as the return type of setters, at the very end.
.replace(/( set\w*\(.*\)) {/g, '$1: void {');
// Other adhoc generic fixes
cleanedTypedCode = cleanedTypedCode.replace('const i = 0,', 'let i = 0,');
if (fileName === 'runtimegame.ts') {
cleanedTypedCode = cleanedTypedCode
.replace(
'_adaptGameResolutionAtRuntime: float',
'_adaptGameResolutionAtRuntime: boolean'
)
.replace('options?: RuntimeGameOptions', 'options: RuntimeGameOptions')
.replace('let sessionId = null;', 'let sessionId: string | null = null;');
}
if (fileName === 'variablescontainer.ts') {
cleanedTypedCode = cleanedTypedCode
.replace(
`}
VariablesContainer._deletedVars = VariablesContainer._deletedVars || [];`,
'static _deletedVars: Array<string | undefined> = [];'
)
.replace(
`VariablesContainer.badVariablesContainer = {`,
'static badVariablesContainer = {'
)
.replace(`VariablesContainer.badVariable = {`, 'static badVariable = {')
.replace(
/isUndefinedInContainer\:\s*function\(\) \{\s*return;\s*\}\};/,
`isUndefinedInContainer : function() { return; }\n};\n}`
);
}
// Add back the gdjs namespace:
const classStartPosition = cleanedTypedCode.indexOf('class ');
const exportClassStartPosition = cleanedTypedCode.indexOf('export class ');
const firstCommentEndingPosition = cleanedTypedCode.indexOf('*/') + 3;
const firstJsDocStartPosition = cleanedTypedCode.indexOf('/**');
let namespacePosition = Math.min(
classStartPosition !== -1 ? classStartPosition : Number.MAX_SAFE_INTEGER,
exportClassStartPosition !== -1
? exportClassStartPosition
: Number.MAX_SAFE_INTEGER,
firstCommentEndingPosition !== -1
? firstCommentEndingPosition
: Number.MAX_SAFE_INTEGER,
firstJsDocStartPosition !== -1
? firstJsDocStartPosition
: Number.MAX_SAFE_INTEGER
);
if (namespacePosition === -1) {
shell.echo(
`⚠️ Could not find where to start the namespace, assuming beginning of the file.`
);
namespacePosition = 0;
}
const cleanedNamespacedTypedCode =
cleanedTypedCode.slice(0, namespacePosition) +
'\nnamespace gdjs {\n' +
cleanedTypedCode.slice(namespacePosition) +
'\n}\n';
const cleanedNamespacedTypedefsTypedCode = translateTypedefs(
cleanedNamespacedTypedCode
);
await fs.writeFile(outPath, cleanedNamespacedTypedefsTypedCode, 'utf-8');
})();