diff --git a/README.md b/README.md index 1ec589ea..d2ced8aa 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,11 @@ default mappings can be disabled: let g:rtagsUseDefaultMappings = 0 +Diagnostics will be retrieved from rtags automatically and displayed as signs in the gutter column. +To disable this behaviour, set: + + let g:rtagsAutoDiagnostics = 0 + By default, search results are showed in a location list. Location lists are local to the current window. To use the vim QuickFix window, which is shared between all windows, set: @@ -76,6 +81,8 @@ It is possible to set its maximum size (number of entries), default is 100: | <Leader>rw | -e -r --rename | Rename symbol under cursor | | <Leader>rv | -k -r | Find virtuals | | <Leader>rd | --diagnose | Diagnose file for warnings and errors | +| <Leader>rD | --diagnose-all | Diagnose all files in project | +| <Leader>rx | --fixits | Apply diagnostic fixits to current buffer | | <Leader>rb | N/A | Jump to previous location | ## Unite sources @@ -87,15 +94,17 @@ This plugin defines three Unite sources: * `rtags/project` - list/switch projects. ## Code completion -Code completion functionality uses ```completefunc``` (i.e. CTRL-X CTRL-U). If ```completefunc``` -is set, vim-rtags will not override it with ```RtagsCompleteFunc```. This functionality is still -unstable, but if you want to try it you will have to set ```completefunc``` by +The ```omnifunc``` (i.e. CTRL-X CTRL-O) is overridden with ```RtagsCompleteFunc``` for cpp +filetypes by default. This can be toggled using ```let g:rtagsCppOmnifunc = 0```. +If ```g:rtagsCppOmnifunc``` is set to ```0``` then the ```completefunc``` (i.e. CTRL-X CTRL-U) +will be set instead, but only if it's not already used. - set completefunc=RtagsCompleteFunc - -Also ```RtagsCompleteFunc``` can be used as omnifunc. For example, you can use -such approach with [neocomplete](https://github.com/Shougo/neocomplete.vim)(for more details read it's docs): +Compatibility with [YouCompleteMe](https://valloric.github.io/YouCompleteMe/) is just a matter of +disabling their built-in cpp completions and allowing vim-rtags to take over via their fallback to +the ```omnifunc```. +Compatibility with [neocomplete](https://github.com/Shougo/neocomplete.vim) can be achieved with +(for more details read it's docs): ``` function! SetupNeocompleteForCppWithRtags() " Enable heavy omni completion. @@ -105,14 +114,13 @@ function! SetupNeocompleteForCppWithRtags() let g:neocomplete#sources#omni#input_patterns = {} endif let l:cpp_patterns='[^.[:digit:] *\t]\%(\.\|->\)\|\h\w*::' - let g:neocomplete#sources#omni#input_patterns.cpp = l:cpp_patterns + let g:neocomplete#sources#omni#input_patterns.cpp = l:cpp_patterns set completeopt+=longest,menuone endfunction autocmd FileType cpp,c call SetupNeocompleteForCppWithRtags() - ``` -Such config provides automatic calls, of omnicompletion on c and cpp entity accessors. +Such config provides automatic calls of omnicompletion on c and cpp entity accessors. ### Current limitations * There is no support for overridden functions and methods @@ -123,6 +131,8 @@ Such config provides automatic calls, of omnicompletion on c and cpp entity acce # Development Unit tests for some plugin functions can be found in ```tests``` directory. -To run tests, execute: - +To run tests, execute (note `nose` is required for python tests): +``` $ vim tests/test_rtags.vim +UnitTest + $ nosetests tests +``` diff --git a/doc/rtags.txt b/doc/rtags.txt old mode 100644 new mode 100755 index abc7f026..47edd78e --- a/doc/rtags.txt +++ b/doc/rtags.txt @@ -47,8 +47,8 @@ g:rtagsRdmCmd location if it is installed to a non standard location or the location doesn't appear in the PATH. - *g:rtagsAutoLaunchRdm* - *rtags-variable-auto-launch-rdm* + *g:rtagsAutoLaunchRdm* + *rtags-variable-auto-launch-rdm* g:rtagsAutoLaunchRdm Default: 0. @@ -79,8 +79,8 @@ g:rtagsUseDefaultMappings Otherwise, no mappings are set up and custom mappings can be configured by a user. - *g:rtagsMinCharsForCommandCompletion* - *rtags-variable-min-chars-for-cmd-compl* + *g:rtagsCppOmnifunc* + *rtags-variable-cpp-omnifunc* g:rtagsMinCharsForCommandCompletion Default: 4. @@ -88,6 +88,15 @@ g:rtagsMinCharsForCommandCompletion available for commands provided by the plugin or pluging mappings that require user input. + *g:rtagsMinCharsForCommandCompletion* + *rtags-variable-min-chars-for-cmd-compl* +g:rtagsCppOmnifunc + + Default: 1. + Override the vim completion |omnifunc| for the cpp filetype. If disabled, + then the |completefunc| will be used instead, but only if it's not already + set. + *g:rtagsMaxSearchResultWindowHeight* *rtags-variable-max-search-result-window-height* g:rtagsMaxSearchResultWindowHeight @@ -96,13 +105,39 @@ g:rtagsMaxSearchResultWindowHeight Determines the maximum height of the search result window. When number of results is less than this parameter, the height is set to the number of results. + *g:rtagsAutoDiagnostics* + *rtags-variable-auto-diagnostics* +g:rtagsAutoDiagnostics + + Default: 1 + If enabled then diagnostic gutter signs will be updated in the currently + active buffer automatically, and each line's diagnostic message will show + in the message window as the cursor moves through the file. + + *g:rtagsDiagnosticsPollingInterval* + *rtags-variable-diagnostics-polling-interval* +g:rtagsDiagnosticsPollingInterval + + Default: 3000 + If |g:rtagsAutoDiagnostics| is enabled, then poll for changes to the RTags + project with this interval and update diagnostic signs and lists if changes + are found. Set to <=0 to disable polling. If disabled, updates to + diagnostics will be checked only after |CursorHold| and |BufEnter| events. *g:rtagsLog* *rtags-variable-rtags-log* g:rtagsLog - Default: empty - When set to filename, rtags will put its logs in that file. + Default: |tempname| + File to log vim-rtags output. + + *g:rtagsRdmLog* + *rtags-variable-rtags-rdm-log* +g:rtagsRdmLog + + Default: |tempname| + If |g:rtagsAutoLaunchRdm| is enabled, then the RTags rdm daemon will log + its output to this file. *rtags-mappings* 4. Mappings @@ -192,6 +227,26 @@ g:rtagsLog *rtags-FindSubClasses* rc Find the subclasses of the class under the cursor. + *rtags-leader-rd* + *rtags-Diagnostics* + rd Show diagnostics for the current buffer in the location list, + along with diagnostic signs in the current window's + gutter. + + *rtags-leader-rD* + *rtags-DiagnosticsAll* + rD Show diagnostics for the whole project in the location or + quickfix list (depending on the value of + |g:rtagsUseLocationList|). That is, all the errors and + warnings from rdm's clang build of the current project. + + *rtags-leader-rx* + *rtags-FixIts* + rx Apply diagnostic clang fixits to the current buffer. Fixits + are identified with "Fx" in the sign column, and are + suffixed with "[FIXIT]" in the diagnostics lists. + + *rtags-commands* 5. Commands @@ -203,6 +258,14 @@ g:rtagsLog - RtagsIFindRefsByName - RtagsLoadCompilationDb + *RtagsResetCaches* + + RtagsResetCaches + + Reset the buffer and RTags project cache. This may be needed if you create + or modify the RTags project during a vim session. + + *rtags-integrations* 6. Integrations with other plugins diff --git a/plugin/__init__.py b/plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin/rtags.vim b/plugin/rtags.vim index 3bc8bfcb..d80a0663 100644 --- a/plugin/rtags.vim +++ b/plugin/rtags.vim @@ -27,6 +27,14 @@ if !exists("g:rtagsRdmCmd") let g:rtagsRdmCmd = "rdm" endif +if !exists("g:rtagsLog") + let g:rtagsLog = tempname() +endif + +if !exists("g:rtagsRdmLog") + let g:rtagsRdmLog = tempname() +endif + if !exists("g:rtagsAutoLaunchRdm") let g:rtagsAutoLaunchRdm = 0 endif @@ -57,12 +65,18 @@ if !exists("g:rtagsMaxSearchResultWindowHeight") let g:rtagsMaxSearchResultWindowHeight = 10 endif -if g:rtagsAutoLaunchRdm - call system(g:rtagsRcCmd." -w") - if v:shell_error != 0 - call system(g:rtagsRdmCmd." --daemon > /dev/null") - end -end +if !exists("g:rtagsAutoDiagnostics") + let g:rtagsAutoDiagnostics = 1 +endif + +if !exists("g:rtagsDiagnosticsPollingInterval") + let g:rtagsDiagnosticsPollingInterval = 3000 +endif + +if !exists("g:rtagsCppOmnifunc") + let g:rtagsCppOmnifunc = 1 +endif + let g:SAME_WINDOW = 'same_window' let g:H_SPLIT = 'hsplit' @@ -96,6 +110,8 @@ if g:rtagsUseDefaultMappings == 1 noremap rC :call rtags#FindSuperClasses() noremap rc :call rtags#FindSubClasses() noremap rd :call rtags#Diagnostics() + noremap rD :call rtags#DiagnosticsAll() + noremap rx :call rtags#ApplyFixit() endif let s:script_folder_path = escape( expand( ':p:h' ), '\' ) @@ -110,15 +126,11 @@ function! rtags#InitPython() exe g:rtagsPy." ".s:pyInitScript endfunction -call rtags#InitPython() - """ " Logging routine """ function! rtags#Log(message) - if exists("g:rtagsLog") - call writefile([string(a:message)], g:rtagsLog, "a") - endif + call writefile([strftime("%Y-%m-%d %H:%M:%S", localtime()) . " | vim | " . string(a:message)], g:rtagsLog, "a") endfunction " @@ -487,10 +499,14 @@ function! rtags#JumpToHandler(results, args) if len(results) >= 0 && open_opt != g:SAME_WINDOW call rtags#cloneCurrentBuffer(open_opt) endif - + call rtags#Log("JumpTo results with ".json_encode(a:args).": ".json_encode(results)) if len(results) > 1 call rtags#DisplayResults(results) elseif len(results) == 1 + if results[0] == "Not indexed" + echom "Failed to jump - file is not indexed" + return + endif let [location; symbol_detail] = split(results[0], '\s\+') let [jump_file, lnum, col; rest] = split(location, ':') @@ -499,6 +515,8 @@ function! rtags#JumpToHandler(results, args) if rtags#jumpToLocation(jump_file, lnum, col) normal! zz endif + else + echom "Failed to jump - cannot follow symbol" endif endfunction @@ -544,7 +562,7 @@ function! rtags#saveLocation() endfunction function! rtags#pushToStack(location) - let jumpListLen = len(g:rtagsJumpStack) + let jumpListLen = len(g:rtagsJumpStack) if jumpListLen > g:rtagsJumpStackMaxSize call remove(g:rtagsJumpStack, 0) endif @@ -737,7 +755,7 @@ function! rtags#ExecuteHandlers(output, handlers) return endtry endif - endfor + endfor endfunction function! rtags#ExecuteThen(args, handlers) @@ -886,53 +904,47 @@ function! rtags#FindSymbolsOfWordUnderCursor() endfunction function! rtags#Diagnostics() - let s:file = expand("%:p") - return s:Pyeval("vimrtags.get_diagnostics()") + return s:Pyeval("vimrtags.Buffer.current().show_diagnostics_list()") endfunction -" -" This function assumes it is invoked from insert mode -" -function! rtags#CompleteAtCursor(wordStart, base) - let flags = "--synchronous-completions -l" - let file = expand("%:p") - let pos = getpos('.') - let line = pos[1] - let col = pos[2] +function! rtags#DiagnosticsAll() + return s:Pyeval("vimrtags.Buffer.show_all_diagnostics()") +endfunction - if index(['.', '::', '->'], a:base) != -1 - let col += 1 +function! rtags#ApplyFixit() + return s:Pyeval("vimrtags.Buffer.current().apply_fixits()") +endfunction + +function! rtags#NotifyEdit() + return s:Pyeval("vimrtags.Buffer.current().on_edit()") +endfunction + +function! rtags#NotifyWrite() + return s:Pyeval("vimrtags.Buffer.current().on_write()") +endfunction + +function! rtags#NotifyIdle() + return s:Pyeval("vimrtags.Buffer.current().on_idle()") +endfunction + +function! rtags#NotifyCursorMoved() + return s:Pyeval("vimrtags.Buffer.current().on_cursor_moved()") +endfunction + +function! rtags#Poll(timer) + if &filetype == "cpp" || &filetype == "c" + call s:Pyeval("vimrtags.Buffer.current().on_poll()") endif + call timer_start(g:rtagsDiagnosticsPollingInterval, "rtags#Poll") +endfunction - let rcRealCmd = rtags#getRcCmd() - - exec "normal! \" - let stdin_lines = join(getline(1, "$"), "\n").a:base - let offset = len(stdin_lines) - - exec "startinsert!" - " echomsg getline(line) - " sleep 1 - " echomsg "DURING INVOCATION POS: ".pos[2] - " sleep 1 - " echomsg stdin_lines - " sleep 1 - " sed command to remove CDATA prefix and closing xml tag from rtags output - let sed_cmd = "sed -e 's/.*CDATA\\[//g' | sed -e 's/.*\\/completions.*//g'" - let cmd = printf("%s %s %s:%s:%s --unsaved-file=%s:%s | %s", rcRealCmd, flags, file, line, col, file, offset, sed_cmd) - call rtags#Log("Command line:".cmd) - - let result = split(system(cmd, stdin_lines), '\n\+') - " echomsg "Got ".len(result)." completions" - " sleep 1 - call rtags#Log("-----------") - "call rtags#Log(result) - call rtags#Log("-----------") - return result - " for r in result - " echo r - " endfor - " call rtags#DisplayResults(result) +" Generic function to get output of a command. +" Used in python for things that can't be read directly via vim.eval(...) +function! rtags#getCommandOutput(cmd_txt) abort + redir => output + silent execute a:cmd_txt + redir END + return output endfunction function! s:Pyeval( eval_string ) @@ -942,7 +954,7 @@ function! s:Pyeval( eval_string ) return pyeval( a:eval_string ) endif endfunction - + function! s:RcExecuteJobCompletion() call rtags#SetJobStateFinish() if ! empty(b:rtags_state['stdout']) && mode() == 'i' @@ -1072,7 +1084,7 @@ endf " - invoke completion through rc " - filter out options that start with meth (in this case). " - show completion options -" +" " Reason: rtags returns all options regardless of already type method name " portion """ @@ -1102,7 +1114,11 @@ function! s:RtagsCompleteFunc(findstart, base, async) endif endfunction -if &completefunc == "" +" Prefer omnifunc, if enabled. +if g:rtagsCppOmnifunc == 1 + autocmd Filetype cpp,c setlocal omnifunc=RtagsCompleteFunc +" Override completefunc if it's available to be used. +elseif &completefunc == "" set completefunc=RtagsCompleteFunc endif @@ -1129,3 +1145,35 @@ command! -nargs=1 -complete=dir RtagsLoadCompilationDb call rtags#LoadCompilatio " The most commonly used find operation command! -nargs=1 -complete=customlist,rtags#CompleteSymbols Rtag RtagsIFindSymbols +" Reset all Python caches - maybe useful if the RTags project has been messed with +" after the vim session has started. +command! RtagsResetCaches call s:Pyeval('vimrtags.reset_caches()') + +if g:rtagsAutoLaunchRdm + call system(g:rtagsRcCmd." -w") + if v:shell_error != 0 + let s:cmd = g:rtagsRdmCmd." --daemon --log-timestamp --log-flush --log-file ".g:rtagsRdmLog + call rtags#Log("Starting RDM: ".s:cmd) + call system(s:cmd) + end +end + + +call rtags#InitPython() + + +if g:rtagsAutoDiagnostics == 1 + augroup rtags_auto_diagnostics + autocmd! + autocmd BufWritePost *.cpp,*.c,*.hpp,*.h call rtags#NotifyWrite() + autocmd TextChanged,TextChangedI *.cpp,*.c,*.hpp,*.h call rtags#NotifyEdit() + autocmd CursorHold,CursorHoldI,BufEnter *.cpp,*.c,*.hpp,*.h call rtags#NotifyIdle() + autocmd CursorMoved,CursorMovedI *.cpp,*.c,*.hpp,*.h call rtags#NotifyCursorMoved() + augroup END + + if g:rtagsDiagnosticsPollingInterval > 0 + call rtags#Log("Starting async update checking") + call rtags#Poll(0) + endif +endif + diff --git a/plugin/vimrtags.py b/plugin/vimrtags.py index 8a3947f5..05bf657c 100644 --- a/plugin/vimrtags.py +++ b/plugin/vimrtags.py @@ -1,63 +1,81 @@ +from __future__ import print_function # For unit tests import vim import json import subprocess -import io import os import sys -import tempfile - +import re import logging -tempdir = tempfile.gettempdir() -logging.basicConfig(filename='%s/vim-rtags-python.log' % tempdir,level=logging.DEBUG) +from time import time + + +print = print # For unit tests + +loglevel = logging.DEBUG +logger = logging.getLogger(__name__) + + +def configure_logger(): + logfile = vim.eval('g:rtagsLog') + handler = logging.FileHandler(logfile) + formatter = logging.Formatter("%(asctime)s | py | %(levelname)s | %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(loglevel) + + +configure_logger() + def get_identifier_beginning(): line = vim.eval('s:line') column = int(vim.eval('s:start')) - logging.debug(line) - logging.debug(column) + logger.debug("Completion line:column=%s:%s" % (line, column)) while column >= 0 and (line[column].isalnum() or line[column] == '_'): column -= 1 return column + 1 -def run_rc_command(arguments, content = None): + +def run_rc_command(arguments, content=None): rc_cmd = os.path.expanduser(vim.eval('g:rtagsRcCmd')) - cmdline = rc_cmd + " " + arguments + cmdline = [rc_cmd] + arguments encoding = 'utf-8' out = None err = None + logger.debug("RTags command: %s" % cmdline) if sys.version_info.major == 3 and sys.version_info.minor >= 5: r = subprocess.run( - cmdline.split(), - input = content.encode(encoding), - stdout = subprocess.PIPE, - stderr = subprocess.PIPE, + cmdline, + input=content and content.encode(encoding), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE ) out, err = r.stdout, r.stderr - if not out is None: + if out is not None: out = out.decode(encoding) - if not err is None: + if err is not None: err = err.decode(encoding) elif sys.version_info.major == 3 and sys.version_info.minor < 5: r = subprocess.Popen( - cmdline.split(), + cmdline, bufsize=0, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT ) out, err = r.communicate(input=content.encode(encoding)) - if not out is None: + if out is not None: out = out.decode(encoding) - if not err is None: + if err is not None: err = err.decode(encoding) else: r = subprocess.Popen( - cmdline.split(), + cmdline, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT @@ -65,7 +83,10 @@ def run_rc_command(arguments, content = None): out, err = r.communicate(input=content) if r.returncode != 0: - logging.debug(err) + logger.debug(err) + return None + elif "is not indexed" in out: + logger.debug(out) return None return out @@ -74,32 +95,47 @@ def run_rc_command(arguments, content = None): def get_rtags_variable(name): return vim.eval('g:rtags' + name) + def parse_completion_result(data): result = json.loads(data) - logging.debug(result) completions = [] for c in result['completions']: - k = c['kind'] - kind = '' - if k == 'FunctionDecl' or k == 'FunctionTemplate': - kind = 'f' - elif k == 'CXXMethod' or k == 'CXXConstructor': - kind = 'm' - elif k == 'VarDecl': - kind = 'v' - elif k == 'macro definition': - kind = 'd' - elif k == 'EnumDecl': - kind = 'e' - elif k == 'TypedefDecl' or k == 'StructDecl' or k == 'EnumConstantDecl': - kind = 't' - - match = {'menu': c['completion'], 'word': c['completion'], 'kind': kind} - completions.append(match) + k = c['kind'] + kind = '' + if k == 'FunctionDecl' or k == 'FunctionTemplate': + kind = 'f' + description = _completion_description(c, ['parent', 'signature']) + elif k == 'CXXMethod' or k == 'CXXConstructor': + kind = 'm' + description = _completion_description(c, ['parent', 'signature']) + elif k == 'VarDecl': + kind = 'v' + description = _completion_description(c, ['parent', 'signature']) + elif k == 'macro definition': + kind = 'd' + description = _completion_description(c, ['signature']) + elif k == 'EnumDecl': + kind = 'e' + description = _completion_description(c, ['parent']) + elif k in ('TypedefDecl', 'StructDecl', 'EnumConstantDecl', 'ClassDecl', 'FieldDecl'): + kind = 't' + description = _completion_description(c, ['parent']) + else: + description = _completion_description(c, []) + + match = {'menu': description, 'word': c['completion'], 'kind': kind} + completions.append(match) return completions + +def _completion_description(completion, fields): + fields += ['brief_comment'] + fields = [field for field in fields if completion[field] != completion['completion']] + return " -- ".join(filter(None, [completion[field] for field in fields])) + + def send_completion_request(): filename = vim.eval('s:file') line = int(vim.eval('s:line')) @@ -107,92 +143,663 @@ def send_completion_request(): prefix = vim.eval('s:prefix') for buffer in vim.buffers: - logging.debug(buffer.name) + logger.debug(buffer.name) if buffer.name == filename: lines = [x for x in buffer] content = '\n'.join(lines[:line - 1] + [lines[line - 1] + prefix] + lines[line:]) - cmd = ('--synchronous-completions -l %s:%d:%d --unsaved-file=%s:%d --json' - % (filename, line, column, filename, len(content))) + cmd = [ + '--synchronous-completions', '-l', '%s:%d:%d' % (filename, line, column), + '--unsaved-file=%s:%d' % (filename, len(content)), '--json' + ] + if len(prefix) > 0: - cmd += ' --code-complete-prefix %s' % prefix + cmd += ['--code-complete-prefix', prefix] content = run_rc_command(cmd, content) - if content == None: + # logger.debug("Got completion: %s" % content) + if content is None: return None return parse_completion_result(content) assert False -def display_locations(errors, buffer): - if len(errors) == 0: - return - - error_data = json.dumps(errors) - max_height = int(get_rtags_variable('MaxSearchResultWindowHeight')) - height = min(max_height, len(errors)) - if int(get_rtags_variable('UseLocationList')) == 1: - vim.eval('setloclist(%d, %s)' % (buffer.number, error_data)) +def reset_caches(): + Buffer.reset() + Project.reset() + Sign.reset() + message("RTags caches have been reset") + + +class Buffer(object): + _cache = {} + _cache_last_cleaned = time() + _CACHE_CLEAN_PERIOD = 30 + _DIAGNOSTICS_LIST_TITLE = "RTags diagnostics" + _DIAGNOSTICS_ALL_LIST_TITLE = "RTags diagnostics for all files" + _FIXITS_LIST_TITLE = "RTags fixits applied" + + @staticmethod + def current(): + return Buffer.get(vim.current.buffer.number) + + @staticmethod + def find(name): + """ Find a Buffer by vim buffer (file)name. + """ + for buffer in vim.buffers: + if buffer.name == name: + return Buffer.get(buffer.number) + else: + return None + + @staticmethod + def get(id_): + """ Get a Buffer wrapping a vim buffer, by id_. + + Create the Buffer if necessary. + """ + # Get from cache or create if it's not there. + buff = Buffer._cache.get(id_) + if buff is not None: + return buff + + # Periodically clean closed buffers + Buffer._clean_cache_periodically() + + logger.debug("Wrapping new buffer: %s" % id_) + buff = Buffer(vim.buffers[id_]) + Buffer._cache[id_] = buff + return buff + + @staticmethod + def reset(): + """ Clear the Buffer cache. + + Remove all signs in still-valid buffers, then empty the cache. + """ + Buffer._clean_cache() + for buffer in Buffer._cache.values(): + buffer._reset_signs() + Buffer._cache = {} + + @staticmethod + def _clean_cache_periodically(): + """ Periodically clean closed buffers + """ + if time() - Buffer._cache_last_cleaned > Buffer._CACHE_CLEAN_PERIOD: + Buffer._clean_cache() + + @staticmethod + def _clean_cache(): + """ Clean closed buffers + """ + for id_old in list(Buffer._cache.keys()): + if not Buffer._cache[id_old]._vimbuffer.valid: + logger.debug("Cleaning invalid buffer: %s" % id_old) + del Buffer._cache[id_old] + Buffer._cache_last_cleaned = time() + + @staticmethod + def show_all_diagnostics(): + """ Get all diagnostics for all files and show in quickfix list. + """ + # Get the diagnostics from rtags. + content = run_rc_command([ + '--diagnose-all', '--current-file', vim.current.buffer.name, + '--synchronous-diagnostics', '--json' + ]) + content = run_rc_command(['--diagnose-all', '--synchronous-diagnostics', '--json']) + if content is None: + return error('Failed to get diagnostics') + data = json.loads(content) + + # Construct Diagnostic objects from rtags errors. + diagnostics = [] + for filename, errors in data['checkStyle'].items(): + diagnostics += Diagnostic.from_rtags_errors(filename, errors) + + if not diagnostics: + return message("No errors to display") + + # Convert list of Diagnostic objects to quickfix-compatible dict. + height, lines = Diagnostic.to_qlist_errors(diagnostics) + + # Show diagnostics in location list or quickfix list, depending on user preference. + if int(get_rtags_variable('UseLocationList')) == 1: + vim.eval( + 'setloclist(%d, [], " ", {"items": %s, "title": "%s"})' + % (vim.current.window.number, lines, Buffer._DIAGNOSTICS_ALL_LIST_TITLE) + ) + vim.command('lopen %d' % height) + else: + vim.eval( + 'setqflist([], " ", {"items": %s, "title": "%s"})' + % (lines, Buffer._DIAGNOSTICS_ALL_LIST_TITLE) + ) + vim.command('copen %d' % height) + + def __init__(self, buffer): + self._vimbuffer = buffer + self._signs = [] + self._diagnostics = {} + self._last_diagnostics_time = 0 + self._line_num_last = -1 + self._is_line_diagnostic_shown = False + self._is_dirty = False + + self._project = Project.get(self._vimbuffer.name) + + def on_write(self): + self._is_dirty = False + + def on_edit(self): + """ Mark buffer dirty, potentially flagging a reindex using unsaved content. + """ + if self._project is None: + return + self._is_dirty = True + + def on_idle(self): + """ Reindex or update diagnostics for this buffer. + + Check if the buffer is modified and needs reindexing, if so then reindex, otherwise + check if the RTags project for this file has been updated on disk, if so then update + diagnostics. + """ + # logger.debug("Idle callback for %s" % self._vimbuffer.name) + if self._project is None: + return + + if self._is_really_dirty(): + # Reindex dirty buffer - no point refreshing diagnostics at this point. + logger.debug("Buffer %s needs dirty reindex" % self._vimbuffer.number) + self._last_diagnostics_time = time() + self._rtags_dirty_reindex() + + elif self._last_diagnostics_time < self._project.last_updated_time(): + # Update diagnostics signs/list. + logger.debug( + "Project updated (idle), checking for updated diagnostics for %s" + % self._vimbuffer.name + ) + self._update_diagnostics() + + def on_poll(self): + """ Update diagnostics for this buffer. + + Only update if the buffer does not need reindexing and the RTags project has changed + on disk. + """ + # logger.debug(Poll callback for %s" % self._vimbuffer.name) + if self._project is None: + return + + if ( + not self._is_really_dirty() and + self._last_diagnostics_time < self._project.last_updated_time() + ): + # Update diagnostics signs/list. + logger.debug( + "Project updated (polling), checking for updated diagnostics for %s" + % self._vimbuffer.name + ) + self._update_diagnostics() + + def on_cursor_moved(self): + """ Print diagnostic info for current line + """ + if self._project is None: + return + line_num = vim.current.window.cursor[0] + if line_num != self._line_num_last: + self._line_num_last = line_num + diagnostic = self._diagnostics.get(line_num) + if diagnostic is not None: + print(diagnostic.text) + self._is_line_diagnostic_shown = True + elif self._is_line_diagnostic_shown: + # If there is no diagnostic for this line, clear the message. + print("") + # Make sure we only clear the message when we've recently shown one, so we don't + # splat other plugins messages. + self._is_line_diagnostic_shown = False + + def show_diagnostics_list(self): + """ Show diagnostics for this buffer in location/quickfix list. + + Diagnostics are updated before being displayed, just in case. + """ + if self._project is None: + return invalid_buffer_message(self._vimbuffer.name) + + self._update_diagnostics(open_loclist=True) + if not self._diagnostics: + return message("No errors to display") + + def apply_fixits(self): + """ Fetch fixits from rtags, apply them, and show changed lines in quickfix/location list. + """ + if self._project is None: + return invalid_buffer_message(self._vimbuffer.name) + # Not safe to apply fixits if file is not indexed, make extra sure it is. + if ( + self._rtags_is_reindexing() or ( + int(get_rtags_variable("AutoDiagnostics")) and + self._last_diagnostics_time < self._project.last_updated_time() + ) + ): + return message( + "File is currently reindexing, so fixits are unsafe, please try again shortly" + ) + + # Get the fixits from rtags. + content = run_rc_command(['--fixits', self._vimbuffer.name]) + if content is None: + return error("Failed to fetch fixits") + content = content.strip() + if not content: + return message("No fixits to apply to this file") + + logger.debug("Fixits found:\n%s" % content) + fixits = content.split('\n') + lines = [] + + # Loop over fixits applying each in turn and record what was done for qlist/loclist. + for i, fixit in enumerate(fixits): + # Regex parse the fixits. + fixit = re.match("^(\d+):(\d+) (\d+) (.+)$", fixit) + if fixit is None: + continue + line_num = int(fixit.group(1)) + line_idx = line_num - 1 + char_num = int(fixit.group(2)) + char_idx = char_num - 1 + length = int(fixit.group(3)) + text = fixit.group(4) + + # Edit the buffer to apply the fixit. + self._vimbuffer[line_idx] = ( + self._vimbuffer[line_idx][:char_idx] + text + + self._vimbuffer[line_idx][char_idx + length:] + ) + # Construct quickfix list compatible dict. + lines.append({ + 'lnum': line_num, 'col': char_num, 'text': text, 'filename': self._vimbuffer.name + }) + + # Calculate height of quickfix list. + max_height = int(get_rtags_variable('MaxSearchResultWindowHeight')) + height = min(max_height, len(lines)) + lines = json.dumps(lines) + + # Render lines fixed to location list and open it. + vim.eval( + 'setloclist(%d, [], " ", {"items": %s, "title": "%s"})' + % (vim.current.window.number, lines, Buffer._FIXITS_LIST_TITLE) + ) vim.command('lopen %d' % height) - else: - vim.eval('setqflist(%s)' % error_data) - vim.command('copen %d' % height) - -def display_diagnostics_results(data, buffer): - data = json.loads(data) - logging.debug(data) - - check_style = data['checkStyle'] - vim.command('sign unplace *') - - # There are no errors - if check_style == None: - return - - filename, errors = list(check_style.items())[0] - quickfix_errors = [] - - vim.command('sign define fixit text=F texthl=FixIt') - vim.command('sign define warning text=W texthl=Warning') - vim.command('sign define error text=E texthl=Error') - - for i, e in enumerate(errors): - if e['type'] == 'skipped': - continue - - # strip error prefix - s = ' Issue: ' - index = e['message'].find(s) - if index != -1: - e['message'] = e['message'][index + len(s):] - error_type = 'E' if e['type'] == 'error' else 'W' - quickfix_errors.append({'lnum': e['line'], 'col': e['column'], - 'nr': i, 'text': e['message'], 'filename': filename, - 'type': error_type}) - cmd = 'sign place %d line=%s name=%s file=%s' % (i + 1, e['line'], e['type'], filename) - vim.command(cmd) - - display_locations(quickfix_errors, buffer) - -def get_diagnostics(): - filename = vim.eval('s:file') - for buffer in vim.buffers: - if buffer.name == filename: - is_modified = bool(int((vim.eval('getbufvar(%d, "&mod")' % buffer.number)))) - cmd = '--diagnose %s --synchronous-diagnostics --json' % filename + message("Fixits applied") + + def _is_really_dirty(self): + """ Check the dirty flag and confirm the buffer really is modified. + + `TextChange` autocmd also triggers just by switching buffers, so we have to be sure. + Unfortunately, even this isn't good - if the buffer hasn't been saved and we switch to + it, then this check will be True, even if the file hasn't changed since the last + reindex. + """ + self._is_dirty = self._is_dirty and bool(int( + vim.eval('getbufvar(%d, "&mod")' % self._vimbuffer.number) + )) + return self._is_dirty + + def _rtags_dirty_reindex(self): + """ Reindex unsaved buffer contents in rtags. + """ + content = "\n".join([x for x in self._vimbuffer]) + result = run_rc_command([ + '--json', '--reindex', self._vimbuffer.name, + '--unsaved-file', '%s:%d' % (self._vimbuffer.name, len(content)) + ], content) + self._is_dirty = False + logger.debug("Rtags responded to reindex request: %s" % result) + + def _rtags_is_reindexing(self): + """ Check if rtags has this buffer queued for reindexing. + """ + # Unfortunately, --check-reindex doesn't work if --unsaved-file used with --reindex. + content = run_rc_command(['--status', 'jobs']) + if content is None: + return error("Failed to check if %s needs reindex" % self._vimbuffer.name) + return self._vimbuffer.name in content + + def _update_diagnostics(self, open_loclist=False): + """ Fetch new diagnostics from rtags and update gutter signs and location list. + """ + # Reset current diagnostics. + self._diagnostics = {} + # Reset diagnostic timer, so we don't query too often. + self._last_diagnostics_time = time() + + # Get the diagnostics from rtags. + content = run_rc_command( + ['--diagnose', self._vimbuffer.name, '--synchronous-diagnostics', '--json'] + ) + if content is None: + return error('Failed to get diagnostics for "%s"' % self._vimbuffer.name) + # logger.debug("Diagnostics for %s from rtags: %s" % (self._vimbuffer.name, content)) + data = json.loads(content) + errors = data['checkStyle'][self._vimbuffer.name] + logger.debug("Got %d diagnostics for %s" % (len(errors), self._vimbuffer.name)) + + # Construct Diagnostic objects from rtags response, and cache keyed by line number. + for diagnostic in Diagnostic.from_rtags_errors(self._vimbuffer.name, errors): + self._diagnostics[diagnostic.line_num] = diagnostic + + # Update location list with new diagnostics, if ours is currently at the top of the stack. + self._update_loclist(open_loclist) + # Place gutter signs next to lines with diagnostics to show. + self._place_signs() + # Flag that we've changed cursor line to trick into rerendering diagnostic message. + self._line_num_last = -1 + self.on_cursor_moved() + + def _update_loclist(self, force): + """ Update the location list for the current buffer with updated diagnostics. + + Unfortunately, there doesn't seem to be a simple way to know if the location list for + the current window is actually visible, so just always update it if it's at the top of + the stack. + + If `force` is given then always update, and show, the location list. + """ + # Only bother updating if the active window is showing this buffer. + if self._vimbuffer.number != vim.current.window.buffer.number: + return + # Get title of this buffer's location list, if available. + loclist_info = vim.eval( + 'getloclist(%s, {"title": 0})' % vim.current.window.number + ) + loclist_title = loclist_info.get('title') + # If the title matches our location list, then we want to update, otherwise either create + # or quit. + is_rtags_loclist_open = loclist_title == Buffer._DIAGNOSTICS_LIST_TITLE + if not force and not is_rtags_loclist_open: + logger.debug("Location list not open (title=%s) so not updating it" % loclist_title) + return + + logger.debug("Updating location list with %s diagnostics" % len(self._diagnostics)) + + # Get our diagnostics as quicklist/loclist formatted dict. + height, lines = Diagnostic.to_qlist_errors(self._diagnostics.values()) + # If our loclist is open, we want to replace the contents, otherwise create a new loclist. + if is_rtags_loclist_open: + action = "r" + else: + action = " " + + # Create/replace the loclist with our diagnostics. + vim.eval( + 'setloclist(%d, [], "%s", {"items": %s, "title": "%s"})' + % (vim.current.window.number, action, lines, Buffer._DIAGNOSTICS_LIST_TITLE) + ) + + # If we want to open the loclist and we have something to show, then open it. + if force and height > 0: + vim.command('lopen %d' % height) + + def _place_signs(self): + """ Add gutter indicator signs next to lines that have diagnostics. + """ + self._reset_signs() + used_ids = Sign.used_ids(self._vimbuffer.number) + logger.debug("Appending %s signs to %s" % (len(self._diagnostics), self._vimbuffer.name)) + for diagnostic in self._diagnostics.values(): + self._place_sign(diagnostic.line_num, diagnostic.type, used_ids) + + def _place_sign(self, line_num, name, used_ids): + """ Create, place and remember a diagnostic gutter sign. + """ + # Get last sign ID that we used in this buffer. + id_ = self._signs and self._signs[-1].id or Sign.START_ID + # We need a new ID. + id_ += 1 + # Other plugins could have added signs, so make sure we don't splat them. + while id_ in used_ids: + id_ += 1 + # Construct a Sign, which will also render it in the buffer, and cache it. + self._signs.append(Sign(id_, line_num, name, self._vimbuffer.number)) + + def _reset_signs(self): + """ Remove all diagnostic signs from buffer gutter and reset our cache of them. + """ + for sign in self._signs: + sign.unplace() + self._signs = [] + + +class Project(object): + """ Wrapper for an rtags "project". + + Used to track last modification time of rtags index database. + """ + + _rtags_data_dir = None + _cache = {} + + @staticmethod + def get(filepath): + # A blank filename (e.g. loclist) has no project. + if not filepath: + return None + # Get rtags project that given file belongs to, if any. + project_root = run_rc_command(['--project', filepath]) + # If rc command line failed, then assume nothing to do. + if project_root is None: + return None + # If no rtags project, then nothing to do. + if project_root.startswith("No matches"): + logger.debug("No rtags project found for %s" % filepath) + return None + + # Lazily find the location of the rtags database. We check the modification date of the DB + # to decide if/when we need to update our cache. + if Project._rtags_data_dir is None: + info = run_rc_command(['--status', 'info']) + logger.debug("RTags info:\n%s" % info) + match = re.search("^dataDir: (.*)$", info, re.MULTILINE) + Project._rtags_data_dir = match.group(1) + logger.info("RTags data directory set to %s" % Project._rtags_data_dir) + + project_root = project_root.strip() + # Get the project for the given file from the cache, if available. + project = Project._cache.get(project_root) + if project is not None: + return project + + # Create a new Project and cache it. + logger.info("Found RTags project %s for %s" % (project_root, filepath)) + project = Project(project_root) + Project._cache[project_root] = project + return project + + @staticmethod + def reset(): + Project._rtags_data_dir = None + Project._cache = {} + + def __init__(self, project_root): + self._project_root = project_root + # Calculate the path of project database in the RTags data directory. + self._db_path = os.path.join( + Project._rtags_data_dir, project_root.replace("/", "_") # , "project" + ) + logger.debug("Project %s db path set to %s" % (self._project_root, self._db_path)) + + def last_updated_time(self): + """ Unix timestamp when the rtags database was last updated. + """ + try: + return max(( + os.path.getmtime(os.path.join(self._db_path, f)) for f in os.listdir(self._db_path) + )) + except IOError as e: + logger.warning( + "Failed to get modification time of '%s', so resetting caches: %s" + % (self._db_path, e) + ) + # Project was deleted, reset everything! + reset_caches() + return 0 + + +class Diagnostic(object): + + @staticmethod + def from_rtags_errors(filename, errors): + diagnostics = [] + for e in errors: + if e['type'] == 'skipped': + continue + # strip error prefix + s = ' Issue: ' + index = e['message'].find(s) + if index != -1: + e['message'] = e['message'][index + len(s):] + diagnostics.append( + Diagnostic(filename, e['line'], e['column'], e['type'], e['message']) + ) + return diagnostics + + @staticmethod + def to_qlist_errors(diagnostics): + num_diagnostics = len(diagnostics) + # Sort diagnostics into display order. Errors at top, grouped by filename in line num order. + diagnostics = sorted(diagnostics, key=lambda d: (d.type, d._filename, d.line_num)) + # Convert Diagnostic objects into quickfix compatible dicts. + diagnostics = [d._to_qlist_dict() for d in diagnostics] + # Add error number to diagnostic (shows in list, maybe useful for navigation?). + for idx, diagnostic in enumerate(diagnostics): + diagnostic['nr'] = idx + 1 + diagnostics = json.dumps(diagnostics) + + # Calculate max height of list window. + max_height = int(get_rtags_variable('MaxSearchResultWindowHeight')) + height = min(max_height, num_diagnostics) + + return height, diagnostics + + def __init__(self, filename, line_num, char_num, type_, text): + self._filename = filename + self.line_num = line_num + self._char_num = char_num + self.type = type_ + self.text = text + + def _to_qlist_dict(self): + error_type = "W" if self.type == "warning" else "E" + text = self.text if self.type != "fixit" else self.text + " [FIXIT]" + return { + 'lnum': self.line_num, 'col': self._char_num, 'text': text, + 'filename': self._filename, 'type': error_type + } + + +class Sign(object): + START_ID = 2000 + _is_signs_defined = False + + @staticmethod + def _define_signs(): + """ Define highlight group and gutter signs for diagnostics. + + Must do this lazily because other plugins can go and change SignColumn highlight + group on initialisation (e.g. gitgutter). + """ + logger.debug("Defining gutter diagnostic signs") + + # Recursively search for background colours through group links. + def get_bgcolour(group): + logger.debug("Scanning highlight group %s for background colour" % group) + output = get_command_output("highlight %s" % group) + match = re.search(r"links to (\S+)", output) + if match is None: + ctermbg_match = re.search(r"ctermbg=(\S+)", output) + guibg_match = re.search(r"guibg=(\S+)", output) + return ( + ctermbg_match and ctermbg_match.group(1), + guibg_match and guibg_match.group(1) + ) + return get_bgcolour(match.group(1)) + + ctermbg, guibg = get_bgcolour("SignColumn") + bg = "" + if guibg is not None: + bg += " guibg=%s" % guibg + if ctermbg is not None: + bg += " ctermbg=%s" % ctermbg + + logger.debug("Background colours are %s, %s" % (ctermbg, guibg)) + vim.command( + "highlight rtags_fixit guifg=#ff00ff ctermfg=5 %s" % bg + ) + vim.command( + "highlight rtags_warning guifg=#fff000 ctermfg=11 %s" % bg + ) + vim.command( + "highlight rtags_error guifg=#ff0000 ctermfg=1 %s" % bg + ) - content = '' - if is_modified: - content = '\n'.join([x for x in buffer]) - cmd += ' --unsaved-file=%s:%d' % (filename, len(content)) + vim.command("sign define rtags_fixit text=Fx texthl=rtags_fixit") + vim.command("sign define rtags_warning text=W texthl=rtags_warning") + vim.command("sign define rtags_error text=E texthl=rtags_error") + Sign._is_signs_defined = True + + @staticmethod + def reset(): + Sign._is_signs_defined = False + + @staticmethod + def used_ids(buffer_num): + signs_txt = get_command_output("sign place buffer=%s" % buffer_num) + sign_ids = set() + for sign_match in re.finditer("id=(\d+)", signs_txt): + sign_ids.add(int(sign_match.group(1))) + return sign_ids + + def __init__(self, id_, line_num, name, buffer_num): + self.id = id_ + self._vimbuffer_num = buffer_num + if not Sign._is_signs_defined: + Sign._define_signs() + vim.command( + 'sign place %d line=%s name=rtags_%s buffer=%s' % ( + self.id, line_num, name, self._vimbuffer_num + ) + ) - content = run_rc_command(cmd, content) - if content == None: - return None + def unplace(self): + vim.command('sign unplace %s buffer=%s' % (self.id, self._vimbuffer_num)) + + +def get_command_output(cmd_txt): + return vim.eval('rtags#getCommandOutput("%s")' % cmd_txt) + + +def invalid_buffer_message(filename): + print( + "No RTags project for file: %s" % filename + if filename else "Please select a file buffer and try again" + ) + + +def error(msg): + message("""%s: see log file at "%s" for more information""" % (msg, vim.eval('g:rtagsLog'))) - display_diagnostics_results(content, buffer) - return 0 +def message(msg): + vim.command("""echom '%s'""" % msg) diff --git a/tests/test_vimrtags.py b/tests/test_vimrtags.py new file mode 100644 index 00000000..9c33ef7f --- /dev/null +++ b/tests/test_vimrtags.py @@ -0,0 +1,1338 @@ +import json +import logging +import sys +try: + from unittest import TestCase + from unittest.mock import Mock, MagicMock, patch, call +except ImportError: + from unittest2 import TestCase + from mock import Mock, MagicMock, patch, call + +#from nose.tools import set_trace + +# Create a mocked vim in the environment. +vim = MagicMock() +sys.modules["vim"] = vim +with patch.object(logging, "FileHandler"): + from plugin import vimrtags + +vim.reset_mock() + + +class VimRtagsTest(TestCase): + def setUp(self): + vim.reset_mock() + + +@patch("plugin.vimrtags.logging", autospec=True) +@patch("plugin.vimrtags.logger", autospec=True) +class Test_configure_logger(VimRtagsTest): + + def test_logs_to_user_defined_file(self, logger, logging): + vimrtags.configure_logger() + + vim.eval.assert_called_once_with('g:rtagsLog') + logging.FileHandler.assert_called_once_with(vim.eval.return_value) + logger.addHandler.assert_called_once_with(logging.FileHandler.return_value) + + +class Test_parse_completion_result(VimRtagsTest): + + def test(self): + completions = [] + for kind in ( + 'FunctionDecl', 'FunctionTemplate', 'CXXMethod', 'CXXConstructor', 'VarDecl', + 'macro definition', 'EnumDecl', 'TypedefDecl', 'StructDecl', 'EnumConstantDecl', + 'ClassDecl', 'FieldDecl', 'Unknown' + ): + completions += self._completion(kind) + + completions = {'completions': completions} + + parsed = vimrtags.parse_completion_result(json.dumps(completions)) + + self.assertListEqual( + parsed, [ + self._with_parent_sig('f'), self._simple('f'), self._simple('f'), + self._with_parent_sig('f'), self._simple('f'), self._simple('f'), + self._with_parent_sig('m'), self._simple('m'), self._simple('m'), + self._with_parent_sig('m'), self._simple('m'), self._simple('m'), + self._with_parent_sig('v'), self._simple('v'), self._simple('v'), + self._with_sig('d'), self._simple('d'), self._simple('d'), + self._with_parent('e'), self._simple('e'), self._simple('e'), + self._with_parent('t'), self._simple('t'), self._simple('t'), + self._with_parent('t'), self._simple('t'), self._simple('t'), + self._with_parent('t'), self._simple('t'), self._simple('t'), + self._with_parent('t'), self._simple('t'), self._simple('t'), + self._with_parent('t'), self._simple('t'), self._simple('t'), + self._with_comment(''), self._simple(''), self._simple('') + ] + ) + + def _completion(self, kind): + return [{ + 'kind': kind, + 'completion': 'mock completion', + 'parent': 'mock parent', + 'signature': 'mock signature', + 'brief_comment': 'mock comment', + }, { + 'kind': kind, + 'completion': 'mock completion', + 'parent': 'mock completion', + 'signature': 'mock completion', + 'brief_comment': 'mock completion', + }, { + 'kind': kind, + 'completion': 'mock completion', + 'parent': '', + 'signature': '', + 'brief_comment': '', + }] + + def _with_parent_sig(self, kind): + return { + 'menu': "mock parent -- mock signature -- mock comment", + 'word': "mock completion", 'kind': kind + } + + def _with_sig(self, kind): + return { + 'menu': "mock signature -- mock comment", 'word': "mock completion", + 'kind': kind + } + + def _with_parent(self, kind): + return { + 'menu': "mock parent -- mock comment", 'word': "mock completion", + 'kind': kind + } + + def _with_comment(self, kind): + return { + 'menu': "mock comment", 'word': "mock completion", 'kind': kind + } + + def _simple(self, kind): + return { + 'menu': "", 'word': "mock completion", 'kind': kind + } + + +@patch("plugin.vimrtags.Buffer", autospec=True) +@patch("plugin.vimrtags.Project", autospec=True) +@patch("plugin.vimrtags.Sign", autospec=True) +@patch("plugin.vimrtags.message", autospec=True) +class Test_reset_caches(VimRtagsTest): + def test(self, message, Sign, Project, Buffer): + vimrtags.reset_caches() + + Buffer.reset.assert_called_once_with() + Project.reset.assert_called_once_with() + Sign.reset.assert_called_once_with() + self.assertTrue(message.called) + + +class Test_Buffer_find(VimRtagsTest): + def setUp(self): + super(Test_Buffer_find, self).setUp() + first = Mock() + second = Mock(number=9) + third = Mock() + first.name = "first buf" + second.name = "second buf" + third.name = "third buf" + vim.buffers = [first, second, third] + + def test_not_found(self): + buffer = vimrtags.Buffer.find("wont find") + self.assertIsNone(buffer) + + @patch("plugin.vimrtags.Buffer.get") + def test_found(self, get): + buffer = vimrtags.Buffer.find("second buf") + get.assert_called_once_with(9) + self.assertIs(buffer, get.return_value) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Buffer._cache", {}) +class Test_Buffer_get(VimRtagsTest): + def test_found(self): + cached_buffer = Mock() + vimrtags.Buffer._cache = {3: cached_buffer} + buffer = vimrtags.Buffer.get(3) + self.assertIs(buffer, cached_buffer) + + @patch("plugin.vimrtags.Project", MagicMock()) + @patch("plugin.vimrtags.Buffer._clean_cache_periodically") + def test_not_found(self, _clean_cache_periodically): + vimbuffer = Mock() + vim.buffers.append(vimbuffer) + other_buffer = Mock() + vimrtags.Buffer._cache = {4: other_buffer} + + buffer = vimrtags.Buffer.get(3) + buffer_again = vimrtags.Buffer.get(3) + + _clean_cache_periodically.assert_called_once_with() + self.assertIsInstance(buffer, vimrtags.Buffer) + self.assertIs(buffer._vimbuffer, vimbuffer) + self.assertDictEqual(vimrtags.Buffer._cache, {3: buffer, 4: other_buffer}) + self.assertIs(buffer, buffer_again) + + +@patch("plugin.vimrtags.Buffer._cache", None) +@patch("plugin.vimrtags.Buffer._clean_cache") +class Test_Buffer_reset(VimRtagsTest): + def test(self, _clean_cache): + buffer1 = Mock(spec=vimrtags.Buffer) + buffer2 = Mock(spec=vimrtags.Buffer) + vimrtags.Buffer._cache = {2: buffer1, 7: buffer2} + calls = MagicMock() + calls.attach_mock(_clean_cache, "clean") + calls.attach_mock(buffer1._reset_signs, "reset1") + calls.attach_mock(buffer2._reset_signs, "reset2") + + vimrtags.Buffer.reset() + + self.assertListEqual( + calls.method_calls, [ + call.clean(), call.reset1(), call.reset2() + ] + ) + self.assertDictEqual(vimrtags.Buffer._cache, {}) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.time", autospec=True) +@patch("plugin.vimrtags.Buffer._cache", None) +@patch("plugin.vimrtags.Buffer._cache_last_cleaned", 6) +@patch("plugin.vimrtags.Buffer._CACHE_CLEAN_PERIOD", 4) +class Test_Buffer__clean_cache_periodically(VimRtagsTest): + def prepare(self): + self.buffer = Mock(_vimbuffer=Mock(valid=False)) + vimrtags.Buffer._cache = {3: self.buffer} + + def test_throttled(self, time): + self.prepare() + time.return_value = 10 + vimrtags.Buffer._clean_cache_periodically() + self.assertIs(vimrtags.Buffer._cache[3], self.buffer) + + def test_removes(self, time): + self.prepare() + time.return_value = 11 + vimrtags.Buffer._clean_cache_periodically() + self.assertDictEqual(vimrtags.Buffer._cache, {}) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Buffer._cache", None) +class Test_Buffer__clean_cache(VimRtagsTest): + def test(self): + valid_buffer = Mock(_vimbuffer=Mock(valid=True)) + invalid_buffer = Mock(_vimbuffer=Mock(valid=False)) + vimrtags.Buffer._cache = {3: valid_buffer, 5: invalid_buffer} + + vimrtags.Buffer._clean_cache() + + self.assertDictEqual(vimrtags.Buffer._cache, {3: valid_buffer}) + + +@patch("plugin.vimrtags.run_rc_command", autospec=True) +class Test_Buffer_show_all_diagnostics(VimRtagsTest): + + @patch("plugin.vimrtags.error", autospec=True) + def test_rc_fails(self, error, run_rc_command): + run_rc_command.return_value = None + vimrtags.Buffer.show_all_diagnostics() + self.assertTrue(error.called) + self.assertFalse(vim.command.called) + + @patch("plugin.vimrtags.message", autospec=True) + def test_no_diagnostics(self, message, run_rc_command): + run_rc_command.return_value = '{"checkStyle": {}}' + vimrtags.Buffer.show_all_diagnostics() + self.assertTrue(message.called) + self.assertFalse(vim.command.called) + + def prepare(self, get_rtags_variable, Diagnostic, run_rc_command): + run_rc_command.return_value = ( + '{"checkStyle": {"file 1": ["error 1", "error 2"], "file 2": ["error 3"]}}' + ) + Diagnostic.from_rtags_errors.side_effect = [["diag 1", "diag 2"], ["diag 3"]] + Diagnostic.to_qlist_errors.return_value = (123, "lines") + vim.current.window.number = 7 + + @patch("plugin.vimrtags.Buffer._DIAGNOSTICS_ALL_LIST_TITLE", "list title") + @patch("plugin.vimrtags.Diagnostic", autospec=True) + @patch("plugin.vimrtags.get_rtags_variable", autospec=True) + def test_use_loclist(self, get_rtags_variable, Diagnostic, run_rc_command): + self.prepare(get_rtags_variable, Diagnostic, run_rc_command) + + get_rtags_variable.return_value = 1 + vimrtags.Buffer.show_all_diagnostics() + + self.assert_diagnostics_constructed(Diagnostic) + vim.eval.assert_called_once_with( + 'setloclist(7, [], " ", {"items": lines, "title": "list title"})' + ) + vim.command.assert_called_once_with("lopen 123") + + @patch("plugin.vimrtags.Buffer._DIAGNOSTICS_ALL_LIST_TITLE", "list title") + @patch("plugin.vimrtags.Diagnostic", autospec=True) + @patch("plugin.vimrtags.get_rtags_variable", autospec=True) + def test_use_qlist(self, get_rtags_variable, Diagnostic, run_rc_command): + self.prepare(get_rtags_variable, Diagnostic, run_rc_command) + + get_rtags_variable.return_value = 0 + vimrtags.Buffer.show_all_diagnostics() + + self.assert_diagnostics_constructed(Diagnostic) + vim.eval.assert_called_once_with( + 'setqflist([], " ", {"items": lines, "title": "list title"})' + ) + vim.command.assert_called_once_with("copen 123") + + def assert_diagnostics_constructed(self, Diagnostic): + self.assertListEqual( + Diagnostic.from_rtags_errors.call_args_list, [ + call("file 1", ["error 1", "error 2"]), + call("file 2", ["error 3"]) + ] + ) + Diagnostic.to_qlist_errors.assert_called_once_with(["diag 1", "diag 2", "diag 3"]) + + +@patch("plugin.vimrtags.Project", autospec=True) +class Test_Buffer_init(VimRtagsTest): + def test(self, Project): + vimbuffer = Mock() + vimbuffer.name = "mock buffer" + + buffer = vimrtags.Buffer(vimbuffer) + + self.assertIs(buffer._vimbuffer, vimbuffer) + Project.get.assert_called_once_with("mock buffer") + self.assertIs(buffer._project, Project.get.return_value) + + +class MockVimBuffer(list): + def __init__(self): + super(MockVimBuffer, self).__init__() + self.name = "mock buffer" + self.number = 7 + + +class BufferInstanceTest(VimRtagsTest): + def setUp(self): + super(BufferInstanceTest, self).setUp() + self.project = Mock(spec=vimrtags.Project) + self.vimbuffer = MockVimBuffer() + + with patch("plugin.vimrtags.Project.get", Mock(return_value=self.project)): + self.buffer = vimrtags.Buffer(self.vimbuffer) + + +class Test_Buffer_on_write(BufferInstanceTest): + def test(self): + self.buffer._is_dirty = True + + self.buffer.on_write() + + self.assertFalse(self.buffer._is_dirty) + + +class Test_Buffer_on_edit(BufferInstanceTest): + def test_no_project(self): + self.buffer._project = None + + self.buffer.on_edit() + + self.assertFalse(self.buffer._is_dirty) + + def test_has_project(self): + self.buffer.on_edit() + + self.assertTrue(self.buffer._is_dirty) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Buffer._is_really_dirty", autospec=True) +@patch("plugin.vimrtags.Buffer._rtags_dirty_reindex", autospec=True) +@patch("plugin.vimrtags.Buffer._update_diagnostics", autospec=True) +class Test_Buffer_on_idle(BufferInstanceTest): + def test_no_project(self, _update_diagnostics, _rtags_dirty_reindex, _is_really_dirty): + self.buffer._project = None + + self.buffer.on_idle() + + self.assertFalse(_is_really_dirty.called) + self.assertFalse(_update_diagnostics.called) + self.assertFalse(_rtags_dirty_reindex.called) + + def test_nothing_to_do(self, _update_diagnostics, _rtags_dirty_reindex, _is_really_dirty): + _is_really_dirty.return_value = False + self.buffer._last_diagnostics_time = 1 + self.project.last_updated_time.return_value = 1 + + self.buffer.on_idle() + + self.assertFalse(_update_diagnostics.called) + self.assertFalse(_rtags_dirty_reindex.called) + + @patch("plugin.vimrtags.time", autospec=True) + def test_reindex(self, time, _update_diagnostics, _rtags_dirty_reindex, _is_really_dirty): + _is_really_dirty.return_value = True + + self.buffer.on_idle() + + self.assertIs(self.buffer._last_diagnostics_time, time.return_value) + _rtags_dirty_reindex.assert_called_once_with(self.buffer) + self.assertFalse(_update_diagnostics.called) + + def test_update_diagnostics(self, _update_diagnostics, _rtags_dirty_reindex, _is_really_dirty): + _is_really_dirty.return_value = False + self.buffer._last_diagnostics_time = 1 + self.project.last_updated_time.return_value = 2 + + self.buffer.on_idle() + + self.assertFalse(_rtags_dirty_reindex.called) + _update_diagnostics.assert_called_once_with(self.buffer) + + +@patch("plugin.vimrtags.Buffer._is_really_dirty", autospec=True) +@patch("plugin.vimrtags.Buffer._update_diagnostics", autospec=True) +class Test_Buffer_on_poll(BufferInstanceTest): + def test_no_project(self, _update_diagnostics, _is_really_dirty): + self.buffer._project = None + + self.buffer.on_poll() + + self.assertFalse(_is_really_dirty.called) + self.assertFalse(_update_diagnostics.called) + + def test_is_dirty(self, _update_diagnostics, _is_really_dirty): + _is_really_dirty.return_value = True + self.buffer._last_diagnostics_time = 1 + self.project.last_updated_time.return_value = 2 + + self.buffer.on_poll() + + self.assertFalse(_update_diagnostics.called) + + def test_too_soon(self, _update_diagnostics, _is_really_dirty): + _is_really_dirty.return_value = False + self.buffer._last_diagnostics_time = 2 + self.project.last_updated_time.return_value = 2 + + self.buffer.on_poll() + + self.assertFalse(_update_diagnostics.called) + + @patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) + def test_update(self, _update_diagnostics, _is_really_dirty): + _is_really_dirty.return_value = False + self.buffer._last_diagnostics_time = 1 + self.project.last_updated_time.return_value = 2 + + self.buffer.on_poll() + + _update_diagnostics.assert_called_once_with(self.buffer) + + +@patch("plugin.vimrtags.print", autospec=True) +class Test_Buffer_on_cursor_moved(BufferInstanceTest): + def test_no_project(self, print_): + self.buffer._project = None + + self.buffer.on_cursor_moved() + + self.assertFalse(print_.called) + + def test_line_num_not_changed(self, print_): + self.buffer._line_num_last = 2 + vim.current.window.cursor = (2, 123) + + self.buffer.on_cursor_moved() + + self.assertFalse(print_.called) + + def test_no_diagnostic_now_or_before(self, print_): + self.buffer._line_num_last = 1 + vim.current.window.cursor = (2, 123) + self.buffer._diagnostics = {3: Mock(text="mock diagnostic")} + + self.buffer.on_cursor_moved() + + self.assertFalse(print_.called) + + def test_has_diagnostic(self, print_): + self.buffer._line_num_last = 1 + vim.current.window.cursor = (3, 123) + self.buffer._diagnostics = {3: Mock(text="mock diagnostic")} + + self.buffer.on_cursor_moved() + + print_.assert_called_once_with("mock diagnostic") + self.assertTrue(self.buffer._is_line_diagnostic_shown) + + def test_no_diagnostic_now_but_had_one_before(self, print_): + self.buffer._line_num_last = 1 + vim.current.window.cursor = (2, 123) + self.buffer._diagnostics = {3: Mock(text="mock diagnostic")} + self.buffer._is_line_diagnostic_shown = True + + self.buffer.on_cursor_moved() + + print_.assert_called_once_with("") + self.assertFalse(self.buffer._is_line_diagnostic_shown) + + +@patch("plugin.vimrtags.Buffer._update_diagnostics", autospec=True) +class Test_Buffer_show_diagnostics_list(BufferInstanceTest): + + @patch("plugin.vimrtags.invalid_buffer_message", autospec=True) + def test_no_project(self, invalid_buffer_message, _update_diagnostics): + self.buffer._project = None + + self.buffer.show_diagnostics_list() + + self.assertFalse(_update_diagnostics.called) + invalid_buffer_message.assert_called_once_with("mock buffer") + + @patch("plugin.vimrtags.message", autospec=True) + def test_has_diagnostics(self, message, _update_diagnostics): + + def set_diagnostics(*args, **kwargs): + self.buffer._diagnostics = "something" + + _update_diagnostics.side_effect = set_diagnostics + + self.buffer.show_diagnostics_list() + + _update_diagnostics.assert_called_once_with(self.buffer, open_loclist=True) + self.assertFalse(message.called) + + @patch("plugin.vimrtags.message", autospec=True) + def test_no_diagnostics(self, message, _update_diagnostics): + + def set_diagnostics(*args, **kwargs): + self.buffer._diagnostics = None + + _update_diagnostics.side_effect = set_diagnostics + + self.buffer.show_diagnostics_list() + + _update_diagnostics.assert_called_once_with(self.buffer, open_loclist=True) + self.assertTrue(message.called) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.get_rtags_variable", autospec=True) +@patch("plugin.vimrtags.run_rc_command", autospec=True) +@patch("plugin.vimrtags.Buffer._rtags_is_reindexing", autospec=True) +class Test_Buffer_apply_fixits(BufferInstanceTest): + + def prepare(self, _rtags_is_reindexing, get_rtags_variable): + _rtags_is_reindexing.return_value = False + get_rtags_variable.side_effect = ["1", "20"] + self.buffer._last_diagnostics_time = 1 + self.buffer._project.last_updated_time.return_value = 1 + + @patch("plugin.vimrtags.invalid_buffer_message", autospec=True) + def test_no_project( + self, invalid_buffer_message, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + self.buffer._project = None + + self.buffer.apply_fixits() + + invalid_buffer_message.assert_called_once_with("mock buffer") + self.assertFalse(_rtags_is_reindexing.called) + self.assertFalse(run_rc_command.called) + self.assertFalse(vim.eval.called) + self.assertFalse(vim.command.called) + + @patch("plugin.vimrtags.message", autospec=True) + def test_is_reindexing( + self, message, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + _rtags_is_reindexing.return_value = True + + self.buffer.apply_fixits() + + self.assertTrue(message.called) + self.assertFalse(run_rc_command.called) + self.assertFalse(vim.command.called) + + @patch("plugin.vimrtags.message", autospec=True) + def test_has_changes_to_project( + self, message, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + self.buffer._project.last_updated_time.return_value = 2 + + self.buffer.apply_fixits() + + self.assertTrue(message.called) + self.assertFalse(run_rc_command.called) + self.assertFalse(vim.command.called) + + @patch("plugin.vimrtags.error", autospec=True) + def test_rc_fails( + self, error, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + run_rc_command.return_value = None + + self.buffer.apply_fixits() + + run_rc_command.assert_called_once_with(['--fixits', 'mock buffer']) + self.assertTrue(error.called) + self.assertFalse(vim.command.called) + + @patch("plugin.vimrtags.message", autospec=True) + def test_no_fixits_found( + self, message, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + run_rc_command.return_value = " " + vim.current.window.number = 5 + + self.buffer.apply_fixits() + + run_rc_command.assert_called_once_with(['--fixits', 'mock buffer']) + self.assertTrue(message.called) + self.assertFalse(vim.command.called) + + def prepare_buffer(self, run_rc_command, dumps): + fixit_txt = """ +some junk +1:2 3 first +10:11 12 second +""" + run_rc_command.return_value = fixit_txt + vim.current.window.number = 5 + dumps.return_value = "some json" + self.vimbuffer += [ + "some mock text to be fixed", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "some other mock text to be fixed", + "not modified", + "not modified" + ] + + def assert_buffer_and_loclist(self, dumps, message): + self.assertListEqual( + self.vimbuffer, [ + "sfirst mock text to be fixed", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "not modified", + "some othersecondo be fixed", + "not modified", + "not modified" + ]) + + dumps.assert_called_once_with([ + {'lnum': 1, 'col': 2, 'text': "first", 'filename': "mock buffer"}, + {'lnum': 10, 'col': 11, 'text': "second", 'filename': "mock buffer"} + ]) + vim.eval.assert_called_once_with( + 'setloclist(5, [], " ", {"items": some json, "title": "list title"})' + ) + self.assertTrue(message.called) + + @patch("plugin.vimrtags.Buffer._FIXITS_LIST_TITLE", "list title") + @patch("plugin.vimrtags.message", autospec=True) + @patch("plugin.vimrtags.json.dumps", autospec=True) + def test_fixits_found_fits_in_max_height( + self, dumps, message, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + self.prepare_buffer(run_rc_command, dumps) + + self.buffer.apply_fixits() + + self.assert_buffer_and_loclist(dumps, message) + vim.command.assert_called_once_with('lopen 2') + + @patch("plugin.vimrtags.Buffer._FIXITS_LIST_TITLE", "list title") + @patch("plugin.vimrtags.message", autospec=True) + @patch("plugin.vimrtags.json.dumps", autospec=True) + def test_fixits_found_doesnt_fit_in_max_height( + self, dumps, message, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + self.prepare_buffer(run_rc_command, dumps) + get_rtags_variable.side_effect = ["1", "1"] + + self.buffer.apply_fixits() + + self.assert_buffer_and_loclist(dumps, message) + vim.command.assert_called_once_with('lopen 1') + + @patch("plugin.vimrtags.Buffer._FIXITS_LIST_TITLE", "list title") + @patch("plugin.vimrtags.message", autospec=True) + @patch("plugin.vimrtags.json.dumps", autospec=True) + def test_fixits_found_when_auto_diagnostics_disabled( + self, dumps, message, _rtags_is_reindexing, run_rc_command, get_rtags_variable + ): + self.prepare(_rtags_is_reindexing, get_rtags_variable) + self.prepare_buffer(run_rc_command, dumps) + get_rtags_variable.side_effect = ["0", "20"] + self.buffer._project.last_updated_time.return_value = 2 + + self.buffer.apply_fixits() + + self.assert_buffer_and_loclist(dumps, message) + vim.command.assert_called_once_with('lopen 2') + + +class Test_Buffer__is_really_dirty(BufferInstanceTest): + def test_not_dirty(self): + self.buffer._is_dirty = False + + is_dirty = self.buffer._is_really_dirty() + + self.assertFalse(is_dirty) + self.assertFalse(vim.eval.called) + + def test_is_dirty_but_not_really(self): + self.buffer._is_dirty = True + vim.eval.return_value = "0" + + is_dirty = self.buffer._is_really_dirty() + + vim.eval.assert_called_once_with('getbufvar(7, "&mod")') + self.assertFalse(is_dirty) + + def test_is_dirty_really(self): + self.buffer._is_dirty = True + vim.eval.return_value = "1" + + is_dirty = self.buffer._is_really_dirty() + + vim.eval.assert_called_once_with('getbufvar(7, "&mod")') + self.assertTrue(is_dirty) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.run_rc_command", autospec=True) +class Test_Buffer__rtags_dirty_reindex(BufferInstanceTest): + def test(self, run_rc_command): + self.vimbuffer += ["ab", "cd"] + self.buffer._is_dirty = True + + self.buffer._rtags_dirty_reindex() + + run_rc_command.assert_called_once_with([ + '--json', '--reindex', "mock buffer", '--unsaved-file', 'mock buffer:5' + ], "ab\ncd") + self.assertFalse(self.buffer._is_dirty) + + +@patch("plugin.vimrtags.run_rc_command", autospec=True) +class Test_Buffer__rtags_is_reindexing(BufferInstanceTest): + + @patch("plugin.vimrtags.error", autospec=True) + def test_rc_fails(self, error, run_rc_command): + run_rc_command.return_value = None + + is_reindexing = self.buffer._rtags_is_reindexing() + + self.assertTrue(error.called) + self.assertIs(is_reindexing, error.return_value) + + def test_not_reindexing(self, run_rc_command): + run_rc_command.return_value = "something" + + is_reindexing = self.buffer._rtags_is_reindexing() + + self.assertFalse(is_reindexing) + + def test_is_reindexing(self, run_rc_command): + run_rc_command.return_value = "something mock buffer something" + + is_reindexing = self.buffer._rtags_is_reindexing() + + self.assertTrue(is_reindexing) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.time", autospec=True) +@patch("plugin.vimrtags.run_rc_command", autospec=True) +@patch("plugin.vimrtags.Diagnostic", autospec=True) +@patch("plugin.vimrtags.Buffer._update_loclist", autospec=True) +@patch("plugin.vimrtags.Buffer._place_signs", autospec=True) +@patch("plugin.vimrtags.Buffer.on_cursor_moved", autospec=True) +class Test_Buffer__update_diagnostics(BufferInstanceTest): + + @patch("plugin.vimrtags.error", autospec=True) + def test_rc_failed( + self, error, on_cursor_moved, _place_signs, _update_loclist, Diagnostic, run_rc_command, + time + ): + run_rc_command.return_value = None + + self.buffer._update_diagnostics() + + self.assertTrue(error.called) + self.assertFalse(_update_loclist.called) + self.assertFalse(_place_signs.called) + + def test_success( + self, on_cursor_moved, _place_signs, _update_loclist, Diagnostic, run_rc_command, time + ): + # setup + + self.buffer._diagnostics = "replace me" + self.buffer._line_num_last = "replace me" + open_loclist = Mock() + + run_rc_command.return_value = '{"checkStyle": {"mock buffer": ["diag 1", "diag 2"]}}' + + diag1 = Mock(line_num=3) + diag2 = Mock(line_num=12) + Diagnostic.from_rtags_errors.return_value = [diag1, diag2] + + on_cursor_moved.side_effect = ( + lambda *a, **k: self.assertEqual(self.buffer._line_num_last, -1) + ) + + # action + + self.buffer._update_diagnostics(open_loclist=open_loclist) + + # confirm + + run_rc_command.assert_called_once_with([ + '--diagnose', "mock buffer", '--synchronous-diagnostics', '--json' + ]) + Diagnostic.from_rtags_errors.assert_called_once_with("mock buffer", ["diag 1", "diag 2"]) + + self.assertDictEqual(self.buffer._diagnostics, {3: diag1, 12: diag2}) + + _update_loclist.assert_called_once_with(self.buffer, open_loclist) + _place_signs.assert_called_once_with(self.buffer) + on_cursor_moved.assert_called_once_with(self.buffer) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Buffer._DIAGNOSTICS_LIST_TITLE", "list title") +@patch("plugin.vimrtags.Diagnostic", autospec=True) +class Test_Buffer__update_loclist(BufferInstanceTest): + def prepare(self, Diagnostic): + vim.current.window.number = 6 + vim.current.window.buffer.number = 7 + vim.eval.return_value = {'title': "list title"} + Diagnostic.to_qlist_errors.return_value = (3, "some json") + + def test_not_current_window(self, Diagnostic): + self.prepare(Diagnostic) + vim.current.window.buffer.number = 123 + + self.buffer._update_loclist(False) + + self.assertFalse(Diagnostic.to_qlist_errors.called) + self.assertFalse(vim.eval.called) + self.assertFalse(vim.command.called) + + def test_not_force_not_already_open(self, Diagnostic): + self.prepare(Diagnostic) + vim.eval.return_value = {'title': "some other loclist"} + + self.buffer._update_loclist(False) + + vim.eval.assert_called_once_with('getloclist(6, {"title": 0})') + self.assertFalse(Diagnostic.to_qlist_errors.called) + self.assertFalse(vim.command.called) + + def test_not_force_and_already_open(self, Diagnostic): + self.prepare(Diagnostic) + + self.buffer._update_loclist(False) + + self.assertListEqual( + vim.method_calls, [ + call.eval('getloclist(6, {"title": 0})'), + call.eval('setloclist(6, [], "r", {"items": some json, "title": "list title"})') + ] + ) + + def test_force_and_already_open_no_results(self, Diagnostic): + self.prepare(Diagnostic) + Diagnostic.to_qlist_errors.return_value = (0, "some json") + + self.buffer._update_loclist(True) + + self.assertListEqual( + vim.method_calls, [ + call.eval('getloclist(6, {"title": 0})'), + call.eval('setloclist(6, [], "r", {"items": some json, "title": "list title"})') + ] + ) + + def test_force_and_already_open_has_results(self, Diagnostic): + self.prepare(Diagnostic) + + self.buffer._update_loclist(True) + + self.assertListEqual( + vim.method_calls, [ + call.eval('getloclist(6, {"title": 0})'), + call.eval('setloclist(6, [], "r", {"items": some json, "title": "list title"})'), + call.command('lopen 3') + ] + ) + + def test_force_not_already_open_has_results(self, Diagnostic): + self.prepare(Diagnostic) + vim.eval.return_value = {'title': "some other loclist"} + + self.buffer._update_loclist(True) + + self.assertListEqual( + vim.method_calls, [ + call.eval('getloclist(6, {"title": 0})'), + call.eval('setloclist(6, [], " ", {"items": some json, "title": "list title"})'), + call.command('lopen 3') + ] + ) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Sign", autospec=True) +@patch("plugin.vimrtags.Buffer._reset_signs") +@patch("plugin.vimrtags.Buffer._place_sign") +class Test_Buffer__place_signs(BufferInstanceTest): + + def test(self, _place_sign, _reset_signs, Sign): + self.buffer._diagnostics = MagicMock(values=Mock(return_value=[ + Mock(spec=vimrtags.Diagnostic, line_num=4, type="a"), + Mock(spec=vimrtags.Diagnostic, line_num=65, type="b") + ])) + calls = MagicMock() + calls.attach_mock(_reset_signs, "reset") + calls.attach_mock(_place_sign, "place") + + self.buffer._place_signs() + + Sign.used_ids.assert_called_once_with(7) + self.assertListEqual( + calls.method_calls, [ + call.reset(), + call.place(4, "a", Sign.used_ids.return_value), + call.place(65, "b", Sign.used_ids.return_value) + ] + ) + + +@patch("plugin.vimrtags.Sign", autospec=True) +class Test_Buffer__place_sign(BufferInstanceTest): + def test(self, Sign): + Sign.START_ID = 100 + Sign.side_effect = lambda id_, *a: Mock(id=id_) + used_ids = set([50, 102]) + + self.buffer._place_sign(123, "name 1", used_ids) + self.buffer._place_sign(456, "name 2", used_ids) + self.buffer._place_sign(789, "name 3", used_ids) + + self.assertListEqual( + Sign.call_args_list, [ + call(101, 123, "name 1", 7), + call(103, 456, "name 2", 7), + call(104, 789, "name 3", 7) + ] + ) + self.assertEqual(len(self.buffer._signs), 3) + self.assertEqual(self.buffer._signs[0].id, 101) + self.assertEqual(self.buffer._signs[1].id, 103) + self.assertEqual(self.buffer._signs[2].id, 104) + + +class Test_Buffer__reset_signs(BufferInstanceTest): + def test(self): + sign1 = Mock(spec=vimrtags.Sign) + sign2 = Mock(spec=vimrtags.Sign) + self.buffer._signs = [sign1, sign2] + + self.buffer._reset_signs() + + sign1.unplace.assert_called_once_with() + sign2.unplace.assert_called_once_with() + self.assertListEqual(self.buffer._signs, []) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.run_rc_command", autospec=True) +class Test_Project_get(VimRtagsTest): + + def test_no_filename(self, run_rc_command): + project = vimrtags.Project.get("") + + self.assertFalse(run_rc_command.called) + self.assertIsNone(project) + + def test_rc_fails(self, run_rc_command): + run_rc_command.return_value = None + + project = vimrtags.Project.get("/path/to/file.ext") + + run_rc_command.assert_called_once_with(['--project', "/path/to/file.ext"]) + self.assertIsNone(project) + + def test_no_project_root(self, run_rc_command): + run_rc_command.return_value = "No matches found" + + project = vimrtags.Project.get("/path/to/file.ext") + + run_rc_command.assert_called_once_with(['--project', "/path/to/file.ext"]) + self.assertIsNone(project) + + @patch("plugin.vimrtags.Project._cache", None) + @patch("plugin.vimrtags.Project._rtags_data_dir", "/data/dir") + def test_known_data_dir_known_project(self, run_rc_command): + run_rc_command.return_value = " /project/root " + cached_project = Mock() + vimrtags.Project._cache = {"/other/project": Mock(), "/project/root": cached_project} + + project = vimrtags.Project.get("/path/to/file.ext") + + run_rc_command.assert_called_once_with(['--project', "/path/to/file.ext"]) + self.assertIs(project, cached_project) + + @patch("plugin.vimrtags.Project._cache", None) + @patch("plugin.vimrtags.Project._rtags_data_dir", None) + def test_unknown_data_dir_known_project(self, run_rc_command): + run_rc_command.side_effect = [ + " /project/root ", + """ +some junk +dataDir: /rtags/data +other junk +""" + ] + cached_project = Mock() + vimrtags.Project._cache = {"/other/project": Mock(), "/project/root": cached_project} + + project = vimrtags.Project.get("/path/to/file.ext") + + self.assertListEqual( + run_rc_command.call_args_list, [ + call(['--project', "/path/to/file.ext"]), + call(['--status', 'info']) + ] + ) + self.assertIs(project, cached_project) + self.assertEqual(vimrtags.Project._rtags_data_dir, "/rtags/data") + + @patch("plugin.vimrtags.Project._cache", None) + @patch("plugin.vimrtags.Project._rtags_data_dir", "/data/dir") + def test_known_data_dir_unknown_project(self, run_rc_command): + run_rc_command.return_value = " /project/root " + other_project = Mock() + vimrtags.Project._cache = {"/other/project": other_project} + + project = vimrtags.Project.get("/path/to/file.ext") + + run_rc_command.assert_called_once_with(['--project', "/path/to/file.ext"]) + self.assertIsInstance(project, vimrtags.Project) + self.assertEqual(project._project_root, "/project/root") + self.assertDictEqual( + vimrtags.Project._cache, { + "/other/project": other_project, "/project/root": project + } + ) + + +@patch("plugin.vimrtags.Project._rtags_data_dir", "/data/dir") +@patch("plugin.vimrtags.Project._cache", {"some": "stuff"}) +class Test_Project_reset(VimRtagsTest): + def test(self): + vimrtags.Project.reset() + + self.assertIsNone(vimrtags.Project._rtags_data_dir) + self.assertDictEqual(vimrtags.Project._cache, {}) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Project._rtags_data_dir", "/data/dir") +class Test_Project_init(VimRtagsTest): + def test(self): + project = vimrtags.Project("/project/root/") + + self.assertEqual(project._project_root, "/project/root/") + self.assertEqual(project._db_path, "/data/dir/_project_root_") + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Project._rtags_data_dir", "/data/dir") +@patch("plugin.vimrtags.os.path.getmtime", autospec=True) +@patch("plugin.vimrtags.os.listdir", autospec=True) +class Test_Project_last_updated_time(VimRtagsTest): + def test_ok(self, listdir, getmtime): + # setup + listdir.return_value = ["file1", "file2", "file3"] + getmtime.side_effect = [1, 3, 2] + project = vimrtags.Project("/file/path/") + + # action + mtime = project.last_updated_time() + + # confirm + listdir.assert_called_once_with("/data/dir/_file_path_") + self.assertListEqual( + getmtime.call_args_list, [ + call("/data/dir/_file_path_/file1"), call("/data/dir/_file_path_/file2"), + call("/data/dir/_file_path_/file3") + ] + ) + self.assertEqual(mtime, 3) + + @patch("plugin.vimrtags.reset_caches", autospec=True) + def test_ioerror(self, reset_caches, listdir, getmtime): + listdir.side_effect = IOError("Boom") + project = vimrtags.Project("/file/path/") + + mtime = project.last_updated_time() + + reset_caches.assert_called_once_with() + self.assertEqual(mtime, 0) + + +class Test_Diagnostic_from_rtags_errors(VimRtagsTest): + def test(self): + errors = [ + {'type': "type 1", 'line': 123, 'column': 345, 'message': "a Issue: message 1"}, + {'type': "skipped"}, + {'type': "type 2", 'line': 567, 'column': 789, 'message': "a Issue: message 2"} + ] + + diagnostics = vimrtags.Diagnostic.from_rtags_errors("/some/file.ext", errors) + + self.assertEqual(len(diagnostics), 2) + self.assertEqual(diagnostics[0]._filename, "/some/file.ext") + self.assertEqual(diagnostics[0].line_num, 123) + self.assertEqual(diagnostics[0]._char_num, 345) + self.assertEqual(diagnostics[0].type, "type 1") + self.assertEqual(diagnostics[0].text, "message 1") + self.assertEqual(diagnostics[1]._filename, "/some/file.ext") + self.assertEqual(diagnostics[1].line_num, 567) + self.assertEqual(diagnostics[1]._char_num, 789) + self.assertEqual(diagnostics[1].type, "type 2") + self.assertEqual(diagnostics[1].text, "message 2") + + +@patch("plugin.vimrtags.get_rtags_variable", autospec=True) +@patch("plugin.vimrtags.Diagnostic._to_qlist_dict", autospec=True) +class Test_Diagnostic_to_qlist_errors(VimRtagsTest): + def prepare(self, _to_qlist_dict): + self.diagnostics = [ + vimrtags.Diagnostic("file2", 3, 1, "W", "message"), + vimrtags.Diagnostic("file2", 4, 2, "E", "message"), + vimrtags.Diagnostic("file2", 4, 3, "W", "message"), + vimrtags.Diagnostic("file1", 4, 4, "W", "message") + ] + # Unused char_num property used to record original ordering. + _to_qlist_dict.side_effect = lambda d: {"order": d._char_num} + + def test_within_max_height(self, _to_qlist_dict, get_rtags_variable): + self.prepare(_to_qlist_dict) + get_rtags_variable.return_value = "10" + + height, qlist = vimrtags.Diagnostic.to_qlist_errors(self.diagnostics) + + self.assertEqual(height, 4) + self.assert_diagnostics(qlist) + + def test_outside_max_height(self, _to_qlist_dict, get_rtags_variable): + self.prepare(_to_qlist_dict) + get_rtags_variable.return_value = "2" + + height, qlist = vimrtags.Diagnostic.to_qlist_errors(self.diagnostics) + + self.assertEqual(height, 2) + self.assert_diagnostics(qlist) + + def assert_diagnostics(self, qlist): + self.assertListEqual( + json.loads(qlist), [ + {"order": 2, "nr": 1}, + {"order": 4, "nr": 2}, + {"order": 1, "nr": 3}, + {"order": 3, "nr": 4} + ] + ) + + +class Test_Diagnostic__to_qlist_dict(VimRtagsTest): + def test_warning(self): + diagnostic = vimrtags.Diagnostic("file", 3, 4, "warning", "mock text") + + self.assertDictEqual( + diagnostic._to_qlist_dict(), { + 'lnum': 3, 'col': 4, 'text': "mock text", 'filename': "file", 'type': "W" + } + ) + + def test_error(self): + diagnostic = vimrtags.Diagnostic("file", 3, 4, "error", "mock text") + + self.assertDictEqual( + diagnostic._to_qlist_dict(), { + 'lnum': 3, 'col': 4, 'text': "mock text", 'filename': "file", 'type': "E" + } + ) + + def test_fixit(self): + diagnostic = vimrtags.Diagnostic("file", 3, 4, "fixit", "mock text") + + self.assertDictEqual( + diagnostic._to_qlist_dict(), { + 'lnum': 3, 'col': 4, 'text': "mock text [FIXIT]", 'filename': "file", 'type': "E" + } + ) + + +@patch("plugin.vimrtags.logger", Mock(spec=logging.Logger)) +@patch("plugin.vimrtags.Sign._is_signs_defined", False) +@patch("plugin.vimrtags.get_command_output", autospec=True) +class Test_Sign__define_signs(VimRtagsTest): + + def test_no_bg_colours(self, get_command_output): + get_command_output.return_value = ( + "MockGroup xxx term=underline ctermfg=236 guifg=#3F3F3F" + ) + + vimrtags.Sign._define_signs() + + get_command_output.assert_called_once_with("highlight SignColumn") + self.assertEqual(vimrtags.Sign._is_signs_defined, True) + self.assert_signs("") + + def test_no_link(self, get_command_output): + get_command_output.return_value = ( + "MockGroup " + "xxx term=underline ctermfg=236 ctermbg=233 guifg=#3F3F3F guibg=#121212" + ) + + vimrtags.Sign._define_signs() + + get_command_output.assert_called_once_with("highlight SignColumn") + self.assertEqual(vimrtags.Sign._is_signs_defined, True) + self.assert_signs(" guibg=#121212 ctermbg=233") + + def test_with_link(self, get_command_output): + get_command_output.side_effect = [ + "MockGroup xxx ctermfg=141 guifg=#BF81FA guibg=#1F1F1F" + " links to MockLink", + "MockLink " + "xxx term=underline ctermfg=236 ctermbg=233 guifg=#3F3F3F guibg=#121212" + ] + + vimrtags.Sign._define_signs() + + self.assertListEqual( + get_command_output.call_args_list, [ + call("highlight SignColumn"), call("highlight MockLink") + ] + ) + self.assertEqual(vimrtags.Sign._is_signs_defined, True) + self.assert_signs(" guibg=#121212 ctermbg=233") + + def assert_signs(self, bg): + vim.command.assert_has_calls([ + call("highlight rtags_fixit guifg=#ff00ff ctermfg=5 %s" % bg), + call("highlight rtags_warning guifg=#fff000 ctermfg=11 %s" % bg), + call("highlight rtags_error guifg=#ff0000 ctermfg=1 %s" % bg), + call("sign define rtags_fixit text=Fx texthl=rtags_fixit"), + call("sign define rtags_warning text=W texthl=rtags_warning"), + call("sign define rtags_error text=E texthl=rtags_error") + ]) + + +@patch("plugin.vimrtags.Sign._is_signs_defined", True) +class Test_Sign_reset(VimRtagsTest): + def test(self): + vimrtags.Sign.reset() + + self.assertFalse(vimrtags.Sign._is_signs_defined) + + +@patch("plugin.vimrtags.get_command_output", autospec=True) +class Test_Sign_used_ids(VimRtagsTest): + def test(self, get_command_output): + get_command_output.return_value = (""" +--- Signs --- +Signs for path/to/file.ext: + line=363 id=3000 name=MockSign + line=439 id=3001 name=MockSign + line=664 id=3005 name=MockSign + line=665 id=3006 name=MockSign +""") + + used_ids = vimrtags.Sign.used_ids(4) + + self.assertSetEqual(used_ids, set([3000, 3001, 3005, 3006])) + + +@patch("plugin.vimrtags.Sign._is_signs_defined", None) +@patch("plugin.vimrtags.Sign._define_signs") +class Test_Sign_init(VimRtagsTest): + def test_signs_already_defined(self, _define_signs): + vimrtags.Sign._is_signs_defined = True + + sign = vimrtags.Sign(123, 234, "mock", 456) + + self.assertFalse(_define_signs.called) + vim.command.assert_called_once_with('sign place 123 line=234 name=rtags_mock buffer=456') + self.assertEqual(sign.id, 123) + self.assertEqual(sign._vimbuffer_num, 456) + + def test_signs_not_defined(self, _define_signs): + vimrtags.Sign._is_signs_defined = False + + sign = vimrtags.Sign(123, 234, "mock", 456) + + _define_signs.assert_called_once_with() + vim.command.assert_called_once_with('sign place 123 line=234 name=rtags_mock buffer=456') + self.assertEqual(sign.id, 123) + self.assertEqual(sign._vimbuffer_num, 456) + + +@patch("plugin.vimrtags.Sign._is_signs_defined", True) +class Test_Sign_unplace(VimRtagsTest): + def test(self): + sign = vimrtags.Sign(123, 456, "mock", 567) + vim.command.reset_mock() + + sign.unplace() + + vim.command.assert_called_once_with('sign unplace 123 buffer=567') + + +class Test_get_command_output(VimRtagsTest): + def test(self): + output = vimrtags.get_command_output("some command") + + vim.eval.assert_called_once_with('rtags#getCommandOutput("some command")') + self.assertIs(output, vim.eval.return_value)