123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422openCoreopenAsyncopenVcamlletneovim_path=Core.Sys.getenv"NEOVIM_PATH"|>Option.value~default:"nvim"lethundred_ms=Time_ns.Span.create~ms:100()lettime_source_at_epoch=Time_source.read_only(Time_source.create~now:Time_ns.epoch());;(* Start with no init.vim, no shada file, and no swap files. *)letdefault_args=["--clean";"-n"](* Start the editor without a gui, use stdin and stdout instead of Unix pipe for
communication with the plugin, place socket relative to the temporary working directory
since there's some undocumented internal limit for the socket length (it doesn't appear
in `:h limits). *)letrequired_args=["--headless";"--embed";"--listen";"./socket"]letwith_client?(args=default_args)?env?links?(time_source=time_source_at_epoch)?(on_error=`Raise)?(before_connecting=ignore)f=Expect_test_helpers_async.within_temp_dir?links(fun()->letnvim_log_file="nvim_low_level_log.txt"inletargs=required_args@argsinlet%bindworking_dir=Sys.getcwd()inletenv=letenv=letbase=Core_unix.Env.expand(`Extend["NVIM_LOG_FILE",nvim_log_file;"NVIM_RPLUGIN_MANIFEST","rplugin.vim"])inmatchenvwith|None->base|Somegetenv->Core_unix.Env.expand~base:(Lazy.from_valbase)(getenv(`Tmpdirworking_dir))in`Replace_rawenvinletclient=Client.create~on_errorinbefore_connectingclient;let%bindclient,process=Client.attachclient(Embed{prog=neovim_path;args;working_dir;env})~time_source>>|ok_exninlet%bindresult=fclient>>|ok_exninlet%bind()=Client.closeclientinlet%bind()=(* Because the client is embedded, stdin and stdout are used for Msgpack RPC.
However, there still may be errors reported on stderr that we should capture. *)let%mapstderr=Reader.contents(Process.stderrprocess)andlow_level_log=Reader.file_contentsnvim_log_filein[stderr;low_level_log]|>List.filter~f:(Fn.nonString.is_empty)|>String.concat~sep:"\n"|>print_stringinreturnresult);;letprint_s?machsexp=letworking_dir=Sys_unix.getcwd()inletrecfilter~tmp_dir:Sexp.t->Sexp.t=function|Atomatom->Atom(String.substr_replace_allatom~pattern:tmp_dir~with_:"${TMPDIR}")|Listlist->List(List.maplist~f:(filter~tmp_dir))in(* [Expect_test_helpers_async.with_temp_dir] uses this suffix. *)matchString.is_suffixworking_dir~suffix:".tmp"with|false->print_s?machsexp|true->print_s?mach(filtersexp~tmp_dir:working_dir);;letsimpleherekto_sexp=with_client(funclient->let%map.Deferred.Or_errorresult=run_joinhereclientkinprint_s(to_sexpresult));;moduleTest_ui=structtypet={mutablebuffer:stringarrayarray;mutablecursor_col:int;mutablecursor_row:int;flushed:[`Awaiting_first_flush|`Flushofstring|`Detached]Mvar.Read_write.t;ui:Ui.tSet_once.t;client:[`connected]Client.t}letui_to_stringt=letmoduleBuffer=Core.Bufferinletbuffer=Buffer.create0inBuffer.add_stringbuffer"╭";Buffer.add_stringbuffer(List.init(Array.lengtht.buffer.(0))~f:(Fn.const"─")|>String.concat);Buffer.add_stringbuffer"╮";Buffer.add_charbuffer'\n';Array.itert.buffer~f:(funrow->ifString.equalrow.(0)"─"thenBuffer.add_stringbuffer"├"elseBuffer.add_stringbuffer"│";Array.iterrow~f:(funstring->Buffer.add_stringbufferstring);ifString.equal(Array.lastrow)"─"thenBuffer.add_stringbuffer"┤"elseBuffer.add_stringbuffer"│";Buffer.add_charbuffer'\n');Buffer.add_stringbuffer"╰";Buffer.add_stringbuffer(List.init(Array.lengtht.buffer.(0))~f:(Fn.const"─")|>String.concat);Buffer.add_stringbuffer"╯";Buffer.contentsbuffer;;(* Applies a message from the neovim "redraw" ui message sequence. *)letapplyt(event:Ui.Event.t)=letunflusht=matchMvar.peekt.flushedwith|None|Some(`Awaiting_first_flush|`Detached)->()|Some(`Flush_)->ignore(Mvar.take_now_exnt.flushed:_)inmatcheventwith|Flush->(matchMvar.peekt.flushedwith|Some`Detached->()|Some`Awaiting_first_flush->ignore(Mvar.take_now_exnt.flushed:_)|None|Some(`Flush_)->Mvar.sett.flushed(`Flush(ui_to_stringt)))|Grid_line{grid=1;row;col_start;data}->unflusht;letcol=refcol_startinletwritestr=t.buffer.(row).(!col)<-str;incrcolinList.iterdata~f:(function|Array([Stringstr]|[Stringstr;Integer_])->writestr|Array[Stringstr;Integer_;Integerrepeat]->for_=1torepeatdowritestrdone|_->raise_s[%message"Malformed gridline data"(data:Msgpack.tlist)])|Grid_clear{grid=1}->unflusht;Array.itert.buffer~f:(funrow->Array.fillrow~pos:0~len:(Array.lengthrow)" ")|Grid_cursor_goto{grid=1;row;col}->unflusht;t.cursor_col<-col;t.cursor_row<-row|Grid_resize{grid=1;width;height}->unflusht;letnew_array=Array.initheight~f:(fun_->Array.create~len:width" ")inArray.iterit.buffer~f:(funyrow->Array.iterirow~f:(funxc->ifx<width&&y<heightthennew_array.(y).(x)<-c));t.buffer<-new_array|Grid_scroll{grid=1;top;bot;left=_;right=_;rows;cols=0}->(* In Neovim 0.7.0, [cols] is fixed at [0] so we never need [left] or [right]. *)unflusht;(* Establish our understanding of grid scrolling. If this is violated we are
probably misinterpreting this event. *)assert(absrows<bot-top);(matchSign.of_introwswith|Zero->()|Neg->fori=bot-1downtotop-rowsdot.buffer.(i)<-Array.copyt.buffer.(i+rows)done|Pos->fori=toptobot-1-rowsdot.buffer.(i)<-Array.copyt.buffer.(i+rows)done)|Win_viewport_->(* This only applies to ext_multigrid but is sent anyway due to a bug:
https://github.com/neovim/neovim/issues/14956 *)()|Default_colors_set_|Highlight_set_|Hl_attr_define_|Hl_group_set_|Mode_change_|Mode_info_set_|Mouse_off|Mouse_on|Option_set_|Update_bg_|Update_fg_|Update_sp_->()|_->raise_s[%message"Ignored UI event"(event:Ui.Event.t)];;letattach?(width=80)?(height=30)hereclient=letopenDeferred.Or_error.Let_syntaxinlett={buffer=[||];cursor_col=0;cursor_row=0;flushed=Mvar.create();ui=Set_once.create();client}inMvar.sett.flushed`Awaiting_first_flush;let%bindui=Ui.attachhereclient~width~height~options:Ui.Options.default~on_event:(applyt)~on_parse_error:`RaiseinSet_once.set_exnt.ui[%here]ui;returnt;;letdetachthere=Mvar.sett.flushed`Detached;Ui.detach(Set_once.get_exnt.ui[%here])here;;letwith_ui?width?heighthereclientf=letopenDeferred.Or_error.Let_syntaxinlet%bindt=attachhere?width?heightclientinlet%bindresult=ftinlet%bind()=detachthereinreturnresult;;endletrecget_screen_contentshereui=matchMvar.peekui.Test_ui.flushedwith|None->let%bind()=Mvar.value_availableui.flushedinget_screen_contentshereui|Some`Awaiting_first_flush->let%bind()=Clock_ns.afterhundred_msinget_screen_contentshereui|Some(`Flushscreen)->(* Attempt to confirm that Neovim has finished sending updates. We don't want to grab
a flush if more data is immediately following. *)choose[choice(Mvar.takenui.flushed)(fun()->get_screen_contentshereui);choice(Clock_ns.afterhundred_ms)(fun()->Deferred.Or_error.returnscreen)]|>Deferred.join|Some`Detached->Deferred.Or_error.error_s[%message"Tried to get screen contents of detached UI"];;letwait_until_text?(timeout=Time_ns.Span.of_int_sec2)hereui~f=letopenDeferred.Or_error.Let_syntaxinletwait_until_text~f=letis_timed_out=reffalseinClock_ns.run_aftertimeout(fun()->is_timed_out:=true)();let%bindresult=letrepeating()=let%bindoutput=get_screen_contentshereuiinmatchfoutput,!is_timed_outwith|true,_->return(`Finished(Ok()))|false,true->return(`Finished(Erroroutput))|false,false->let%map_=Deferred.ok(Clock_ns.afterhundred_ms)in`Repeat()inDeferred.Or_error.repeat_until_finished()repeatinginmatchresultwith|Ok()->return()|Errorscreen_contents->(* print here instead of returning the string in the error in order to
keep the sexp-printing from ruining all the unicode chars *)leterror=Error.of_string"ERROR: timeout when looking for value on screen"inprintf!"%{Error.to_string_hum}\n%s\n"errorscreen_contents;Deferred.Or_error.failerrorinletwait_until_text_stabilizes()=letprev_text=refNoneinlet%bind()=wait_until_text~f:(funtext->match!prev_textwith|Someprev_textwhenString.equaltextprev_text->true|Some_|None->prev_text:=Sometext;false)inreturn(Option.value_exn!prev_text)inlet%bind()=wait_until_text~finwait_until_text_stabilizes();;letwith_ui_client?width?height?time_source?on_error?before_connectingf=with_client?time_source?on_error?before_connecting(funclient->Test_ui.with_ui[%here]?width?heightclient(funui->fclientui));;letsocket_client?(time_source=time_source_at_epoch)?(on_error=`Raise)?(before_connecting=ignore)socket=letclient=Client.create~on_errorinbefore_connectingclient;Client.attachclient~time_source(Unix(`Socketsocket));;moduleFor_debugging=structletwith_ui_client?(time_source=time_source_at_epoch)?(on_error=`Raise)?(before_connecting=ignore)~socketf=let%bindclient=letclient=Client.create~on_errorinbefore_connectingclient;Client.attachclient~time_source(Unix(`Socketsocket))>>|ok_exninlet%bindattached_uis=run_join[%here]clientUi.describe_attached_uis>>|ok_exninletwidth,height=attached_uis|>List.map~f:(fun{width;height;_}->width,height)|>List.unzip|>Tuple2.map~f:(List.min_elt~compare)|>Tuple2.map~f:(funopt->Option.value_exnopt)inlet%bindresult=Test_ui.with_ui[%here]~width~heightclient(funui->fclientui)>>|ok_exninlet%map()=Client.closeclientinresult;;endlet%expect_test"We cannot have two blocking RPCs with the same name"=letregister_dummy_rpc_handler~nameclient=register_request_blockingclient~name~type_:Defun.Ocaml.Sync.(Nil@->returnNil)~f:(fun~keyboard_interrupted:_~client:_()->Deferred.Or_error.return())inlet%map()=with_client(funclient->register_dummy_rpc_handlerclient~name:"test";Expect_test_helpers_base.require_does_raise[%here](fun()->register_dummy_rpc_handlerclient~name:"test");Deferred.Or_error.return())in[%expect{| (Failure "Already defined synchronous RPC 'test'") |}];;let%expect_test"We cannot have two async RPCs with the same name"=letregister_dummy_rpc_handler~nameclient=register_request_asyncclient~name~type_:Defun.Ocaml.Async.(Nil@->unit)~f:(fun~client:_()->Deferred.Or_error.return())inlet%map()=with_client(funclient->register_dummy_rpc_handlerclient~name:"test";Expect_test_helpers_base.require_does_raise[%here](fun()->register_dummy_rpc_handlerclient~name:"test");Deferred.Or_error.return())in[%expect{| (Failure "Already defined asynchronous RPC 'test'") |}];;(* We allow this in case a plugin wants to implement slightly different semantics based
on whether it is called with [rpcrequest] or [rpcnotify]. *)let%expect_test"We can have an async RPC and a blocking RPC with the same name"=let%map()=with_client(funclient->letname="test"inregister_request_blockingclient~name~type_:Defun.Ocaml.Sync.(Nil@->returnNil)~f:(fun~keyboard_interrupted:_~client:_()->Deferred.Or_error.return());register_request_asyncclient~name~type_:Defun.Ocaml.Async.(Nil@->unit)~f:(fun~client:_()->Deferred.Or_error.return());Deferred.Or_error.return())in[%expect{| |}];;let%expect_test"We can have two separate Embedded connections with RPC handlers sharing \
names without error (no bleeding state)"=letregister_dummy_rpc_handler~nameclient=register_request_blockingclient~name~type_:Defun.Ocaml.Sync.(Nil@->returnNil)~f:(fun~keyboard_interrupted:_~client:_()->Deferred.Or_error.return());Deferred.Or_error.return()inlet%bind()=with_client(register_dummy_rpc_handler~name:"test")inlet%map()=with_client(register_dummy_rpc_handler~name:"test")in[%expect{| |}];;