diff --git a/.gitignore b/.gitignore index 7fd312bad..59c01ade2 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ src/kword_test # Wasm /src/a.out.js +/src/a.out.wasm /src/vim.bc /autom4te.cache diff --git a/src/evalfunc.c b/src/evalfunc.c index b6998993f..807d4d9ef 100644 --- a/src/evalfunc.c +++ b/src/evalfunc.c @@ -227,6 +227,9 @@ static void f_items(typval_T *argvars, typval_T *rettv); static void f_join(typval_T *argvars, typval_T *rettv); static void f_js_decode(typval_T *argvars, typval_T *rettv); static void f_js_encode(typval_T *argvars, typval_T *rettv); +#ifdef FEAT_GUI_WASM +static void f_jsevalfunc(typval_T *argvars, typval_T *rettv); +#endif static void f_json_decode(typval_T *argvars, typval_T *rettv); static void f_json_encode(typval_T *argvars, typval_T *rettv); static void f_keys(typval_T *argvars, typval_T *rettv); @@ -719,6 +722,9 @@ static struct fst {"join", 1, 2, f_join}, {"js_decode", 1, 1, f_js_decode}, {"js_encode", 1, 1, f_js_encode}, +#ifdef FEAT_GUI_WASM + {"jsevalfunc", 1, 3, f_jsevalfunc}, +#endif {"json_decode", 1, 1, f_json_decode}, {"json_encode", 1, 1, f_json_encode}, {"keys", 1, 1, f_keys}, @@ -6405,6 +6411,9 @@ f_has(typval_T *argvars, typval_T *rettv) #ifdef FEAT_GUI_MSWIN "gui_win32", #endif +#ifdef FEAT_GUI_WASM + "gui_wasm", +#endif #ifdef FEAT_HANGULIN "hangul_input", #endif @@ -8958,6 +8967,89 @@ f_pyxeval(typval_T *argvars, typval_T *rettv) } #endif +#ifdef FEAT_GUI_WASM +/* + * "jsevalfunc()" function + */ +static void +f_jsevalfunc(typval_T *argvars, typval_T *rettv) +{ + char_u *script; + char_u *args_json = NULL; + char *ret_json = NULL; + int just_notify = 0; + + if (check_restricted() || check_secure()) { + return; + } + + script = tv_get_string_chk(&argvars[0]); + if (script == NULL) { + return; + } + + if (argvars[1].v_type != VAR_UNKNOWN) { + list_T *args_list = NULL; + listitem_T *item; + + if (argvars[1].v_type != VAR_LIST) { + emsg(_(e_listreq)); + return; + } + + if (argvars[2].v_type != VAR_UNKNOWN) { + int error = FALSE; + + just_notify = tv_get_number_chk(&argvars[2], &error); + if (error) { + return; + } + } + + args_json = json_encode(&argvars[1], 0); + if (*args_json == NUL) { + // Failed to encode argument as JSON + vim_free(args_json); + return; + } + } + + GUI_WASM_DBG("jsevalfunc: Will evaluate script '%s' with %s args (notify_only=%d)", script, args_json, just_notify); + ret_json = vimwasm_eval_js((char *)script, (char *)args_json, just_notify); + if (args_json != NULL) { + vim_free(args_json); + } + + GUI_WASM_DBG("jsevalfunc: vimwasm_eval_js() returned %s", ret_json); + if (ret_json == NULL) { + // Two cases reach here. + // 1. Error occurred in vimwasm_eval_js() and it returned NULL + // 2. just_notify is 1 so the result was not returned from vimwasm_eval_js() + // + // Note: On 1., error output should already be done by calling emsg() from JavaScript + return; + } + + // Note: `ret_json` may be empty. It means undefined was passed to JSON.stringify(). + // An empty string is mapped to v:none by json_decode() Vim script function. + { + js_read_T reader; + + reader.js_buf = (char_u *) ret_json; + reader.js_fill = NULL; + reader.js_used = 0; + + json_decode_all(&reader, rettv, 0); + } + + // Note: vim_free is not available since the pointer was allocated by malloc() + // directly in JavaScript runtime. + free(ret_json); + + // Note: Do not free `script` since it was not newly allocated +} +#endif + /* * "range()" function */ diff --git a/src/gui_wasm.c b/src/gui_wasm.c index ed03251d0..cc82de373 100644 --- a/src/gui_wasm.c +++ b/src/gui_wasm.c @@ -22,12 +22,6 @@ #endif #define RGB(r, g, b) (((r)<<16) | ((g)<<8) | (b)) -#ifdef GUI_WASM_DEBUG -#define GUI_WASM_DBG(fmt, ...) printf("C: %s: " fmt "\n", __func__, __VA_ARGS__) -#else -#define GUI_WASM_DBG(...) ((void) 0) -#endif - static int clipboard_available = TRUE; /* diff --git a/src/vim.h b/src/vim.h index 2bc3ca969..7f13fbf80 100644 --- a/src/vim.h +++ b/src/vim.h @@ -173,6 +173,12 @@ # undef HAVE_SELINUX # undef HAVE_ICONV + +# ifdef GUI_WASM_DEBUG +# define GUI_WASM_DBG(fmt, ...) printf("C: %s: " fmt "\n", __func__, __VA_ARGS__) +# else +# define GUI_WASM_DBG(...) +# endif #endif/* FEAT_GUI_WASM */ /* diff --git a/src/wasm_runtime.h b/src/wasm_runtime.h index 329fe410d..15a6cda3b 100644 --- a/src/wasm_runtime.h +++ b/src/wasm_runtime.h @@ -45,6 +45,7 @@ int vimwasm_wait_for_event(int timeout); int vimwasm_export_file(char *fullpath); char *vimwasm_read_clipboard(void); void vimwasm_write_clipboard(char *text, unsigned long size); +char *vimwasm_eval_js(char *script, char *args_json, int just_notify); #endif /* FEAT_GUI_WASM */ diff --git a/wasm/.eslintrc.js b/wasm/.eslintrc.js index 60241211e..ce73e355e 100644 --- a/wasm/.eslintrc.js +++ b/wasm/.eslintrc.js @@ -46,15 +46,6 @@ module.exports = { // Enabled 'no-console': 'error', '@typescript-eslint/no-floating-promises': 'error', - 'mocha/no-exclusive-tests': 'error', - 'mocha/no-skipped-tests': 'error', - 'mocha/handle-done-callback': 'error', - 'mocha/no-identical-title': 'error', - 'mocha/no-mocha-arrows': 'error', - 'mocha/no-return-and-callback': 'error', - 'mocha/no-sibling-hooks': 'error', - 'mocha/prefer-arrow-callback': 'error', - 'mocha/no-async-describe': 'error', // Configured '@typescript-eslint/array-type': ['error', 'array-simple'], @@ -77,6 +68,15 @@ module.exports = { files: ['test/*.ts'], rules: { '@typescript-eslint/no-non-null-assertion': 'off', + 'mocha/no-exclusive-tests': 'error', + 'mocha/no-skipped-tests': 'error', + 'mocha/handle-done-callback': 'error', + 'mocha/no-identical-title': 'error', + 'mocha/no-mocha-arrows': 'error', + 'mocha/no-return-and-callback': 'error', + 'mocha/no-sibling-hooks': 'error', + 'mocha/prefer-arrow-callback': 'error', + 'mocha/no-async-describe': 'error', }, }, { diff --git a/wasm/README.md b/wasm/README.md index 0b44a51f9..87c1cd0fc 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -6,6 +6,8 @@ **WARNING!: This npm package is experimental until v0.1.0 beta release.** +## Introduction + This is an [npm][] package to install pre-built [vim.wasm][project] binary easily. This package contains: - `vim.wasm`: WebAssembly binary @@ -288,12 +290,96 @@ vim.start({ }); ``` -## TypeScript support +## Evaluate JavaScript from Vim script + +To integrate JavaScript browser APIs into Vim script, `jsevalfunc()` Vim script function is implemented. + +``` +jsevalfunc({script} [, {args} [, {notify_only}]]) +``` + +The first `{script}` argument is a string of JavaScript code which represents **a function body**. +To return a value from JavaScript to Vim script, `return` statement is necessary. Arguments are accessible +via `arguments` object in the code. + +The second `{args}` optional argument is a list value which represents arguments passed to the JavaScript +function. If it is omitted, the function will be called with no argument. + +The third `{notify_only}` optional argument is a number or boolean value which indicates if returned +value from the JavaScript function call is notified back to Vim script or not. If the value is truthy, +function body and arguments are just notified to main thread and the returned value will never be notified +back to Vim. In the case, `jsevalfunc()` call always returns `0` and doesn't wait the JavaScript function +call has completed. If it is omitted, the default value is `v:false`. This flag is useful when the returned +value is not necessary since returning a value from main thread to Vim in worker may take time to serialize +and convert values. + +The JavaScript code is evaluated in main thread as a JavaScript function. So DOM element and other Web APIs +are available. + +```vim +" Get Location object in JavaScript as dict +let location = jsevalfunc('return window.location') + +" Get element text +let selector = '.description' +let text = jsevalfunc(' + \ const elem = document.querySelector(arguments[0]); + \ if (elem === null) { + \ return null; + \ } + \ return elem.textContent; + \', [selector]) + +" Run script but does not wait for the script being completed +call jsevalfunc('document.title = arguments[0]', ['hello from Vim'], v:true) +``` + +Since values are passed by being encoded in JSON between Vim script, arguments passed to JavaScript function +call and returned value from JavaScript function must be JSON serializable. As a special case, `undefined` +is translated to `v:none` in Vim script. + +```vim +" Error because funcref is not JSON serializable +call jsevalfunc('return "hello"', [function('empty')]) + +" Error because Function object is not JSON serializable +let f = jsevalfunc('return fetch') +``` + +The JavaScript function is called in asynchronous context. So `await` operator is available as follows: -[npm package][npm-pkg] provides complete TypeScript support. Type definitions are put in `vimwasm.d.ts` +```vim +let slug = 'rhysd/vim.wasm' +let repo = jsevalfunc(' + \ const res = await fetch("https://api.github.com/repos/" + arguments[0]); + \ if (!res.ok) { + \ return null; + \ } + \ return JSON.parse(await res.text()); + \ ', [slug]) +echo repo +``` + +`jsevalfunc()` throws an exception when: + +- some argument passed at 2nd argument is not JSON serializable +- JavaScript code causes syntax error +- evaluating JavaScript code throws an exception +- returned value from the function call is not JSON serializable + +## TypeScript Support + +[This npm package][npm-pkg] provides complete TypeScript support. Type definitions are put in `vimwasm.d.ts` and automatically referenced by TypeScript compiler. -## Sources +## Ported Vim + +- Current version: 8.1.1661 +- Current feature: normal and small + +## Development + +### Sources This directory contains a browser runtime for `wasm` GUI frontend written in [TypeScript](https://www.typescriptlang.org/). @@ -309,7 +395,7 @@ be generated. Please host this directory on web server and access to `index.htm Files are formatted by [prettier](https://prettier.io/). -## Testing +### Testing Unit tests are developed at [test](./test) directory. Since `vim.wasm` assumes to be run on browsers, they are run on headless Chromium using [karma](https://karma-runner.github.io/latest/index.html) test runner. @@ -337,11 +423,6 @@ npm run vtest `npm test` is run at [Travis CI][travis-ci] for every remote push. -## Ported Vim - -- Current version: 8.1.1661 -- Current feature: normal and small - ## Notes ### ES Modules in Worker diff --git a/wasm/common.d.ts b/wasm/common.d.ts index d849d8f22..639da7102 100644 --- a/wasm/common.d.ts +++ b/wasm/common.d.ts @@ -65,7 +65,7 @@ interface SharedBufResponseFromWorker { readonly buffer: SharedArrayBuffer; readonly bufId: number; } -type MessageFromWorkerWithoutTS = +type MessageFromWorkerWithoutTimestamp = | { readonly kind: 'draw'; readonly event: DrawEventMessage; @@ -103,9 +103,15 @@ type MessageFromWorkerWithoutTS = readonly kind: 'eval'; readonly path: string; readonly contents: ArrayBuffer; + } + | { + readonly kind: 'evalfunc'; + readonly body: string; + readonly argsJson: string | undefined; + readonly notifyOnly: boolean; }; -type MessageFromWorker = MessageFromWorkerWithoutTS & { timestamp?: number }; +type MessageFromWorker = MessageFromWorkerWithoutTimestamp & { timestamp?: number }; type MessageKindFromWorker = MessageFromWorker['kind']; -type EventStatusFromMain = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +type EventStatusFromMain = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; diff --git a/wasm/karma.conf.js b/wasm/karma.conf.js index 0348cf9cd..aeefde0c3 100644 --- a/wasm/karma.conf.js +++ b/wasm/karma.conf.js @@ -47,6 +47,11 @@ module.exports = function(config) { included: false, served: true, }, + { + pattern: './test/*.vim', + included: false, + served: true, + }, ], client: { mocha: { diff --git a/wasm/runtime.ts b/wasm/runtime.ts index c94ab0249..4e7009048 100644 --- a/wasm/runtime.ts +++ b/wasm/runtime.ts @@ -24,6 +24,7 @@ declare interface VimWasmRuntime { writeClipboard(text: string): void; setTitle(title: string): void; evalJS(file: string): number; + evalJavaScriptFunc(func: string, argsJson: string | undefined, notifyOnly: boolean): CharPtr; } declare const VW: { runtime: VimWasmRuntime; @@ -43,6 +44,7 @@ const VimWasmLibrary = { const STATUS_REQUEST_CMDLINE = 5 as const; const STATUS_REQUEST_SHARED_BUF = 6 as const; const STATUS_NOTIFY_ERROR_OUTPUT = 7 as const; + const STATUS_NOTIFY_EVAL_FUNC_RET = 8 as const; function statusName(s: EventStatusFromMain): string { switch (s) { @@ -62,6 +64,8 @@ const VimWasmLibrary = { return 'REQUEST_SHARED_BUF'; case STATUS_NOTIFY_ERROR_OUTPUT: return 'NOTIFY_ERROR_OUTPUT'; + case STATUS_NOTIFY_EVAL_FUNC_RET: + return 'STATUS_NOTIFY_EVAL_FUNC_RET'; default: return `Unknown command: ${s}`; } @@ -325,6 +329,52 @@ const VimWasmLibrary = { } } + evalJavaScriptFunc(func: string, argsJson: string | undefined, notifyOnly: boolean) { + debug('Will send function and args to main for jsevalfunc():', func, argsJson, notifyOnly); + + this.sendMessage({ + kind: 'evalfunc', + body: func, + argsJson, + notifyOnly, + }); + + if (notifyOnly) { + debug('Evaluating JavaScript does not require result', func); + return 0 as CharPtr; + } + + this.waitUntilStatus(STATUS_NOTIFY_EVAL_FUNC_RET); + const isError = this.buffer[1]; + const bufId = this.buffer[2]; + Atomics.store(this.buffer, 0, STATUS_NOT_SET); + + const buffer = this.sharedBufs.takeBuffer(STATUS_NOTIFY_EVAL_FUNC_RET, bufId); + const arr = new Uint8Array(buffer); + if (isError) { + const decoder = new TextDecoder(); + // Copy Uint8Array since TextDecoder cannot decode SharedArrayBuffer + guiWasmEmsg(decoder.decode(new Uint8Array(arr))); + return 0 as CharPtr; // NULL + } + + const ptr = Module._malloc(arr.byteLength + 1); // `+ 1` for NULL termination + if (ptr === 0) { + return 0 as CharPtr; // NULL + } + Module.HEAPU8.set(arr, ptr as number); + Module.HEAPU8[ptr + arr.byteLength] = 0; // Ensure to set NULL at the end + + debug( + 'Malloced', + arr.byteLength, + 'bytes and wrote evaluated function result', + arr.byteLength, + 'bytes', + ); + return ptr; + } + private main(args: string[]) { this.started = true; debug('Start main function() with args', args); @@ -881,6 +931,14 @@ const VimWasmLibrary = { VW.runtime.writeClipboard(text); }, + // char *vimwasm_eval_js(char *script, char *args_json, int just_notify); + vimwasm_eval_js(scriptPtr: CharPtr, argsJsonPtr: CharPtr, justNotify: number) { + // Note: argsJsonPtr is NULL when no arguments are set + const script = UTF8ToString(scriptPtr); + const argsJson = argsJsonPtr === 0 ? undefined : UTF8ToString(argsJsonPtr); + return VW.runtime.evalJavaScriptFunc(script, argsJson, !!justNotify); + }, + /* eslint-enable @typescript-eslint/camelcase */ }; diff --git a/wasm/test/jsevalfunc_spec.ts b/wasm/test/jsevalfunc_spec.ts new file mode 100644 index 000000000..834ce094e --- /dev/null +++ b/wasm/test/jsevalfunc_spec.ts @@ -0,0 +1,42 @@ +import { VimWasm } from '../vimwasm.js'; +import { DummyDrawer, startVim, stopVim } from './helper.js'; + +describe('jsevalfunc()', function() { + let drawer: DummyDrawer; + let editor: VimWasm; + + before(async function() { + [drawer, editor] = await startVim({ + debug: true, + fetchFiles: { + '/test.vim': '/base/test/test_jsevalfunc.vim', + }, + }); + }); + + after(async function() { + await stopVim(drawer, editor); + }); + + afterEach(function() { + editor.onFileExport = undefined; + }); + + it('passes unit tests written in Vim script', async function() { + // Test results are sent from Vim script side with :export + const exported = new Promise<[string, ArrayBuffer]>(resolve => { + editor.onFileExport = (fpath, contents) => { + resolve([fpath, contents]); + }; + }); + + // Trigger to run tests written in Vim script + await editor.cmdline('source /test.vim'); + + // Wait for tests have completed + const [file, contents] = await exported; + assert.strictEqual(file, '/test_jsevalfunc_result.txt'); + const text = new TextDecoder().decode(contents); + assert.isEmpty(text); + }); +}); diff --git a/wasm/test/test_jsevalfunc.vim b/wasm/test/test_jsevalfunc.vim new file mode 100644 index 000000000..676260441 --- /dev/null +++ b/wasm/test/test_jsevalfunc.vim @@ -0,0 +1,146 @@ +function! s:test_serialize_return() abort + let i = jsevalfunc('return 42') + call assert_equal(42, i, 'integer') + let f = jsevalfunc('return 3.14') + call assert_equal(3.14, f, 'float') + let s = jsevalfunc('return "test"') + call assert_equal('test', s, 'string') + let b = jsevalfunc('return true') + call assert_equal(v:true, b, 'bool') + let x = jsevalfunc('return null') + call assert_equal(v:null, x, 'null') + let y = jsevalfunc('return undefined') + call assert_equal(v:none, y, 'none') + let a = jsevalfunc('return [1, 2, 3]') + call assert_equal([1, 2, 3], a, 'array') + let d = jsevalfunc('return {a: 42}') + call assert_equal({'a': 42}, d, 'dict') + let z = jsevalfunc('return {a: [1, "test", []], b: null, c: {}}') + call assert_equal({'a': [1, "test", []], 'b': v:null, 'c': {}}, z, 'all') +endfunction + +function! s:test_serialize_argument() abort + let i = jsevalfunc('return arguments[0]', [42]) + call assert_equal(42, i, 'integer') + let f = jsevalfunc('return arguments[0]', [3.14]) + call assert_equal(3.14, f, 'float') + let s = jsevalfunc('return arguments[0]', ['test']) + call assert_equal('test', s, 'string') + let b = jsevalfunc('return arguments[0]', [v:true]) + call assert_equal(v:true, b, 'bool') + let x = jsevalfunc('return arguments[0]', [v:null]) + call assert_equal(v:null, x, 'null') + let a = jsevalfunc('return arguments[0]', [[1, 3.14]]) + call assert_equal([1, 3.14], a, 'array') + let d = jsevalfunc('return arguments[0]', [{'a': 42}]) + call assert_equal({'a': 42}, d, 'dict') +endfunction + +function! s:test_arguments() abort + let a = jsevalfunc('return Array.from(arguments)', []) + call assert_equal([], a) + let a = jsevalfunc('return Array.from(arguments)', [1]) + call assert_equal([1], a) + let a = jsevalfunc('return Array.from(arguments)', [1, 2]) + call assert_equal([1, 2], a) +endfunction + +function! s:test_function_body() abort + let n = jsevalfunc('const x = arguments[0] + arguments[1]; return x * 2', [3, 4]) + call assert_equal(14, n, 'multiple statements') + let n = jsevalfunc('return await Promise.resolve(42)') + call assert_equal(42, n, 'await') + let s = jsevalfunc('return window.location.href') + call assert_equal(v:t_string, type(s), 'window access') + call assert_false(empty(s), 'window access') + let s = jsevalfunc('return document.body.tagName') + call assert_equal(s, 'BODY', 'DOM access') + let n = jsevalfunc(' + \ const x = arguments[0]; + \ const p1 = new Promise(r => setTimeout(() => r(x), 1)); + \ const i = await Promise.resolve(arguments[1]); + \ const j = await p1; + \ return i + j; + \', [12, 13]) + call assert_equal(25, n, 'multiple await operators') + let x = jsevalfunc('') + call assert_equal(v:none, x, 'without return') + let x = jsevalfunc('return') + call assert_equal(v:none, x, 'only return') + + " Test evaluate the body with blocking + let start = reltime() + call jsevalfunc(' + \ const p = new Promise(r => setTimeout(() => r(42), 500)); + \ return await p; + \') + let duration = reltimefloat(reltime(start)) + call assert_true(duration > 0.5, 'duration was ' . string(duration)) +endfunction + +function! s:test_notify_only() abort + " Enable + let i = jsevalfunc('return 42', [], 1) + call assert_equal(0, i, 'integer') + let i = jsevalfunc('return 42', [], v:true) + call assert_equal(0, i, 'integer') + + " Explicitly disable + let i = jsevalfunc('return 42', [], 0) + call assert_equal(42, i, 'integer') + let i = jsevalfunc('return 42', [], v:false) + call assert_equal(42, i, 'integer') + + " Test it does not wait for respoonse + let start = reltime() + let i = jsevalfunc(' + \ const p = new Promise(r => setTimeout(() => r(42), 5000)); + \ return await p; + \', [], 1) + let duration = reltimefloat(reltime(start)) + call assert_equal(i, 0) + call assert_true(duration < 5.0, 'duration was ' . string(duration)) +endfunction + +function! s:test_arguments_error() abort + call assert_fails('call jsevalfunc()', 'E119', 'not enough arguments') + call assert_fails('call jsevalfunc(function("empty"))', 'E729', 'first argument must be string') + call assert_fails('call jsevalfunc("", {})', 'E714', 'second argument must be list') + call assert_fails('call jsevalfunc("", [function("empty")])', 'E474', 'argument is not JSON serializable') + call assert_fails('call jsevalfunc("", [], {})', 'E728', 'notify_only flag must be number compatible value') +endfunction + +function! s:test_eval_error() abort + let source = 'throw new Error("This is test error")' + call assert_fails('call jsevalfunc(source)', 'E9999: Exception was thrown while evaluating function', 'exception is thrown') + let source = 'await Promise.reject(new Error("This is test error"))' + call assert_fails('call jsevalfunc(source)', 'E9999: Exception was thrown while evaluating function', 'exception is thrown') + call assert_fails('call jsevalfunc("[")', 'E9999: Could not construct function', 'JavaScript syntax error') + let source = ' + \ const d = {}; + \ d.x = d; /* circular dependency */ + \ return d; + \' + call assert_fails('call jsevalfunc(source)', 'E9999: Could not serialize return value', 'return value is not serializable') +endfunction + +" TODO: Test jsevalfunc() raises an error even if notify_only is set to truthy +" value + +let v:errors = [] +try + call s:test_serialize_return() + call s:test_serialize_argument() + call s:test_arguments() + call s:test_function_body() + call s:test_notify_only() + call s:test_arguments_error() + call s:test_eval_error() +catch + let v:errors += ['Exception was thrown while running tests: ' . v:exception . ' at ' . v:throwpoint] +endtry + +call writefile(v:errors, '/test_jsevalfunc_result.txt') +echom 'RESULT:' . string(v:errors) +" Send results to JavaScript side +export /test_jsevalfunc_result.txt diff --git a/wasm/tsconfig.main.json b/wasm/tsconfig.main.json index cbc43ec95..0987b94cd 100644 --- a/wasm/tsconfig.main.json +++ b/wasm/tsconfig.main.json @@ -15,6 +15,7 @@ "test/filesystem_spec.ts", "test/cmd_args_spec.ts", "test/js_eval_spec.ts", - "test/title_spec.ts" + "test/title_spec.ts", + "test/jsevalfunc_spec.ts" ] } diff --git a/wasm/vimwasm.ts b/wasm/vimwasm.ts index cab5653d2..319d2eb71 100644 --- a/wasm/vimwasm.ts +++ b/wasm/vimwasm.ts @@ -34,6 +34,8 @@ export interface KeyModifiers { export const VIM_VERSION = '8.1.1661'; +const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; + function noop() { /* do nothing */ } @@ -46,6 +48,7 @@ const STATUS_NOTIFY_CLIPBOARD_WRITE_COMPLETE = 4 as const; const STATUS_REQUEST_CMDLINE = 5 as const; const STATUS_REQUEST_SHARED_BUF = 6 as const; const STATUS_NOTIFY_ERROR_OUTPUT = 7 as const; +const STATUS_NOTIFY_EVAL_FUNC_RET = 8 as const; export function checkBrowserCompatibility(): string | undefined { function notSupported(feat: string): string { @@ -195,6 +198,35 @@ export class VimWorker { debug('Sent error message output:', message); } + async notifyEvalFuncRet(ret: string) { + const encoded = new TextEncoder().encode(ret); + const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength); + new Uint8Array(buffer).set(encoded); + + this.sharedBuffer[1] = +false; // isError + this.sharedBuffer[2] = bufId; + this.awakeWorkerThread(STATUS_NOTIFY_EVAL_FUNC_RET); + debug('Sent return value of evaluated JS function:', ret); + } + + async notifyEvalFuncError(msg: string, err: Error, dontReply: boolean) { + const errmsg = `${msg} for jsevalfunc(): ${err.message}: ${err.stack}`; + if (dontReply) { + debug('Will send error output from jsevalfunc() though the invocation was notify-only:', errmsg); + return this.notifyErrorOutput(errmsg); + } + + const encoded = new TextEncoder().encode('E9999: ' + errmsg); + const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength); + new Uint8Array(buffer).set(encoded); + + this.sharedBuffer[1] = +true; // isError + this.sharedBuffer[2] = bufId; + this.awakeWorkerThread(STATUS_NOTIFY_EVAL_FUNC_RET); + + debug('Sent exception thrown by evaluated JS function:', msg, err); + } + private async waitForOneshotMessage(kind: MessageKindFromWorker) { return new Promise(resolve => { this.onOneshotMessage.set(kind, resolve); @@ -845,6 +877,42 @@ export class VimWasm { } } + private async evalFunc(body: string, args: any[], notifyOnly: boolean) { + debug('Evaluating JavaScript function:', body, args); + + let f; + try { + f = new AsyncFunction(body); + } catch (err) { + return this.worker.notifyEvalFuncError('Could not construct function', err, notifyOnly); + } + + let ret; + try { + ret = await f(...args); + } catch (err) { + return this.worker.notifyEvalFuncError('Exception was thrown while evaluating function', err, notifyOnly); + } + + if (notifyOnly) { + debug('Evaluated JavaScript result was discarded since the message was notify-only:', ret, body); + return Promise.resolve(); + } + + let retJson; + try { + retJson = JSON.stringify(ret); + } catch (err) { + return this.worker.notifyEvalFuncError( + 'Could not serialize return value as JSON from function', + err, + false, + ); + } + + return this.worker.notifyEvalFuncRet(retJson); + } + private onMessage(msg: MessageFromWorker) { if (this.perf && msg.timestamp !== undefined) { // performance.now() is not available because time origin is different between Window and Worker @@ -863,6 +931,11 @@ export class VimWasm { this.screen.draw(msg.event); debug('draw event', msg.event); break; + case 'evalfunc': { + const args = msg.argsJson === undefined ? [] : JSON.parse(msg.argsJson); + this.evalFunc(msg.body, args, msg.notifyOnly).catch(this.handleError); + break; + } case 'title': if (this.onTitleUpdate) { debug('title was updated:', msg.title);