From 553a3a3a10de19b94677010352ec11f62fcd4590 Mon Sep 17 00:00:00 2001 From: rhysd Date: Mon, 29 Jul 2019 23:06:43 +0900 Subject: [PATCH 1/9] first implementation of jseval() Vim script function --- src/evalfunc.c | 96 ++++++++++++++++++++++++++++++++++++++++++++++ src/wasm_runtime.h | 1 + wasm/common.d.ts | 11 ++++-- wasm/runtime.ts | 49 +++++++++++++++++++++++ wasm/vimwasm.ts | 33 ++++++++++++++++ 5 files changed, 187 insertions(+), 3 deletions(-) diff --git a/src/evalfunc.c b/src/evalfunc.c index b6998993f..0f61ff88b 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_jseval(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 + {"jseval", 1, 2, f_jseval}, +#endif {"json_decode", 1, 1, f_json_decode}, {"json_encode", 1, 1, f_json_encode}, {"keys", 1, 1, f_keys}, @@ -8958,6 +8964,96 @@ f_pyxeval(typval_T *argvars, typval_T *rettv) } #endif +#ifdef FEAT_GUI_WASM +/* + * "jseval()" function + */ +static void +f_jseval(typval_T *argvars, typval_T *rettv) +{ + char_u *script; + char **json_args = NULL; + int args_len = 0; + char *ret_json = NULL; + + 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)); + vim_free(script); + return; + } + + args_list = argvars[1].vval.v_list; + args_len = list_len(args_list); + + if (args_len > 0) { + int i = 0; + + json_args = (char **) alloc(sizeof(char *) * args_len); + + for (listitem_T *li = args_list->lv_first; li != NULL; li = li->li_next) { + char_u *encoded = json_encode(&li->li_tv, 0); + + // Failed to encode argument as JSON + if (*encoded == NUL) { + vim_free(encoded); + vim_free(json_args); + return; + } + + json_args[i] = (char *)encoded; + i++; + } + } + } + + ret_json = vimwasm_eval_js((char *)script, json_args, args_len); + if (ret_json == NULL) { + // 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); + } + + // Clean up encoded arguments + + // vim_free is not available since the pointer was allocated by malloc() + // directly in JavaScript runtime. + free(ret_json); + + if (json_args != NULL) { + for (int i = 0; i < args_len; ++i) { + vim_free(json_args[i]); + } + vim_free(json_args); + } + + // Note: Do not free `script` since it was not newly allocated +} +#endif + /* * "range()" function */ diff --git a/src/wasm_runtime.h b/src/wasm_runtime.h index 329fe410d..924bbb0e8 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, int args_len); #endif /* FEAT_GUI_WASM */ diff --git a/wasm/common.d.ts b/wasm/common.d.ts index d849d8f22..67802afcd 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,14 @@ type MessageFromWorkerWithoutTS = readonly kind: 'eval'; readonly path: string; readonly contents: ArrayBuffer; + } + | { + readonly kind: 'evalfunc'; + readonly body: string; + readonly args: string[]; }; -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/runtime.ts b/wasm/runtime.ts index c94ab0249..129ef5ec1 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, args: string[]): 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,37 @@ const VimWasmLibrary = { } } + evalJavaScriptFunc(func: string, args: string[]) { + this.sendMessage({ + kind: 'evalfunc', + body: func, + args, + }); + + this.waitUntilStatus(STATUS_NOTIFY_EVAL_FUNC_RET); + const bufId = this.buffer[1]; + Atomics.store(this.buffer, 0, STATUS_NOT_SET); + + const buffer = this.sharedBufs.takeBuffer(STATUS_NOTIFY_EVAL_FUNC_RET, bufId); + const arr = new Uint8Array(buffer); + arr[arr.byteLength - 1] = 0; // Ensure to set NULL at the end + + const ptr = Module._malloc(arr.byteLength); + if (ptr === 0) { + return 0 as CharPtr; // NULL + } + Module.HEAPU8.set(arr, ptr as number); + + 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 +916,20 @@ const VimWasmLibrary = { VW.runtime.writeClipboard(text); }, + // char *vimwasm_eval_js(char *script, char **args, int args_len); + vimwasm_eval_js(scriptPtr: CharPtr, argsPtr: number, argsLen: number) { + const script = UTF8ToString(scriptPtr); + const args: string[] = []; + if (argsLen > 0) { + const argsBuf = new Uint32Array(Module.HEAPU8.buffer, argsPtr, argsLen); + argsBuf.forEach(argPtr => { + args.push(UTF8ToString(argPtr as CharPtr)); + }); + } + debug('vimwasm_eval_js', script, args); + return VW.runtime.evalJavaScriptFunc(script, args); + }, + /* eslint-enable @typescript-eslint/camelcase */ }; diff --git a/wasm/vimwasm.ts b/wasm/vimwasm.ts index cab5653d2..9ae1fdb5b 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,16 @@ 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 + 1); + new Uint8Array(buffer).set(encoded); + + this.sharedBuffer[1] = bufId; + this.awakeWorkerThread(STATUS_NOTIFY_EVAL_FUNC_RET); + debug('Sent return value of evaluated function:', ret); + } + private async waitForOneshotMessage(kind: MessageKindFromWorker) { return new Promise(resolve => { this.onOneshotMessage.set(kind, resolve); @@ -845,6 +858,23 @@ export class VimWasm { } } + private async evalFunc(body: string, jsonArgs: string[]) { + const args = jsonArgs.map(j => JSON.parse(j)); + debug('Evaluating JavaScript function:', body, args); + + try { + const f = new AsyncFunction(body); + const ret = await f(args); + const retJson = JSON.stringify(ret); + return this.worker.notifyEvalFuncRet(retJson); + } catch (err) { + // TODO: Throw an exception on Vim script instead of showing an error + debug('Failed to create function', body, 'with error:', err); + const msg = `${err.message}\n\n${err.stack}`; + return this.worker.notifyEvalFuncRet(JSON.stringify(msg)); + } + } + 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 +893,9 @@ export class VimWasm { this.screen.draw(msg.event); debug('draw event', msg.event); break; + case 'evalfunc': + this.evalFunc(msg.body, msg.args).catch(this.handleError); + break; case 'title': if (this.onTitleUpdate) { debug('title was updated:', msg.title); From 6fc79e470bd91557b0d23836227d4ea07b8d4681 Mon Sep 17 00:00:00 2001 From: rhysd Date: Mon, 29 Jul 2019 23:07:03 +0900 Subject: [PATCH 2/9] enable mocha eslint rules only for tests --- wasm/.eslintrc.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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', }, }, { From 8a38dcfd63ba229901f67d109fc05086839322d4 Mon Sep 17 00:00:00 2001 From: rhysd Date: Tue, 30 Jul 2019 09:24:59 +0900 Subject: [PATCH 3/9] throw an exception when exception is thrown in JavaScript context --- wasm/runtime.ts | 13 ++++++++++--- wasm/vimwasm.ts | 47 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/wasm/runtime.ts b/wasm/runtime.ts index 129ef5ec1..275a2e88d 100644 --- a/wasm/runtime.ts +++ b/wasm/runtime.ts @@ -337,18 +337,25 @@ const VimWasmLibrary = { }); this.waitUntilStatus(STATUS_NOTIFY_EVAL_FUNC_RET); - const bufId = this.buffer[1]; + 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); - arr[arr.byteLength - 1] = 0; // Ensure to set NULL at the end + 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); + 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', diff --git a/wasm/vimwasm.ts b/wasm/vimwasm.ts index 9ae1fdb5b..65c608a6c 100644 --- a/wasm/vimwasm.ts +++ b/wasm/vimwasm.ts @@ -200,12 +200,26 @@ export class VimWorker { async notifyEvalFuncRet(ret: string) { const encoded = new TextEncoder().encode(ret); - const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength + 1); + const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength); new Uint8Array(buffer).set(encoded); - this.sharedBuffer[1] = bufId; + 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) { + const errmsg = `E9999: ${msg} for jseval(): ${err.message}: ${err.stack}`; + const encoded = new TextEncoder().encode(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 return value of evaluated function:', ret); + + debug('Sent exception thrown by evaluated JS function:', msg, err); } private async waitForOneshotMessage(kind: MessageKindFromWorker) { @@ -862,17 +876,28 @@ export class VimWasm { const args = jsonArgs.map(j => JSON.parse(j)); debug('Evaluating JavaScript function:', body, args); + let f; + try { + f = new AsyncFunction(body); + } catch (err) { + return this.worker.notifyEvalFuncError('Could not create function', err); + } + + let ret; try { - const f = new AsyncFunction(body); - const ret = await f(args); - const retJson = JSON.stringify(ret); - return this.worker.notifyEvalFuncRet(retJson); + ret = await f(args); } catch (err) { - // TODO: Throw an exception on Vim script instead of showing an error - debug('Failed to create function', body, 'with error:', err); - const msg = `${err.message}\n\n${err.stack}`; - return this.worker.notifyEvalFuncRet(JSON.stringify(msg)); + return this.worker.notifyEvalFuncError('Exception was thrown while evaluating function', err); } + + let retJson; + try { + retJson = JSON.stringify(ret); + } catch (err) { + return this.worker.notifyEvalFuncError('Could not serialize return value as JSON from function', err); + } + + return this.worker.notifyEvalFuncRet(retJson); } private onMessage(msg: MessageFromWorker) { From 8c4b881ebd8ca0e36d31ffbfea7f1c5d959be49a Mon Sep 17 00:00:00 2001 From: rhysd Date: Tue, 30 Jul 2019 11:40:33 +0900 Subject: [PATCH 4/9] rename jseval() to jsevalfunc() and describe it in README --- src/evalfunc.c | 8 ++--- wasm/README.md | 88 ++++++++++++++++++++++++++++++++++++++++++++----- wasm/vimwasm.ts | 2 +- 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/src/evalfunc.c b/src/evalfunc.c index 0f61ff88b..55a7dd573 100644 --- a/src/evalfunc.c +++ b/src/evalfunc.c @@ -228,7 +228,7 @@ 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_jseval(typval_T *argvars, typval_T *rettv); +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); @@ -723,7 +723,7 @@ static struct fst {"js_decode", 1, 1, f_js_decode}, {"js_encode", 1, 1, f_js_encode}, #ifdef FEAT_GUI_WASM - {"jseval", 1, 2, f_jseval}, + {"jsevalfunc", 1, 2, f_jsevalfunc}, #endif {"json_decode", 1, 1, f_json_decode}, {"json_encode", 1, 1, f_json_encode}, @@ -8966,10 +8966,10 @@ f_pyxeval(typval_T *argvars, typval_T *rettv) #ifdef FEAT_GUI_WASM /* - * "jseval()" function + * "jsevalfunc()" function */ static void -f_jseval(typval_T *argvars, typval_T *rettv) +f_jsevalfunc(typval_T *argvars, typval_T *rettv) { char_u *script; char **json_args = NULL; diff --git a/wasm/README.md b/wasm/README.md index 0b44a51f9..d93f56c37 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,85 @@ 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}]) +``` + +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}` argument is a list which represents arguments passed to the JavaScript function. +If it is omitted, the function will be called with no argument. + +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]) +``` + +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 await res.text(); + \ ', [slug]) +let data = json_decode(repo) +``` + +`jsevalfunc()` throws an exception when: + +- Some argument is not 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 +384,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 +412,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/vimwasm.ts b/wasm/vimwasm.ts index 65c608a6c..ff05098df 100644 --- a/wasm/vimwasm.ts +++ b/wasm/vimwasm.ts @@ -210,7 +210,7 @@ export class VimWorker { } async notifyEvalFuncError(msg: string, err: Error) { - const errmsg = `E9999: ${msg} for jseval(): ${err.message}: ${err.stack}`; + const errmsg = `E9999: ${msg} for jsevalfunc(): ${err.message}: ${err.stack}`; const encoded = new TextEncoder().encode(errmsg); const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength); new Uint8Array(buffer).set(encoded); From 2e0282be211ac3120e6aae42ce67863e26440c57 Mon Sep 17 00:00:00 2001 From: rhysd Date: Tue, 30 Jul 2019 18:40:10 +0900 Subject: [PATCH 5/9] add notify_only flag to jsevalfunc() --- src/evalfunc.c | 24 ++++++++++++++++++++---- src/wasm_runtime.h | 2 +- wasm/README.md | 17 ++++++++++++++--- wasm/common.d.ts | 1 + wasm/runtime.ts | 16 +++++++++++----- wasm/vimwasm.ts | 30 ++++++++++++++++++++++-------- 6 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/evalfunc.c b/src/evalfunc.c index 55a7dd573..e18668b1d 100644 --- a/src/evalfunc.c +++ b/src/evalfunc.c @@ -723,7 +723,7 @@ static struct fst {"js_decode", 1, 1, f_js_decode}, {"js_encode", 1, 1, f_js_encode}, #ifdef FEAT_GUI_WASM - {"jsevalfunc", 1, 2, f_jsevalfunc}, + {"jsevalfunc", 1, 3, f_jsevalfunc}, #endif {"json_decode", 1, 1, f_json_decode}, {"json_encode", 1, 1, f_json_encode}, @@ -6411,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 @@ -8975,6 +8978,7 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) char **json_args = NULL; int args_len = 0; char *ret_json = NULL; + int just_notify = 0; if (check_restricted() || check_secure()) { return; @@ -8985,13 +8989,21 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) return; } + if (argvars[2].v_type != VAR_UNKNOWN) { + int error = FALSE; + + just_notify = tv_get_number_chk(&argvars[2], &error); + if (error) { + 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)); - vim_free(script); return; } @@ -9019,9 +9031,13 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) } } - ret_json = vimwasm_eval_js((char *)script, json_args, args_len); + ret_json = vimwasm_eval_js((char *)script, json_args, args_len, just_notify); if (ret_json == NULL) { - // Error output should already be done by calling emsg() from JavaScript + // 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; } diff --git a/src/wasm_runtime.h b/src/wasm_runtime.h index 924bbb0e8..6f0197a0d 100644 --- a/src/wasm_runtime.h +++ b/src/wasm_runtime.h @@ -45,7 +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, int args_len); +char *vimwasm_eval_js(char *script, char **args, int args_len, int just_notify); #endif /* FEAT_GUI_WASM */ diff --git a/wasm/README.md b/wasm/README.md index d93f56c37..e5f75c26e 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -295,15 +295,23 @@ vim.start({ To integrate JavaScript browser APIs into Vim script, `jsevalfunc()` Vim script function is implemented. ``` -jsevalfunc({script} [, {args}]) +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}` argument is a list which represents arguments passed to the JavaScript function. -If it is omitted, the function will be called with no argument. +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. @@ -321,6 +329,9 @@ let text = jsevalfunc(' \ } \ 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 diff --git a/wasm/common.d.ts b/wasm/common.d.ts index 67802afcd..9e5b95450 100644 --- a/wasm/common.d.ts +++ b/wasm/common.d.ts @@ -108,6 +108,7 @@ type MessageFromWorkerWithoutTimestamp = readonly kind: 'evalfunc'; readonly body: string; readonly args: string[]; + readonly notifyOnly: boolean; }; type MessageFromWorker = MessageFromWorkerWithoutTimestamp & { timestamp?: number }; diff --git a/wasm/runtime.ts b/wasm/runtime.ts index 275a2e88d..300bbb492 100644 --- a/wasm/runtime.ts +++ b/wasm/runtime.ts @@ -24,7 +24,7 @@ declare interface VimWasmRuntime { writeClipboard(text: string): void; setTitle(title: string): void; evalJS(file: string): number; - evalJavaScriptFunc(func: string, args: string[]): CharPtr; + evalJavaScriptFunc(func: string, args: string[], notifyOnly: boolean): CharPtr; } declare const VW: { runtime: VimWasmRuntime; @@ -329,13 +329,19 @@ const VimWasmLibrary = { } } - evalJavaScriptFunc(func: string, args: string[]) { + evalJavaScriptFunc(func: string, args: string[], notifyOnly: boolean) { this.sendMessage({ kind: 'evalfunc', body: func, args, + 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]; @@ -923,8 +929,8 @@ const VimWasmLibrary = { VW.runtime.writeClipboard(text); }, - // char *vimwasm_eval_js(char *script, char **args, int args_len); - vimwasm_eval_js(scriptPtr: CharPtr, argsPtr: number, argsLen: number) { + // char *vimwasm_eval_js(char *script, char **args, int args_len, int just_notify); + vimwasm_eval_js(scriptPtr: CharPtr, argsPtr: number, argsLen: number, justNotify: number) { const script = UTF8ToString(scriptPtr); const args: string[] = []; if (argsLen > 0) { @@ -934,7 +940,7 @@ const VimWasmLibrary = { }); } debug('vimwasm_eval_js', script, args); - return VW.runtime.evalJavaScriptFunc(script, args); + return VW.runtime.evalJavaScriptFunc(script, args, !!justNotify); }, /* eslint-enable @typescript-eslint/camelcase */ diff --git a/wasm/vimwasm.ts b/wasm/vimwasm.ts index ff05098df..10c5d01b3 100644 --- a/wasm/vimwasm.ts +++ b/wasm/vimwasm.ts @@ -209,9 +209,14 @@ export class VimWorker { debug('Sent return value of evaluated JS function:', ret); } - async notifyEvalFuncError(msg: string, err: Error) { - const errmsg = `E9999: ${msg} for jsevalfunc(): ${err.message}: ${err.stack}`; - const encoded = new TextEncoder().encode(errmsg); + 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); @@ -872,7 +877,7 @@ export class VimWasm { } } - private async evalFunc(body: string, jsonArgs: string[]) { + private async evalFunc(body: string, jsonArgs: string[], notifyOnly: boolean) { const args = jsonArgs.map(j => JSON.parse(j)); debug('Evaluating JavaScript function:', body, args); @@ -880,21 +885,30 @@ export class VimWasm { try { f = new AsyncFunction(body); } catch (err) { - return this.worker.notifyEvalFuncError('Could not create function', err); + return this.worker.notifyEvalFuncError('Could not create function', err, notifyOnly); } let ret; try { ret = await f(args); } catch (err) { - return this.worker.notifyEvalFuncError('Exception was thrown while evaluating function', 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); + return this.worker.notifyEvalFuncError( + 'Could not serialize return value as JSON from function', + err, + false, + ); } return this.worker.notifyEvalFuncRet(retJson); @@ -919,7 +933,7 @@ export class VimWasm { debug('draw event', msg.event); break; case 'evalfunc': - this.evalFunc(msg.body, msg.args).catch(this.handleError); + this.evalFunc(msg.body, msg.args, msg.notifyOnly).catch(this.handleError); break; case 'title': if (this.onTitleUpdate) { From 8779760dc11d7364d627fd3db6b5b91a80a0bd18 Mon Sep 17 00:00:00 2001 From: rhysd Date: Wed, 31 Jul 2019 00:34:56 +0900 Subject: [PATCH 6/9] fix checking optional argument in f_jsevalfunc() --- src/evalfunc.c | 20 +++++++++++--------- src/gui_wasm.c | 6 ------ src/vim.h | 6 ++++++ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/evalfunc.c b/src/evalfunc.c index e18668b1d..90cdaca49 100644 --- a/src/evalfunc.c +++ b/src/evalfunc.c @@ -8989,15 +8989,6 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) return; } - if (argvars[2].v_type != VAR_UNKNOWN) { - int error = FALSE; - - just_notify = tv_get_number_chk(&argvars[2], &error); - if (error) { - return; - } - } - if (argvars[1].v_type != VAR_UNKNOWN) { list_T *args_list = NULL; listitem_T *item; @@ -9007,6 +8998,15 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) return; } + if (argvars[2].v_type != VAR_UNKNOWN) { + int error = FALSE; + + just_notify = tv_get_number_chk(&argvars[2], &error); + if (error) { + return; + } + } + args_list = argvars[1].vval.v_list; args_len = list_len(args_list); @@ -9031,7 +9031,9 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) } } + GUI_WASM_DBG("jsevalfunc: Will evaluate script '%s' with %d args (notify_only=%d)", script, args_len, just_notify); ret_json = vimwasm_eval_js((char *)script, json_args, args_len, just_notify); + 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 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 */ /* From a24905a32b1a23285bf8800f5d7a59bb0837c626 Mon Sep 17 00:00:00 2001 From: rhysd Date: Wed, 31 Jul 2019 13:25:04 +0900 Subject: [PATCH 7/9] add tests for jsevalfunc() --- wasm/karma.conf.js | 5 ++ wasm/test/jsevalfunc_spec.ts | 41 ++++++++++++ wasm/test/test_jsevalfunc.vim | 122 ++++++++++++++++++++++++++++++++++ wasm/tsconfig.main.json | 3 +- wasm/vimwasm.ts | 4 +- 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 wasm/test/jsevalfunc_spec.ts create mode 100644 wasm/test/test_jsevalfunc.vim 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/test/jsevalfunc_spec.ts b/wasm/test/jsevalfunc_spec.ts new file mode 100644 index 000000000..3d3afd43e --- /dev/null +++ b/wasm/test/jsevalfunc_spec.ts @@ -0,0 +1,41 @@ +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() { + await editor.cmdline('source /test.vim'); + + const exported = new Promise<[string, ArrayBuffer]>(resolve => { + editor.onFileExport = (fpath, contents) => { + resolve([fpath, contents]); + }; + }); + + await editor.cmdline('export /test_jsevalfunc_result.txt'); + + 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..dffafc912 --- /dev/null +++ b/wasm/test/test_jsevalfunc.vim @@ -0,0 +1,122 @@ +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': 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 asssert_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 asssert_equal(25, n, 'multiple await operators') + let x = jsevalfunc('') + call asssert_equal(v:none, x, 'without return') + let x = jsevalfunc('return') + call asssert_equal(v:none, x, 'only return') +endfunction + +function! s:test_notify_only() abort + let i = jsevalfunc('return 42', [], 1) + call assert_equal(0, i, 'integer') + let f = jsevalfunc('return 3.14', [], 1) + call assert_equal(0, f, 'float') + let s = jsevalfunc('return "test"', [], 1) + call assert_equal(0, s, 'string') + let b = jsevalfunc('return true', [], 1) + call assert_equal(0, b, 'bool') + let x = jsevalfunc('return null', [], 1) + call assert_equal(0, x, 'null') + let y = jsevalfunc('return undefined', [], 1) + call assert_equal(0, y, 'none') + let a = jsevalfunc('return [1, 2, 3]', [], 1) + call assert_equal(0, a, 'array') + let d = jsevalfunc('return {a: 42}', [], 1) + call assert_equal(0, d, 'dict') + let z = jsevalfunc('return {a: [1, "test", []], b: null, c: {}}', [], 1) + call assert_equal(0, z, 'all') +endfunction + +function! s:test_arguments_error() abort + call assert_fails('call jsevalfunc()', 'E119', 'not enough arguments') + call assert_fails('call jsevalfunc("", {})', 'E714', 'arguments 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 + call assert_fails('call jsevalfunc("throw new Error(''This is test error'')")', '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 + +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() + +call writefile(v:errors, '/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 10c5d01b3..d5827ad7d 100644 --- a/wasm/vimwasm.ts +++ b/wasm/vimwasm.ts @@ -885,12 +885,12 @@ export class VimWasm { try { f = new AsyncFunction(body); } catch (err) { - return this.worker.notifyEvalFuncError('Could not create function', err, notifyOnly); + return this.worker.notifyEvalFuncError('Could not construct function', err, notifyOnly); } let ret; try { - ret = await f(args); + ret = await f(...args); } catch (err) { return this.worker.notifyEvalFuncError('Exception was thrown while evaluating function', err, notifyOnly); } From 72214b2f492dedddd46962ad74af53704dca76d1 Mon Sep 17 00:00:00 2001 From: rhysd Date: Wed, 31 Jul 2019 19:20:40 +0900 Subject: [PATCH 8/9] encode list of arguments as JSON for jsevalfunc() instead of encoding each argument as JSON. --- src/evalfunc.c | 48 +++++++++++++--------------------------------- src/wasm_runtime.h | 2 +- wasm/common.d.ts | 2 +- wasm/runtime.ts | 24 ++++++++++------------- wasm/vimwasm.ts | 9 +++++---- 5 files changed, 30 insertions(+), 55 deletions(-) diff --git a/src/evalfunc.c b/src/evalfunc.c index 90cdaca49..807d4d9ef 100644 --- a/src/evalfunc.c +++ b/src/evalfunc.c @@ -8975,8 +8975,7 @@ static void f_jsevalfunc(typval_T *argvars, typval_T *rettv) { char_u *script; - char **json_args = NULL; - int args_len = 0; + char_u *args_json = NULL; char *ret_json = NULL; int just_notify = 0; @@ -9007,32 +9006,20 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) } } - args_list = argvars[1].vval.v_list; - args_len = list_len(args_list); - - if (args_len > 0) { - int i = 0; - - json_args = (char **) alloc(sizeof(char *) * args_len); - - for (listitem_T *li = args_list->lv_first; li != NULL; li = li->li_next) { - char_u *encoded = json_encode(&li->li_tv, 0); - - // Failed to encode argument as JSON - if (*encoded == NUL) { - vim_free(encoded); - vim_free(json_args); - return; - } - - json_args[i] = (char *)encoded; - i++; - } + 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 %d args (notify_only=%d)", script, args_len, just_notify); - ret_json = vimwasm_eval_js((char *)script, json_args, args_len, just_notify); + 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. @@ -9055,19 +9042,10 @@ f_jsevalfunc(typval_T *argvars, typval_T *rettv) json_decode_all(&reader, rettv, 0); } - // Clean up encoded arguments - - // vim_free is not available since the pointer was allocated by malloc() + // Note: vim_free is not available since the pointer was allocated by malloc() // directly in JavaScript runtime. free(ret_json); - if (json_args != NULL) { - for (int i = 0; i < args_len; ++i) { - vim_free(json_args[i]); - } - vim_free(json_args); - } - // Note: Do not free `script` since it was not newly allocated } #endif diff --git a/src/wasm_runtime.h b/src/wasm_runtime.h index 6f0197a0d..15a6cda3b 100644 --- a/src/wasm_runtime.h +++ b/src/wasm_runtime.h @@ -45,7 +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, int args_len, int just_notify); +char *vimwasm_eval_js(char *script, char *args_json, int just_notify); #endif /* FEAT_GUI_WASM */ diff --git a/wasm/common.d.ts b/wasm/common.d.ts index 9e5b95450..639da7102 100644 --- a/wasm/common.d.ts +++ b/wasm/common.d.ts @@ -107,7 +107,7 @@ type MessageFromWorkerWithoutTimestamp = | { readonly kind: 'evalfunc'; readonly body: string; - readonly args: string[]; + readonly argsJson: string | undefined; readonly notifyOnly: boolean; }; diff --git a/wasm/runtime.ts b/wasm/runtime.ts index 300bbb492..4e7009048 100644 --- a/wasm/runtime.ts +++ b/wasm/runtime.ts @@ -24,7 +24,7 @@ declare interface VimWasmRuntime { writeClipboard(text: string): void; setTitle(title: string): void; evalJS(file: string): number; - evalJavaScriptFunc(func: string, args: string[], notifyOnly: boolean): CharPtr; + evalJavaScriptFunc(func: string, argsJson: string | undefined, notifyOnly: boolean): CharPtr; } declare const VW: { runtime: VimWasmRuntime; @@ -329,11 +329,13 @@ const VimWasmLibrary = { } } - evalJavaScriptFunc(func: string, args: string[], notifyOnly: boolean) { + 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, - args, + argsJson, notifyOnly, }); @@ -929,18 +931,12 @@ const VimWasmLibrary = { VW.runtime.writeClipboard(text); }, - // char *vimwasm_eval_js(char *script, char **args, int args_len, int just_notify); - vimwasm_eval_js(scriptPtr: CharPtr, argsPtr: number, argsLen: number, justNotify: number) { + // 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 args: string[] = []; - if (argsLen > 0) { - const argsBuf = new Uint32Array(Module.HEAPU8.buffer, argsPtr, argsLen); - argsBuf.forEach(argPtr => { - args.push(UTF8ToString(argPtr as CharPtr)); - }); - } - debug('vimwasm_eval_js', script, args); - return VW.runtime.evalJavaScriptFunc(script, args, !!justNotify); + const argsJson = argsJsonPtr === 0 ? undefined : UTF8ToString(argsJsonPtr); + return VW.runtime.evalJavaScriptFunc(script, argsJson, !!justNotify); }, /* eslint-enable @typescript-eslint/camelcase */ diff --git a/wasm/vimwasm.ts b/wasm/vimwasm.ts index d5827ad7d..319d2eb71 100644 --- a/wasm/vimwasm.ts +++ b/wasm/vimwasm.ts @@ -877,8 +877,7 @@ export class VimWasm { } } - private async evalFunc(body: string, jsonArgs: string[], notifyOnly: boolean) { - const args = jsonArgs.map(j => JSON.parse(j)); + private async evalFunc(body: string, args: any[], notifyOnly: boolean) { debug('Evaluating JavaScript function:', body, args); let f; @@ -932,9 +931,11 @@ export class VimWasm { this.screen.draw(msg.event); debug('draw event', msg.event); break; - case 'evalfunc': - this.evalFunc(msg.body, msg.args, msg.notifyOnly).catch(this.handleError); + 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); From d4857c1ef5dbae9793dbf603393adf3b08e7cae9 Mon Sep 17 00:00:00 2001 From: rhysd Date: Wed, 31 Jul 2019 19:21:43 +0900 Subject: [PATCH 9/9] fix and add tests to test_jsevalfunc.vim --- .gitignore | 1 + wasm/README.md | 10 ++-- wasm/test/jsevalfunc_spec.ts | 7 +-- wasm/test/test_jsevalfunc.vim | 86 ++++++++++++++++++++++------------- 4 files changed, 65 insertions(+), 39 deletions(-) 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/wasm/README.md b/wasm/README.md index e5f75c26e..87c1cd0fc 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -355,17 +355,17 @@ let repo = jsevalfunc(' \ if (!res.ok) { \ return null; \ } - \ return await res.text(); + \ return JSON.parse(await res.text()); \ ', [slug]) -let data = json_decode(repo) +echo repo ``` `jsevalfunc()` throws an exception when: -- Some argument is not serializable +- 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 +- evaluating JavaScript code throws an exception +- returned value from the function call is not JSON serializable ## TypeScript Support diff --git a/wasm/test/jsevalfunc_spec.ts b/wasm/test/jsevalfunc_spec.ts index 3d3afd43e..834ce094e 100644 --- a/wasm/test/jsevalfunc_spec.ts +++ b/wasm/test/jsevalfunc_spec.ts @@ -23,16 +23,17 @@ describe('jsevalfunc()', function() { }); it('passes unit tests written in Vim script', async function() { - await editor.cmdline('source /test.vim'); - + // Test results are sent from Vim script side with :export const exported = new Promise<[string, ArrayBuffer]>(resolve => { editor.onFileExport = (fpath, contents) => { resolve([fpath, contents]); }; }); - await editor.cmdline('export /test_jsevalfunc_result.txt'); + // 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); diff --git a/wasm/test/test_jsevalfunc.vim b/wasm/test/test_jsevalfunc.vim index dffafc912..676260441 100644 --- a/wasm/test/test_jsevalfunc.vim +++ b/wasm/test/test_jsevalfunc.vim @@ -16,7 +16,7 @@ function! s:test_serialize_return() abort 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': null, 'c': {}}, z, 'all') + call assert_equal({'a': [1, "test", []], 'b': v:null, 'c': {}}, z, 'all') endfunction function! s:test_serialize_argument() abort @@ -46,7 +46,7 @@ function! s:test_arguments() abort endfunction function! s:test_function_body() abort - let n = jsevalfunc('const x = arguments[0] + arguments[1]; return x * 2', [3, 4]); + 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') @@ -54,7 +54,7 @@ function! s:test_function_body() abort 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 asssert_equal(s, 'BODY', 'DOM access') + call assert_equal(s, 'BODY', 'DOM access') let n = jsevalfunc(' \ const x = arguments[0]; \ const p1 = new Promise(r => setTimeout(() => r(x), 1)); @@ -62,43 +62,59 @@ function! s:test_function_body() abort \ const j = await p1; \ return i + j; \', [12, 13]) - call asssert_equal(25, n, 'multiple await operators') + call assert_equal(25, n, 'multiple await operators') let x = jsevalfunc('') - call asssert_equal(v:none, x, 'without return') + call assert_equal(v:none, x, 'without return') let x = jsevalfunc('return') - call asssert_equal(v:none, x, 'only 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 f = jsevalfunc('return 3.14', [], 1) - call assert_equal(0, f, 'float') - let s = jsevalfunc('return "test"', [], 1) - call assert_equal(0, s, 'string') - let b = jsevalfunc('return true', [], 1) - call assert_equal(0, b, 'bool') - let x = jsevalfunc('return null', [], 1) - call assert_equal(0, x, 'null') - let y = jsevalfunc('return undefined', [], 1) - call assert_equal(0, y, 'none') - let a = jsevalfunc('return [1, 2, 3]', [], 1) - call assert_equal(0, a, 'array') - let d = jsevalfunc('return {a: 42}', [], 1) - call assert_equal(0, d, 'dict') - let z = jsevalfunc('return {a: [1, "test", []], b: null, c: {}}', [], 1) - call assert_equal(0, z, 'all') + 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("", {})', 'E714', 'arguments must be list') + 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 - call assert_fails('call jsevalfunc("throw new Error(''This is test error'')")', 'E9999: Exception was thrown while evaluating function', 'exception is thrown') + 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 = {}; @@ -111,12 +127,20 @@ endfunction " TODO: Test jsevalfunc() raises an error even if notify_only is set to truthy " value -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() +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