tabby/clients/vim/autoload/tabby.vim

560 lines
13 KiB
VimL

if exists('g:autoloaded_tabby')
finish
endif
let g:autoloaded_tabby = 1
" Table of Contents
" 1. Commands: Implement *:Tabby* commands
" 2. Settings: Handle global *Tabby-options*
" 3. Node Job: Manage node process, implement IO callbacks
" 4. Scheduler: Schedule completion requests
" 5. Completion UI: Show up completion, handle hotkeys
" 6. Utils: Utility functions
" 1. Commands
" See *:Tabby* in help document for more details.
"
" Notable script-local variables:
" - s:commmands
" A dictionary contains all commands. Use name as key and function as value.
"
let s:commands = {}
function! s:commands.status(...)
call tabby#Status()
endfunction
function! s:commands.enable(...)
call tabby#Enable()
call tabby#Status()
endfunction
function! s:commands.disable(...)
call tabby#Disable()
call tabby#Status()
endfunction
function! s:commands.toggle(...)
call tabby#Toggle()
endfunction
function! s:commands.help(...)
let args = get(a:, 1, [])
if len(args) < 1
execute 'help Tabby'
return
endif
try
execute 'help Tabby-' . join(args, '-')
return
catch
endtry
try
execute 'help tabby_' . join(args, '_')
return
catch
endtry
execute 'help Tabby'
endfunction
function! tabby#CompleteCommands(arglead, cmd, pos)
let words = split(a:cmd[0:a:pos].'#', ' ')
if len(words) > 3
return []
endif
if len(words) == 3
if words[1] == 'help'
let candidates = ['compatibility', 'commands', 'options', 'keybindings']
else
return []
endif
else
let candidates = keys(s:commands)
endif
let end_index = len(a:arglead) - 1
if end_index < 0
return candidates
else
return filter(candidates, { idx, val ->
\ val[0:end_index] ==# a:arglead
\})
endif
endfunction
function! tabby#Command(args)
let args = split(a:args, ' ')
if len(args) < 1
call tabby#Status()
echo 'Use :help Tabby to see available commands.'
return
endif
if has_key(s:commands, args[0])
call s:commands[args[0]](args[1:])
else
echo 'Unknown command'
endif
endfunction
" 2. Settings
" See *Tabby-options* in help document for more details.
"
" Available global options:
" - g:tabby_enabled
" - g:tabby_suggestion_delay
" - g:tabby_filetype_to_languages
" - g:tabby_server_url
" - g:tabby_max_prefix_lines
" - g:tabby_max_suffix_lines
"
if !exists('g:tabby_enabled')
let g:tabby_enabled = v:true
endif
if !exists('g:tabby_suggestion_delay')
let g:tabby_suggestion_delay = 150
endif
if !exists('g:tabby_max_prefix_lines')
let g:tabby_max_prefix_lines = 20
endif
if !exists('g:tabby_max_suffix_lines')
let g:tabby_max_suffix_lines = 20
endif
if !exists('g:tabby_filetype_to_languages')
" From: vim filetype https://github.com/vim/vim/blob/master/runtime/filetype.vim
" To: vscode language identifier https://code.visualstudio.com/docs/languages/identifiers#_known-language-identifiers
" Not listed filetype will be used as language identifier directly.
let g:tabby_filetype_to_languages = {
\ "bash": "shellscript",
\ "cs": "csharp",
\ "objc": "objective-c",
\ "objcpp": "objective-cpp",
\ }
endif
if !exists('g:tabby_server_url')
let g:tabby_server_url = 'http://localhost:8080'
endif
if !exists('g:tabby_agent_logs')
let g:tabby_agent_logs = 'error'
endif
" 3. Node Job
"
" Notable script-local variables:
" - s:tabby
" Stores the job id of current node process
"
" - s:tabby_status
" Syncs with status of node agent, updated by notification from agent
"
" - s:errmsg
" Stores error message if self check failed before starting node process
"
function! tabby#Enable()
let g:tabby_enabled = v:true
if !tabby#IsRunning()
call tabby#Start()
endif
endfunction
function! tabby#Disable()
let g:tabby_enabled = v:false
if tabby#IsRunning()
call tabby#Stop()
endif
endfunction
function! tabby#Toggle()
if g:tabby_enabled
call tabby#Disable()
else
call tabby#Enable()
endif
endfunction
function! tabby#Start()
if !g:tabby_enabled || tabby#IsRunning()
return
endif
let check_job = tabby#job#Check()
if !check_job.ok
let s:errmsg = check_job.message
return
endif
let check_virtual_text = tabby#virtual_text#Check()
if !check_virtual_text.ok
let s:errmsg = check_virtual_text.message
return
endif
call tabby#virtual_text#Init()
if !executable('node')
let s:errmsg = 'Tabby requires node to be installed.'
return
endif
let tabby_root = expand('<script>:h:h')
let node_script = tabby_root . '/node_scripts/tabby-agent.js'
if !filereadable(node_script)
let s:errmsg = 'Tabby node script should be download first. Try to run `yarn upgrade-agent`.'
return
endif
let s:tabby_status = 'connecting'
let command = 'node ' . node_script
let s:tabby = tabby#job#Start(command, #{
\ in_mode: 'json',
\ out_mode: 'json',
\ out_cb: function('s:HandleNotification'),
\ err_cb: function('s:HandleError'),
\ exit_cb: function('s:HandleExit'),
\ })
call s:Initialize()
endfunction
function! tabby#Stop()
if tabby#IsRunning()
call tabby#job#Stop(s:tabby)
endif
endfunction
function! tabby#IsRunning()
return exists('s:tabby')
endfunction
function! tabby#Status()
if !g:tabby_enabled
echo 'Tabby is disabled'
return
endif
if tabby#IsRunning()
if s:tabby_status == 'ready'
echo 'Tabby is online'
elseif s:tabby_status == 'connecting'
echo 'Tabby is connecting to server'
elseif s:tabby_status == 'disconnected'
echo 'Tabby cannot connect to server'
endif
elseif exists('s:errmsg')
echo s:errmsg
else
echo 'Tabby is enabled but not running'
endif
endfunction
function! s:Initialize()
if !tabby#IsRunning()
return
endif
call tabby#job#Send(s:tabby, #{
\ func: 'initialize',
\ args: [#{
\ config: #{
\ server: #{ endpoint: g:tabby_server_url },
\ logs: #{ level: g:tabby_agent_logs },
\ },
\ client: s:GetVersionString()
\ }],
\ })
endfunction
function! s:GetCompletion(id)
if !tabby#IsRunning()
return
endif
if exists('s:pending_request_id')
call tabby#job#Send(s:tabby, #{
\ func: 'cancelRequest',
\ args: [s:pending_request_id],
\ })
endif
let s:pending_request_id = tabby#job#Send(s:tabby, #{
\ func: 'getCompletions',
\ args: [s:CreateCompletionRequest()],
\ }, #{
\ callback: function('s:HandleCompletion', [a:id]),
\ })
endfunction
function! s:PostEvent(event_type)
if !tabby#IsRunning()
return
endif
if !exists('s:completion') || !exists('s:choice_index')
return
endif
call tabby#job#Send(s:tabby, #{
\ func: 'postEvent',
\ args: [#{
\ type: a:event_type,
\ completion_id: s:completion.id,
\ choice_index: s:completion.choices[s:choice_index].index,
\ }],
\ })
endfunction
function! s:HandleNotification(channel, data)
if (type(a:data) == v:t_dict) && has_key(a:data, 'event') && (a:data.event == 'statusChanged')
let s:tabby_status = a:data.status
endif
endfunction
function! s:HandleCompletion(id, channel, data)
if !exists('s:trigger_id') || (a:id != s:trigger_id)
return
endif
if (type(a:data) == v:t_dict) && has_key(a:data, 'choices') &&
\ (type(a:data.choices) == v:t_list) && (len(a:data.choices) > 0)
let s:completion = a:data
let s:choice_index = 0
call tabby#Show()
endif
endfunction
function! s:HandleError(channel, data)
" For Debug
" echoerr "HandleError: " . string(a:data)
endfunction
function! s:HandleExit(channel, data)
if exists('s:tabby')
unlet s:tabby
endif
if exists('s:tabby_status')
unlet s:tabby_status
endif
endfunction
" 4. Scheduler
"
" Notable script-local variables:
" - s:scheduled:
" Stores the timer id of current scheduled next trigger.
"
" - s:trigger_id:
" Use a timestamp to identify current triggered completion request. This is
" used to filter out outdated completion results.
"
function! tabby#Schedule()
if !tabby#IsRunning()
return
endif
call tabby#Clear()
let s:scheduled = timer_start(g:tabby_suggestion_delay, function('tabby#Trigger'))
endfunction
function! tabby#Trigger(timer)
if !tabby#IsRunning()
return
endif
call tabby#Clear()
let id = join(reltime(), '.')
let s:trigger_id = id
call s:GetCompletion(id)
endfunction
" 5. Completion UI
"
" Notable script-local variables:
" - s:completion:
" Stores current completion data, a dictionary that has same struct as server
" returned completion response.
"
" - s:choice_index:
" Stores index of current choice to display. A 0-based index of choice item
" of `s:completion.choices` array, may not equals to the value of `index`
" field `s:completion.choices[s:choice_index].index`.
" A exception is that when `s:choice_index` is equal to the length of
" `s:completion.choices`. In this state, the completion UI should show nothing
" to notice users that they are cycling from last choice forward to first
" choice, or from first choice back to last choice.
" This variable does not change when user dismisses the completion UI.
"
" - s:shown_lines:
" Stores the text that are shown in completion UI. `s:shown_lines` exists or
" not means whether the completion UI is shown or not.
"
" - s:text_to_insert:
" Used as a buffer to store the text that should be inserted when user accepts
" the completion. We hide completion UI first and clear `s:shown_lines` at
" same time, then insert the text, so that we need to store the text in a
" buffer until text is inserted.
function! tabby#Show()
call s:HideCompletion()
if !s:IsCompletionAvailable()
return
endif
if s:choice_index == len(s:completion.choices)
" Show empty to indicate that user is cycling back to first choice.
return
endif
let choice = s:completion.choices[s:choice_index]
if (type(choice.text) != v:t_string) || (len(choice.text) == 0)
return
endif
let lines = split(choice.text, "\n")
" split will not give an empty line if text starts with "\n" or ends with "\n"
if choice.text[0] == "\n"
call insert(lines, '')
endif
if choice.text[-1] == "\n"
call add(lines, '')
endif
call tabby#virtual_text#Show(lines)
let s:shown_lines = lines
call s:PostEvent('view')
endfunction
function! tabby#ConsumeInsertion()
if !exists('s:text_to_insert')
return ''
else
let text = s:text_to_insert
unlet s:text_to_insert
return text
endif
endfunction
" This function is designed to replace <Tab> key input, so we need a fallback
" when completion UI is not shown.
function! tabby#Accept(fallback)
if !exists('s:shown_lines')
return a:fallback
endif
let lines = s:shown_lines
if len(lines) == 1
let s:text_to_insert = lines[0]
let insertion = "\<C-R>\<C-O>=tabby#ConsumeInsertion()\<CR>"
else
let current_line = getbufline('%', line('.'), line('.'))[0]
let suffix_chars_to_replace = len(current_line) - col('.') + 1
let s:text_to_insert = join(lines, "\n")
let insertion = repeat("\<Del>", suffix_chars_to_replace) . "\<C-R>\<C-O>=tabby#ConsumeInsertion()\<CR>"
endif
call s:HideCompletion()
call s:PostEvent('select')
return insertion
endfunction
" This function is designed to replace <C-]> key input, so we need a fallback
" when completion UI is not shown.
function! tabby#Dismiss(fallback)
if !exists('s:shown_lines')
return a:fallback
endif
call s:HideCompletion()
return ''
endfunction
function! tabby#Next()
if !s:IsCompletionAvailable()
return
endif
if !exists('s:shown_lines')
if s:choice_index == len(s:completion.choices)
let s:choice_index = 0
endif
else
let s:choice_index += 1
if s:choice_index > len(s:completion.choices)
let s:choice_index = 0
endif
endif
call tabby#Show()
endfunction
function! tabby#Prev()
if !s:IsCompletionAvailable()
return
endif
if !exists('s:shown_lines')
if s:choice_index == len(s:completion.choices)
let s:choice_index = len(s:completion.choices) - 1
endif
else
let s:choice_index -= 1
if s:choice_index < 0
let s:choice_index = len(s:completion.choices)
endif
endif
call tabby#Show()
endfunction
function! tabby#Clear()
call s:HideCompletion()
if exists('s:scheduled')
call timer_stop(s:scheduled)
unlet s:scheduled
endif
if exists('s:trigger_id')
unlet s:trigger_id
endif
if exists('s:completion')
unlet s:completion
endif
if exists('s:choice_index')
unlet s:choice_index
endif
endfunction
function! s:IsCompletionAvailable()
if !exists('s:completion') || !exists('s:choice_index')
return v:false
endif
if (type(s:completion.choices) != v:t_list) || (len(s:completion.choices) == 0)
return v:false
endif
return v:true
endfunction
function! s:HideCompletion()
call tabby#virtual_text#Clear()
if exists('s:shown_lines')
unlet s:shown_lines
endif
endfunction
" 6. Utils
function! s:CreateCompletionRequest()
return #{
\ filepath: expand('%:p'),
\ language: s:GetLanguage(),
\ text: join(getbufline('%', 1, '$'), "\n"),
\ position: line2byte(line('.')) + col('.') - 2,
\ maxPrefixLines: g:tabby_max_prefix_lines,
\ maxSuffixLines: g:tabby_max_suffix_lines,
\ }
endfunction
function! s:GetLanguage()
let filetype = getbufvar('%', '&filetype')
if has_key(g:tabby_filetype_to_languages, filetype)
return g:tabby_filetype_to_languages[filetype]
else
return filetype
endif
endfunction
function! s:GetVersionString()
let version_string = execute('version')
return split(version_string, "\n")[0]
endfunction