diff --git a/bin/wasmoon b/bin/wasmoon index 9100384..d262f60 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,114 +1,278 @@ #!/usr/bin/env node -const { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, decorate } = require('../dist') -const fs = require('fs') -const path = require('path') -const readline = require('readline') - -async function* walk(dir) { - const dirents = await fs.promises.readdir(dir, { withFileTypes: true }) - for (const dirent of dirents) { - const res = path.resolve(dir, dirent.name) - if (dirent.isDirectory()) { - yield* walk(res) +import { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, LuaRawResult, decorate, decorateFunction } from '../dist/index.js' +import pkg from '../package.json' with { type: 'json' } +import fs from 'node:fs' +import path from 'node:path' +import readline from 'node:readline' + +function printUsage() { + console.log( + ` +usage: wasmoon [options] [script [args]] +Available options are: + -e stat execute string 'stat' + -i enter interactive mode after executing 'script' + -l mod require library 'mod' into global 'mod' + -l g=mod require library 'mod' into global 'g' + -v show version information + -E ignore environment variables + -W turn warnings on + -- stop handling options + - stop handling options and execute stdin +`.trim(), + ) + process.exit(1) +} + +function parseArgs(args) { + const executeSnippets = [] + const includeModules = [] + let forceInteractive = false + let warnings = false + let ignoreEnv = false + let showVersion = false + let scriptFile = null + + const outArgs = [] + + let i = 0 + for (; i < args.length; i++) { + const arg = args[i] + + if (arg === '--') { + i++ + break + } + + if (arg.startsWith('-') && arg.length > 1) { + switch (arg) { + case '-v': + showVersion = true + break + + case '-W': + warnings = true + break + + case '-E': + ignoreEnv = true + break + + case '-i': + forceInteractive = true + break + + case '-e': + i++ + if (i >= args.length) { + console.error('Missing argument after -e') + printUsage() + } + executeSnippets.push(args[i]) + break + + case '-l': + i++ + if (i >= args.length) { + console.error('Missing argument after -l') + printUsage() + } + includeModules.push(args[i]) + break + + case '-': + scriptFile = '-' + i++ + break + + default: + console.log(`unrecognized option: '${arg}'`) + printUsage() + break + } } else { - yield res + scriptFile = arg + i++ + break } } + + outArgs.push(...args.slice(i)) + + return { + executeSnippets, + includeModules, + forceInteractive, + warnings, + showVersion, + scriptFile, + scriptArgs: outArgs, + ignoreEnv, + } +} + +const { executeSnippets, includeModules, ignoreEnv, forceInteractive, warnings, showVersion, scriptFile, scriptArgs } = parseArgs( + process.argv.slice(2), +) + +const factory = new LuaFactory({ env: ignoreEnv ? undefined : process.env }) +const luamodule = await factory.getLuaModule() +const lua = await factory.createEngine() + +if (showVersion) { + console.log(`wasmoon ${pkg.version} (${lua.global.get('_VERSION')})`) + process.exit(0) +} + +if (warnings) { + luamodule.lua_warning(lua.global.address, '@on', 0) } -async function main() { - const factory = new LuaFactory() - const luamodule = await factory.getLuaModule() - const lua = await factory.createEngine() +for (const snippet of executeSnippets) { + await lua.doString(snippet) +} - let snippets = process.argv.splice(2) +function currentDir() { + const pointer = luamodule.module._malloc(128); + const baseidx = pointer >> 2; - const consumeOption = (option, single) => { - let i = -1 - const values = [] - while ((i = snippets.indexOf(option)) >= 0) { - values.push(snippets.splice(i, single ? 1 : 2).reverse()[0]) + try { + if (!luamodule.lua_getstack(lua.global.address, 1, pointer)) { + return null; + } + + if (!luamodule.lua_getinfo(lua.global.address, 'S', pointer)) { + return null; } - return values - } - const includes = consumeOption('-l') - const forceInteractive = consumeOption('-i', true).length > 0 - const runFile = process.stdin.isTTY && consumeOption(snippets[0], true)[0] - const args = snippets + // https://www.lua.org/manual/5.4/manual.html#lua_Debug + const sourcePtr = luamodule.module.HEAPU32[baseidx + 4]; + let source = luamodule.module.UTF8ToString(sourcePtr); - for (const include of includes) { - const relativeInclude = path.resolve(process.cwd(), include) - const stat = await fs.promises.lstat(relativeInclude) - if (stat.isFile()) { - await factory.mountFile(relativeInclude, await fs.promises.readFile(relativeInclude)) + if (!source || source === '=[C]' || source.startsWith('return ')) { + return '.' } else { - for await (const file of walk(relativeInclude)) { - await factory.mountFile(file, await fs.promises.readFile(file)) + if (source.startsWith('@')) { + source = source.substring(1) } + + return source } + } finally { + luamodule.module._free(pointer); } +} - lua.global.set('arg', decorate(args, { disableProxy: true })) +lua.global.getTable('package', (packageIdx) => { + luamodule.lua_getfield(lua.global.address, packageIdx, 'searchers') - const interactive = process.stdin.isTTY && (forceInteractive || !runFile) + lua.global.pushValue(decorateFunction((thread, moduleName) => { + if (moduleName.endsWith('.lua')) { + const calledDirectory = currentDir() + const luafile = path.resolve(calledDirectory, moduleName) - if (runFile) { - const relativeRunFile = path.resolve(process.cwd(), runFile) - await factory.mountFile(relativeRunFile, await fs.promises.readFile(relativeRunFile)) + try { + const content = fs.readFileSync(luafile) + factory.mountFileSync(luamodule, luafile, content) + } catch { + // ignore + } - await lua.doFile(relativeRunFile) - console.log(lua.global.indexToString(-1)) - } + const load = luamodule.luaL_loadfilex(lua.global.address, luafile, null) + if (load === LuaReturn.Ok) { + thread.pushValue(luafile) + return new LuaRawResult(2) + } + } + }, { receiveThread: true })) + + const len = luamodule.lua_rawlen(lua.global.address, -1) + luamodule.lua_rawseti(lua.global.address, -2, len + BigInt(1)) + + lua.global.pop() +}) + +lua.global.set('loadfile', (filename) => { + // TODO +}) + +for (const include of includeModules) { + // TODO +} + +lua.global.set('arg', decorate(scriptArgs, { disableProxy: true })) - if (!interactive && runFile) { - return +const isTTY = process.stdin.isTTY + +if (scriptFile) { + if (scriptFile === '-') { + const input = fs.readFileSync(0, 'utf-8') + await lua.doString(input) + console.log(lua.global.indexToString(-1)) + } else { + const absolutePath = path.resolve(process.cwd(), scriptFile) + const content = await fs.promises.readFile(absolutePath) + await factory.mountFile(absolutePath, content) + await lua.doFile(absolutePath) + console.log(lua.global.indexToString(-1)) } +} - if (interactive) { - // Call directly from module to bypass the result verification - const loadcode = (code) => !lua.global.setTop(0) && luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok +const shouldInteractive = isTTY && (forceInteractive || (!scriptFile && !executeSnippets.length)) - const version = require('../package.json').version - console.log('Welcome to Wasmoon v' + version) +if (shouldInteractive) { + // Bypass result verification for interactive mode + const loadcode = (code) => { + lua.global.setTop(0) + return luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok + } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - removeHistoryDuplicates: true, - }) + const version = pkg.version + const luaversion = lua.global.get('_VERSION') + console.log(`Welcome to Wasmoon ${version} (${luaversion})`) + console.log('Type Lua code and press Enter to execute. Ctrl+C to exit.\n') - rl.prompt() + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + removeHistoryDuplicates: true, + prompt: '> ', + }) - for await (const line of rl) { - const loaded = loadcode(line) || loadcode(`return ${line}`) - if (!loaded) { - console.log(lua.global.getValue(-1, LuaType.String)) - rl.prompt() - continue - } + rl.prompt() - const result = luamodule.lua_pcallk(lua.global.address, 0, LUA_MULTRET, 0, 0, null) - if (result === LuaReturn.Ok) { - const returnValues = Array.from({ length: lua.global.getTop() }).map((_, i) => lua.global.indexToString(i + 1)) + for await (const line of rl) { + // try to load (compile) it first as an expression (return ) and second as a statement + const loaded = loadcode(`return ${line}`) || loadcode(line) + if (!loaded) { + // Failed to parse + const err = lua.global.getValue(-1, LuaType.String) + console.log(err) + rl.prompt() + continue + } - if (returnValues.length) { - console.log(...returnValues) + const result = luamodule.lua_pcallk(lua.global.address, 0, LUA_MULTRET, 0, 0, null) + if (result === LuaReturn.Ok) { + const count = lua.global.getTop() + if (count > 0) { + const returnValues = [] + for (let i = 1; i <= count; i++) { + returnValues.push(lua.global.indexToString(i)) } - } else { - console.log(lua.global.getValue(-1, LuaType.String)) + console.log(...returnValues) } - - rl.prompt() + } else { + console.log(lua.global.getValue(-1, LuaType.String)) } - } else { - await lua.doString(fs.readFileSync(0, 'utf-8')) - console.log(lua.global.indexToString(-1)) + + rl.prompt() } +} else if (!scriptFile) { + // If we're not interactive, and we did NOT run a file, + // read from stdin until EOF. (Non-TTY or no -i, no file). + const input = fs.readFileSync(0, 'utf-8') + await lua.doString(input) + console.log(lua.global.indexToString(-1)) } - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/build.sh b/build.sh index 9d54f96..774b0c3 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,9 @@ emcc \ 'setValue', \ 'lengthBytesUTF8', \ 'stringToUTF8', \ - 'stringToNewUTF8' + 'stringToNewUTF8', \ + 'UTF8ToString', \ + 'HEAPU32' ]" \ -s INCOMING_MODULE_JS_API="[ 'locateFile', \ diff --git a/eslint.config.js b/eslint.config.js index 1610949..23e7807 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,7 @@ export default [ }, }, { - files: ['**/*.js', '**/*.mjs', '**/*.ts'], + files: ['**/*.js', '**/*.mjs', '**/*.ts', './bin/*'], ignores: ['**/test/*', '**/bench/*'], plugins: { 'simple-import-sort': simpleImportSort, diff --git a/src/factory.ts b/src/factory.ts index 63449f5..0ef86d7 100755 --- a/src/factory.ts +++ b/src/factory.ts @@ -2,7 +2,7 @@ import version from 'package-version' import LuaEngine from './engine' import LuaWasm from './luawasm' -import { CreateEngineOptions, EnvironmentVariables } from './types' +import { CreateEngineOptions } from './types' /** * Represents a factory for creating and configuring Lua engines. @@ -12,21 +12,24 @@ export default class LuaFactory { /** * Constructs a new LuaFactory instance. - * @param [customWasmUri] - Custom URI for the Lua WebAssembly module. - * @param [environmentVariables] - Environment variables for the Lua engine. + * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. + * @param opts.env - Environment variables for the Lua engine. + * @param opts.stdin - Standard input for the Lua engine. + * @param opts.stdout - Standard output for the Lua engine. + * @param opts.stderr - Standard error for the Lua engine. */ - public constructor(customWasmUri?: string, environmentVariables?: EnvironmentVariables) { - if (customWasmUri === undefined) { + public constructor(opts: Parameters[0] = {}) { + if (opts.wasmFile === undefined) { const isBrowser = (typeof window === 'object' && typeof window.document !== 'undefined') || (typeof self === 'object' && self?.constructor?.name === 'DedicatedWorkerGlobalScope') if (isBrowser) { - customWasmUri = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` + opts.wasmFile = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` } } - this.luaWasmPromise = LuaWasm.initialize(customWasmUri, environmentVariables) + this.luaWasmPromise = LuaWasm.initialize(opts) } /** diff --git a/src/global.ts b/src/global.ts index e006d5e..0fbef7a 100755 --- a/src/global.ts +++ b/src/global.ts @@ -177,7 +177,7 @@ export default class Global extends Thread { } finally { // +1 for the table if (this.getTop() !== startStackTop + 1) { - console.warn(`getTable: expected stack size ${startStackTop} got ${this.getTop()}`) + console.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) } this.setTop(startStackTop) } diff --git a/src/luawasm.ts b/src/luawasm.ts index 98ed2cd..032ba9e 100755 --- a/src/luawasm.ts +++ b/src/luawasm.ts @@ -1,5 +1,7 @@ import initWasmModule from '../build/glue.js' -import { EnvironmentVariables, LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types' +import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types' + +type EnvironmentVariables = Record interface LuaEmscriptenModule extends EmscriptenModule { ccall: typeof ccall @@ -11,6 +13,7 @@ interface LuaEmscriptenModule extends EmscriptenModule { stringToNewUTF8: typeof allocateUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 + UTF8ToString: typeof UTF8ToString ENV: EnvironmentVariables _realloc: (pointer: number, size: number) => number } @@ -21,15 +24,23 @@ interface ReferenceMetadata { } export default class LuaWasm { - public static async initialize(customWasmFileLocation?: string, environmentVariables?: EnvironmentVariables): Promise { + public static async initialize(opts: { + wasmFile?: string + env?: EnvironmentVariables + stdin?: () => number | null + stdout?: () => number | null + stderr?: () => number | null + }): Promise { const module: LuaEmscriptenModule = await initWasmModule({ locateFile: (path: string, scriptDirectory: string) => { - return customWasmFileLocation || scriptDirectory + path + return opts.wasmFile || scriptDirectory + path }, preRun: (initializedModule: LuaEmscriptenModule) => { - if (typeof environmentVariables === 'object') { - Object.entries(environmentVariables).forEach(([k, v]) => (initializedModule.ENV[k] = v)) + if (typeof opts?.env === 'object') { + Object.entries(opts.env).forEach(([k, v]) => (initializedModule.ENV[k] = v)) } + + initializedModule.FS.init(opts?.stdin ?? null, opts?.stdout ?? null, opts?.stderr ?? null) }, }) return new LuaWasm(module) @@ -111,7 +122,7 @@ export default class LuaWasm { public lua_tointegerx: (L: LuaState, idx: number, isnum: number | null) => bigint public lua_toboolean: (L: LuaState, idx: number) => number public lua_tolstring: (L: LuaState, idx: number, len: number | null) => string - public lua_rawlen: (L: LuaState, idx: number) => number + public lua_rawlen: (L: LuaState, idx: number) => bigint public lua_tocfunction: (L: LuaState, idx: number) => number public lua_touserdata: (L: LuaState, idx: number) => number public lua_tothread: (L: LuaState, idx: number) => LuaState diff --git a/src/types.ts b/src/types.ts index 64e89a8..1e12c75 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,5 @@ export type LuaState = number -export type EnvironmentVariables = Record - export interface CreateEngineOptions { /** Injects all the lua standard libraries (math, coroutine, debug) */ openStandardLibs?: boolean diff --git a/test/engine.test.js b/test/engine.test.js index 23e6361..3a1ab1f 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -806,13 +806,13 @@ describe('Engine', () => { it('lots of doString calls should succeed', async () => { const engine = await getEngine() - const length = 10000; + const length = 10000 for (let i = 0; i < length; i++) { - const a = Math.floor(Math.random() * 100); - const b = Math.floor(Math.random() * 100); - const result = await engine.doString(`return ${a} + ${b};`); - expect(result).to.equal(a + b); + const a = Math.floor(Math.random() * 100) + const b = Math.floor(Math.random() * 100) + const result = await engine.doString(`return ${a} + ${b};`) + expect(result).to.equal(a + b) } }) }) diff --git a/tsconfig.json b/tsconfig.json index f911e66..14dbd93 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,8 +16,7 @@ "noUnusedLocals": true, "importHelpers": true, "strict": true, - "resolveJsonModule": true, - + "resolveJsonModule": true }, "include": ["src/**/*", "test/**/*", "bench/**/*", "eslint.config.js"], "exclude": ["node_modules"]