diff --git a/README.md b/README.md index f3d8236bf..fc393add6 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ On updating the modules, **vitalizer** shows any breaking changes to help you mi Module | Description ------------------------------------------------ | ------------------------------ [Assertion](doc/vital/Assertion.txt) | assertion library +[Async.Promise](doc/vital/Async/Promise.txt) | An asynchronous operation like ES6 Promise [Bitwise](doc/vital/Bitwise.txt) | bitwise operators [ConcurrentProcess](doc/vital/ConcurrentProcess.txt) | manages processes concurrently with vimproc [Data.Base64](doc/vital/Data/Base64.txt) | base64 utilities library diff --git a/autoload/vital/__vital__/Async/Promise.vim b/autoload/vital/__vital__/Async/Promise.vim new file mode 100644 index 000000000..60f012d17 --- /dev/null +++ b/autoload/vital/__vital__/Async/Promise.vim @@ -0,0 +1,257 @@ +" ECMAScript like Promise library for asynchronous operations. +" Spec: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise +" This implementation is based upon es6-promise npm package. +" Repo: https://github.com/stefanpenner/es6-promise + +" States of promise +let s:PENDING = 0 +let s:FULFILLED = 1 +let s:REJECTED = 2 + +let s:DICT_T = type({}) + +" @vimlint(EVL103, 1, a:resolve) +" @vimlint(EVL103, 1, a:reject) +function! s:noop(resolve, reject) abort +endfunction +" @vimlint(EVL103, 0, a:resolve) +" @vimlint(EVL103, 0, a:reject) +let s:NOOP = function('s:noop') + +" Internal APIs + +let s:PROMISE = { + \ '_state': s:PENDING, + \ '_children': [], + \ '_fulfillments': [], + \ '_rejections': [], + \ '_result': v:null, + \ } + +let s:id = -1 +function! s:_next_id() abort + let s:id += 1 + return s:id +endfunction + +" ... is added to use this function as a callback of timer_start() +function! s:_invoke_callback(settled, promise, callback, result, ...) abort + let has_callback = a:callback isnot v:null + let success = 1 + let err = v:null + if has_callback + try + let l:Result = a:callback(a:result) + catch + let err = { + \ 'exception' : v:exception, + \ 'throwpoint' : v:throwpoint, + \ } + let success = 0 + endtry + else + let l:Result = a:result + endif + + if a:promise._state != s:PENDING + " Do nothing + elseif has_callback && success + call s:_resolve(a:promise, Result) + elseif !success + call s:_reject(a:promise, err) + elseif a:settled == s:FULFILLED + call s:_fulfill(a:promise, Result) + elseif a:settled == s:REJECTED + call s:_reject(a:promise, Result) + endif +endfunction + +" ... is added to use this function as a callback of timer_start() +function! s:_publish(promise, ...) abort + let settled = a:promise._state + if settled == s:PENDING + throw 'vital: Async.Promise: Cannot publish a pending promise' + endif + + if empty(a:promise._children) + return + endif + + for i in range(len(a:promise._children)) + if settled == s:FULFILLED + let l:CB = a:promise._fulfillments[i] + else + " When rejected + let l:CB = a:promise._rejections[i] + endif + let child = a:promise._children[i] + if child isnot v:null + call s:_invoke_callback(settled, child, l:CB, a:promise._result) + else + call l:CB(a:promise._result) + endif + endfor + + let a:promise._children = [] + let a:promise._fulfillments = [] + let a:promise._rejections = [] +endfunction + +function! s:_subscribe(parent, child, on_fulfilled, on_rejected) abort + let a:parent._children += [ a:child ] + let a:parent._fulfillments += [ a:on_fulfilled ] + let a:parent._rejections += [ a:on_rejected ] +endfunction + +function! s:_handle_thenable(promise, thenable) abort + if a:thenable._state == s:FULFILLED + call s:_fulfill(a:promise, a:thenable._result) + elseif a:thenable._state == s:REJECTED + call s:_reject(a:promise, a:thenable._result) + else + call s:_subscribe( + \ a:thenable, + \ v:null, + \ function('s:_resolve', [a:promise]), + \ function('s:_reject', [a:promise]), + \ ) + endif +endfunction + +function! s:_resolve(promise, ...) abort + let l:Result = a:0 > 0 ? a:1 : v:null + if s:is_promise(Result) + call s:_handle_thenable(a:promise, Result) + else + call s:_fulfill(a:promise, Result) + endif +endfunction + +function! s:_fulfill(promise, value) abort + if a:promise._state != s:PENDING + return + endif + let a:promise._result = a:value + let a:promise._state = s:FULFILLED + if !empty(a:promise._children) + call timer_start(0, function('s:_publish', [a:promise])) + endif +endfunction + +function! s:_reject(promise, ...) abort + if a:promise._state != s:PENDING + return + endif + let a:promise._result = a:0 > 0 ? a:1 : v:null + let a:promise._state = s:REJECTED + call timer_start(0, function('s:_publish', [a:promise])) +endfunction + +function! s:_notify_done(wg, index, value) abort + let a:wg.results[a:index] = a:value + let a:wg.remaining -= 1 + if a:wg.remaining == 0 + call a:wg.resolve(a:wg.results) + endif +endfunction + +function! s:_all(promises, resolve, reject) abort + let total = len(a:promises) + if total == 0 + call a:resolve([]) + return + endif + + let wait_group = { + \ 'results': repeat([v:null], total), + \ 'resolve': a:resolve, + \ 'remaining': total, + \ } + + " 'for' statement is not available here because iteration variable is captured into lambda + " expression by **reference**. + call map( + \ copy(a:promises), + \ {i, p -> p.then({v -> s:_notify_done(wait_group, i, v)}, a:reject)}, + \ ) +endfunction + +function! s:_race(promises, resolve, reject) abort + for p in a:promises + call p.then(a:resolve, a:reject) + endfor +endfunction + +" Public APIs + +function! s:new(resolver) abort + let promise = deepcopy(s:PROMISE) + let promise._vital_promise = s:_next_id() + try + if a:resolver != s:NOOP + call a:resolver( + \ function('s:_resolve', [promise]), + \ function('s:_reject', [promise]), + \ ) + endif + catch + call s:_reject(promise, { + \ 'exception' : v:exception, + \ 'throwpoint' : v:throwpoint, + \ }) + endtry + return promise +endfunction + +function! s:all(promises) abort + return s:new(function('s:_all', [a:promises])) +endfunction + +function! s:race(promises) abort + return s:new(function('s:_race', [a:promises])) +endfunction + +function! s:resolve(...) abort + let promise = s:new(s:NOOP) + call s:_resolve(promise, a:0 > 0 ? a:1 : v:null) + return promise +endfunction + +function! s:reject(...) abort + let promise = s:new(s:NOOP) + call s:_reject(promise, a:0 > 0 ? a:1 : v:null) + return promise +endfunction + +function! s:is_available() abort + return has('lambda') && has('timers') +endfunction + +function! s:is_promise(maybe_promise) abort + return type(a:maybe_promise) == s:DICT_T && has_key(a:maybe_promise, '_vital_promise') +endfunction + +function! s:_promise_then(...) dict abort + let parent = self + let state = parent._state + let child = s:new(s:NOOP) + let l:Res = a:0 > 0 ? a:1 : v:null + let l:Rej = a:0 > 1 ? a:2 : v:null + if state == s:FULFILLED + call timer_start(0, function('s:_invoke_callback', [state, child, Res, parent._result])) + elseif state == s:REJECTED + call timer_start(0, function('s:_invoke_callback', [state, child, Rej, parent._result])) + else + call s:_subscribe(parent, child, Res, Rej) + endif + return child +endfunction +let s:PROMISE.then = function('s:_promise_then') + +" .catch() is just a syntax sugar of .then() +function! s:_promise_catch(...) dict abort + return self.then(v:null, a:0 > 0 ? a:1 : v:null) +endfunction +let s:PROMISE.catch = function('s:_promise_catch') + +" vim:set et ts=2 sts=2 sw=2 tw=0: diff --git a/doc/vital.txt b/doc/vital.txt index b39d2c746..492f02241 100644 --- a/doc/vital.txt +++ b/doc/vital.txt @@ -160,6 +160,7 @@ LINKS *Vital-links* Vital modules *Vital-modules* |vital/Assertion.txt| assertion library. + |vital/Async/Promise.txt| An asynchronous operation like ES6 Promise |vital/Bitwise.txt| bitwise operators. |vital/ConcurrentProcess.txt| Manages processes concurrently with vimproc. |vital/Data/Base64.txt| base64 utilities library. diff --git a/doc/vital/Async/Promise.txt b/doc/vital/Async/Promise.txt new file mode 100644 index 000000000..4151a769d --- /dev/null +++ b/doc/vital/Async/Promise.txt @@ -0,0 +1,444 @@ +*vital/Async/Promise.txt* an asynchronous operation like ES6 Promise + +Maintainer: rhysd + +============================================================================== +CONTENTS *Vital.Async.Promise-contents* + +INTRODUCTION |Vital.Async.Promise-introduction| +REQUIREMENTS |Vital.Async.Promise-requirements| +EXAMPLE |Vital.Async.Promise-example| +FUNCTIONS |Vital.Async.Promise-functions| +OBJECTS |Vital.Async.Promise-objects| + Promise Object |Vital.Async.Promise-objects-Promise| + Exception Object |Vital.Async.Promise-objects-Exception| + + + +============================================================================== +INTRODUCTION *Vital.Async.Promise-introduction* + +*Vital.Async.Promise* is a library to represent the eventual completion or +failure of an asynchronous operation. APIs are aligned to ES6 Promise. If you +already know them, you can start to use this library easily. + +Instead of callbacks, Promise provides: + +- a guarantee that all operations are asynchronous. Functions given to .then() + method or .catch() method is executed on next tick (or later) using + |timer_start()|. +- chaining asynchronous operations. Chained operation's order is sequentially + run and the order is guaranteed. +- persistent error handling using .catch() method. Please be careful of + floating Promise. All Promise should have .catch() call not to squash an + exception. +- flow control such as awaiting all Promise objects completed or selecting + the fastest one of Promises objects. + +If you know the detail of APIs, documents for ES6 Promise at Mozilla Developer +Network and ECMA-262 specs would be great. + +Mozilla Developer Network: +https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise +https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises + +ECMA-262: +https://www.ecma-international.org/publications/standards/Ecma-262.htm + + + +============================================================================== +REQUIREMENTS *Vital.Async.Promise-requirements* + +|Vital.Async.Promise| requires |lambda| and |timers| features. +So Vim 8.0 or later is required. The recent version of Neovim also supports +them. + + + +============================================================================== +EXAMPLE *Vital.Async.Promise-example* + +Before explaining the detail of APIs, let's see actual examples. + +(1) Timer *Vital.Async.Promise-example-timer* +> + let s:Promise = vital#vital#import('Async.Promise') + + function! s:wait(ms) + return s:Promise.new({resolve -> timer_start(a:ms, resolve)}) + endfunction + + call s:wait(500).then({-> execute('echo "After 500ms"', '')}) +< + + One of most simple asynchronous operation is a timer. It calls a specified + callback when exceeding the timeout. "s:Promise.new" creates a new Promise + object with given callback. In the callback, function "resolve" (and + "reject" if needed) is passed. When the asynchronous operation is done (in + this case, when the timer is expired), call "resolve" on success or call + "reject" on failure. + + +(2) Next tick *Vital.Async.Promise-example-next-tick* +> + let s:Promise = vital#vital#import('Async.Promise') + + function! s:next_tick() + return s:Promise.new({resolve -> timer_start(0, resolve)}) + endfunction + + call s:next_tick() + \.then({-> 'Execute lower priority tasks here'}) + \.catch({err -> execute('echom err', '')}) +< + By giving 0 to |timer_start()| as timeout, it waits for "next tick". It's the + first time when Vim waits for input. It means that Vim gives higher priority + to user input and executes the script (in the callback of |timer_start()|) + after. + + +(3) Job *Vital.Async.Promise-example-job* +> + let s:Promise = vital#vital#import('Async.Promise') + + function! s:read(chan, part) abort + let out = [] + while ch_status(a:chan, {'part' : a:part}) =~# 'open\|buffered' + call add(out, ch_read(a:chan, {'part' : a:part})) + endwhile + return join(out, "\n") + endfunction + + function! s:sh(...) abort + let cmd = join(a:000, ' ') + return s:Promise.new({resolve, reject -> job_start(cmd, { + \ 'drop' : 'never', + \ 'close_cb' : {ch -> 'do nothing'}, + \ 'exit_cb' : {ch, code -> + \ code ? reject(s:read(ch, 'err')) : resolve(s:read(ch, 'out')) + \ }, + \ })}) + endfunction +< + |job| is a feature to run commands asynchronously. But it is a bit hard to use + because it requires a callback. By wrapping it with Promise, it makes + further easier to use commands and handle errors asynchronously. + + s:read() is just a helper function which reads all output of channel from + the job. So it's not so important. + + Important part is "return ..." in s:sh(). It creates a Promise which starts + a job and resolves when the given command has done. It calls resolve() when + the command finished successfully with an output from stdout, and calls + reject() when the command failed with an output from stderr. + + "ls -l" can be executed as follows: +> + call s:sh('ls', '-l') + \.then({out -> execute('echo "Output: " . out', '')}) + \.catch({err -> execute('echo "Error: " . err', '')}) +< + As the more complex example, following code clones 4 repositories and shows + a message when all of them has completed. When one of them fails, it shows + an error message without waiting for other operations. +> + call s:Promise.all([ + \ s:sh('git', 'clone', 'https://github.com/thinca/vim-quickrun.git'), + \ s:sh('git', 'clone', 'https://github.com/tyru/open-browser-github.git'), + \ s:sh('git', 'clone', 'https://github.com/easymotion/vim-easymotion.git'), + \ s:sh('git', 'clone', 'https://github.com/rhysd/clever-f.vim.git'), + \] + \) + \.then({-> execute('echom "All repositories were successfully cloned!"', '')}) + \.catch({err -> execute('echom "Failed to clone: " . err', '')}) +< + s:Promise.all(...) awaits all given promises have completed, or one of them + has failed. + + +(4) Timeout *Vital.Async.Promise-example-timeout* + + Let's see how Promise realizes timeout easily. +> + let s:Promise = vital#vital#import('Async.Promise') + + call s:Promise.race([ + \ s:sh('git', 'clone', 'https://github.com/vim/vim.git').then({-> v:false}), + \ s:wait(10000).then({-> v:true}), + \]).then({timed_out -> + \ execute('echom timed_out ? "Timeout!" : "Cloned!"', '') + \}) +< + s:sh() and s:wait() are explained above. And .race() awaits one of given + Promise objects have finished. + + The .race() awaits either s:sh(...) or s:wait(...) has completed or failed. + It means that it clones Vim repository from GitHub via git command, but if + it exceeds 10 seconds, it does not wait for the clone operation anymore. + + By adding .then() and giving the result value (v:false or v:true here), you + can know whether the asynchronous operation was timed out or not in + succeeding .then() method. The parameter "timed_out" represents it. + + +(5) REST API call *Vital.Async.Promise-example-rest-api* + + At last, let's see how Promise handles API call with |job| and curl + command. Here, we utilize previous "s:sh" function and encodeURIComponent() + function in |Vital.Web.HTTP| module to encode a query string. +> + let s:HTTP = vital#vital#import('Web.HTTP') + + function! s:github_issues(query) abort + let q = s:HTTP.encodeURIComponent(a:query) + let url = 'https://api.github.com/search/issues?q=' . q + return s:sh('curl', url) + \.then({data -> json_decode(data)}) + \.then({res -> has_key(res, 'items') ? + \ res.items : + \ execute('throw ' . string(res.message))}) + endfunction + + call s:github_issues('repo:vim/vim sort:reactions-+1') + \.then({issues -> execute('echom issues[0].url', '')}) + \.catch({err -> execute('echom "ERROR: " . err', '')}) +< + In this example, it searches the issue in Vim repository on GitHub which + gained the most :+1: reactions. + + In s:github_issues(), it calls GitHub Issue Search API using curl command + and s:sh() function explained above. And it decodes the returned JSON by + |json_decode()| and checks the content. If the curl command failed or API + returned failure response, the Promise value will be rejected. The rejection + will be caught in .catch() method at the last line and an error message will + be shown. + + + +============================================================================== +FUNCTIONS *Vital.Async.Promise-functions* + +new({executor}) *Vital.Async.Promise.new()* + + Creates a new Promise object with given {executor}. + + {executor} is a |Funcref| which represents how to create a Promise + object. It is called _synchronously_. It receives two functions as + parameters. The first parameter is "resolve". It accepts one or zero + argument. By calling it in {executor}, new() returns a resolved + Promise object. The second parameter is "reject". It also accepts one + or zero argument. By calling it in {executor}, new() returns rejected + Promise object. +> + " Resolved Promise object with 42 + let p = Promise.new({resolve -> resolve(42)}) + + " Rejected Promise object with 'ERROR!' + let p = Promise.new({_, reject -> reject('ERROR!')}) + let p = Promise.new({-> execute('throw "ERROR!"')}) +< + When another Promise object is passed to "resolve" or "reject" + function call, new() returns a pending Promise object which awaits + until the given other Promise object has finished. + + If an exception is thrown in {executor}, new() returns a rejected + Promise object with the exception. + + Calling "resolve" or "reject" more than once does not affect. + + If "resolve" or "reject" is called with no argument, it resolves a + Promise object with |v:null|. +> + " :echo outputs 'v:null' + Promise.new({resolve -> resolve()}) + \.then({x -> execute('echo x', '')}) +< +resolve([{value}]) *Vital.Async.Promise.resolve()* + + Creates a resolved Promise object. + It is a helper function equivalent to calling "resolve" immediately in + new(): +> + " Followings are equivalent + let p = Promise.resolve(42) + let p = Promise.new({resolve -> resolve(42)}) +< + If {value} is a Promise object, it resolves/rejects with a value which + given Promise object resolves/rejects with. +> + call Promise.resolve(Promise.resolve(42)) + \.then({x -> execute('echo x', '')}) + " Outputs '42' + + call Promise.resolve(Promise.reject('ERROR!')) + \.catch({reason -> execute('echo reason', '')}) + " Outputs 'ERROR!' +< +reject([{value}]) *Vital.Async.Promise.reject()* + + Creates a rejected Promise object. + It is a helper function equivalent to calling "reject" immediately in + new(): +> + " Followings are equivalent + let p = Promise.reject('Rejected!') + let p = Promise.new({_, reject -> reject('Rejected!')}) +< +all({promises}) *Vital.Async.Promise.all()* + + Creates a Promise object which awaits all of {promises} has completed. + It resolves the Promise object with a list of results of {promises} as + following: +> + call Promise.all([Promise.resolve(1), Promise.resolve('foo')]) + \.then({arr -> execute('echo arr', '')}) + " It shows [1, 'foo'] +< + If one of them is rejected, it does not await other Promise objects + and the Promise object is rejected immediately. + +> + call Promise.all([Promise.resolve(1), Promise.reject('ERROR!')]) + \.catch({err -> execute('echo err', '')}) + " It shows 'ERROR!' +< + If an empty list is given, it is equivalent to Promise.resolve([]). + +race({promises}) *Vital.Async.Promise.race()* + + Creates a Promise object which resolves or rejects as soon as one of + {promises} resolves or rejects. +> + call Promise.race([ + \ Promise.new({resolve -> timer_start(50, {-> resolve('first')})}), + \ Promise.new({resolve -> timer_start(100, {-> resolve('second')})}), + \]) + \.then({v -> execute('echo v', '')}) + " It outputs 'first' + + call Promise.race([ + \ Promise.new({resolve -> timer_start(50, {-> execute('throw "ERROR!"')})}), + \ Promise.new({resolve -> timer_start(100, {-> resolve('second')})}), + \]) + \.then({v -> execute('echo v', '')}) + \.catch({e -> execute('echo e', '')}) + " It outputs 'ERROR!' +< + If {promises} is an empty list, the returned Promise object will never + be resolved or rejected. + +is_promise({value}) *Vital.Async.Promise.is_promise()* + + Returns TRUE when {value} is a Promise object. Otherwise, returns + FALSE. + +is_available() *Vital.Async.Promise.is_available()* + + Returns TRUE when requirements for using |Vital.Async.Promise| are + met. Please look at |Vital.Async.Promise-requirements| to know the + detail of the requirements. + Otherwise, returns FALSE. +> + if Promise.is_available() + " Asynchronous operations using Promise + else + " Fallback into synchronous operations + endif +< + + +============================================================================== +OBJECTS *Vital.Async.Promise-objects* + +------------------------------------------------------------------------------ +Promise Object *Vital.Async.Promise-objects-Promise* + +Promise object represents the eventual completion or failure of an +asynchronous operation. It represents one of following states: + +- Operation has not done yet +- Operation has completed successfully +- Operation has failed with an error + + *Vital.Async.Promise-Promise.then()* +{promise}.then([{onResolved} [, {onRejected}]]) + + Creates a new Promise object which is resolved/rejected after + {promise} is resolved or rejected. {onResolved} and {onRejected} must + be |Funcref| and they are guaranteed to be called __asynchronously__. +> + echo 'hi' + call Promise.new({resolve -> execute('echo "halo" | call resolve(42)', '')}) + \.then({-> execute('echo "bye"', '')}, {-> execute('echo "ah"', '')}) + echo 'yo' +< + Above script outputs messages as following: +> + hi + halo + yo + bye +< + If {onResolved} is specified, it is called after {promise} is + resolved. When {onResolved} returns non-Promise value, the returned + Promise object from .then() is resolved with it. + When {onResolved} returns a Promise object, the returned Promise + object awaits until the Promise object has finished. + + If {onRejected} is specified, it is called after {promise} is + rejected. When {onRejected} returns non-Promise value, the returned + Promise object from .then() is resolved with it. + When {onRejected} returns a Promise object, the returned Promise + object awaits until the Promise object has finished. + + When an exception is thrown in {onResolved} or {onRejected}, the + returned Promise object from .then() will be rejected with an + exception object. + Please read |Vital.Async.Promise-objects-Exception| to know an + exception object. +> + " Both followings create a rejected Promise value asynchronously + call Promise.resolve(42).then({-> execute('throw "ERROR!"')}) + call Promise.resolve(42).then({-> Promise.reject('ERROR!')}) +< + {onResolved} and {onRejected} can be |v:null|. + +{promise}.catch([{onRejected}]) *Vital.Async.Promise-Promise.catch()* + + It is a shortcut function of calling .then() where the first argument + is |v:null|. +> + " Followings are equal + call Promise.reject('ERROR').then(v:null, {msg -> execute('echo msg', '')}) + call Promise.reject('ERROR').catch({msg -> execute('echo msg', '')}) +< + +------------------------------------------------------------------------------ +Exception Object *Vital.Async.Promise-objects-Exception* + +Exception object represents an exception of Vim script. Since Vim script's +|v:exception| is a |String| value and a stack trace of the exception is +separated to |v:throwpoint| variable, it does not fit Promise API. +So we need to define our own exception object. It is passed to {onRejected} +parameter of .then() or .catch() method. + +Example: +> + call Promise.new({-> execute('throw "ERROR!"')}) + \.catch({ex -> execute('echo ex', '')}) + " Output: + " {'exception': 'ERROR!', 'throwpoint': '...'} + + call Promise.new({-> 42 == []}) + \.catch({ex -> execute('echo ex', '')}) + " Output: + " {'exception': 'Vim(return):E691: ...', 'throwpoint': '...'} +< +Exception object has two fields; "exception" and "throwpoint". +"exception" is an error message. It's corresponding to |v:exception|. And +"throwpoint" is a stack trace of the caught exception. It's corresponding to +|v:throwpoint|. + +============================================================================== +vim:tw=78:fo=tcq2mM:ts=8:ft=help:norl diff --git a/test/.themisrc b/test/.themisrc index 7866bd535..3b89d2e35 100644 --- a/test/.themisrc +++ b/test/.themisrc @@ -2,6 +2,9 @@ set encoding=utf-8 call themis#option('recursive', 1) call themis#option('exclude', ['test/_testdata/', 'test/README.md']) +if !has('nvim') && v:version < 800 + call themis#option('exclude', ['test/Async/Promise.vimspec']) +endif let g:Expect = themis#helper('expect') call themis#helper('command').with(themis#helper('assert')).with({'Expect': g:Expect}) diff --git a/test/Async/Promise.vimspec b/test/Async/Promise.vimspec new file mode 100644 index 000000000..90e980905 --- /dev/null +++ b/test/Async/Promise.vimspec @@ -0,0 +1,649 @@ +function! s:wait_has_key(obj, name) abort + let i = 0 + while i < 500 + sleep 10m + if has_key(a:obj, a:name) + return + endif + let i += 1 + endwhile + throw 's:wait_has_key(): After 5000ms, ' . string(a:obj) . ' did not have key ' . a:name +endfunction + +function! s:resolver(resolve, reject) abort + call a:resolve('ok') +endfunction + +function! s:rejector(resolve, reject) abort + call a:reject('error') +endfunction + +Describe Async.Promise + Before all + let P = vital#vital#import('Async.Promise') + + " Constants + let PENDING = 0 + let FULFILLED = 1 + let REJECTED = 2 + + " Utilities + let Wait = {ms -> P.new({res -> timer_start(ms, res)})} + let RejectAfter = {ms -> P.new({_, rej -> timer_start(ms, rej)})} + End + + Describe .new() + It should create a Promise object with proper state synchronously + for l:Val in [42, 'foo', {'foo': 42}, {}, [1, 2, 3], [], function('empty')] + let p = P.new({resolve -> resolve(l:Val)}) + Assert Equals(p._state, FULFILLED) + Assert HasKey(p, '_vital_promise') + + let p = P.new({_, reject -> reject(l:Val)}) + Assert Equals(p._state, REJECTED) + Assert HasKey(p, '_vital_promise') + + unlet l:Val + endfor + End + + It should make settled Promise with v:null when no argument is given to resolve()/reject() + let l = l: + call P.new({resolve -> resolve()}).then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, v:null) + + unlet result + call P.new({_, reject -> reject()}).catch({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, v:null) + End + + It should create a rejected Promise object when an exception was thrown + let l = l: + let p = P.new({-> execute('throw "ERROR"')}) + Assert Equals(p._state, REJECTED) + call p.catch({exc -> extend(l, {'err': exc})}) + call s:wait_has_key(l, 'err') + Assert HasKey(err, 'exception') + Assert HasKey(err, 'throwpoint') + Assert Equals(err.exception, 'ERROR') + Assert NotEmpty(err.throwpoint) + unlet err + + let p = P.new({-> execute('echom {}')}) + Assert Equals(p._state, REJECTED) + call p.catch({exc -> extend(l, {'err': exc})}) + call s:wait_has_key(l, 'err') + Assert HasKey(err, 'exception') + Assert HasKey(err, 'throwpoint') + Assert Match(err.exception, '^Vim(echomsg):E731:') + Assert NotEmpty(err.throwpoint) + End + + It should do nothing when calling resolve()/reject() after resolved + let l = l: + + let p = P.new({resolve -> resolve(42) || resolve(99)}) + Assert Equals(p._state, FULFILLED) + call p.then({v -> extend(l, {'result' : v})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + unlet result + + let p = P.new({resolve, reject -> resolve(52) || reject(99)}) + Assert Equals(p._state, FULFILLED) + call p.then({v -> extend(l, {'result' : v})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 52) + End + + It should do nothing when calling resolve()/reject() after rejected + let l = l: + + let p = P.new({resolve, reject -> reject(42) || resolve(99)}) + Assert Equals(p._state, REJECTED) + call p.catch({v -> extend(l, {'reason' : v})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 42) + unlet reason + + let p = P.new({_, reject -> reject(52) || reject(99)}) + Assert Equals(p._state, REJECTED) + call p.catch({v -> extend(l, {'reason' : v})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 52) + End + + It should ignore thrown exception when the Promise is already settled + let l = l: + call P.new({resolve -> resolve('ok') || execute('throw "HELLO"')}).then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 'ok') + unlet result + call P.new({_, reject -> reject('error') || execute('throw "HELLO"')}).catch({x -> extend(l, {'reason' : x})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 'error') + End + + It should be pending forever when neither resolve() nor reject() is called + let l = l: + let done = 0 + let p = P.new({-> 42}) + Assert Equals(p._state, PENDING) + let p = p.then({-> extend(l, {'done' : 1})}) + let p = p.catch({-> extend(l, {'done' : 2})}) + sleep 30m + Assert Equals(p._state, PENDING) + Assert Equals(done, 0) + End + + It can take funcref as constructor + let l = l: + call P.new(function('s:resolver')).then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 'ok') + unlet result + call P.new(function('s:rejector')).catch({x -> extend(l, {'reason' : x})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 'error') + End + + It should assimilate when fulfilled Promise is given to resolve() + let p = P.new({resolve -> resolve(P.resolve('ok'))}) + Assert Equals(p._state, FULFILLED) + let l = l: + call p.then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 'ok') + End + + It should not create resolved Promise with rejected Promise + let p = P.new({resolve -> resolve(P.reject('error'))}) + Assert Equals(p._state, REJECTED) + let l = l: + call p.catch({x -> extend(l, {'reason' : x})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 'error') + End + End + + Describe Promise object + Describe .then() + It should call its callback asynchronously + let l = l: + for l:Val in [42, 'foo', {'foo': 42}, {}, [1, 2, 3], [], function('empty')] + let p = P.new({resolve -> resolve(Val)}) + Assert Equals(p._state, FULFILLED) + let p2 = p.then({x -> x}).then({r -> extend(l, {'Result' : r})}) + Assert False(exists('l:Result')) + call s:wait_has_key(l, 'Result') + Assert Equals(Result, Val) + Assert Equals(p2._state, FULFILLED) + unlet l:Result + unlet l:Val + endfor + End + + It should be chainable + let l = l: + let p = P.resolve(42).then({r -> P.resolve(r + 42)}).then({r -> extend(l, {'result' : r})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 84) + Assert Equals(p._state, FULFILLED) + End + + It should be chainable asynchronously + let l = l: + let p = Wait(50).then({-> Wait(50)}).then({-> extend(l, {'result' : 42})}) + Assert Equals(p._state, PENDING) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + Assert Equals(p._state, FULFILLED) + End + + It should resolve with funcref directly + let l = l: + let p = P.resolve(50).then(Wait).then({-> extend(l, {'result' : 42})}) + Assert Equals(p._state, PENDING) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + Assert Equals(p._state, FULFILLED) + End + + It should accept to resolve multiple times + let l = l: + let p = P.resolve(42) + + let p2 = p.then({v -> extend(l, {'value' : v + 10})}) + call s:wait_has_key(l, 'value') + Assert Equals(value, 52) + Assert Equals(p2._state, FULFILLED) + unlet value + + let p2 = p.then({v -> extend(l, {'value' : v + 20})}) + call s:wait_has_key(l, 'value') + Assert Equals(value, 62) + Assert Equals(p2._state, FULFILLED) + End + + It should accept to resolve multiple times asynchronously + let l = l: + let value = 100 + let p1 = Wait(50).then({-> 100}) + let p2 = p1.then({v -> extend(l, {'value' : l.value + v, 'ok1': v:true})}) + let p3 = p1.then({v -> extend(l, {'value' : l.value + v, 'ok2': v:true})}) + for p in [p1, p2, p3] + Assert Equals(p._state, PENDING) + endfor + call s:wait_has_key(l, 'ok1') + call s:wait_has_key(l, 'ok2') + for p in [p1, p2, p3] + Assert Equals(p._state, FULFILLED) + endfor + Assert Equals(value, 300) + End + + It should reject Promise when an exception was thrown + let l = l: + + let p = P.resolve(42).then({-> execute('throw "ERROR"')}) + call p.catch({exc -> extend(l, {'err': exc})}) + call s:wait_has_key(l, 'err') + Assert Equals(p._state, REJECTED) + Assert HasKey(err, 'exception') + Assert HasKey(err, 'throwpoint') + Assert Equals(err.exception, 'ERROR') + unlet err + + let p = P.resolve(42).then({-> execute('echom {}')}) + call p.catch({exc -> extend(l, {'err': exc})}) + call s:wait_has_key(l, 'err') + Assert Equals(p._state, REJECTED) + Assert HasKey(err, 'exception') + Assert HasKey(err, 'throwpoint') + Assert Match(err.exception, '^Vim(echomsg):E731:') + End + + It can omit all parameters + let l = l: + call P.resolve(42).then().then().then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + End + + It should stop chain as soon as Promise is rejected + let p = P.resolve(42).then({x -> x + 1}).then({x -> execute('throw x')}).then({x -> x + 1}) + let l = l: + call p.catch({x -> extend(l, {'reason' : x})}) + call s:wait_has_key(l, 'reason') + Assert Equals(p._state, REJECTED) + Assert Equals(reason.exception, 43) + End + + It can take rejection handler at 2nd parameter + let l = l: + let p = P.reject(42).then({x -> extend(l, {'did' : 'resolve'})}, {x -> extend(l, {'did' : 'reject'})}) + call s:wait_has_key(l, 'did') + Assert Equals(p._state, FULFILLED) + Assert Equals(did, 'reject') + End + End + + Describe .catch() + It calls its callback asynchronously + let l = l: + for l:Val in [42, 'foo', {'foo': 42}, {}, [1, 2, 3], [], function('empty')] + let p = P.new({_, reject -> reject(Val)}) + let p2 = p.then({-> extend(l, {'Result' : 'Error: resolved to .then'})}).catch({v -> extend(l, {'Result' : v})}) + Assert False(exists('l:Result')) + call s:wait_has_key(l, 'Result') + Assert Equals(Result, Val) + Assert Equals(p._state, REJECTED) + Assert Equals(p2._state, FULFILLED) + unlet l:Val + unlet l:Result + endfor + End + + It is called when an exceptioin is thrown in upstream + let l = l: + let p = P.new({-> execute('throw 42')}) + \.catch({r -> extend(l, {'reason' : r})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason.exception, 42) + Assert Equals(p._state, FULFILLED) + End + + It resolves thenable object + let l = l: + let p = P.reject(42).catch({r -> P.resolve(r + 42)}).then({r -> extend(l, {'reason' : r})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 84) + Assert Equals(p._state, FULFILLED) + End + + It resolves thenable object asynchronously + let l = l: + let p = RejectAfter(50).catch({-> RejectAfter(50)}).catch({-> extend(l, {'reason' : 42})}) + Assert Equals(p._state, PENDING) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 42) + Assert Equals(p._state, FULFILLED) + End + + It resolves by funcref directly + let l = l: + let p = P.reject(50).catch(Wait).then({-> extend(l, {'result' : 42})}) + Assert Equals(p._state, PENDING) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + Assert Equals(p._state, FULFILLED) + End + + It should resolve the same rejected promise multiple times + let l = l: + let p = P.reject(42) + let p2 = p.catch({v -> extend(l, {'value' : v + 10})}) + call s:wait_has_key(l, 'value') + Assert Equals(value, 52) + Assert Equals(p2._state, FULFILLED) + unlet value + let p2 = p.catch({v -> extend(l, {'value' : v + 20})}) + call s:wait_has_key(l, 'value') + Assert Equals(value, 62) + Assert Equals(p2._state, FULFILLED) + End + + It should resolve the same rejected promise multiple times asynchronously + let l = l: + let value = 100 + let p1 = RejectAfter(50) + let p2 = p1.catch({v -> extend(l, {'value' : l.value + 100, 'ok1' : v:true})}) + let p3 = p1.catch({v -> extend(l, {'value' : l.value + 100, 'ok2' : v:true})}) + for p in [p1, p2, p3] + Assert Equals(p._state, PENDING) + endfor + call s:wait_has_key(l, 'ok1') + call s:wait_has_key(l, 'ok2') + Assert Equals(p1._state, REJECTED) + for p in [p2, p3] + Assert Equals(p._state, FULFILLED) + endfor + Assert Equals(value, 300) + End + + It should reject Promise when an exception was thrown + let l = l: + + let p = P.reject(42).catch({-> execute('throw "ERROR"')}) + call p.catch({exc -> extend(l, {'reason': exc})}) + call s:wait_has_key(l, 'reason') + Assert Equals(p._state, REJECTED) + Assert HasKey(reason, 'exception') + Assert HasKey(reason, 'throwpoint') + Assert Equals(reason.exception, 'ERROR') + unlet reason + + let p = P.reject(42).catch({-> execute('echom {}')}) + call p.catch({exc -> extend(l, {'reason': exc})}) + call s:wait_has_key(l, 'reason') + Assert Equals(p._state, REJECTED) + Assert HasKey(reason, 'exception') + Assert HasKey(reason, 'throwpoint') + Assert Match(reason.exception, '^Vim(echomsg):E731:') + End + + It should pass through the exception when all parameters are omitted + let l = l: + let p = P.reject(42).catch().catch() + call p.catch({x -> extend(l, {'reason' : x})}) + call s:wait_has_key(l, 'reason') + Assert Equals(p._state, REJECTED) + Assert Equals(reason, 42) + End + End + End + + Describe .all() + It should wait all and resolve with all the resolved values as array + let l = l: + let p1 = Wait(10).then({-> 10}) + let p2 = Wait(200).then({-> 20}) + let p3 = Wait(100).then({-> extend(l, {'ongoing' : v:true})}).then({-> 30}) + let result = [] + let p4 = P.all([p1, p2, p3]).then({a -> extend(l, {'result' : a, 'done' : v:true})}) + call s:wait_has_key(l, 'ongoing') + Assert Equals(result, []) + Assert Equals(p4._state, PENDING) + call s:wait_has_key(l, 'done') + Assert Equals(len(result), 3) + Assert Equals(result[0], 10) + Assert Equals(result[1], 20) + Assert Equals(result[2], 30) + Assert Equals(p4._state, FULFILLED) + End + + It should reject Promise immediately when one of children was rejected + let l = l: + let p1 = Wait(10).then({ -> execute('throw 1') }) + let p2 = Wait(200) + let p3 = P.all([p1, p2]).catch({r -> extend(l, {'reason' : r})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason.exception, 1) + Assert Equals(p3._state, FULFILLED) + Assert Equals(p2._state, PENDING) + End + + It should create a pending Promise when given array is empty + let l = l: + let p = P.all([]).then({-> extend(l, {'done' : 1})}) + call s:wait_has_key(l, 'done') + Assert Equals(p._state, FULFILLED) + Assert Equals(done, 1) + End + + It should work where filfilled and pending Promises are mixed + let l = l: + let p1 = P.resolve('a') + let p2 = Wait(30).then({-> 'b'}) + let p3 = Wait(10).then({-> 'c'}) + let p = P.all([p1, p2, p3]) + Assert Equals(p._state, PENDING) + call p.then({a -> extend(l, {'result' : a})}) + call s:wait_has_key(l, 'result') + Assert Equals(len(result), 3) + Assert Equals(result[0], 'a') + Assert Equals(result[1], 'b') + Assert Equals(result[2], 'c') + End + End + + Describe .race() + It should make a promise resolving after first of children resolved + let l = l: + let p1 = Wait(10).then({-> 42}) + let p2 = Wait(200).then({-> 21}) + let p4 = P.race([p1, p2]).then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + Assert Equals(p4._state, FULFILLED) + Assert Equals(p2._state, PENDING) + End + + It should reject promise immediately when first child was rejected + let l = l: + let p1 = Wait(10).then({ -> execute('throw 1') }) + let p2 = Wait(200) + let p3 = P.race([p1, p2]).catch({r -> extend(l, {'reason' : r})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason.exception, 1) + Assert Equals(p1._state, REJECTED) + Assert Equals(p2._state, PENDING) + Assert Equals(p3._state, FULFILLED) + End + + It should resolve promise even if succeeding promise is rejected + let l = l: + let p1 = Wait(10).then({-> 42}) + let p2 = RejectAfter(200) + let p3 = P.race([p1, p2]).then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + Assert Equals(p3._state, FULFILLED) + Assert Equals(p2._state, PENDING) + End + + It should create a pending Promise when given array is empty + let p = P.race([]) + Assert Equals(p._state, PENDING) + End + + It should work where filfilled and pending Promises are mixed + let p1 = Wait(10).then({-> 'a'}) + let p2 = P.resolve('b') + let p3 = Wait(50).then({-> 'c'}) + let p = P.race([p1, p2, p3]) + + let l = l: + call p.then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(p._state, FULFILLED) + Assert Equals(result, 'b') + End + End + + Describe .resolve() + It should create resolved Promise with given non-Promise value + let l = l: + for l:Val in [42, 'foo', {'foo': 42}, {}, [1, 2, 3], [], function('empty'), v:null, v:true] + let p = P.resolve(Val) + Assert Equals(p._state, FULFILLED) + call p.then({x -> extend(l, {'Result' : x})}).catch({x -> extend(l, {'Result' : x})}) + call s:wait_has_key(l, 'Result') + Assert Equals(l:Result, Val) + unlet l:Result + unlet l:Val + endfor + End + + It should create resolved Promise with given Promise value + for p in [ + \ P.resolve(P.resolve(42)), + \ P.resolve(P.resolve(P.resolve(P.resolve(42)))), + \ P.resolve(Wait(10).then({-> 42})) + \ ] + Assert Equals(p._state, FULFILLED) + let l = l: + call p.then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + unlet result + endfor + End + + It should create rejected Promise with rejected Promise value + let p = P.resolve(P.reject(42)) + Assert Equals(p._state, REJECTED) + let l = l: + call p.catch({x -> extend(l, {'reason' : x})}) + call s:wait_has_key(l, 'reason') + Assert Equals(reason, 42) + End + + It should create pending Promise with pending Promise + let p = P.resolve(P.new({-> 42})) + Assert Equals(p._state, PENDING) + End + + It can omit parameter + let l = l: + call P.resolve().then({x -> extend(l, {'result': x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, v:null) + End + + It should wait for given pending Promise being resolved + let l = l: + let p = P.resolve(Wait(30).then({-> 42})) + Assert Equals(p._state, PENDING) + call p.then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + End + + It should wait for given pending Promise being rejected + let l = l: + let p = P.resolve(Wait(30).then({-> execute('throw "error"')})) + Assert Equals(p._state, PENDING) + call p.catch({x -> extend(l, {'reason' : x})}) + call s:wait_has_key(l, 'reason') + Assert Equals(p._state, REJECTED) + Assert Equals(reason.exception, 'error') + End + End + + Describe .reject() + It should create rejected Promise with non-Promise value + let l = l: + for l:Val in [42, 'foo', {'foo': 42}, {}, [1, 2, 3], [], function('empty'), v:null, v:true] + let p = P.reject(Val) + Assert Equals(p._state, REJECTED) + call p.then({-> extend(l, {'Result' : 'Error: resolve to .then()'})}).catch({x -> extend(l, {'Result' : x})}) + call s:wait_has_key(l, 'Result') + Assert Equals(Result, Val) + unlet l:Result + unlet l:Val + endfor + End + + It should create rejected Promise with rejected Promise + for p in [ + \ P.reject(P.reject(42)), + \ P.reject(P.reject(P.reject(P.reject(42)))), + \ P.reject(Wait(10).then({-> P.reject(42)})), + \ P.reject(Wait(10).then({-> execute('throw 42')})), + \ ] + Assert Equals(p._state, REJECTED) + endfor + End + + It should create rejected Promise with pending Promise + let p = P.reject(P.new({-> 42})) + Assert Equals(p._state, REJECTED) + End + + It should create rejected Promise with resolved Promise + let l = l: + let p = P.reject(P.resolve(42)) + Assert Equals(p._state, REJECTED) + call p.catch({x -> x}).then({x -> extend(l, {'result' : x})}) + call s:wait_has_key(l, 'result') + Assert Equals(result, 42) + End + + It can omit parameter + let l = l: + call P.reject().catch({x -> extend(l, {'reason': x})}) + call s:wait_has_key(l, 'reason') + Assert True(reason == v:null, 'Actual: ' . string(reason)) + End + End + + Describe .is_available() + It should return true on Vim8 or Neovim + Assert True(P.is_available()) + End + End + + Describe is_promise() + It should return a given value is Promise instance or not + Assert True(P.is_promise(P.resolve(42))) + Assert False(P.is_promise({})) + Assert False(P.is_promise(v:null)) + Assert False(P.is_promise(42)) + End + End +End + +" vim:et ts=2 sts=2 sw=2 tw=0: