diff --git a/.gitignore b/.gitignore index 2fef3ae..a29bc85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ *.un~ *.sw[op] __pycache__ +.vscode +tests/*.res +tests/messages +tests/debuglog +test.log +gadgets/ +*.pyc diff --git a/.gitmodules b/.gitmodules index e69de29..6c0bbe7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "vendor/vader.vim"] + path = vendor/vader.vim + url = https://github.com/junegunn/vader.vim +[submodule "vendor/vim-themis"] + path = vendor/vim-themis + url = https://github.com/thinca/vim-themis diff --git a/autoload/vimspector.vim b/autoload/vimspector.vim index 44b8ea0..c3138c7 100644 --- a/autoload/vimspector.vim +++ b/autoload/vimspector.vim @@ -102,6 +102,10 @@ function! vimspector#ShowOutput( category ) abort py3 _vimspector_session.ShowOutput( vim.eval( 'a:category' ) ) endfunction +function! vimspector#ListBreakpoints() abort + py3 _vimspector_session.ListBreakpoints() +endfunction + " Boilerplate {{{ let &cpo=s:save_cpo unlet s:save_cpo diff --git a/autoload/vimspector/internal/channel.vim b/autoload/vimspector/internal/channel.vim index c58ff64..d9f5871 100644 --- a/autoload/vimspector/internal/channel.vim +++ b/autoload/vimspector/internal/channel.vim @@ -40,6 +40,7 @@ endfunction function! s:_Send( msg ) abort call ch_sendraw( s:ch, a:msg ) + return 1 endfunction function! vimspector#internal#channel#Timeout( id ) abort diff --git a/autoload/vimspector/internal/job.vim b/autoload/vimspector/internal/job.vim index 4f30ebf..2d95fe7 100644 --- a/autoload/vimspector/internal/job.vim +++ b/autoload/vimspector/internal/job.vim @@ -20,44 +20,42 @@ set cpo&vim " }}} function! s:_OnServerData( channel, data ) abort - py3 << EOF -_vimspector_session.OnChannelData( vim.eval( 'a:data' ) ) -EOF + py3 _vimspector_session.OnChannelData( vim.eval( 'a:data' ) ) endfunction function! s:_OnServerError( channel, data ) abort - py3 << EOF -_vimspector_session.OnServerStderr( vim.eval( 'a:data' ) ) -EOF + py3 _vimspector_session.OnServerStderr( vim.eval( 'a:data' ) ) endfunction function! s:_OnExit( channel, status ) abort echom "Channel exit with status " . a:status + unlet s:job + py3 _vimspector_session.OnServerExit( vim.eval( 'a:status' ) ) endfunction function! s:_OnClose( channel ) abort echom "Channel closed" - " py3 _vimspector_session.OnChannelClosed() endfunction function! s:_Send( msg ) abort if ! exists( 's:job' ) echom "Can't send message: Job was not initialised correctly" - return + return 0 endif if job_status( s:job ) != 'run' echom "Can't send message: Job is not running" - return + return 0 endif let ch = job_getchannel( s:job ) if ch == 'channel fail' echom "Channel was closed unexpectedly!" - return + return 0 endif call ch_sendraw( ch, a:msg ) + return 1 endfunction function! vimspector#internal#job#StartDebugSession( config ) abort @@ -98,10 +96,9 @@ function! vimspector#internal#job#StopDebugSession() abort endif if job_status( s:job ) == 'run' - call job_stop( s:job, 'term' ) + echom "Terminating job" + call job_stop( s:job, 'kill' ) endif - - unlet s:job endfunction function! vimspector#internal#job#Reset() abort @@ -117,6 +114,59 @@ function! vimspector#internal#job#ForceRead() abort endif endfunction +function! vimspector#internal#job#StartCommandWithLog( cmd, category ) + if ! exists( 's:commands' ) + let s:commands = {} + endif + + if ! has_key( s:commands, a:category ) + let s:commands[ a:category ] = [] + endif + + let l:index = len( s:commands[ a:category ] ) + + call add( s:commands[ a:category ], job_start( + \ a:cmd, + \ { + \ 'out_io': 'buffer', + \ 'in_io': 'null', + \ 'err_io': 'buffer', + \ 'out_name': '_vimspector_log_' . a:category . '_out', + \ 'err_name': '_vimspector_log_' . a:category . '_err', + \ 'out_modifiable': 0, + \ 'err_modifiable': 0, + \ 'stoponexit': 'kill' + \ } ) ) + + if job_status( s:commands[ a:category ][ index ] ) !=# 'run' + echom "Unable to start job for " . a:cmd + return v:none + endif + + let l:stdout = ch_getbufnr( + \ job_getchannel( s:commands[ a:category ][ index ] ), 'out' ) + let l:stderr = ch_getbufnr( + \ job_getchannel( s:commands[ a:category ][ index ] ), 'err' ) + + return [ l:stdout, l:stderr ] +endfunction + + +function! vimspector#internal#job#CleanUpCommand( category ) + if ! exists( 's:commands' ) + let s:commands = {} + endif + + if ! has_key( s:commands, a:category ) + return + endif + for j in s:commands[ a:category ] + call job_stop( j, 'kill' ) + endfor + + unlet s:commands[ a:category ] +endfunction + " Boilerplate {{{ let &cpo=s:save_cpo unlet s:save_cpo diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..a6d15da --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,35 @@ +# Starter pipeline +# Start with a minimal pipeline that you can customize to build and deploy your code. +# Add steps that build, run tests, deploy, and more: +# https://aka.ms/yaml + +jobs: +- job: 'linax' + pool: + vimImage: 'ubuntu-16.04' + container: 'puremourning/vimspector:test' + steps: + - bash: python3 install_gadget.py + displayName: 'Install gadgets' + + - bash: vim --version + displayName: 'Print vim version information' + + - bash: ./run_tests + displayName: 'Run the tests' + +- job: 'macos' + pool: + vmImage: 'macOS-10.13' + steps: + - bash: brew install macvim + displayName: 'Install vim' + + - bash: python3 install_gadget.py + displayName: 'Install gadgets' + + - bash: vim --version + displayName: 'Print vim version information' + + - bash: ./run_tests + displayName: 'Run the tests' diff --git a/install_gadget.py b/install_gadget.py new file mode 100755 index 0000000..daf6502 --- /dev/null +++ b/install_gadget.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python + +# vimspector - A multi-language debugging system for Vim +# Copyright 2019 Ben Jackson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +try: + import urllib.request as urllib2 +except ImportError: + import urllib2 + +import contextlib +import os +import collections +import string +import zipfile +import shutil +import subprocess +import traceback +import tarfile +import hashlib +import sys +import json + +# Include vimspector source, for utils +sys.path.insert( 1, os.path.join( os.path.dirname( __file__ ), + 'python3' ) ) + +from vimspector import install + +GADGETS = { + 'vscode-cpptools': { + 'download': { + 'url': ( 'https://github.com/Microsoft/vscode-cpptools/releases/download/' + '${version}/${file_name}' ), + }, + 'do': lambda name, root: InstallCppTools( name, root ), + 'all': { + 'version': '0.21.0', + }, + 'linux': { + 'file_name': 'cpptools-linux.vsix', + 'checksum': None, + }, + 'macos': { + 'file_name': 'cpptools-osx.vsix', + 'checksum': '4c149df241f8a548f928d824565aa9bb3ccaaa426c07aac3b47db3a51ebbb1f4', + }, + 'windows': { + 'file_name': 'cpptools-win32.vsix', + 'checksum': None, + }, + "adapters": { + "vscode-cpptools": { + "name": "cppdbg", + "command": [ + "${gadgetDir}/vscode-cpptools/debugAdapters/OpenDebugAD7" + ], + "attach": { + "pidProperty": "processId", + "pidSelect": "ask" + }, + }, + }, + }, + 'vscode-python': { + 'download': { + 'url': ( 'https://github.com/Microsoft/vscode-python/releases/download/' + '${version}/${file_name}' ), + }, + 'all': { + 'version': '2019.3.6352', + 'file_name': 'ms-python-release.vsix', + 'checksum': + 'f7e5552db3783d6b45ba4b84005d7b42a372033ca84c0fce82eb70e7372336c6', + }, + 'adapters': { + "vscode-python": { + "name": "vscode-python", + "command": [ + "node", + "${gadgetDir}/vscode-python/out/client/debugger/debugAdapter/main.js", + ], + } + }, + }, + 'tclpro': { + 'repo': { + 'url': 'https://github.com/puremourning/TclProDebug', + 'ref': 'master', + }, + 'do': lambda name, root: InstallTclProDebug( name, root ) + }, + 'vscode-mono-debug': { + 'enabled': False, + 'download': { + 'url': 'https://marketplace.visualstudio.com/_apis/public/gallery/' + 'publishers/ms-vscode/vsextensions/mono-debug/${version}/' + 'vspackage', + 'target': 'vscode-mono-debug.tar.gz', + 'format': 'tar', + }, + 'all': { + 'file_name': 'vscode-mono-debug.vsix', + 'version': '0.15.8', + 'checksum': '723eb2b621b99d65a24f215cb64b45f5fe694105613a900a03c859a62a810470', + } + }, + 'vscode-bash-debug': { + 'download': { + 'url': ( 'https://github.com/rogalmic/vscode-bash-debug/releases/' + 'download/${version}/${file_name}' ), + }, + 'all': { + 'file_name': 'bash-debug-0.3.3.vsix', + 'version': 'untagged-3c529a47de44a70c9c76', + 'checksum': '', + } + } +} + +@contextlib.contextmanager +def CurrentWorkingDir( d ): + cur_d = os.getcwd() + try: + os.chdir( d ) + yield + finally: + os.chdir( cur_d ) + + +def MakeExecutable( file_path ): + # TODO: import stat and use them by _just_ adding the X bit. + print( 'Making executable: {}'.format( file_path ) ) + os.chmod( file_path, 0o755 ) + + +def InstallCppTools( name, root ): + extension = os.path.join( root, 'extension' ) + + # It's hilarious, but the execute bits aren't set in the vsix. So they + # actually have javascript code which does this. It's just a horrible horrible + # hoke that really is not funny. + MakeExecutable( os.path.join( extension, 'debugAdapters', 'OpenDebugAD7' ) ) + with open( os.path.join( extension, 'package.json' ) ) as f: + package = json.load( f ) + runtime_dependencies = package[ 'runtimeDependencies' ] + for dependency in runtime_dependencies: + for binary in dependency.get( 'binaries' ): + file_path = os.path.abspath( os.path.join( extension, binary ) ) + if os.path.exists( file_path ): + MakeExecutable( os.path.join( extension, binary ) ) + + MakeExtensionSymlink( name, root ) + + +def InstallTclProDebug( name, root ): + configure = [ './configure' ] + + if OS == 'macos': + # Apple removed the headers from system frameworks because they are + # determined to make life difficult. And the TCL configure scripts are super + # old so don't know about this. So we do their job for them and try and find + # a tclConfig.sh. + # + # NOTE however that in Apple's infinite wisdom, installing the "headers" in + # the other location is actually broken because the paths in the + # tclConfig.sh are pointing at the _old_ location. You actually do have to + # run the package installation which puts the headers back in order to work. + # This is why the below list is does not contain stuff from + # /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform + # '/Applications/Xcode.app/Contents/Developer/Platforms' + # '/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System' + # '/Library/Frameworks/Tcl.framework', + # '/Applications/Xcode.app/Contents/Developer/Platforms' + # '/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System' + # '/Library/Frameworks/Tcl.framework/Versions' + # '/Current', + for p in [ '/usr/local/opt/tcl-tk/lib' ]: + if os.path.exists( os.path.join( p, 'tclConfig.sh' ) ): + configure.append( '--with-tcl=' + p ) + break + + + with CurrentWorkingDir( os.path.join( root, 'lib', 'tclparser' ) ): + subprocess.check_call( configure ) + subprocess.check_call( [ 'make' ] ) + + MakeSymlink( gadget_dir, name, root ) + + +def DownloadFileTo( url, destination, file_name = None, checksum = None ): + if not file_name: + file_name = url.split( '/' )[ -1 ] + + file_path = os.path.abspath( os.path.join( destination, file_name ) ) + + if not os.path.isdir( destination ): + os.makedirs( destination ) + + if os.path.exists( file_path ): + if checksum: + if ValidateCheckSumSHA256( file_path, checksum ): + print( "Checksum matches for {}, using it".format( file_path ) ) + return file_path + else: + print( "Checksum doesn't match for {}, removing it".format( + file_path ) ) + + print( "Removing existing {}".format( file_path ) ) + os.remove( file_path ) + + + r = urllib2.Request( url, headers = { 'User-Agent': 'Vimspector' } ) + + print( "Downloading {} to {}/{}".format( url, destination, file_name ) ) + + with contextlib.closing( urllib2.urlopen( r ) ) as u: + with open( file_path, 'wb' ) as f: + f.write( u.read() ) + + if checksum: + if not ValidateCheckSumSHA256( file_path, checksum ): + raise RuntimeError( + 'Checksum for {} ({}) does not match expected {}'.format( + file_path, + GetChecksumSHA254( file_path ), + checksum ) ) + else: + print( "Checksum for {}: {}".format( file_path, + GetChecksumSHA254( file_path ) ) ) + + return file_path + + +def GetChecksumSHA254( file_path ): + with open( file_path, 'rb' ) as existing_file: + return hashlib.sha256( existing_file.read() ).hexdigest() + + +def ValidateCheckSumSHA256( file_path, checksum ): + existing_sha256 = GetChecksumSHA254( file_path ) + return existing_sha256 == checksum + + +def RemoveIfExists( destination ): + if os.path.exists( destination ) or os.path.islink( destination ): + if os.path.islink( destination ): + print( "Removing file {}".format( destination ) ) + os.remove( destination ) + else: + print( "Removing dir {}".format( destination ) ) + shutil.rmtree( destination ) + + +# Python's ZipFile module strips execute bits from files, for no good reason +# other than crappy code. Let's do it's job for it. +class ModePreservingZipFile( zipfile.ZipFile ): + def extract( self, member, path = None, pwd = None ): + if not isinstance(member, zipfile.ZipInfo): + member = self.getinfo(member) + + if path is None: + path = os.getcwd() + + ret_val = self._extract_member(member, path, pwd) + attr = member.external_attr >> 16 + os.chmod(ret_val, attr) + return ret_val + + +def ExtractZipTo( file_path, destination, format ): + print( "Extracting {} to {}".format( file_path, destination ) ) + RemoveIfExists( destination ) + + if format == 'zip': + with ModePreservingZipFile( file_path ) as f: + f.extractall( path = destination ) + return + elif format == 'tar': + try: + with tarfile.open( file_path ) as f: + f.extractall( path = destination ) + except Exception: + # There seems to a bug in python's tarfile that means it can't read some + # windows-generated tar files + os.makedirs( destination ) + with CurrentWorkingDir( destination ): + subprocess.check_call( [ 'tar', 'zxvf', file_path ] ) + + +def MakeExtensionSymlink( name, root ): + MakeSymlink( gadget_dir, name, os.path.join( root, 'extension' ) ), + + +def MakeSymlink( in_folder, link, pointing_to ): + RemoveIfExists( os.path.join( in_folder, link ) ) + + in_folder = os.path.abspath( in_folder ) + pointing_to = os.path.relpath( os.path.abspath( pointing_to ), + in_folder ) + os.symlink( pointing_to, os.path.join( in_folder, link ) ) + + +def CloneRepoTo( url, ref, destination ): + RemoveIfExists( destination ) + subprocess.check_call( [ 'git', 'clone', url, destination ] ) + subprocess.check_call( [ 'git', '-C', destination, 'checkout', ref ] ) + +OS = install.GetOS() +gadget_dir = install.GetGadgetDir( os.path.dirname( __file__ ), OS ) + +print( 'OS = ' + OS ) +print( 'gadget_dir = ' + gadget_dir ) + +failed = [] +all_adapters = {} +for name, gadget in GADGETS.items(): + if not gadget.get( 'enabled', True ): + continue + + try: + v = {} + v.update( gadget.get( 'all', {} ) ) + v.update( gadget.get( OS, {} ) ) + + if 'download' in gadget: + if 'file_name' not in v: + raise RuntimeError( "Unsupported OS {} for gadget {}".format( OS, + name ) ) + + destination = os.path.join( gadget_dir, 'download', name, v[ 'version' ] ) + + url = string.Template( gadget[ 'download' ][ 'url' ] ).substitute( v ) + + file_path = DownloadFileTo( + url, + destination, + file_name = gadget[ 'download' ].get( 'target' ), + checksum = v.get( 'checksum' ) ) + root = os.path.join( destination, 'root' ) + ExtractZipTo( file_path, + root, + format = gadget[ 'download' ].get( 'format', 'zip' ) ) + elif 'repo' in gadget: + url = string.Template( gadget[ 'repo' ][ 'url' ] ).substitute( v ) + ref = string.Template( gadget[ 'repo' ][ 'ref' ] ).substitute( v ) + + destination = os.path.join( gadget_dir, 'download', name ) + CloneRepoTo( url, ref, destination ) + root = destination + + if 'do' in gadget: + gadget[ 'do' ]( name, root ) + else: + MakeExtensionSymlink( name, root ) + + all_adapters.update( gadget.get( 'adapters', {} ) ) + + + print( "Done installing {}".format( name ) ) + except Exception as e: + traceback.print_exc() + failed.append( name ) + print( "FAILED installing {}: {}".format( name, e ) ) + + +with open( install.GetGadgetConfigFile( os.path.dirname( __file__ ) ), + 'w' ) as f: + json.dump( { 'adapters': all_adapters }, f, indent=2, sort_keys=True ) + +if failed: + raise RuntimeError( 'Failed to install gadgets: {}'.format( + ','.join( failed ) ) ) + + diff --git a/plugin/vimspector.vim b/plugin/vimspector.vim index f6d68c9..3655bb0 100644 --- a/plugin/vimspector.vim +++ b/plugin/vimspector.vim @@ -42,7 +42,7 @@ if s:mappings == 'VISUAL_STUDIO' nnoremap :call vimspector#Restart() nnoremap :call vimspector#Pause() nnoremap :call vimspector#ToggleBreakpoint() - nnoremap :call vimspector#AddFunctionBreakpoint( '' ) + nnoremap :call vimspector#AddFunctionBreakpoint( expand( '' ) ) nnoremap :call vimspector#StepOver() nnoremap :call vimspector#StepInto() nnoremap :call vimspector#StepOut() @@ -52,7 +52,7 @@ elseif s:mappings == 'HUMAN' nnoremap :call vimspector#Restart() nnoremap :call vimspector#Pause() nnoremap :call vimspector#ToggleBreakpoint() - nnoremap :call vimspector#AddFunctionBreakpoint( '' ) + nnoremap :call vimspector#AddFunctionBreakpoint( expand( '' ) ) nnoremap :call vimspector#StepOver() nnoremap :call vimspector#StepInto() nnoremap :call vimspector#StepOut() diff --git a/python3/vimspector/breakpoints.py b/python3/vimspector/breakpoints.py new file mode 100644 index 0000000..e15a649 --- /dev/null +++ b/python3/vimspector/breakpoints.py @@ -0,0 +1,270 @@ +# vimspector - A multi-language debugging system for Vim +# Copyright 2019 Ben Jackson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import defaultdict + +import vim +import os + +import json +from vimspector import utils + + +class ProjectBreakpoints( object ): + def __init__( self ): + self._connection = None + + # These are the user-entered breakpoints. + self._line_breakpoints = defaultdict( list ) + self._func_breakpoints = [] + + # FIXME: Remove this. Remove breakpoints nonesense from code.py + self._breakpoints_handler = None + self._exceptionBreakpoints = None + self._server_capabilities = {} + + self._next_sign_id = 1 + + # TODO: Change to sign_define ? + vim.command( 'sign define vimspectorBP text==> texthl=Error' ) + vim.command( 'sign define vimspectorBPDisabled text=!> texthl=Warning' ) + + + def ConnectionUp( self, connection ): + self._connection = connection + + + def SetServerCapabilities( self, server_capabilities ): + self._server_capabilities = server_capabilities + + + def ConnectionClosed( self ): + self._breakpoints_handler = None + self._exceptionBreakpoints = None + self._server_capabilities = {} + self._connection = None + + # for each breakpoint: + # clear its resolved status + + + def ListBreakpoints( self ): + # FIXME: Handling of breakpoints is a mess, split between _codeView and this + # object. This makes no sense and should be centralised so that we don't + # have this duplication and bug factory. + qf = [] + if self._connection and self._codeView: + qf = self._codeView.BreakpointsAsQuickFix() + else: + for file_name, breakpoints in self._line_breakpoints.items(): + for bp in breakpoints: + qf.append( { + 'filename': file_name, + 'lnum': bp[ 'line' ], + 'col': 1, + 'type': 'L', + 'valid': 1 if bp[ 'state' ] == 'ENABLED' else 0, + 'text': "Line breakpoint - {}".format( + bp[ 'state' ] ) + } ) + # I think this shows that the qf list is not right for this. + for bp in self._func_breakpoints: + qf.append( { + 'filename': '', + 'lnum': 1, + 'col': 1, + 'type': 'F', + 'valid': 1, + 'text': "Function breakpoint: {}".format( bp[ 'function' ] ), + } ) + + vim.eval( 'setqflist( {} )'.format( json.dumps( qf ) ) ) + + def ToggleBreakpoint( self ): + line, column = vim.current.window.cursor + file_name = vim.current.buffer.name + + if not file_name: + return + + found_bp = False + for index, bp in enumerate( self._line_breakpoints[ file_name] ): + if bp[ 'line' ] == line: + found_bp = True + if bp[ 'state' ] == 'ENABLED': + bp[ 'state' ] = 'DISABLED' + else: + if 'sign_id' in bp: + vim.command( 'sign unplace {0} group=VimspectorBP'.format( + bp[ 'sign_id' ] ) ) + del self._line_breakpoints[ file_name ][ index ] + + if not found_bp: + self._line_breakpoints[ file_name ].append( { + 'state': 'ENABLED', + 'line': line, + # 'sign_id': , + # + # Used by other breakpoint types: + # 'condition': ..., + # 'hitCondition': ..., + # 'logMessage': ... + } ) + + self.UpdateUI() + + def AddFunctionBreakpoint( self, function ): + self._func_breakpoints.append( { + 'state': 'ENABLED', + 'function': function, + } ) + + # TODO: We don't really have aanything to update here, but if we're going to + # have a UI list of them we should update that at this point + self.UpdateUI() + + + def UpdateUI( self ): + if self._connection: + self.SendBreakpoints() + else: + self._ShowBreakpoints() + + + def SetBreakpointsHandler( self, handler ): + # FIXME: Remove this temporary compat .layer + self._breakpoints_handler = handler + + + def SendBreakpoints( self ): + if not self._breakpoints_handler: + def handler( source, msg ): + return self._ShowBreakpoints() + else: + handler = self._breakpoints_handler + + for file_name, line_breakpoints in self._line_breakpoints.items(): + breakpoints = [] + for bp in line_breakpoints: + if bp[ 'state' ] != 'ENABLED': + continue + + if 'sign_id' in bp: + vim.command( 'sign unplace {0} group=VimspectorBP'.format( + bp[ 'sign_id' ] ) ) + del bp[ 'sign_id' ] + + breakpoints.append( { 'line': bp[ 'line' ] } ) + + source = { + 'name': os.path.basename( file_name ), + 'path': file_name, + } + + self._connection.DoRequest( + lambda msg: handler( source, msg ), + { + 'command': 'setBreakpoints', + 'arguments': { + 'source': source, + 'breakpoints': breakpoints, + }, + 'sourceModified': False, # TODO: We can actually check this + } + ) + + if self._server_capabilities.get( 'supportsFunctionBreakpoints' ): + self._connection.DoRequest( + lambda msg: handler( None, msg ), + { + 'command': 'setFunctionBreakpoints', + 'arguments': { + 'breakpoints': [ + { 'name': bp[ 'function' ] } + for bp in self._func_breakpoints if bp[ 'state' ] == 'ENABLED' + ], + } + } + ) + + if self._exceptionBreakpoints is None: + self._SetUpExceptionBreakpoints() + + if self._exceptionBreakpoints: + self._connection.DoRequest( + None, # There is nothing on the response to this + { + 'command': 'setExceptionBreakpoints', + 'arguments': self._exceptionBreakpoints + } + ) + + def _SetUpExceptionBreakpoints( self ): + exceptionBreakpointFilters = self._server_capabilities.get( + 'exceptionBreakpointFilters', + [] ) + + if exceptionBreakpointFilters or not self._server_capabilities.get( + 'supportsConfigurationDoneRequest' ): + exceptionFilters = [] + if exceptionBreakpointFilters: + for f in exceptionBreakpointFilters: + response = utils.AskForInput( + "Enable exception filter '{}'? (Y/N)".format( f[ 'label' ] ) ) + + if response == 'Y': + exceptionFilters.append( f[ 'filter' ] ) + elif not response and f.get( 'default' ): + exceptionFilters.append( f[ 'filter' ] ) + + self._exceptionBreakpoints = { + 'filters': exceptionFilters + } + + if self._server_capabilities.get( 'supportsExceptionOptions' ): + # FIXME Sigh. The python debug adapter requires this + # key to exist. Even though it is optional. + break_mode = utils.SelectFromList( 'When to break on exception?', + [ 'never', + 'always', + 'unhandled', + 'userHandled' ] ) + + if not break_mode: + break_mode = 'unhandled' + + path = [ { 'nagate': True, 'names': [ 'DO_NOT_MATCH' ] } ] + self._exceptionBreakpoints[ 'exceptionOptions' ] = [ { + 'path': path, + 'breakMode': break_mode + } ] + + def _ShowBreakpoints( self ): + for file_name, line_breakpoints in self._line_breakpoints.items(): + for bp in line_breakpoints: + if 'sign_id' in bp: + vim.command( 'sign unplace {0} group=VimspectorBP '.format( + bp[ 'sign_id' ] ) ) + else: + bp[ 'sign_id' ] = self._next_sign_id + self._next_sign_id += 1 + + vim.command( + 'sign place {0} group=VimspectorBP line={1} name={2} file={3}'.format( + bp[ 'sign_id' ] , + bp[ 'line' ], + 'vimspectorBP' if bp[ 'state' ] == 'ENABLED' + else 'vimspectorBPDisabled', + file_name ) ) diff --git a/python3/vimspector/code.py b/python3/vimspector/code.py index 09fcc95..9aa85da 100644 --- a/python3/vimspector/code.py +++ b/python3/vimspector/code.py @@ -20,8 +20,6 @@ from collections import defaultdict from vimspector import utils -SIGN_ID_OFFSET = 10000000 - class CodeView( object ): def __init__( self, window ): @@ -30,7 +28,7 @@ class CodeView( object ): self._logger = logging.getLogger( __name__ ) utils.SetUpLogging( self._logger ) - self._next_sign_id = SIGN_ID_OFFSET + self._next_sign_id = 1 self._breakpoints = defaultdict( list ) self._signs = { 'vimspectorPC': None, @@ -53,7 +51,8 @@ class CodeView( object ): def SetCurrentFrame( self, frame ): if self._signs[ 'vimspectorPC' ]: - vim.command( 'sign unplace {0}'.format( self._signs[ 'vimspectorPC' ] ) ) + vim.command( 'sign unplace {} group=VimspectorCode'.format( + self._signs[ 'vimspectorPC' ] ) ) self._signs[ 'vimspectorPC' ] = None if not frame or not frame.get( 'source' ): @@ -64,23 +63,21 @@ class CodeView( object ): vim.current.window = self._window - buffer_number = int( vim.eval( 'bufnr( "{0}", 1 )'.format( - frame[ 'source' ][ 'path' ] ) ) ) - try: - vim.command( 'bu {0}'.format( buffer_number ) ) - self._window.cursor = ( frame[ 'line' ], frame[ 'column' ] ) - except vim.error as e: - if 'E325' not in str( e ): - self._logger.exception( - 'Unexpected error from vim: loading buffer {}'.format( - buffer_number ) ) - return False + utils.OpenFileInCurrentWindow( frame[ 'source' ][ 'path' ] ) + except vim.error: + self._logger.exception( 'Unexpected vim error opening file {}'.format( + frame[ 'source' ][ 'path' ] ) ) + return False + + self._window.cursor = ( frame[ 'line' ], frame[ 'column' ] ) self._signs[ 'vimspectorPC' ] = self._next_sign_id self._next_sign_id += 1 - vim.command( 'sign place {0} line={1} name=vimspectorPC file={2}'.format( + vim.command( 'sign place {0} group=VimspectorCode priority=20 ' + 'line={1} name=vimspectorPC ' + 'file={2}'.format( self._signs[ 'vimspectorPC' ], frame[ 'line' ], frame[ 'source' ][ 'path' ] ) ) @@ -89,7 +86,8 @@ class CodeView( object ): def Clear( self ): if self._signs[ 'vimspectorPC' ]: - vim.command( 'sign unplace {0}'.format( self._signs[ 'vimspectorPC' ] ) ) + vim.command( 'sign unplace {} group=VimspectorCode'.format( + self._signs[ 'vimspectorPC' ] ) ) self._signs[ 'vimspectorPC' ] = None self._UndisplaySigns() @@ -144,7 +142,7 @@ class CodeView( object ): def _UndisplaySigns( self ): for sign_id in self._signs[ 'breakpoints' ]: - vim.command( 'sign unplace {0}'.format( sign_id ) ) + vim.command( 'sign unplace {} group=VimspectorCode'.format( sign_id ) ) self._signs[ 'breakpoints' ].clear() @@ -164,7 +162,10 @@ class CodeView( object ): self._next_sign_id += 1 self._signs[ 'breakpoints' ].append( sign_id ) vim.command( - 'sign place {0} line={1} name={2} file={3}'.format( + 'sign place {0} group=VimspectorCode priority=9 ' + 'line={1} ' + 'name={2} ' + 'file={3}'.format( sign_id, breakpoint[ 'line' ], 'vimspectorBP' if breakpoint[ 'verified' ] @@ -172,6 +173,22 @@ class CodeView( object ): file_name ) ) + def BreakpointsAsQuickFix( self ): + qf = [] + for file_name, breakpoints in self._breakpoints.items(): + for breakpoint in breakpoints: + qf.append( { + 'filename': file_name, + 'lnum': breakpoint.get( 'line', 1 ), + 'col': 1, + 'type': 'L', + 'valid': 1 if breakpoint.get( 'verified' ) else 0, + 'text': "Line breakpoint - {}".format( + 'VERIFIED' if breakpoint.get( 'verified' ) else 'INVALID' ) + } ) + return qf + + def LaunchTerminal( self, params ): # kind = params.get( 'kind', 'integrated' ) diff --git a/python3/vimspector/debug_adapter_connection.py b/python3/vimspector/debug_adapter_connection.py index ae9cf37..844bb23 100644 --- a/python3/vimspector/debug_adapter_connection.py +++ b/python3/vimspector/debug_adapter_connection.py @@ -56,11 +56,14 @@ class DebugAdapterConnection( object ): 'timer_start( {}, "vimspector#internal#channel#Timeout" )'.format( timeout ) ) - self._outstanding_requests[ this_id ] = PendingRequest( msg, - handler, - failure_handler, - expiry_id ) - self._SendMessage( msg ) + request = PendingRequest( msg, + handler, + failure_handler, + expiry_id ) + self._outstanding_requests[ this_id ] = request + + if not self._SendMessage( msg ): + self._AbortRequest( request, 'Unable to send message' ) def OnRequestTimeout( self, timer_id ): request_id = None @@ -144,7 +147,7 @@ class DebugAdapterConnection( object ): data = 'Content-Length: {0}\r\n\r\n{1}'.format( len( msg ), msg ) # self._logger.debug( 'Sending: {0}'.format( data ) ) - self._Write( data ) + return self._Write( data ) def _ReadHeaders( self ): parts = self._buffer.split( bytes( '\r\n\r\n', 'utf-8' ), 1 ) @@ -196,11 +199,11 @@ class DebugAdapterConnection( object ): self._logger.debug( 'Message received: {0}'.format( message ) ) - try: - self._OnMessageReceived( message ) - finally: - # Don't allow exceptions to break message reading - self._SetState( 'READ_HEADER' ) + # We read the message, so the next time we get data from the socket it must + # be a header. + self._SetState( 'READ_HEADER' ) + self._OnMessageReceived( message ) + def _OnMessageReceived( self, message ): if not self._handler: diff --git a/python3/vimspector/debug_session.py b/python3/vimspector/debug_session.py index 63d4c92..581eb6b 100644 --- a/python3/vimspector/debug_session.py +++ b/python3/vimspector/debug_session.py @@ -18,17 +18,23 @@ import vim import json import os import functools +import subprocess +import shlex from collections import defaultdict -from vimspector import ( code, +from vimspector import ( breakpoints, + code, debug_adapter_connection, + install, output, stack_trace, utils, variables ) -SIGN_ID_OFFSET = 10005000 +VIMSPECTOR_HOME = os.path.abspath( os.path.join( os.path.dirname( __file__ ), + '..', + '..' ) ) class DebugSession( object ): @@ -36,75 +42,27 @@ class DebugSession( object ): self._logger = logging.getLogger( __name__ ) utils.SetUpLogging( self._logger ) - self._connection = None + self._logger.info( 'VIMSPECTOR_HOME = %s', VIMSPECTOR_HOME ) + self._logger.info( 'gadgetDir = %s', + install.GetGadgetDir( VIMSPECTOR_HOME, + install.GetOS() ) ) self._uiTab = None self._stackTraceView = None self._variablesView = None self._outputView = None + self._breakpoints = breakpoints.ProjectBreakpoints() - self._next_sign_id = SIGN_ID_OFFSET + self._run_on_server_exit = None - # FIXME: This needs redesigning. There are a number of problems: - # - breakpoints don't have to be line-wise (e.g. method/exception) - # - when the server moves/changes a breakpoint, this is not updated, - # leading to them getting out of sync - # - the split of responsibility between this object and the CodeView is - # messy and ill-defined. - self._line_breakpoints = defaultdict( list ) - self._func_breakpoints = [] + self._ResetServerState() + + def _ResetServerState( self ): + self._connection = None self._configuration = None - - vim.command( 'sign define vimspectorBP text==> texthl=Error' ) - vim.command( 'sign define vimspectorBPDisabled text=!> texthl=Warning' ) - - def ToggleBreakpoint( self ): - line, column = vim.current.window.cursor - file_name = vim.current.buffer.name - - if not file_name: - return - - found_bp = False - for index, bp in enumerate( self._line_breakpoints[ file_name] ): - if bp[ 'line' ] == line: - found_bp = True - if bp[ 'state' ] == 'ENABLED': - bp[ 'state' ] = 'DISABLED' - else: - if 'sign_id' in bp: - vim.command( 'sign unplace {0}'.format( bp[ 'sign_id' ] ) ) - del self._line_breakpoints[ file_name ][ index ] - - if not found_bp: - self._line_breakpoints[ file_name ].append( { - 'state': 'ENABLED', - 'line': line, - # 'sign_id': , - # - # Used by other breakpoint types: - # 'condition': ..., - # 'hitCondition': ..., - # 'logMessage': ... - } ) - - self._UpdateUIBreakpoints() - - def _UpdateUIBreakpoints( self ): - if self._connection: - self._SendBreakpoints() - else: - self._ShowBreakpoints() - - def AddFunctionBreakpoint( self, function ): - self._func_breakpoints.append( { - 'state': 'ENABLED', - 'function': function, - } ) - - # TODO: We don't really have aanything to update here, but if we're going to - # have a UI list of them we should update that at this point - self._UpdateUIBreakpoints() + self._init_complete = False + self._launch_complete = False + self._server_capabilities = {} def Start( self, launch_variables = {} ): self._configuration = None @@ -120,55 +78,89 @@ class DebugSession( object ): with open( launch_config_file, 'r' ) as f: database = json.load( f ) - launch_config = database.get( 'configurations' ) - adapters = database.get( 'adapters' ) + configurations = database.get( 'configurations' ) + adapters = {} - if len( launch_config ) == 1: - configuration = next( iter( launch_config.keys() ) ) + for gadget_config_file in [ install.GetGadgetConfigFile( VIMSPECTOR_HOME ), + utils.PathToConfigFile( '.gadgets.json' ) ]: + if gadget_config_file and os.path.exists( gadget_config_file ): + with open( gadget_config_file, 'r' ) as f: + adapters.update( json.load( f ).get( 'adapters' ) or {} ) + + adapters.update( database.get( 'adapters' ) or {} ) + + if len( configurations ) == 1: + configuration_name = next( iter( configurations.keys() ) ) else: - configuration = utils.SelectFromList( 'Which launch configuration?', - list( launch_config.keys() ) ) - if not configuration: + configuration_name = utils.SelectFromList( + 'Which launch configuration?', + sorted( list( configurations.keys() ) ) ) + + if not configuration_name or configuration_name not in configurations: return self._workspace_root = os.path.dirname( launch_config_file ) - variables = { - 'dollar': '$', # HAAACK: work around not having a way to include a literal - 'workspaceRoot': self._workspace_root - } - variables.update( launch_variables ) - utils.ExpandReferencesInDict( launch_config[ configuration ], variables ) - - adapter = launch_config[ configuration ].get( 'adapter' ) + configuration = configurations[ configuration_name ] + adapter = configuration.get( 'adapter' ) if isinstance( adapter, str ): adapter = adapters.get( adapter ) - utils.ExpandReferencesInDict( adapter, variables ) - self._StartWithConfiguration( launch_config[ configuration ], - adapter ) + # TODO: Do we want some form of persistence ? e.g. self._staticVariables, + # set from an api call like SetLaunchParam( 'var', 'value' ), perhaps also a + # way to load .vimspector.local.json which just sets variables + self._variables = { + 'dollar': '$', # HACK + 'workspaceRoot': self._workspace_root, + 'gadgetDir': install.GetGadgetDir( VIMSPECTOR_HOME, install.GetOS() ) + } + self._variables.update( + utils.ParseVariables( adapter.get( 'variables', {} ) ) ) + self._variables.update( + utils.ParseVariables( configuration.get( 'variables', {} ) ) ) + self._variables.update( launch_variables ) + + utils.ExpandReferencesInDict( configuration, self._variables ) + utils.ExpandReferencesInDict( adapter, self._variables ) + + if not adapter: + utils.UserMessage( 'No adapter configured for {}'.format( + configuration_name ), persist=True ) + return + + self._StartWithConfiguration( configuration, adapter ) def _StartWithConfiguration( self, configuration, adapter ): - self._configuration = configuration - self._adapter = adapter - - self._logger.info( 'Configuration: {0}'.format( json.dumps( - self._configuration ) ) ) - self._logger.info( 'Adapter: {0}'.format( json.dumps( - self._adapter ) ) ) - def start(): - self._StartDebugAdapter() - self._Initialise() + self._configuration = configuration + self._adapter = adapter + + self._logger.info( 'Configuration: {0}'.format( json.dumps( + self._configuration ) ) ) + self._logger.info( 'Adapter: {0}'.format( json.dumps( + self._adapter ) ) ) if not self._uiTab: self._SetUpUI() else: vim.current.tabpage = self._uiTab - # FIXME: Encapsulation - self._stackTraceView._connection = self._connection - self._variablesView._connection = self._connection - self._outputView._connection = self._connection + + self._StartDebugAdapter() + self._Initialise() + + self._stackTraceView.ConnectionUp( self._connection ) + self._variablesView.ConnectionUp( self._connection ) + self._outputView.ConnectionUp( self._connection ) + self._breakpoints.ConnectionUp( self._connection ) + + def update_breakpoints( source, message ): + if 'body' not in message: + return + self._codeView.AddBreakpoints( source, + message[ 'body' ][ 'breakpoints' ] ) + self._codeView.ShowBreakpoints() + + self._breakpoints.SetBreakpointsHandler( update_breakpoints ) if self._connection: self._StopDebugAdapter( start ) @@ -181,6 +173,9 @@ class DebugSession( object ): # FIXME: For some reason this doesn't work when run from the WinBar. It just # beeps and doesn't display the config selector. One option is to just not # display the selector and restart with the same opitons. + if not self._configuration or not self._adapter: + return Start() + self._StartWithConfiguration( self._configuration, self._adapter ) def OnChannelData( self, data ): @@ -190,13 +185,15 @@ class DebugSession( object ): def OnServerStderr( self, data ): self._logger.info( "Server stderr: %s", data ) if self._outputView: - self._outputView.ServerEcho( data ) + self._outputView.Print( 'server', data ) + def OnRequestTimeout( self, timer_id ): if self._connection: self._connection.OnRequestTimeout( timer_id ) def OnChannelClosed( self ): + # TODO: Not calld self._connection = None def Stop( self ): @@ -216,13 +213,10 @@ class DebugSession( object ): self._codeView.Reset() vim.current.tabpage = self._uiTab vim.command( 'tabclose!' ) - - vim.eval( 'vimspector#internal#{}#Reset()'.format( - self._connection_type ) ) - vim.eval( 'vimspector#internal#state#Reset()' ) + self._uiTab = None # make sure that we're displaying signs in any still-open buffers - self._UpdateUIBreakpoints() + self._breakpoints.UpdateUI() def StepOver( self ): if self._stackTraceView.GetCurrentThreadId() is None: @@ -358,9 +352,18 @@ class DebugSession( object ): return True def _StartDebugAdapter( self ): + if self._connection: + utils.UserMessage( 'The connection is already created. Please try again', + persist = True ) + return + self._logger.info( 'Starting debug adapter with: {0}'.format( json.dumps( self._adapter ) ) ) + self._init_complete = False + self._launch_complete = False + self._run_on_server_exit = None + self._connection_type = 'job' if 'port' in self._adapter: self._connection_type = 'channel' @@ -392,77 +395,141 @@ class DebugSession( object ): self._logger.info( 'Debug Adapter Started' ) - vim.command( 'augroup vimspector_cleanup' ) - vim.command( 'autocmd!' ) - vim.command( 'autocmd VimLeavePre * py3 ' - '_vimspector_session.CloseDown()' ) - vim.command( 'augroup END' ) - - def CloseDown( self ): - # We have to use a dict because of python's scoping/assignment rules (state - # = False would touch a state variable in handler, not in the enclosing - # scope) - state = { 'done': False } - - def handler( *args ): - state[ 'done' ] = True - - self._connection.DoRequest( handler, { - 'command': 'disconnect', - 'arguments': { - 'terminateDebugee': True - }, - }, failure_handler = handler, timeout = 5000 ) - - # This request times out after 5 seconds - while not state[ 'done' ]: - vim.eval( 'vimspector#internal#{}#ForceRead()'.format( - self._connection_type ) ) - - vim.eval( 'vimspector#internal#{}#StopDebugSession()'.format( - self._connection_type ) ) - def _StopDebugAdapter( self, callback = None ): def handler( *args ): vim.eval( 'vimspector#internal#{}#StopDebugSession()'.format( self._connection_type ) ) - vim.command( 'au! vimspector_cleanup' ) - - self._connection.Reset() - self._connection = None - self._stackTraceView.ConnectionClosed() - self._variablesView.ConnectionClosed() - self._outputView.ConnectionClosed() if callback: - callback() + assert not self._run_on_server_exit + self._run_on_server_exit = callback + + arguments = {} + if self._server_capabilities.get( 'supportTerminateDebuggee' ): + arguments[ 'terminateDebugee' ] = True self._connection.DoRequest( handler, { 'command': 'disconnect', - 'arguments': { - 'terminateDebugee': True - }, + 'arguments': arguments, }, failure_handler = handler, timeout = 5000 ) - def _SelectProcess( self, adapter_config, launch_config ): - atttach_config = adapter_config[ 'attach' ] - if atttach_config[ 'pidSelect' ] == 'ask': - pid = utils.AskForInput( 'Enter PID to attach to: ' ) - launch_config[ atttach_config[ 'pidProperty' ] ] = pid - return - elif atttach_config[ 'pidSelect' ] == 'none': - return + # TODO: Use the 'tarminate' request if supportsTerminateRequest set - raise ValueError( 'Unrecognised pidSelect {0}'.format( - atttach_config[ 'pidSelect' ] ) ) + def _PrepareAttach( self, adapter_config, launch_config ): + atttach_config = adapter_config.get( 'attach' ) + + if not atttach_config: + return + + if 'remote' in atttach_config: + # FIXME: We almost want this to feed-back variables to be expanded later, + # e.g. expand variables when we use them, not all at once. This would + # remove the whole %PID% hack. + remote = atttach_config[ 'remote' ] + ssh = [ 'ssh' ] + + if 'account' in remote: + ssh.append( remote[ 'account' ] + '@' + remote[ 'host' ] ) + else: + ssh.append( remote[ 'host' ] ) + + cmd = ssh + remote[ 'pidCommand' ] + + self._logger.debug( 'Getting PID: %s', cmd ) + pid = subprocess.check_output( ssh + remote[ 'pidCommand' ] ).decode( + 'utf-8' ).strip() + self._logger.debug( 'Got PID: %s', pid ) + + if not pid: + # FIXME: We should raise an exception here or something + utils.UserMessage( 'Unable to get PID', persist = True ) + return + + commands = self._GetCommands( remote, 'attach' ) + + for command in commands: + cmd = ssh + command[:] + + for index, item in enumerate( cmd ): + cmd[ index ] = item.replace( '%PID%', pid ) + + self._logger.debug( 'Running remote app: %s', cmd ) + self._outputView.RunJobWithOutput( 'Remote', cmd ) + else: + if atttach_config[ 'pidSelect' ] == 'ask': + pid = utils.AskForInput( 'Enter PID to attach to: ' ) + launch_config[ atttach_config[ 'pidProperty' ] ] = pid + return + elif atttach_config[ 'pidSelect' ] == 'none': + return + + raise ValueError( 'Unrecognised pidSelect {0}'.format( + atttach_config[ 'pidSelect' ] ) ) + + + + def _PrepareLaunch( self, command_line, adapter_config, launch_config ): + run_config = adapter_config.get( 'launch', {} ) + + if 'remote' in run_config: + remote = run_config[ 'remote' ] + ssh = [ 'ssh' ] + if 'account' in remote: + ssh.append( remote[ 'account' ] + '@' + remote[ 'host' ] ) + else: + ssh.append( remote[ 'host' ] ) + + commands = self._GetCommands( remote, 'run' ) + + for index, command in enumerate( commands ): + cmd = ssh + command[:] + full_cmd = [] + for item in cmd: + if isinstance( command_line, list ): + if item == '%CMD%': + full_cmd.extend( command_line ) + else: + full_cmd.append( item ) + else: + full_cmd.append( item.replace( '%CMD%', command_line ) ) + + self._logger.debug( 'Running remote app: %s', full_cmd ) + self._outputView.RunJobWithOutput( 'Remote{}'.format( index ), + full_cmd ) + + + def _GetCommands( self, remote, pfx ): + commands = remote.get( pfx + 'Commands', None ) + + if isinstance( commands, list ): + return commands + elif commands is not None: + raise ValueError( "Invalid commands; must be list" ) + + command = remote[ pfx + 'Command' ] + + if isinstance( command, str ): + command = shlex.split( command ) + + if not isinstance( command, list ): + raise ValueError( "Invalid command; must be list/string" ) + + if not command: + raise ValueError( 'Could not determine commands for ' + pfx ) + + return [ command ] def _Initialise( self ): - adapter_config = self._adapter - self._connection.DoRequest( lambda msg: self._Launch(), { + def handle_initialize_response( msg ): + self._server_capabilities = msg.get( 'body' ) or {} + self._breakpoints.SetServerCapabilities( self._server_capabilities ) + self._Launch() + + self._connection.DoRequest( handle_initialize_response, { 'command': 'initialize', 'arguments': { - 'adapterID': adapter_config.get( 'name', 'adapter' ), + 'adapterID': self._adapter.get( 'name', 'adapter' ), 'clientID': 'vimspector', 'clientName': 'vimspector', 'linesStartAt1': True, @@ -477,15 +544,26 @@ class DebugSession( object ): def OnFailure( self, reason, message ): - self._outputView.ServerEcho( reason ) + msg = "Request for '{}' failed: {}".format( message[ 'command' ], + reason ) + self._outputView.Print( 'server', msg ) def _Launch( self ): self._logger.debug( "LAUNCH!" ) adapter_config = self._adapter launch_config = self._configuration[ 'configuration' ] - if launch_config.get( 'request' ) == "attach": - self._SelectProcess( adapter_config, launch_config ) + request = self._configuration.get( + 'remote-request', + launch_config.get( 'request', 'launch' ) ) + + if request == "attach": + self._PrepareAttach( adapter_config, launch_config ) + elif request == "launch": + # FIXME: This cmdLine hack is not fun. + self._PrepareLaunch( self._configuration.get( 'remote-cmdLine', [] ), + adapter_config, + launch_config ) # FIXME: name is mandatory. Forcefully add it (we should really use the # _actual_ name, but that isn't actually remembered at this point) @@ -493,15 +571,7 @@ class DebugSession( object ): launch_config[ 'name' ] = 'test' self._connection.DoRequest( - # NOTE: You might think we should only load threads on a stopped event, - # but the spec is clear: - # - # After a successful launch or attach the development tool requests the - # baseline of currently existing threads with the threads request and - # then starts to listen for thread events to detect new or terminated - # threads. - # - lambda msg: self._stackTraceView.LoadThreads( True ), + lambda msg: self._OnLaunchComplete(), { 'command': launch_config[ 'request' ], 'arguments': launch_config @@ -509,25 +579,54 @@ class DebugSession( object ): ) - def _UpdateBreakpoints( self, source, message ): - if 'body' not in message: - return - self._codeView.AddBreakpoints( source, message[ 'body' ][ 'breakpoints' ] ) - self._codeView.ShowBreakpoints() + def _OnLaunchComplete( self ): + self._launch_complete = True + self._LoadThreadsIfReady() + + def _OnInitializeComplete( self ): + self._init_complete = True + self._LoadThreadsIfReady() + + def _LoadThreadsIfReady( self ): + # NOTE: You might think we should only load threads on a stopped event, + # but the spec is clear: + # + # After a successful launch or attach the development tool requests the + # baseline of currently existing threads with the threads request and + # then starts to listen for thread events to detect new or terminated + # threads. + # + # Of course, specs are basically guidelines. MS's own cpptools simply + # doesn't respond top threads request when attaching via gdbserver. At + # least it would apear that way. + # + if self._launch_complete and self._init_complete: + self._stackTraceView.LoadThreads( True ) + + + def OnEvent_capabiilities( self, msg ): + self._server_capabilities.update( + ( msg.get( 'body' ) or {} ).get( 'capabilities' ) or {} ) def OnEvent_initialized( self, message ): - self._SendBreakpoints() - self._connection.DoRequest( - None, - { - 'command': 'configurationDone', - } - ) + self._codeView.ClearBreakpoints() + self._breakpoints.SendBreakpoints() + + if self._server_capabilities.get( 'supportsConfigurationDoneRequest' ): + self._connection.DoRequest( + lambda msg: self._OnInitializeComplete(), + { + 'command': 'configurationDone', + } + ) + else: + self._OnInitializeComplete() def OnEvent_thread( self, message ): self._stackTraceView.OnThreadEvent( message[ 'body' ] ) + def OnEvent_breakpoint( self, message ): reason = message[ 'body' ][ 'reason' ] bp = message[ 'body' ][ 'breakpoint' ] @@ -543,8 +642,10 @@ class DebugSession( object ): def OnRequest_runInTerminal( self, message ): params = message[ 'arguments' ] - if 'cwd' not in params: + if not params.get( 'cwd' ) : params[ 'cwd' ] = self._workspace_root + self._logger.debug( 'Defaulting working directory to %s', + params[ 'cwd' ] ) buffer_number = self._codeView.LaunchTerminal( params ) @@ -574,88 +675,57 @@ class DebugSession( object ): self._stackTraceView.Clear() self._variablesView.Clear() - def OnEvent_terminated( self, message ): + def OnServerExit( self, status ): self.Clear() + + self._connection.Reset() + self._stackTraceView.ConnectionClosed() + self._variablesView.ConnectionClosed() + self._outputView.ConnectionClosed() + self._breakpoints.ConnectionClosed() + + self._ResetServerState() + + if self._run_on_server_exit: + self._run_on_server_exit() + + def OnEvent_terminated( self, message ): + # We will handle this when the server actually exists utils.UserMessage( "Debugging was terminated." ) - def _RemoveBreakpoints( self ): - for breakpoints in self._line_breakpoints.values(): - for bp in breakpoints: - if 'sign_id' in bp: - vim.command( 'sign unplace {0}'.format( bp[ 'sign_id' ] ) ) - del bp[ 'sign_id' ] - - def _SendBreakpoints( self ): - self._codeView.ClearBreakpoints() - - for file_name, line_breakpoints in self._line_breakpoints.items(): - breakpoints = [] - for bp in line_breakpoints: - if bp[ 'state' ] != 'ENABLED': - continue - - if 'sign_id' in bp: - vim.command( 'sign unplace {0}'.format( bp[ 'sign_id' ] ) ) - del bp[ 'sign_id' ] - - breakpoints.append( { 'line': bp[ 'line' ] } ) - - source = { - 'name': os.path.basename( file_name ), - 'path': file_name, - } - - self._connection.DoRequest( - functools.partial( self._UpdateBreakpoints, source ), - { - 'command': 'setBreakpoints', - 'arguments': { - 'source': source, - 'breakpoints': breakpoints, - }, - 'sourceModified': False, # TODO: We can actually check this - } - ) - - self._connection.DoRequest( - functools.partial( self._UpdateBreakpoints, None ), - { - 'command': 'setFunctionBreakpoints', - 'arguments': { - 'breakpoints': [ - { 'name': bp[ 'function' ] } - for bp in self._func_breakpoints if bp[ 'state' ] == 'ENABLED' - ], - } - } - ) - - def _ShowBreakpoints( self ): - for file_name, line_breakpoints in self._line_breakpoints.items(): - for bp in line_breakpoints: - if 'sign_id' in bp: - vim.command( 'sign unplace {0}'.format( bp[ 'sign_id' ] ) ) - else: - bp[ 'sign_id' ] = self._next_sign_id - self._next_sign_id += 1 - - vim.command( - 'sign place {0} line={1} name={2} file={3}'.format( - bp[ 'sign_id' ] , - bp[ 'line' ], - 'vimspectorBP' if bp[ 'state' ] == 'ENABLED' - else 'vimspectorBPDisabled', - file_name ) ) - def OnEvent_output( self, message ): if self._outputView: self._outputView.OnOutput( message[ 'body' ] ) def OnEvent_stopped( self, message ): event = message[ 'body' ] + reason = event.get( 'reason' ) or '' + description = event.get( 'description' ) + text = event.get( 'text' ) - utils.UserMessage( 'Paused in thread {0} due to {1}'.format( + if description: + explanation = description + '(' + reason + ')' + else: + explanation = reason + + if text: + explanation += ': ' + text + + msg = 'Paused in thread {0} due to {1}'.format( event.get( 'threadId', '' ), - event.get( 'description', event.get( 'reason', '' ) ) ) ) + explanation ) + utils.UserMessage( msg, persist = True ) + + if self._outputView: + self._outputView.Print( 'server', msg ) self._stackTraceView.OnStopped( event ) + + def ListBreakpoints( self ): + return self._breakpoints.ListBreakpoints() + + def ToggleBreakpoint( self ): + return self._breakpoints.ToggleBreakpoint() + + def AddFunctionBreakpoint( self, function ): + return self._breakpoints.AddFunctionBreakpoint( function ) diff --git a/python3/vimspector/install.py b/python3/vimspector/install.py new file mode 100644 index 0000000..280b07b --- /dev/null +++ b/python3/vimspector/install.py @@ -0,0 +1,32 @@ +# vimspector - A multi-language debugging system for Vim +# Copyright 2019 Ben Jackson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import platform +import os + +def GetOS(): + if platform.system() == 'Darwin': + return 'macos' + elif platform.system() == 'Winwdows': + return 'windows' + else: + return 'linux' + +def GetGadgetDir( vimspector_base, OS ): + return os.path.join( os.path.abspath( vimspector_base ), 'gadgets', OS ) + +def GetGadgetConfigFile( vimspector_base ): + return os.path.join( GetGadgetDir( vimspector_base, GetOS() ), + '.gadgets.json' ) diff --git a/python3/vimspector/output.py b/python3/vimspector/output.py index 601183e..fbeedce 100644 --- a/python3/vimspector/output.py +++ b/python3/vimspector/output.py @@ -19,6 +19,14 @@ import vim import json +class TabBuffer( object ): + def __init__( self, buf, index ): + self.buf = buf + self.index = index + self.flag = False + self.is_job = False + + BUFFER_MAP = { 'console': 'Console', 'stdout': 'Console', @@ -40,9 +48,13 @@ class OutputView( object ): for b in set( BUFFER_MAP.values() ): self._CreateBuffer( b ) - self.ShowOutput( 'Console' ) + self._CreateBuffer( + 'Vimspector', + file_name = vim.eval( 'expand( "~/.vimspector.log" )' ) ) - def ServerEcho( self, text ): + self._ShowOutput( 'Console' ) + + def Print( self, categroy, text ): self._Print( 'server', text.splitlines() ) def OnOutput( self, event ): @@ -58,37 +70,56 @@ class OutputView( object ): if category not in self._buffers: self._CreateBuffer( category ) - buf = self._buffers[ category ] + buf = self._buffers[ category ].buf + with utils.ModifiableScratchBuffer( buf ): utils.AppendToBuffer( buf, text_lines ) + self._ToggleFlag( category, True ) + # Scroll the buffer with utils.RestoreCurrentWindow(): with utils.RestoreCurrentBuffer( self._window ): - self.ShowOutput( category ) - vim.command( 'normal G' ) + self._ShowOutput( category ) + + def ConnectionUp( self, connection ): + self._connection = connection def ConnectionClosed( self ): + # Don't clear because output is probably still useful self._connection = None def Reset( self ): self.Clear() def Clear( self ): - for buf in self._buffers: - vim.command( 'bwipeout! {0}'.format( self._buffers[ buf ].name ) ) + for category, tab_buffer in self._buffers.items(): + if tab_buffer.is_job: + utils.CleanUpCommand( category ) + try: + vim.command( 'bdelete! {0}'.format( tab_buffer.buf.number ) ) + except vim.error as e: + # FIXME: For now just ignore the "no buffers were deleted" error + if 'E516' not in e: + raise self._buffers.clear() - def ShowOutput( self, category ): + def _ShowOutput( self, category ): vim.current.window = self._window - vim.command( 'bu {0}'.format( self._buffers[ category ].name ) ) + vim.command( 'bu {0}'.format( self._buffers[ category ].buf.name ) ) + vim.command( 'normal G' ) + + def ShowOutput( self, category ): + self._ToggleFlag( category, False ) + self._ShowOutput( category ) def Evaluate( self, frame, expression ): if not frame: + self.Print( 'Console', 'There is no current stack frame' ) return - console = self._buffers[ 'Console' ] + console = self._buffers[ 'Console' ].buf utils.AppendToBuffer( console, 'Evaluating: ' + expression ) def print_result( message ): @@ -110,24 +141,70 @@ class OutputView( object ): } } ) - def _CreateBuffer( self, category ): + def _ToggleFlag( self, category, flag ): + if self._buffers[ category ].flag != flag: + self._buffers[ category ].flag = flag + with utils.RestoreCurrentWindow(): + vim.current.window = self._window + self._RenderWinBar( category ) + + + def RunJobWithOutput( self, category, cmd ): + self._CreateBuffer( category, cmd = cmd ) + + + def _CreateBuffer( self, category, file_name = None, cmd = None ): with utils.RestoreCurrentWindow(): vim.current.window = self._window with utils.RestoreCurrentBuffer( self._window ): - vim.command( 'enew' ) - self._buffers[ category ] = vim.current.buffer - if category == 'Console': - utils.SetUpPromptBuffer( self._buffers[ category ], - 'vimspector.Console', - '> ', - 'vimspector#EvaluateConsole', - hidden=True ) + if file_name is not None: + assert cmd is None + cmd = [ 'tail', '-F', '-n', '+1', '--', file_name ] + + if cmd is not None: + out, err = utils.SetUpCommandBuffer( cmd, category ) + self._buffers[ category + '-out' ] = TabBuffer( out, + len( self._buffers ) ) + self._buffers[ category + '-out' ].is_job = True + self._buffers[ category + '-err' ] = TabBuffer( err, + len( self._buffers ) ) + self._buffers[ category + '-err' ].is_job = False + self._RenderWinBar( category + '-out' ) + self._RenderWinBar( category + '-err' ) else: - utils.SetUpHiddenBuffer( self._buffers[ category ], - 'vimspector.Output:{0}'.format( category ) ) + vim.command( 'enew' ) + tab_buffer = TabBuffer( vim.current.buffer, len( self._buffers ) ) + self._buffers[ category ] = tab_buffer + if category == 'Console': + utils.SetUpPromptBuffer( tab_buffer.buf, + 'vimspector.Console', + '> ', + 'vimspector#EvaluateConsole', + hidden=True ) + else: + utils.SetUpHiddenBuffer( + tab_buffer.buf, + 'vimspector.Output:{0}'.format( category ) ) - vim.command( "nnoremenu WinBar.{0} " - ":call vimspector#ShowOutput( '{0}' )".format( - utils.Escape( category ) ) ) + self._RenderWinBar( category ) + + def _RenderWinBar( self, category ): + tab_buffer = self._buffers[ category ] + + try: + if tab_buffer.flag: + vim.command( 'nunmenu WinBar.{}'.format( utils.Escape( category ) ) ) + else: + vim.command( 'nunmenu WinBar.{}*'.format( utils.Escape( category ) ) ) + except vim.error as e: + # E329 means the menu doesn't exist; ignore that. + if 'E329' not in str( e ): + raise + + vim.command( "nnoremenu 1.{0} WinBar.{1}{2} " + ":call vimspector#ShowOutput( '{1}' )".format( + tab_buffer.index, + utils.Escape( category ), + '*' if tab_buffer.flag else '' ) ) diff --git a/python3/vimspector/stack_trace.py b/python3/vimspector/stack_trace.py index 920f0da..61f1d64 100644 --- a/python3/vimspector/stack_trace.py +++ b/python3/vimspector/stack_trace.py @@ -37,6 +37,15 @@ class StackTraceView( object ): self._line_to_frame = {} self._line_to_thread = {} + # TODO: We really need a proper state model + # + # AWAIT_CONNECTION -- OnServerReady / RequestThreads --> REQUESTING_THREADS + # REQUESTING -- OnGotThreads / RequestScopes --> REQUESTING_SCOPES + # + # When we attach using gdbserver, this whole thing breaks because we request + # the threads over and over and get duff data back on later threads. + self._requesting_threads = False + def GetCurrentThreadId( self ): return self._currentThread @@ -51,6 +60,10 @@ class StackTraceView( object ): with utils.ModifiableScratchBuffer( self._buf ): utils.ClearBuffer( self._buf ) + def ConnectionUp( self, connection ): + self._connection = connection + self._requesting_threads = False + def ConnectionClosed( self ): self.Clear() self._connection = None @@ -60,26 +73,38 @@ class StackTraceView( object ): # TODO: delete the buffer ? def LoadThreads( self, infer_current_frame ): + pending_request = False + if self._requesting_threads: + pending_request = True + return + def consume_threads( message ): - self._threads.clear() + self._requesting_threads = False if not message[ 'body' ][ 'threads' ]: - # This is a protocol error. It is required to return at least one! - utils.UserMessage( 'Server returned no threads. Is it running?', - persist = True ) - return + if pending_request: + # We may have hit a thread event, so try again. + self.LoadThreads( infer_current_frame ) + return + else: + # This is a protocol error. It is required to return at least one! + utils.UserMessage( 'Server returned no threads. Is it running?', + persist = True ) + + self._threads.clear() for thread in message[ 'body' ][ 'threads' ]: self._threads.append( thread ) if infer_current_frame and thread[ 'id' ] == self._currentThread: self._LoadStackTrace( thread, True ) - elif infer_current_frame and not self._currentThread: + elif infer_current_frame and self._currentThread is None: self._currentThread = thread[ 'id' ] self._LoadStackTrace( thread, True ) self._DrawThreads() + self._requesting_threads = True self._connection.DoRequest( consume_threads, { 'command': 'threads', } ) @@ -146,7 +171,7 @@ class StackTraceView( object ): elif event.get( 'allThreadsStopped', False ) and self._threads: self._currentThread = self._threads[ 0 ][ 'id' ] - if self._currentThread: + if self._currentThread is not None: for thread in self._threads: if thread[ 'id' ] == self._currentThread: self._LoadStackTrace( thread, True ) @@ -156,10 +181,11 @@ class StackTraceView( object ): def OnThreadEvent( self, event ): if event[ 'reason' ] == 'started' and self._currentThread is None: + self._currentThread = event[ 'threadId' ] self.LoadThreads( True ) def Continue( self ): - if not self._currentThread: + if self._currentThread is None: utils.UserMessage( 'No current thread', persist = True ) return @@ -174,7 +200,7 @@ class StackTraceView( object ): self.LoadThreads( True ) def Pause( self ): - if not self._currentThread: + if self._currentThread is None: utils.UserMessage( 'No current thread', persist = True ) return diff --git a/python3/vimspector/utils.py b/python3/vimspector/utils.py index 53654fd..4496e0e 100644 --- a/python3/vimspector/utils.py +++ b/python3/vimspector/utils.py @@ -21,7 +21,8 @@ import vim import json import string -_log_handler = logging.FileHandler( os.path.expanduser( '~/.vimspector.log' ) ) +_log_handler = logging.FileHandler( os.path.expanduser( '~/.vimspector.log' ), + mode = 'w' ) _log_handler.setFormatter( logging.Formatter( '%(asctime)s - %(levelname)s - %(message)s' ) ) @@ -32,6 +33,48 @@ def SetUpLogging( logger ): logger.addHandler( _log_handler ) +def BufferNumberForFile( file_name ): + return int( vim.eval( 'bufnr( "{0}", 1 )'.format( file_name ) ) ) + + +def BufferForFile( file_name ): + return vim.buffers[ BufferNumberForFile( file_name ) ] + + +def OpenFileInCurrentWindow( file_name ): + buffer_number = BufferNumberForFile( file_name ) + try: + vim.command( 'bu {0}'.format( buffer_number ) ) + except vim.error as e: + if 'E325' not in str( e ): + raise + + return vim.buffers[ buffer_number ] + + +def SetUpCommandBuffer( cmd, name ): + bufs = vim.bindeval( + 'vimspector#internal#job#StartCommandWithLog( {}, "{}" )'.format( + json.dumps( cmd ), + name ) ) + + if bufs is None: + raise RuntimeError( "Unable to start job {}: {}".format( cmd, name ) ) + elif not all( [ b > 0 for b in bufs ] ): + raise RuntimeError( "Unable to get all streams for job {}: {}".format( + name, + cmd ) ) + + UserMessage( 'Bufs: {}'.format( [ int(b) for b in bufs ] ), persist = True ) + + return [ vim.buffers[ b ] for b in bufs ] + + +def CleanUpCommand( name ): + return vim.eval( 'vimspector#internal#job#CleanUpCommand( "{}" )'.format( + name ) ) + + def SetUpScratchBuffer( buf, name ): buf.options[ 'buftype' ] = 'nofile' buf.options[ 'swapfile' ] = False @@ -199,7 +242,10 @@ def SelectFromList( prompt, options ): def AskForInput( prompt ): # TODO: Handle the ctrl-c and such responses returning empty or something with InputSave(): - return vim.eval( "input( '{0}' )".format( Escape( prompt ) ) ) + try: + return vim.eval( "input( '{0}' )".format( Escape( prompt ) ) ) + except KeyboardInterrupt: + return '' def AppendToBuffer( buf, line_or_lines, modified=False ): @@ -215,14 +261,11 @@ def AppendToBuffer( buf, line_or_lines, modified=False ): else: line = 1 buf[:] = line_or_lines - except vim.error as e: - # There seem to be a lot of Vim bugs that lead to E351, whose help says that + except: + # There seem to be a lot of Vim bugs that lead to E315, whose help says that # this is an internal error. Ignore the error, but write a trace to the log. - if 'E315' in str( e ): - logging.getLogger( __name__ ).exception( - 'Internal error while updating buffer' ) - else: - raise e + logging.getLogger( __name__ ).exception( + 'Internal error while updating buffer %s (%s)', buf.name, buf.number ) finally: if not modified: buf.options[ 'modified' ] = False @@ -283,3 +326,42 @@ def ExpandReferencesInDict( obj, mapping, **kwargs ): for k in obj.keys(): obj[ k ] = expand_refs_in_object( obj[ k ] ) + + +def ParseVariables( variables ): + new_variables = {} + for n,v in variables.items(): + if isinstance( v, dict ): + if 'shell' in v: + import subprocess + import shlex + + new_v = v.copy() + # Bit of a hack. Allows environment variables to be used. + ExpandReferencesInDict( new_v, {} ) + + env = os.environ.copy() + env.update( new_v.get( 'env' ) or {} ) + cmd = new_v[ 'shell' ] + if not isinstance( cmd, list ): + cmd = shlex.split( cmd ) + + new_variables[ n ] = subprocess.check_output( + cmd, + cwd = new_v.get( 'cwd' ) or os.getcwd(), + env = env ).decode( 'utf-8' ).strip() + else: + raise ValueError( + "Unsupported variable defn {}: Missing 'shell'".format( n ) ) + else: + new_variables[ n ] = v + + return new_variables + + +def DisplayBaloon( is_term, display ): + if not is_term: + display = '\n'.join( display ) + + vim.eval( "balloon_show( {0} )".format( + json.dumps( display ) ) ) diff --git a/python3/vimspector/variables.py b/python3/vimspector/variables.py index 423cdf7..a66bf57 100644 --- a/python3/vimspector/variables.py +++ b/python3/vimspector/variables.py @@ -14,7 +14,6 @@ # limitations under the License. import vim -import json import logging from collections import namedtuple from functools import partial @@ -85,6 +84,7 @@ class VariablesView( object ): self._oldoptions[ 'balloonevalterm' ] = vim.options[ 'balloonevalterm' ] vim.options[ 'balloonevalterm' ] = True + self._is_term = not bool( int( vim.eval( "has( 'gui_running' )" ) ) ) def Clear( self ): with utils.ModifiableScratchBuffer( self._vars.win.buffer ): @@ -92,6 +92,9 @@ class VariablesView( object ): with utils.ModifiableScratchBuffer( self._watch.win.buffer ): utils.ClearBuffer( self._watch.win.buffer ) + def ConnectionUp( self, connection ): + self._connection = connection + def ConnectionClosed( self ): self.Clear() self._connection = None @@ -353,13 +356,11 @@ class VariablesView( object ): 'Type: ' + body.get( 'type', '' ), 'Value: ' + result ] - vim.eval( "balloon_show( {0} )".format( - json.dumps( display ) ) ) + utils.DisplayBaloon( self._is_term, display ) def failure_handler( reason, message ): display = [ reason ] - vim.eval( "balloon_show( {0} )".format( - json.dumps( display ) ) ) + utils.DisplayBaloon( self._is_term, display ) self._connection.DoRequest( handler, { diff --git a/run_test_vim b/run_test_vim new file mode 100755 index 0000000..6d79b4b --- /dev/null +++ b/run_test_vim @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +RUN_VIM="vim --noplugin --clean --not-a-term -Nu vimrc" +RUN_TEST="${RUN_VIM} -S run_test.vim" + +pushd tests > /dev/null + +exec $RUN_VIM "$@" diff --git a/run_tests b/run_tests new file mode 100755 index 0000000..a24442f --- /dev/null +++ b/run_tests @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +RUN_VIM="vim --noplugin --clean --not-a-term" +RUN_TEST="${RUN_VIM} -S run_test.vim" + +pushd tests > /dev/null + +echo "Running Vimspector Vim tests" + +RESULT=0 + +for t in *.test.vim; do + echo "" + echo "%RUN: $t" + rm -f messages debuglog + + if ${RUN_TEST} $t --cmd 'au SwapExists * let v:swapchoice = "e"'; then + echo "%PASS: $t PASSED" + else + cat messages + echo "%FAIL: $t FAILED" + RESULT=1 + fi +done + +popd > /dev/null + +echo "" +echo "All done." + +exit $RESULT diff --git a/tests/breakpoints.test.vim b/tests/breakpoints.test.vim new file mode 100644 index 0000000..1b21d60 --- /dev/null +++ b/tests/breakpoints.test.vim @@ -0,0 +1,93 @@ +function! SetUp() + if exists ( 'g:loaded_vimpector' ) + unlet g:loaded_vimpector + endif + + source vimrc + + " This is a bit of a hack + runtime! plugin/**/*.vim +endfunction + +function! ClearDown() + if exists( '*vimspector#internal#state#Reset' ) + call vimspector#internal#state#Reset() + endif +endfunction + +function! SetUp_Test_Mappings_Are_Added_HUMAN() + let g:vimspector_enable_mappings = 'HUMAN' +endfunction + +function! Test_Mappings_Are_Added_HUMAN() + call assert_true( hasmapto( 'vimspector#Continue()' ) ) + call assert_false( hasmapto( 'vimspector#Launch()' ) ) + call assert_true( hasmapto( 'vimspector#Stop()' ) ) + call assert_true( hasmapto( 'vimspector#Restart()' ) ) + call assert_true( hasmapto( 'vimspector#ToggleBreakpoint()' ) ) + call assert_true( hasmapto( 'vimspector#AddFunctionBreakpoint' ) ) + call assert_true( hasmapto( 'vimspector#StepOver()' ) ) + call assert_true( hasmapto( 'vimspector#StepInto()' ) ) + call assert_true( hasmapto( 'vimspector#StepOut()' ) ) +endfunction + +function! Test_Mappings_Are_Added_VISUAL_STUDIO() + call assert_true( hasmapto( 'vimspector#Continue()' ) ) + call assert_false( hasmapto( 'vimspector#Launch()' ) ) + call assert_true( hasmapto( 'vimspector#Stop()' ) ) + call assert_true( hasmapto( 'vimspector#Restart()' ) ) + call assert_true( hasmapto( 'vimspector#ToggleBreakpoint()' ) ) + call assert_true( hasmapto( 'vimspector#AddFunctionBreakpoint' ) ) + call assert_true( hasmapto( 'vimspector#StepOver()' ) ) + call assert_true( hasmapto( 'vimspector#StepInto()' ) ) + call assert_true( hasmapto( 'vimspector#StepOut()' ) ) +endfunction + +function! SetUp_Test_Signs_Placed_Using_API_Are_Shown() + let g:vimspector_enable_mappings = 'VISUAL_STUDIO' +endfunction + +function! Test_Signs_Placed_Using_API_Are_Shown() + " We need a real file + edit testdata/cpp/simple.cpp + call feedkeys( '/printf' ) + + " Set breakpoint + call vimspector#ToggleBreakpoint() + + call assert_true( exists( '*vimspector#ToggleBreakpoint' ) ) + + let signs = sign_getplaced( '.', { + \ 'group': 'VimspectorBP', + \ 'line': line( '.' ) + \ } ) + + call assert_true( len( signs ) == 1 ) + call assert_true( len( signs[ 0 ].signs ) == 1 ) + call assert_true( signs[ 0 ].signs[ 0 ].name == 'vimspectorBP' ) + + " Disable breakpoint + call vimspector#ToggleBreakpoint() + + let signs = sign_getplaced( '.', { + \ 'group': 'VimspectorBP', + \ 'line': line( '.' ) + \ } ) + + call assert_true( len( signs ) == 1 ) + call assert_true( len( signs[ 0 ].signs ) == 1 ) + call assert_true( signs[ 0 ].signs[ 0 ].name == 'vimspectorBPDisabled' ) + + " Remove breakpoint + call vimspector#ToggleBreakpoint() + + let signs = sign_getplaced( '.', { + \ 'group': 'VimspectorBP', + \ 'line': line( '.' ) + \ } ) + + call assert_true( len( signs ) == 1 ) + call assert_true( len( signs[ 0 ].signs ) == 0 ) + + " TODO: Use the screen dump test ? +endfunction diff --git a/tests/ci/image/Dockerfile b/tests/ci/image/Dockerfile new file mode 100644 index 0000000..1de8b9f --- /dev/null +++ b/tests/ci/image/Dockerfile @@ -0,0 +1,29 @@ +FROM ubuntu:18.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get -y dist-upgrade && \ + apt-get -y install python3-dev \ + ca-cacert \ + libncurses5-dev libncursesw5-dev \ + git \ + tcl-dev \ + tcllib && \ + apt-get -y autoremove + +RUN ln -fs /usr/share/zoneinfo/Europe/London /etc/localtime && \ + dpkg-reconfigure --frontend noninteractive tzdata + +RUN mkdir -p $HOME/vim && \ + cd $HOME/vim && \ + git clone https://github.com/vim/vim && \ + cd vim && \ + git checkout v8.1.0958 && \ + ./configure --with-features=huge \ + --enable-python3interp \ + --enable-terminal \ + --enable-multibyte \ + --enable-fail-if-missing && \ + make -j 8 install + diff --git a/tests/run_test.vim b/tests/run_test.vim new file mode 100644 index 0000000..de454cb --- /dev/null +++ b/tests/run_test.vim @@ -0,0 +1,347 @@ +" This script is sourced while editing the .vim file with the tests. +" When the script is successful the .res file will be created. +" Errors are appended to the test.log file. +" +" To execute only specific test functions, add a second argument. It will be +" matched against the names of the Test_ funtion. E.g.: +" ../vim -u NONE -S runtest.vim test_channel.vim open_delay +" The output can be found in the "messages" file. +" +" The test script may contain anything, only functions that start with +" "Test_" are special. These will be invoked and should contain assert +" functions. See test_assert.vim for an example. +" +" It is possible to source other files that contain "Test_" functions. This +" can speed up testing, since Vim does not need to restart. But be careful +" that the tests do not interfere with each other. +" +" If an error cannot be detected properly with an assert function add the +" error to the v:errors list: +" call add(v:errors, 'test foo failed: Cannot find xyz') +" +" If preparation for each Test_ function is needed, define a SetUp function. +" It will be called before each Test_ function. +" +" If cleanup after each Test_ function is needed, define a TearDown function. +" It will be called after each Test_ function. +" +" When debugging a test it can be useful to add messages to v:errors: +" call add(v:errors, "this happened") + +set rtp=$VIM/vimfiles,$VIMRUNTIME,$VIM/vimfiles/after +if has('packages') + let &packpath = &rtp +endif + +call ch_logfile( 'debuglog', 'w' ) + +" For consistency run all tests with 'nocompatible' set. +" This also enables use of line continuation. +set nocp viminfo+=nviminfo + +" Use utf-8 by default, instead of whatever the system default happens to be. +" Individual tests can overrule this at the top of the file. +set encoding=utf-8 + +" Avoid stopping at the "hit enter" prompt +set nomore + +" Output all messages in English. +lang mess C + +" Always use forward slashes. +set shellslash + +func RunTheTest(test) + echo 'Executing ' . a:test + + " Avoid stopping at the "hit enter" prompt + set nomore + + " Avoid a three second wait when a message is about to be overwritten by the + " mode message. + set noshowmode + + " Clear any overrides. + call test_override('ALL', 0) + + " Some tests wipe out buffers. To be consistent, always wipe out all + " buffers. + %bwipe! + + " The test may change the current directory. Save and restore the + " directory after executing the test. + let save_cwd = getcwd() + + if exists("*SetUp_" . a:test) + try + exe 'call SetUp_' . a:test + catch + call add(v:errors, + \ 'Caught exception in SetUp_' . a:test . ' before ' + \ . a:test + \ . ': ' + \ . v:exception + \ . ' @ ' + \ . v:throwpoint) + endtry + endif + + if exists("*SetUp") + try + call SetUp() + catch + call add(v:errors, + \ 'Caught exception in SetUp() before ' + \ . a:test + \ . ': ' + \ . v:exception + \ . ' @ ' + \ . v:throwpoint) + endtry + endif + + call add(s:messages, 'Executing ' . a:test) + let s:done += 1 + + if a:test =~ 'Test_nocatch_' + " Function handles errors itself. This avoids skipping commands after the + " error. + exe 'call ' . a:test + else + try + let s:test = a:test + au VimLeavePre * call EarlyExit(s:test) + exe 'call ' . a:test + au! VimLeavePre + catch /^\cskipped/ + call add(s:messages, ' Skipped') + call add(s:skipped, + \ 'SKIPPED ' . a:test + \ . ': ' + \ . substitute(v:exception, '^\S*\s\+', '', '')) + catch + call add(v:errors, + \ 'Caught exception in ' . a:test + \ . ': ' + \ . v:exception + \ . ' @ ' + \ . v:throwpoint) + endtry + endif + + " In case 'insertmode' was set and something went wrong, make sure it is + " reset to avoid trouble with anything else. + set noinsertmode + + if exists("*TearDown") + try + call TearDown() + catch + call add(v:errors, + \ 'Caught exception in TearDown() after ' . a:test + \ . ': ' + \ . v:exception + \ . ' @ ' + \ . v:throwpoint) + endtry + endif + + if exists("*TearDown_" . a:test) + try + exe 'call TearDown_' . a:test + catch + call add(v:errors, + \ 'Caught exception in TearDown_' . a:test . ' after ' . a:test + \ . ': ' + \ . v:exception + \ . ' @ ' + \ . v:throwpoint) + endtry + endif + + " Clear any autocommands + au! + + " Close any extra tab pages and windows and make the current one not modified. + while tabpagenr('$') > 1 + quit! + endwhile + + while 1 + let wincount = winnr('$') + if wincount == 1 + break + endif + bwipe! + if wincount == winnr('$') + " Did not manage to close a window. + only! + break + endif + endwhile + + exe 'cd ' . save_cwd +endfunc + +func AfterTheTest() + if len(v:errors) > 0 + let s:fail += 1 + call add(s:errors, 'Found errors in ' . s:test . ':') + call extend(s:errors, v:errors) + let v:errors = [] + endif +endfunc + +func EarlyExit(test) + " It's OK for the test we use to test the quit detection. + if a:test != 'Test_zz_quit_detected()' + call add(v:errors, 'Test caused Vim to exit: ' . a:test) + endif + + call FinishTesting() +endfunc + +" This function can be called by a test if it wants to abort testing. +func FinishTesting() + call AfterTheTest() + + " Don't write viminfo on exit. + set viminfo= + + if s:fail == 0 + " Success, create the .res file so that make knows it's done. + exe 'split ' . fnamemodify(g:testname, ':r') . '.res' + write + endif + + if len(s:errors) > 0 + " Append errors to test.log + split test.log + call append(line('$'), '') + call append(line('$'), 'From ' . g:testname . ':') + call append(line('$'), s:errors) + write + endif + + if s:done == 0 + let message = 'NO tests executed' + else + let message = 'Executed ' . s:done . (s:done > 1 ? ' tests' : ' test') + endif + echo message + call add(s:messages, message) + if s:fail > 0 + let message = s:fail . ' FAILED:' + echo message + call add(s:messages, message) + call extend(s:messages, s:errors) + endif + + " Add SKIPPED messages + call extend(s:messages, s:skipped) + + " Append messages to the file "messages" + split messages + call append(line('$'), '') + call append(line('$'), 'From ' . g:testname . ':') + call append(line('$'), s:messages) + write + + if s:fail > 0 + cquit! + else + qall! + endif +endfunc + +" Source the test script. First grab the file name, in case the script +" navigates away. g:testname can be used by the tests. +let g:testname = expand('%') +let s:done = 0 +let s:fail = 0 +let s:errors = [] +let s:messages = [] +let s:skipped = [] +try + source % +catch + let s:fail += 1 + call add(s:errors, + \ 'Caught exception: ' . + \ v:exception . + \ ' @ ' . v:throwpoint) +endtry + +" Names of flaky tests. +let s:flaky_tests = [] + +" Pattern indicating a common flaky test failure. +let s:flaky_errors_re = '__does_not_match__' + +" Locate Test_ functions and execute them. +redir @q +silent function /^Test_ +redir END +let s:tests = split(substitute(@q, 'function \(\k*()\)', '\1', 'g')) + +" If there is an extra argument filter the function names against it. +if argc() > 1 + let s:tests = filter(s:tests, 'v:val =~ argv(1)') +endif + +" Execute the tests in alphabetical order. +for s:test in sort(s:tests) + " Silence, please! + set belloff=all + let prev_error = '' + let total_errors = [] + let run_nr = 1 + + call RunTheTest(s:test) + + " Repeat a flaky test. Give up when: + " - it fails again with the same message + " - it fails five times (with a different mesage) + if len(v:errors) > 0 + \ && (index(s:flaky_tests, s:test) >= 0 + \ || v:errors[0] =~ s:flaky_errors_re) + while 1 + call add(s:messages, 'Found errors in ' . s:test . ':') + call extend(s:messages, v:errors) + + call add(total_errors, 'Run ' . run_nr . ':') + call extend(total_errors, v:errors) + + if run_nr == 5 || prev_error == v:errors[0] + call add(total_errors, 'Flaky test failed too often, giving up') + let v:errors = total_errors + break + endif + + call add(s:messages, 'Flaky test failed, running it again') + + " Flakiness is often caused by the system being very busy. Sleep a + " couple of seconds to have a higher chance of succeeding the second + " time. + sleep 2 + + let prev_error = v:errors[0] + let v:errors = [] + let run_nr += 1 + + call RunTheTest(s:test) + + if len(v:errors) == 0 + " Test passed on rerun. + break + endif + endwhile + endif + + call AfterTheTest() +endfor + +call FinishTesting() + +" vim: shiftwidth=2 sts=2 expandtab diff --git a/tests/testdata/cpp/simple.cpp b/tests/testdata/cpp/simple.cpp new file mode 100644 index 0000000..e3e623a --- /dev/null +++ b/tests/testdata/cpp/simple.cpp @@ -0,0 +1,7 @@ +#include + +int main( int argc, char ** ) +{ + printf( "this is a test %d", argc ); + return 0; +} diff --git a/tests/vimrc b/tests/vimrc new file mode 100644 index 0000000..9901c3a --- /dev/null +++ b/tests/vimrc @@ -0,0 +1,6 @@ +let g:vimspector_test_plugin_path = expand( ':h:h' ) + +let &rtp = &rtp . ',' . g:vimspector_test_plugin_path + +filetype plugin indent on +syntax enable