const fs = require('fs/promises'); const path = require('path'); const chalk = require('chalk'); const { program } = require('commander'); const declarationsPath = path.resolve(__dirname, './isaac-typescript-definitions/typings/'); const outPath = path.resolve(__dirname, './out/'); const overridePath = path.resolve(__dirname, './override/'); program .name('isaac-lua-definitions-transpiler') .version('0.0.1') .option('-W, --no-warn', 'don\'t show warnings') .option('-s', 'don\'t show file info') .option('-ss', 'don\'t show files') .option('-sss', 'don\'t show anything') .option('-v', 'show class names') .option('-e, --exclude [files...]', 'don\'t compile specified files', '') .option('-o, --only [files...]', 'only compile specified files', ''); program.parse(process.argv); const options = program.opts(); let classes = {}; let tsToLuaTypes = { undefined: 'nil', unknown: 'any', } function transpileType(type) { if (!type) return 'what'; type = type.trim(); if (type.startsWith('(') && type.endsWith(')')) type = type.slice(1, -1); if (tsToLuaTypes[type]) { return tsToLuaTypes[type]; } if (type.endsWith('[]')) { return transpileType(type.slice(0, -2)) + '[]'; } if (type.startsWith('LuaMultiReturn<[') && type.endsWith(']>')) { return type.replace('LuaMultiReturn<[','').replace(']>','').split(',').map(t => transpileType(t.split(':')[1] || t)).join(', '); } if (type.includes('|')) { return type.split('|').map(t => transpileType(t)).join(' | '); } if (type.startsWith('"')) { return 'string'; } if (type.startsWith('Record<') && type.endsWith('>')) { return 'table<' + transpileType(type.split(',')[0].replace('Record<','')) + ', ' + transpileType(type.split(',')[1].replace('>','')) + '>'; } if (type.startsWith('LuaTable<') && type.endsWith('>')) { return 'table<' + transpileType(type.split(',')[0].replace('LuaTable<','')) + ', ' + transpileType(type.split(',')[1].replace('>','')) + '>'; } let match; if (match = /\((.*?)\) ?=> ?(.*)/.exec(type)) { // functions return `fun(${match[1] !== '' ? match[1].split(',').map(c => c.split(':')[0] + ':' + transpileType(c.split(':')[1])).join(',') : ''}): ${transpileType(match[2])}`; } if (match = /(?:Array)|(?:Map)<(.*?)>/.exec(type)) { return transpileType(match[1]) + '[]'; } if (match = /(.*?)<(.*?)>/.exec(type)) { // any other weird type we just discard return transpileType(match[2]); } return type; } function transpile(parsed, indent, prefix, gl) { let global = false; indent = indent || 0; const type = parsed[0]; const d = parsed[1]; let n = '\n' + ' '.repeat(indent); // newline const comments = d.comment ? d.comment.split('\n').map(l => l.trim()).map(l => l.startsWith('*') ? l.replace('*', '') : l).filter(l => l.trim().length !== 0 && !l.trim().startsWith('@')).map(l => '---' + l.trim()).join('\n') + '\n' : ''; switch (type) { case 'namespace': global = true; case 'interface': let contents = d.contents; if (d.extends) { if (classes[d.extends]) { contents.push(...classes[d.extends]); } else { throw new Error(`Class not found in class storage: ${d.extends}`) } } classes[d.name] = contents; return `${comments}---@class ${d.name}${d.extends ? ' : ' + d.extends : ''}${n}${contents.filter(e => e[0] === 'const').map(c => `---@field ${c[1].type.startsWith('Readonly<') ? 'protected' : 'public'} ${c[1].name} ${transpileType(c[1].type)}${n}`).join('')}${global ? '' : '__class_'}${d.name} = {}${n}${contents.filter(e => e[0] !== 'const').map(c => transpile(c, indent, `${global ? '' : '__class_'}${d.name}`, global)).join(n)}\n`; case 'function': if (d.arguments[0] && d.arguments[0].type === 'void') d.arguments = d.arguments.slice(1); return `${comments}${d.arguments.map(p => `---@param ${p.name.replace('?', '')} ${transpileType(p.type)}${p.comment ? ' @' + p.comment : ''}${n}`).join('')}---@return ${transpileType(d.returns)}${d.returnsComment ? ' @' + d.returnsComment : ''}${n}function ${prefix ? prefix + (gl ? '.' : ':') : ''}${d.name}(${d.arguments.map(a => a.name.replace('?', '')).join(', ')}) end`; case 'const': return `${comments}---@type ${transpileType(d.type)}${n}${prefix ? prefix + '.' : ''}${d.name} = nil`; case 'enum': return `${comments}${prefix ? prefix + '.' : ''}${d.name} = {${n} ${d.contents.map(c => `${c.name} = ${c.value}`).join(`,${n} `)}${n}}`; } return ''; } function removeComments(code) { // remove /* */ style comments, for now code = code.replace(/\/\*(.*?)\*\//gs, ''); code = code.split('\n').filter(c => !c.trim().startsWith('//')).join('\n'); return code; } let parsedElements = 0; /** * @param {string} code */ function parse(code, originalCode) { // console.log(code); // we're left with code containing "declare" statements // other code will pass stuff in here recursively, but the declare doesnt _really_ matter // were gonna end up with an ast of sorts anyways as a result, which we can then use instead of declares // sanitize stuff if (!originalCode) originalCode = code; code = removeComments(code); let match; let elements = []; // look for interfaces let interfaceFreeCode = code; const interface = /interface (\w+)(?: extends (\w+))? ?{([^}]*)}/g; while ((match = interface.exec(code)) !== null) { parsedElements++; interfaceFreeCode = interfaceFreeCode.replace(match[0], ''); if (options.v) log(undefined, ' interface ' + chalk.blueBright(match[1])); elements.push(['interface', {name: match[1], extends: match[2], contents: parse(match[3], originalCode)}]); } code = interfaceFreeCode; // look for namespaces let namespaceFreeCode = code; const namespace = /namespace (\w+) ?{([^}]*)}/g; while ((match = namespace.exec(code)) !== null) { parsedElements++; namespaceFreeCode = namespaceFreeCode.replace(match[0], ''); if (options.v) log(undefined, ' namespace ' + chalk.redBright(match[1])); elements.push(['namespace', {name: match[1], contents: parse(match[2], originalCode)}]); } code = namespaceFreeCode; // look for functions let functionFreeCode = code; const functions = /(function)?\s([\w_]+)\s*\(([^;]*)\)(\s*:\s*([\w\[\]\s|<>,:]+))?/g; // wow this sucks while ((match = functions.exec(code)) !== null) { parsedElements++; functionFreeCode = functionFreeCode.replace(match[0], ''); let arguments = match[3].split(',').map(s => { const split = s.split(':').map(s => s.trim()); return {type: split[1], name: split[0]} }).filter(c => c.type && c.name); const origIndex = originalCode.indexOf(match[0]); const comment = /\/\*(.*?)\*\//gs; let comments = []; let comm; while ((comm = comment.exec(originalCode.slice(0, origIndex))) !== null) { comments.push([comm[1], comm.index + comm[0].length]); } comments = comments.filter(c => Math.abs(origIndex - c[1]) < 10); const c = comments.pop(); //if (c) c[0] += 'isaac-lua-definitions debug: comment distance = ' + (origIndex - c[1]).toString(); let returnsComment; // add parameter comments if (c) { c[0].split('\n').map(l => l.trim()).filter(l => l.startsWith('* @param ')).forEach(p => { p = p.slice('* @param '.length); let indx = arguments.findIndex(arg => arg.name.replace('?', '').trim() === p.split(' ')[0]); if (p.split(' ')[1] && indx !== -1) { let comment = p.split(' ').slice(1).join(' '); arguments[indx].comment = comment; } }); // add returns comments, if found let returns = c[0].split('\n').map(l => l.trim()).find(l => l.startsWith('* @returns ')); if (returns) { returnsComment = returns.slice('* @returns '.length); } } elements.push(['function', {name: match[2], arguments: arguments, returns: match[5], returnsComment: returnsComment, comment: (c || [null])[0]}]); } code = functionFreeCode; // looks for enums let enumFreeCode = code; const enums = /enum ([_\w]+) ?{([^}]*)}/g; while ((match = enums.exec(code)) !== null) { parsedElements++; enumFreeCode = enumFreeCode.replace(match[0], ''); const origIndex = originalCode.indexOf(match[0]); const comment = /\/\*(.*?)\*\//gs; let comments = []; let comm; while ((comm = comment.exec(originalCode.slice(0, origIndex))) !== null) { comments.push([comm[1], comm.index + comm[0].length]); } comments = comments.filter(c => Math.abs(origIndex - c[1]) < 10); const c = comments.pop(); //if (c) c[0] += 'isaac-lua-definitions debug: comment distance = ' + (origIndex - c[1]).toString(); if (options.v) log(undefined, ' enum ' + chalk.greenBright(match[1])); elements.push(['enum', {name: match[1], contents: match[2].split(',').filter(c => c.split('=').length === 2).map(c => {return {name: c.split('=')[0].trim(), value: c.split('=')[1].trim()}}), comment: (c || [null])[0]}]); } code = enumFreeCode; // looks for constants / properties let constFreeCode = code; const consts = /([\w_]+)\s*:\s*([\w<>\s,]+)/g; while ((match = consts.exec(code)) !== null) { parsedElements++; constFreeCode = constFreeCode.replace(match[0], ''); const origIndex = originalCode.indexOf(match[0]); const comment = /\/\*(.*?)\*\//gs; let comments = []; let comm; while ((comm = comment.exec(originalCode.slice(0, origIndex))) !== null) { comments.push([comm[1], comm.index + comm[0].length]); } comments = comments.filter(c => Math.abs(origIndex - c[1]) < 10); const c = comments.pop(); //if (c) c[0] += 'isaac-lua-definitions debug: comment distance = ' + (origIndex - c[1]).toString(); elements.push(['const', {name: match[1], type: match[2], comment: (c || [null])[0]}]); } code = constFreeCode; elements = elements.sort((a, b) => b[1].extends ? 1 : 0 - a[1].extends ? 1 : 0).reverse(); // for extending bullshit return elements; } async function recursiveReaddir(rpath, p) { p = p || ''; let fileNames = []; const files = await fs.readdir(rpath); for (const f of files) { const stat = await fs.lstat(path.join(rpath, f)); if (stat.isDirectory() || stat.isSymbolicLink()) { fileNames.push(...await recursiveReaddir(path.join(rpath, f), p + f + '/')); } else { fileNames.push(p + f); } } return fileNames; } let loggedFile = {}; function warn(file, string) { if (!options.warn) return; if (options.Sss) return; if (!loggedFile[file]) { console.log(` ${chalk.cyanBright(file)}`); loggedFile[file] = true; } console.log(`${chalk.redBright('!')} ${string}`); } function log(file, string) { if (options.s && string) return; if (options.Ss) return; if (options.Sss) return; if (file && !loggedFile[file]) { console.log(` ${chalk.cyanBright(file)}`); loggedFile[file] = true; } if (string) console.log(' ' + string); } (async () => { let timeParsing = 0; let timeTotal = 0; try { const d = await fs.lstat(outPath) if (!d.isDirectory()) throw new Error(`${outPath} exists, and isn't a directory!`); } catch(err) { if (err.code === 'ENOENT') { await fs.mkdir(outPath); } else { console.error(err); process.exit(); } } let errored = []; let errorReason = {}; const files = (await recursiveReaddir(declarationsPath)).filter(f => { return !f.includes('unofficial/') && (options.exclude.length == 0 || !options.exclude.includes(f)) && (options.only.length === 0 || options.only.includes(f)) }); const filesAmt = files.length; let i = 0; for (const f of files) { i++; const filePath = path.resolve(declarationsPath, f); if (!options.Ss) log(f); const override = path.resolve(overridePath, f.replace('.d.ts', '.lua')); const luaFilename = f.replace('.d.ts', '.lua').split('/').join('_'); try { if (await fs.stat(override)) { log('file exists in override/, ignoring'); await fs.copyFile(override, path.resolve(outPath, luaFilename)); continue; } } catch(err) {} const file = await fs.readFile(filePath, 'utf8'); let startParse = Date.now(); parsedElements = 0; const parsed = parse(file); log(f, `parsed ${chalk.magentaBright(parsedElements)} objects from ${chalk.cyanBright(f)}`); timeParsing += Date.now() - startParse; if (parsed.length === 0) { warn(f, `no elements parsed, ${chalk.gray('not writing anything')}`); continue; } if (parsed.length > 10) { warn(f, `over 10 top-level objects were parsed - this may be a bad idea`); } let transpiled; try { transpiled = parsed.map(p => transpile(p)).join('\n').trim(); errored = errored.filter(e => e !== f); } catch(err) { errorReason[f] = err; if (errored.length > files.length - i) { console.error(`${chalk.redBright('!')} failed resolving extends!`); errored = [...new Set(errored)]; console.error(`${chalk.redBright('!')} abandoned ${chalk.magentaBright(errored.length)} files:`); let longest = errored.reduce((a, b) => Math.max(a.length || a || 0, b.length)); errored.forEach(e => console.error(` - ${chalk.cyanBright(e)}${' '.repeat(longest - e.length + 2)}${chalk.gray(' → ' + errorReason[e].message.replace('Class not found in class storage: ', ''))}`)); break; } console.error(`${chalk.yellowBright('!')} ${err}`); console.error(`${chalk.yellowBright('!')} pushing to end of queue`); files.push(f); errored.push(f); timeTotal += Date.now() - startParse; continue; } log(f, `transpiled w/ final length of ${chalk.magentaBright(transpiled.length + ' chars')}`); timeTotal += Date.now() - startParse; if (transpiled.length === 0) { warn(f, `nothing transpiled, ${chalk.gray('not writing anything')}`); continue; } //console.log(inspect(parsed, false, 10)); await fs.writeFile(path.resolve(outPath, luaFilename), transpiled); //console.log(` wrote ${chalk.cyanBright(luaFilename)}`); } console.log(`\nfinished transpiling ${chalk.magentaBright(filesAmt)} ${chalk.gray(`+ ${files.length - filesAmt}`)} files in ${chalk.magentaBright(timeTotal + 'ms')} avg ${chalk.magentaBright((Math.floor(timeTotal / filesAmt * 100) / 100) + 'ms')} ${chalk.gray(`(${Math.floor(timeParsing/timeTotal * 1000 + 0.5) / 10}% spent parsing)`)}`); console.log(`total class cache size: ${chalk.magentaBright(Object.keys(classes).length + ' classes')}`) console.log(`check ${chalk.cyanBright(outPath)}`); })();