diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..6659ada --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,45 @@ +# Python CircleCI 2.0 configuration file +# +# Check https://circleci.com/docs/2.0/language-python/ for more details +# +version: 2 +aliases: + cache: &cache + key: v1-vimspector-{{ .Environment.CIRCLE_JOB }} + update-submodules: &update-submodules + run: + name: Update submodules + command: git submodule update --init --recursive + install-vim: &install-vim + install-gadgets: &install-gadgets + run: + name: Install gadgets + command: 'python3 ./install_gadget.py' + run-tests: &run-tests + run: + name: Run tests + command: './run_tests' + # Increase the version key to clear the cache. + save-cache: &save-cache + save_cache: + <<: *cache + paths: + - 'gadgets/download/*/*/*/*.vsix' + - 'gadgets/download/*/*/*/*.gz' + restore-cache: &restore-cache + restore_cache: + <<: *cache +jobs: + build-macos: + macos: + xcode: "9.0" + steps: + - checkout + - *update-submodules + - *restore-cache + - run: + name: Install vim + command: .circleci/install_vim.macos.sh + - *install-gadgets + - *run-tests + - *save-cache diff --git a/.circleci/install_vim.macos.sh b/.circleci/install_vim.macos.sh new file mode 100644 index 0000000..84d6779 --- /dev/null +++ b/.circleci/install_vim.macos.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e + +brea update || brew update +brew install vim --with-override-system-vim diff --git a/.gitignore b/.gitignore index 2fef3ae..56a56f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ *.un~ *.sw[op] __pycache__ +.vscode +tests/*.res +tests/messages +tests/debuglog +test.log +gadgets/ 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/install_gadget.py b/install_gadget.py new file mode 100755 index 0000000..a634b3e --- /dev/null +++ b/install_gadget.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python + +try: + import urllib.request as urllib2 +except ImportError: + import urllib2 + +import contextlib +import os +import collections +import platform +import string +import zipfile +import shutil +import subprocess +import traceback +import tarfile +import hashlib + +GADGETS = { + 'vscode-cpptools': { + 'download': { + 'url': ( 'https://github.com/Microsoft/vscode-cpptools/releases/download/' + '${version}/${file_name}' ), + }, + '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, + }, + }, + 'vscode-python': { + 'download': { + 'url': ( 'https://github.com/Microsoft/vscode-python/releases/download/' + '${version}/${file_name}' ), + }, + 'all': { + 'version': '2018.12.1', + 'file_name': 'ms-python-release.vsix', + 'checksum': '0406028b7d2fbb86ffd6cda18a36638a95111fd35b19cc198d343a2828f5c3b1', + }, + }, + 'tclpro': { + 'repo': { + 'url': 'https://github.com/puremourning/TclProDebug', + 'ref': 'master', + }, + 'do': lambda root: InstallTclProDebug( root ) + }, + 'vscode-mono-debug': { + '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', + } + }, +} + +@contextlib.contextmanager +def CurrentWorkingDir( d ): + cur_d = os.getcwd() + try: + os.chdir( d ) + yield + finally: + os.chdir( cur_d ) + +def InstallTclProDebug( 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 [ '/System/Library/Frameworks/Tcl.framework/', + '/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, 'tclpro', 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, + ) ) + + 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 ): + print( "Removing existing {}".format( destination ) ) + if os.path.isdir( destination ): + shutil.rmtree( destination ) + else: + os.remove( destination ) + + +def ExtractZipTo( file_path, destination, format ): + print( "Extracting {} to {}".format( file_path, destination ) ) + RemoveIfExists( destination ) + + if format == 'zip': + with zipfile.ZipFile( 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 MakeSymlink( in_folder, link, pointing_to ): + RemoveIfExists( os.path.join( in_folder, link ) ) + 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 ] ) + +if platform.system() == 'Darwin': + OS = 'macos' +elif platform.system() == 'Winwdows': + OS = 'windows' +else: + OS = 'linux' + +gadget_dir = os.path.join( os.path.dirname( __file__ ), 'gadgets', OS ) + +for name, gadget in GADGETS.items(): + 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' ]( root ) + else: + MakeSymlink( gadget_dir, name, os.path.join( root, 'extenstion') ), + + print( "Done installing {}".format( name ) ) + except Exception as e: + traceback.print_exc() + print( "FAILED installing {}: {}".format( name, e ) ) diff --git a/python3/vimspector/breakpoints.py b/python3/vimspector/breakpoints.py new file mode 100644 index 0000000..3c8e0b2 --- /dev/null +++ b/python3/vimspector/breakpoints.py @@ -0,0 +1,190 @@ +# 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 functools + + +class ProjectBreakpoints( object ): + def __init__( self ): + self._connection = None + + # These are the user-entered breakpoints. + self._line_breakpoints = defaultdict( list ) + self._func_breakpoints = [] + + 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 ConnectionClosed( self ): + 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 SendBreakpoints( self, 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( + 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} 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 ec64b6a..b1394ee 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' ): @@ -76,7 +75,9 @@ class CodeView( object ): 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' ] ) ) @@ -85,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() @@ -140,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() @@ -160,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' ] diff --git a/python3/vimspector/debug_session.py b/python3/vimspector/debug_session.py index 81d8fb6..e94a09b 100644 --- a/python3/vimspector/debug_session.py +++ b/python3/vimspector/debug_session.py @@ -22,16 +22,14 @@ import subprocess from collections import defaultdict -from vimspector import ( code, +from vimspector import ( breakpoints, + code, debug_adapter_connection, output, stack_trace, utils, variables ) -SIGN_ID_OFFSET = 10005000 - - class DebugSession( object ): def __init__( self ): self._logger = logging.getLogger( __name__ ) @@ -41,112 +39,18 @@ class DebugSession( object ): self._stackTraceView = None self._variablesView = None self._outputView = None + self._breakpoints = breakpoints.ProjectBreakpoints() self._run_on_server_exit = None - self._next_sign_id = SIGN_ID_OFFSET - - # 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() - vim.command( 'sign define vimspectorBP text==> texthl=Error' ) - vim.command( 'sign define vimspectorBPDisabled text=!> texthl=Warning' ) - def _ResetServerState( self ): self._connection = None self._configuration = None self._init_complete = False self._launch_complete = False - 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}'.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() - def Start( self, launch_variables = {} ): self._configuration = None self._adapter = None @@ -275,7 +179,7 @@ class DebugSession( object ): 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: @@ -590,12 +494,6 @@ 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() @@ -621,7 +519,16 @@ class DebugSession( object ): self._stackTraceView.LoadThreads( True ) def OnEvent_initialized( self, message ): - self._SendBreakpoints() + def update_breakpoints( source, message ): + if 'body' not in message: + return + self._codeView.AddBreakpoints( source, + message[ 'body' ][ 'breakpoints' ] ) + self._codeView.ShowBreakpoints() + + self._codeView.ClearBreakpoints() + self._breakpoints.SendBreakpoints( update_breakpoints ) + self._connection.DoRequest( lambda msg: self._OnInitializeComplete(), { @@ -697,75 +604,6 @@ class DebugSession( object ): # 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' ] ) @@ -781,3 +619,12 @@ class DebugSession( object ): 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/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..5a725c9 --- /dev/null +++ b/tests/breakpoints.test.vim @@ -0,0 +1,92 @@ +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 ) +endfunction 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