├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── test.yml ├── .gitignore ├── README.md ├── TODO ├── bandit.yml ├── config └── lnav │ └── nvimgdb.json ├── doc └── nvimgdb.txt ├── install.sh ├── lib ├── gdb_commands.py ├── lldb_commands.py ├── proxy │ ├── bashdb.lua │ ├── impl.lua │ └── pdb.lua └── rr-replay.py ├── lua ├── nvimgdb.lua └── nvimgdb │ ├── app.lua │ ├── backend.lua │ ├── backend │ ├── bashdb.lua │ ├── gdb.lua │ ├── lldb.lua │ └── pdb.lua │ ├── breakpoint.lua │ ├── client.lua │ ├── cmake.lua │ ├── config.lua │ ├── cursor.lua │ ├── efmmgr.lua │ ├── health.lua │ ├── keymaps.lua │ ├── log.lua │ ├── parser_actions.lua │ ├── parser_impl.lua │ ├── proxy.lua │ ├── utils.lua │ └── win.lua ├── plugin └── nvimgdb.vim ├── test ├── .gitignore ├── 02_cmake_spec.lua ├── 05_quit_spec.lua ├── 10_generic_spec.lua ├── 15_multiview_spec.lua ├── 20_breakpoint_spec.lua ├── 30_pdb_spec.lua ├── 40_keymap_spec.lua ├── 45_layout_spec.lua ├── 50_command_spec.lua ├── 60_bashdb_spec.lua ├── 70_quickfix_spec.lua ├── 90_misc_spec.lua ├── all.py ├── config.lua ├── config.py ├── config_ci.lua ├── conftest.lua ├── engine.lua ├── init.lua ├── lib.py ├── main.lua ├── main.py ├── main.sh ├── nvim.py ├── output.lua ├── output_hook.lua ├── prerequisites.py ├── result.lua ├── run-tests.lua ├── spy_ui.py ├── src │ ├── .gitignore │ ├── CMakeLists.txt │ ├── lib.hpp │ └── test.cpp └── thread.lua └── utils ├── setup-testenv.py ├── testenv_darwin.py ├── testenv_linux.py └── testenv_win32.py /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | What are the steps to reproduce this issue? 2 | ------------------------------------------- 3 | 1. … 4 | 2. … 5 | 3. … 6 | 7 | What happens? 8 | ------------- 9 | … 10 | 11 | What were you expecting to happen? 12 | ---------------------------------- 13 | … 14 | 15 | Any logs, error output, etc? 16 | ---------------------------- 17 | 18 | To obtain the logs (`nvimgdb.log`, `proxy.log`, `spy_ui.log`), launch neovim as follows: 19 | `cd /test; CI=1 ./nvim` 20 | 21 | If it’s long, please paste to https://gist.github.com/ and insert the link here. 22 | 23 | 24 | Any other comments? 25 | ------------------- 26 | … 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | TERM: xterm 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-22.04, macos-latest, windows-latest] 13 | python-version: ["3.11"] 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | timeout-minutes: ${{ (matrix.os == 'windows-latest' && 20) || 10 }} 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python utils/setup-testenv.py 30 | python -m pip install --upgrade pip 31 | python -m pip install pynvim packaging 32 | 33 | - name: Run tests 34 | run: | 35 | python ./test/all.py 36 | 37 | - name: Archive script logs 38 | if: ${{ always() }} 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: logs-${{ matrix.os }} 42 | path: test/*.log 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | a.out 3 | a.exe 4 | .ropeproject/ 5 | __pycache__/ 6 | .mypy_cache/ 7 | *.pyc 8 | .nvimlog 9 | backends.txt 10 | .luarocks/ 11 | .luarc.json 12 | lua_modules 13 | *.rockspec 14 | luarocks* 15 | lua.bat 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Test](https://github.com/sakhnik/nvim-gdb/workflows/Test/badge.svg?branch=master)](https://github.com/sakhnik/nvim-gdb/actions?query=workflow%3ATest+branch%3Amaster) 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/f2a7dc2640f84b2a8983ac6da004c7ac)](https://www.codacy.com/app/sakhnik/nvim-gdb?utm_source=github.com&utm_medium=referral&utm_content=sakhnik/nvim-gdb&utm_campaign=Badge_Grade) 4 | 5 | # GDB for neovim 6 | 7 | [Gdb](https://www.gnu.org/software/gdb/), [LLDB](https://lldb.llvm.org/), 8 | [pdb](https://docs.python.org/3/library/pdb.html)/[pdb++](https://github.com/pdbpp/pdbpp) 9 | and [BASHDB](http://bashdb.sourceforge.net/) integration with NeoVim. 10 | 11 | ## Table of contents 12 | 13 | * [Overview](#overview) 14 | * [Installation](#installation) 15 | * [Options](#options) 16 | * [Usage](#usage) 17 | * [Development](#development) 18 | 19 | ## Overview 20 | 21 | Taken from the neovim: [neovim\_gdb.vim](https://github.com/neovim/neovim/blob/master/contrib/gdb/neovim_gdb.vim) 22 | 23 | It is instantly usable: type `dd`, edit GDB launching command, hit ``. 24 | Or type `dl` to do the same with LLDB backend. 25 | Or type `dp` to start debugging a python program. 26 | Or type `db` to start debugging a BASH script. 27 | 28 | Also you can record the execution of a program with [`rr record`](https://rr-project.org/), and then replay its execution systematically with `dr`. 29 | 30 | Supported debuggers and operating systems: 31 | 32 | | Debugger | Linux | Darwin | Windows | 33 | |--------------|--------|--------|---------| 34 | | GNU gdb | `✅` | `❌` | `✅` | 35 | | lldb | `✅` | `✅` | `✅` | 36 | | pdb | `✅` | `✅` | `✅` | 37 | | BashDB | `✅` | `✅` | `❌` | 38 | | rr | `✅` | `❌` | `❌` | 39 | 40 | [![nvim-gdb](https://asciinema.org/a/E8sKlS53Dm6UzK2MJjEolOyam.png)](https://asciinema.org/a/E8sKlS53Dm6UzK2MJjEolOyam?autoplay=1) 41 | 42 | ## Installation 43 | 44 | Check the prerequisites in the script [test/prerequisites.py](https://github.com/sakhnik/nvim-gdb/blob/master/test/prerequisites.py). 45 | 46 | Use the branch `master` for NeoVim ≥ 0.7 or the branch `devel` to benefit from the latest NeoVim features. 47 | 48 | If you use vim-plug, add the following line to your vimrc file: 49 | 50 | ```vim 51 | Plug 'sakhnik/nvim-gdb' 52 | ``` 53 | 54 | ## Options 55 | 56 | To disable the plugin 57 | ```vim 58 | let g:loaded_nvimgdb = 1 59 | ``` 60 | 61 | `:GdbStart` and `:GdbStartLLDB` use `find` and the cmake file API to try to 62 | tab-complete the command with the executable for the current file. To disable 63 | this set `g:nvimgdb_use_find_executables` or `g:nvimgdb_use_cmake_to_find_executables` to `0`. 64 | 65 | The behaviour of the plugin can be tuned by defining specific variables. 66 | For instance, you could overload some command keymaps: 67 | ```vim 68 | " We're going to define single-letter keymaps, so don't try to define them 69 | " in the terminal window. The debugger CLI should continue accepting text commands. 70 | function! NvimGdbNoTKeymaps() 71 | tnoremap 72 | endfunction 73 | 74 | let g:nvimgdb_config_override = { 75 | \ 'key_next': 'n', 76 | \ 'key_step': 's', 77 | \ 'key_finish': 'f', 78 | \ 'key_continue': 'c', 79 | \ 'key_until': 'u', 80 | \ 'key_breakpoint': 'b', 81 | \ 'set_tkeymaps': "NvimGdbNoTKeymaps", 82 | \ } 83 | ``` 84 | 85 | Likewise, you could define your own hooks to be called when the source window 86 | is entered and left. Please refer to the online NeoVim help: `:help nvimgdb`. 87 | 88 | ## Usage 89 | 90 | See `:help nvimgdb` for the complete online documentation. Most notable commands: 91 | 92 | | Mapping | Command | Description | 93 | |------------------|--------------------------------------|----------------------------------------------------------------------| 94 | | <Leader>dd | `:GdbStart gdb -q ./a.out` | Start debugging session, allows editing the launching command | 95 | | <Leader>dl | `:GdbStartLLDB lldb ./a.out` | Start debugging session, allows editing the launching command | 96 | | <Leader>dp | `:GdbStartPDB python -m pdb main.py` | Start Python debugging session, allows editing the launching command | 97 | | <Leader>db | `:GdbStartBashDB bashdb main.sh` | Start BASH debugging session, allows editing the launching command | 98 | | <Leader>dr | `:GdbStartRR` | Start debugging session with [`rr replay`](https://rr-project.org/). | 99 | | <F8> | `:GdbBreakpointToggle` | Toggle breakpoint in the coursor line | 100 | | <F4> | `:GdbUntil` | Continue execution until a given line (`until` in gdb) | 101 | | <F5> | `:GdbContinue` | Continue execution (`continue` in gdb) | 102 | | <F10> | `:GdbNext` | Step over the next statement (`next` in gdb) | 103 | | <F11> | `:GdbStep` | Step into the next statement (`step` in gdb) | 104 | | <F12> | `:GdbFinish` | Step out the current frame (`finish` in gdb) | 105 | | <c-p> | `:GdbFrameUp` | Navigate one frame up (`up` in gdb) | 106 | | <c-n> | `:GdbFrameDown` | Navigate one frame down (`down` in gdb) | 107 | 108 | You can create a watch window evaluating a backend command on every step. 109 | Try `:GdbCreateWatch info locals` in GDB, for istance. 110 | 111 | You can open the list of breakpoints or backtrace locations into the location list. 112 | Try `:GdbLopenBacktrace` or `:GdbLopenBreakpoints`. 113 | 114 | ## Development 115 | 116 | The goal is to have a thin wrapper around 117 | GDB, LLDB, pdb/pdb++ and BASHDB, just like the official 118 | [TUI](https://sourceware.org/gdb/onlinedocs/gdb/TUI.html). NeoVim will enhance 119 | debugging with syntax highlighting and source code navigation. 120 | 121 | The project uses GitHub actions to run the test suite on every commit automatically. 122 | The plugin, proxy and screen logs can be downloaded as the artifacts to be analyzed 123 | locally. 124 | 125 | To ease reproduction of an issue, set the environment variable `CI`, and 126 | launch NeoVim with the auxiliary script `test/nvim.py`. The screen cast will 127 | be written to the log file `spy_ui.log`. Alternatively, consider recording 128 | the terminal script with the ubiquitous command `script`. 129 | 130 | To support development, consider donating: 131 | 132 | * ₿ [1E5Sny3tC5qdr1owAQqbzfyq1SFjaNBQW4](https://bitref.com/1E5Sny3tC5qdr1owAQqbzfyq1SFjaNBQW4) 133 | 134 | ## References 135 | 136 | * Porting to Moonscript: [2018-11-17](https://sakhnik.com/2018/11/17/nvimgdb-lua.html) 137 | * Overview to the first anniversary: [2018-08-10](https://sakhnik.com/2018/08/10/nvim-gdb-anni.html) 138 | 139 | ## Showcase 140 | 141 | [![GdbStartRR](https://asciinema.org/a/506942.svg)](https://asciinema.org/a/506942) 142 | 143 | [![nvim-gdb + llvm](https://asciinema.org/a/162697.png)](https://asciinema.org/a/162697) 144 | 145 | [![clone + test](https://asciinema.org/a/397047.svg)](https://asciinema.org/a/397047) 146 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - run-tests.lua output cut in win32: probably, on_exit is handled before last on_stdout chunks 2 | - pty resizing with neovim 3 | - proxy: when a request comes, stash user typing, execute the request and resume typing 4 | 5 | Store and load breakpoints between sessions 6 | -------------------------------------------------------------------------------- /bandit.yml: -------------------------------------------------------------------------------- 1 | skips: ['B101', 'B606'] 2 | -------------------------------------------------------------------------------- /config/lnav/nvimgdb.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://lnav.org/schemas/format-v1.schema.json", 3 | "nvimgdb": { 4 | "description": "Format file generated from regex101 entry -- https://regex101.com/r/A6H7q7/1", 5 | "regex": { 6 | "std": { 7 | "pattern": "^(?\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}[,.]\\d{3}) \\[(?\\w+)\\] (?.?:?[^:]+):(?[^:]+): +(?.*)$" 8 | } 9 | }, 10 | "level": { 11 | "error": "ERROR", 12 | "warning": "WARN", 13 | "info": "INFO", 14 | "debug": "DEBUG" 15 | }, 16 | "value": { 17 | "file": { 18 | "kind": "string", 19 | "identifier": true 20 | }, 21 | "line": { 22 | "kind": "integer" 23 | } 24 | }, 25 | "sample": [ 26 | { 27 | "line": "2023-07-24 09:01:54,879 [DEBUG] /Users/runner/work/nvim-gdb/nvim-gdb/lua/nvimgdb/config.lua:186: { \"function Config:get(\", \"key_finish\", \")\" }" 28 | }, 29 | { 30 | "line": "2023-07-24 21:01:33,409 [DEBUG] C:\\tools\\msys64\\home\\user\\nvim-gdb/lua/nvimgdb/keymaps.lua:44: { \"function Keymaps:set()\" }" 31 | } 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | echo "OK" 4 | -------------------------------------------------------------------------------- /lib/gdb_commands.py: -------------------------------------------------------------------------------- 1 | """The program injected into GDB to provide a side channel 2 | to the plugin.""" 3 | 4 | import gdb 5 | import json 6 | import logging 7 | import os 8 | import re 9 | import socket 10 | import sys 11 | import threading 12 | 13 | 14 | logger = logging.getLogger("gdb") 15 | logger.setLevel(logging.DEBUG) 16 | lhandl = logging.NullHandler() if not os.environ.get('CI') \ 17 | else logging.FileHandler("gdb.log", encoding='utf-8') 18 | fmt = "%(asctime)s [%(levelname)s]: %(message)s" 19 | lhandl.setFormatter(logging.Formatter(fmt)) 20 | logger.addHandler(lhandl) 21 | 22 | 23 | class NvimGdbInit(gdb.Command): 24 | """Initialize a side channel for nvim-gdb.""" 25 | 26 | def __init__(self): 27 | super(NvimGdbInit, self).__init__("nvim-gdb-init", gdb.COMMAND_OBSCURE) 28 | self.quit = True 29 | self.thrd = None 30 | self.fallback_to_parsing = False 31 | self.state = "stopped" 32 | self.exited_or_ran = False 33 | 34 | def handle_continue(event): 35 | self.state = "running" 36 | self.exited_or_ran = True 37 | gdb.events.cont.connect(handle_continue) 38 | def handle_stop(event): 39 | self.state = "stopped" 40 | def handle_exit(event): 41 | self.state = "stopped" 42 | self.exited_or_ran = True 43 | gdb.events.stop.connect(handle_stop) 44 | gdb.events.exited.connect(handle_exit) 45 | 46 | def invoke(self, arg, from_tty): 47 | if not self.thrd: 48 | self.quit = False 49 | self.thrd = threading.Thread(target=self._server, args=(arg,)) 50 | self.thrd.daemon = True 51 | self.thrd.start() 52 | 53 | def _server(self, server_address): 54 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 55 | sock.bind(('127.0.0.1', 0)) 56 | sock.settimeout(0.25) 57 | _, port = sock.getsockname() 58 | with open(server_address, 'w') as f: 59 | f.write(str(port)) 60 | logger.info("Start listening for commands at port %d", port) 61 | try: 62 | while not self.quit: 63 | try: 64 | data, addr = sock.recvfrom(65536) 65 | except socket.timeout: 66 | continue 67 | command = data.decode("utf-8") 68 | self._handle_command(command, sock, addr) 69 | finally: 70 | logger.info("Stop listening for commands") 71 | try: 72 | os.unlink(server_address) 73 | except OSError: 74 | pass 75 | 76 | def _handle_command(self, command, sock, addr): 77 | logger.debug("Got command: %s", command) 78 | command = re.split(r"\s+", command) 79 | req_id = int(command[0]) 80 | request = command[1] 81 | args = command[2:] 82 | if request == "info-breakpoints": 83 | fname = args[0] 84 | self._send_response(self._get_breaks(os.path.normpath(fname)), 85 | req_id, sock, addr) 86 | elif request == "get-process-state": 87 | self._send_response(self._get_process_state(), 88 | req_id, sock, addr) 89 | elif request == "get-current-frame-location": 90 | self._send_response(self._get_current_frame_location(), 91 | req_id, sock, addr) 92 | elif request == "has-exited-or-ran": 93 | self._send_response(self._get_reset_exited_or_ran(), 94 | req_id, sock, addr) 95 | elif request == "handle-command": 96 | # pylint: disable=broad-except 97 | try: 98 | # TODO Is this used? 99 | if args[0] == 'nvim-gdb-info-breakpoints': 100 | # Fake a command info-breakpoins for 101 | # GdbLopenBreakpoins 102 | self._send_response(self._get_all_breaks(), 103 | req_id, sock, addr) 104 | return 105 | gdb_command = " ".join(args) 106 | if sys.version_info.major < 3: 107 | gdb_command = gdb_command.encode("utf-8") 108 | try: 109 | result = gdb.execute(gdb_command, False, True) 110 | except RuntimeError as err: 111 | result = str(err) 112 | if result is None: 113 | result = "" 114 | self._send_response(result.strip(), req_id, sock, addr) 115 | except Exception as ex: 116 | logger.error("Exception: %s", ex) 117 | 118 | def _send_response(self, response, req_id, sock, addr): 119 | response_msg = { 120 | "request": req_id, 121 | "response": response 122 | } 123 | response_json = json.dumps(response_msg).encode("utf-8") 124 | logger.debug("Sending response: %s", response_json) 125 | sock.sendto(response_json, 0, addr) 126 | 127 | def _get_process_state(self): 128 | return self.state 129 | 130 | def _get_reset_exited_or_ran(self): 131 | if (self.exited_or_ran): 132 | self.exited_or_ran = False 133 | return True 134 | return False 135 | 136 | def _get_current_frame_location(self): 137 | try: 138 | frame = gdb.selected_frame() 139 | if frame is not None: 140 | symtab_and_line = frame.find_sal() 141 | if symtab_and_line.symtab is not None: 142 | filename = symtab_and_line.symtab.fullname() 143 | line = symtab_and_line.line 144 | return [filename, line] 145 | except gdb.error: 146 | pass 147 | return [] 148 | 149 | def _get_breaks_provider(self): 150 | # Older versions of GDB may lack attribute .locations in 151 | # the breakpoint class, will have to parse `info breakpoints` then. 152 | if not self.fallback_to_parsing: 153 | return self._enum_breaks() 154 | return self._enum_breaks_fallback() 155 | 156 | def _get_breaks(self, fname): 157 | """Get list of enabled breakpoints for a given source file.""" 158 | breaks = {} 159 | 160 | try: 161 | for path, line, bid in self._get_breaks_provider(): 162 | if fname.endswith(os.path.normpath(path)): 163 | breaks.setdefault(line, []).append(bid) 164 | except AttributeError: 165 | self.fallback_to_parsing = True 166 | return self._get_breaks(fname) 167 | 168 | # Return the filtered breakpoints 169 | return breaks 170 | 171 | def _enum_breaks(self): 172 | """Get a list of all enabled breakpoints.""" 173 | # Consider every breakpoint while skipping over the disabled ones 174 | for bp in gdb.breakpoints(): 175 | if not bp.is_valid() or not bp.enabled: 176 | continue 177 | bid = bp.number 178 | for location in bp.locations: 179 | if not location.enabled or not location.source: 180 | continue 181 | filename, line = location.source 182 | if location.fullname: 183 | yield location.fullname, line, bid 184 | else: 185 | yield filename, line, bid 186 | 187 | def _enum_breaks_fallback(self): 188 | """Get a list of all enabled breakpoints by parsing 189 | `info breakpoints` output.""" 190 | 191 | # There can be up to two lines for one breakpoint, filename:lnum may be 192 | # on the second line if the screen is too narrow. 193 | bid = None 194 | response = gdb.execute('info breakpoints', False, True) 195 | 196 | column_idx = {} 197 | header = response.splitlines()[0] 198 | contents = response.splitlines()[1:] 199 | 200 | for f in re.finditer(r"(\w+)\s*", header): 201 | s, e, str = f.start(), f.end(), f.group(1) 202 | e = None if e == len(header) else e 203 | column_idx[str] = (s, e) 204 | 205 | def get_column_value(line, column_name): 206 | if column_name not in column_idx: 207 | return "" 208 | s, e = column_idx[column_name] 209 | return line[s:e].strip() 210 | 211 | last_enabled = False 212 | 213 | for line in contents: 214 | bid, enabled, address, what = ( 215 | get_column_value(line, "Num"), 216 | get_column_value(line, "Enb"), 217 | get_column_value(line, "Address"), 218 | get_column_value(line, "What"), 219 | ) 220 | 221 | if enabled == '': 222 | enabled = last_enabled 223 | 224 | if not bid or enabled != 'y' or re.match(r"0x[0-9a-zA-Z]+", address) is None: 225 | continue 226 | 227 | bid = bid.split(".")[0] 228 | 229 | fields = re.split(r"\s+", what) 230 | if len(fields) >= 2 and fields[-2] == "at": 231 | # file.cpp:line 232 | m = re.match(r"^([^:]+):(\d+)$", fields[-1]) 233 | if m and bid: 234 | bpfname, lnum = m.group(1), m.group(2) 235 | yield bpfname, lnum, bid 236 | 237 | def _get_all_breaks(self): 238 | """Get list of all enabled breakpoints suitable for location 239 | list.""" 240 | breaks = [] 241 | try: 242 | for path, line, bid in self._get_breaks_provider(): 243 | breaks.append(str(path) + ':' + str(line) + ' breakpoint ' + str(bid)) 244 | except AttributeError: 245 | self.fallback_to_parsing = True 246 | return self._get_all_breaks() 247 | return "\n".join(breaks) 248 | 249 | 250 | init = NvimGdbInit() 251 | -------------------------------------------------------------------------------- /lib/lldb_commands.py: -------------------------------------------------------------------------------- 1 | """The program injected into LLDB to provide a side channel 2 | to the plugin.""" 3 | 4 | import json 5 | import lldb # type: ignore 6 | import logging 7 | import os 8 | import re 9 | import socket 10 | import sys 11 | import threading 12 | 13 | 14 | logger = logging.getLogger("lldb") 15 | logger.setLevel(logging.DEBUG) 16 | lhandl = logging.NullHandler() if not os.environ.get('CI') \ 17 | else logging.FileHandler("lldb.log", encoding='utf-8') 18 | fmt = "%(asctime)s [%(levelname)s]: %(message)s" 19 | lhandl.setFormatter(logging.Formatter(fmt)) 20 | logger.addHandler(lhandl) 21 | 22 | 23 | def get_current_frame_location(debugger: lldb.SBDebugger): 24 | target = debugger.GetSelectedTarget() 25 | process = target.GetProcess() 26 | thread = process.GetSelectedThread() 27 | frame = thread.GetSelectedFrame() 28 | 29 | if frame.IsValid(): 30 | symbol_context = frame.GetSymbolContext(lldb.eSymbolContextEverything) 31 | line_entry = symbol_context.line_entry 32 | if line_entry.IsValid(): 33 | filespec = line_entry.GetFileSpec() 34 | filepath = os.path.join(filespec.GetDirectory(), 35 | filespec.GetFilename()) 36 | line = line_entry.GetLine() 37 | return [filepath, line] 38 | 39 | return [] 40 | 41 | 42 | def get_process_state(debugger: lldb.SBDebugger): 43 | target = debugger.GetSelectedTarget() 44 | process = target.GetProcess() 45 | state = process.GetState() 46 | if state == lldb.eStateRunning: 47 | return "running" 48 | elif state == lldb.eStateStopped: 49 | return "stopped" 50 | return "other" 51 | 52 | 53 | # Get list of enabled breakpoints for a given source file 54 | def _enum_breaks(debugger: lldb.SBDebugger): 55 | # Ensure target is the actually selected one 56 | target = debugger.GetSelectedTarget() 57 | 58 | # Consider every breakpoint while skipping over the disabled ones 59 | for bidx in range(target.GetNumBreakpoints()): 60 | bpt = target.GetBreakpointAtIndex(bidx) 61 | if not bpt.IsEnabled(): 62 | continue 63 | bid = str(bpt.GetID()) 64 | 65 | # Consider every location of a breakpoint 66 | for lidx in range(bpt.GetNumLocations()): 67 | loc = bpt.GetLocationAtIndex(lidx) 68 | lineentry = loc.GetAddress().GetLineEntry() 69 | filespec = lineentry.GetFileSpec() 70 | filename = filespec.GetFilename() 71 | if not filename: 72 | continue 73 | path = os.path.join(filespec.GetDirectory(), filename) 74 | 75 | yield path, lineentry.GetLine(), bid 76 | 77 | 78 | # Get list of enabled breakpoints for a given source file 79 | def _get_breaks(fname, debugger: lldb.SBDebugger): 80 | breaks = {} 81 | 82 | for path, line, bid in _enum_breaks(debugger): 83 | # See whether the breakpoint is in the file in question 84 | if fname == os.path.normpath(path): 85 | try: 86 | breaks[line].append(bid) 87 | except KeyError: 88 | breaks[line] = [bid] 89 | 90 | # Return the filtered breakpoints 91 | return breaks 92 | 93 | 94 | # Get list of all enabled breakpoints suitable for location list 95 | def _get_all_breaks(debugger: lldb.SBDebugger): 96 | breaks = [] 97 | 98 | for path, line, bid in _enum_breaks(debugger): 99 | breaks.append(f"{path}:{line} breakpoint {bid}") 100 | 101 | return "\n".join(breaks) 102 | 103 | 104 | def send_response(response, req_id, sock, addr): 105 | response_msg = { 106 | "request": req_id, 107 | "response": response 108 | } 109 | response_json = json.dumps(response_msg).encode("utf-8") 110 | logger.debug("Sending response: %s", response_json) 111 | sock.sendto(response_json, 0, addr) 112 | 113 | 114 | def _execute_command(command, req_id, sock, addr, debugger: lldb.SBDebugger): 115 | return_object = lldb.SBCommandReturnObject() 116 | debugger.GetCommandInterpreter().HandleCommand( 117 | command, return_object 118 | ) 119 | result = "" 120 | if return_object.GetError(): 121 | result += return_object.GetError() 122 | if return_object.GetOutput(): 123 | result += return_object.GetOutput() 124 | send_response("" if result is None else result.strip(), 125 | req_id, sock, addr) 126 | 127 | 128 | def _backtrace(req_id, sock, addr, debugger: lldb.SBDebugger): 129 | """This is for GdbLopenBacktrace, a custom frame format is required.""" 130 | try: 131 | return_object = lldb.SBCommandReturnObject() 132 | debugger.GetCommandInterpreter().HandleCommand( 133 | "settings show frame-format", return_object 134 | ) 135 | result = "" 136 | if return_object.GetOutput(): 137 | result = return_object.GetOutput() 138 | orig_frame_format = result[result.index('"')+1:-1] 139 | debugger.GetCommandInterpreter().HandleCommand( 140 | r"settings set frame-format frame #${frame.index}: ${frame.pc}" 141 | r"{ ${module.file.basename}{\`${function.name-with-args}" 142 | r"{${frame.no-debug}${function.pc-offset}}}}" 143 | r"{ at \032\032${line.file.fullpath}:${line.number}}" 144 | r"{${function.is-optimized} [opt]}\n", 145 | return_object 146 | ) 147 | _execute_command("bt", req_id, sock, addr, debugger) 148 | finally: 149 | debugger.GetCommandInterpreter().HandleCommand( 150 | f"settings set frame-format {orig_frame_format}", 151 | return_object 152 | ) 153 | 154 | 155 | def _server(server_address: str, debugger: lldb.SBDebugger): 156 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 157 | sock.bind(('127.0.0.1', 0)) 158 | _, port = sock.getsockname() 159 | with open(server_address, 'w') as f: 160 | f.write(f"{port}") 161 | logger.info("Start listening for commands at port %d", port) 162 | 163 | # debugger = lldb.SBDebugger_FindDebuggerWithID(debugger_id) 164 | 165 | try: 166 | while True: 167 | data, addr = sock.recvfrom(65536) 168 | command = data.decode("utf-8") 169 | logger.debug("Got command: %s", command) 170 | command = re.split(r"\s+", command) 171 | req_id = int(command[0]) 172 | request = command[1] 173 | args = command[2:] 174 | if request == "info-breakpoints": 175 | fname = args[0] 176 | send_response(_get_breaks(os.path.normpath(fname), debugger), 177 | req_id, sock, addr) 178 | elif request == "get-process-state": 179 | send_response(get_process_state(debugger), req_id, sock, addr) 180 | elif request == "get-current-frame-location": 181 | send_response(get_current_frame_location(debugger), 182 | req_id, sock, addr) 183 | elif request == "handle-command": 184 | # pylint: disable=broad-except 185 | try: 186 | if args[0] == 'nvim-gdb-info-breakpoints': 187 | # Fake a command info-breakpoins for GdbLopenBreakpoins 188 | send_response(_get_all_breaks(debugger), 189 | req_id, sock, addr) 190 | return 191 | if args[0] == 'bt': 192 | _backtrace(req_id, sock, addr, debugger) 193 | return 194 | command_to_handle = " ".join(args) 195 | if sys.version_info.major < 3: 196 | command_to_handle = command_to_handle.encode("ascii") 197 | _execute_command(command_to_handle, req_id, sock, addr, 198 | debugger) 199 | except Exception as ex: 200 | logger.error("Exception: %s", ex) 201 | finally: 202 | logger.info("Stop listening for commands") 203 | try: 204 | os.unlink(server_address) 205 | except OSError: 206 | pass 207 | 208 | 209 | def init(debugger: lldb.SBDebugger, command: str, _3, _4): 210 | """Entry point.""" 211 | server_address = command 212 | thrd = threading.Thread(target=_server, args=(server_address, debugger)) 213 | thrd.start() 214 | -------------------------------------------------------------------------------- /lib/proxy/bashdb.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | 3 | local dir = uv.fs_realpath(arg[0]:match('^(.*)[/\\]')) 4 | package.path = dir .. '/?.lua;' .. dir .. '/../../lua/?.lua;' .. package.path 5 | 6 | local log = require'nvimgdb.log' 7 | log.set_filename('bashdb.log') 8 | local ProxyImpl = require'impl' 9 | 10 | local proxy = ProxyImpl.new('[\r\n]bashdb<%(?%d+%)?> ') 11 | proxy:start() 12 | vim.wait(10^9, function() return false end) 13 | -------------------------------------------------------------------------------- /lib/proxy/impl.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | local log = require'nvimgdb.log' 3 | 4 | local is_windows = uv.os_uname().sysname:find('Windows') ~= nil 5 | local cmd_nl = is_windows and '\r\n' or '\n' 6 | 7 | ---@alias Request {req_id: integer, command: string, addr: Address} 8 | ---@alias Address {ip: string, port: integer} 9 | 10 | ---@class ProxyImpl 11 | ---@field private prompt string prompt pattern to detect end of response 12 | ---@field private stdin userdata uv tty handle 13 | ---@field private sock userdata uv udp handle to receive requests 14 | ---@field private command_buffer string whatever the user is typing after the last newline 15 | ---@field private last_command string the last command executed by user 16 | ---@field private stdout_timer userdata uv timer to detect stdout silence 17 | ---@field private request_timer userdata uv timer to give a request deadline 18 | ---@field private request_queue table the queue of outstanding requests 19 | ---@field private request_queue_head integer the point of taking from the queue 20 | ---@field private request_queue_tail integer the point of putting to the queue 21 | ---@field private current_request Request the request currently being executed 22 | ---@field private buffer string the stdout output collected for the current request so far 23 | local ProxyImpl = { } 24 | ProxyImpl.__index = ProxyImpl 25 | 26 | -- Get rid of the script, leave the arguments only 27 | arg[0] = nil 28 | 29 | ---Constructor 30 | ---@param prompt string prompt pattern 31 | ---@return ProxyImpl 32 | function ProxyImpl.new(prompt) 33 | log.info({"ProxyImpl.new", promp = prompt, arg = arg}) 34 | local self = {} 35 | setmetatable(self, ProxyImpl) 36 | self.prompt = prompt 37 | 38 | self.stdin = uv.new_tty(0, true) -- 0 represents stdin file descriptor 39 | local result, error_msg = self.stdin:set_mode(1) -- uv.TTY_MODE_RAW 40 | assert(result, error_msg) 41 | 42 | self:init_socket() 43 | 44 | self.command_buffer = '' 45 | self.last_command = '' 46 | 47 | self.stdout_timer = assert(uv.new_timer()) 48 | self.request_timer = assert(uv.new_timer()) 49 | self.request_queue = {} 50 | self.request_queue_head = 1 51 | self.request_queue_tail = 1 52 | self.current_request = nil 53 | self.buffer = "" 54 | return self 55 | end 56 | 57 | ---Start operation 58 | function ProxyImpl:start() 59 | log.debug({"ProxyImpl:start"}) 60 | self:start_job() 61 | self:start_stdin() 62 | self:start_socket() 63 | end 64 | 65 | ---Start the debugger using the command line arguments 66 | ---@private 67 | function ProxyImpl:start_job() 68 | log.debug({"ProxyImpl:start_job"}) 69 | local width = self.stdin:get_winsize() 70 | 71 | local opts = { 72 | pty = true, 73 | env = { 74 | TERM = vim.env.TERM 75 | }, 76 | width = width, 77 | 78 | on_stdout = function(_, d, _) 79 | local nl = '' 80 | for i, chunk in ipairs(d) do 81 | if chunk ~= '' or i ~= 1 then 82 | self:on_stdout(nl, chunk) 83 | end 84 | nl = '\n' 85 | end 86 | end, 87 | 88 | on_exit = function(_, c, _) 89 | os.exit(c) 90 | end 91 | } 92 | 93 | self.job_id = assert(vim.fn.jobstart(arg, opts)) 94 | end 95 | 96 | ---Start listening for the user input 97 | ---@private 98 | function ProxyImpl:start_stdin() 99 | log.debug({"ProxyImpl:start_stdin"}) 100 | self.stdin:read_start(vim.schedule_wrap(function(err, chunk) 101 | log.debug({"stdin:read", err = err, chunk = chunk}) 102 | assert(not err, err) 103 | 104 | if chunk then 105 | -- Accumulate whatever the user is typing to track the last command entered 106 | self.command_buffer = self.command_buffer .. chunk 107 | local start_index, end_index = self.command_buffer:find('[\n\r]+') 108 | if start_index then 109 | if start_index == 1 then 110 | -- Send previous command 111 | log.debug({"send previous", command = self.last_command}) 112 | vim.fn.chansend(self.job_id, self.last_command) 113 | else 114 | -- Remember the command 115 | self.last_command = self.command_buffer:sub(1, start_index - 1) 116 | log.debug({"remember command", self.last_command}) 117 | end 118 | -- Reset the command buffer 119 | self.command_buffer = self.command_buffer:sub(end_index + 1) 120 | end 121 | vim.fn.chansend(self.job_id, chunk) 122 | else 123 | -- End of input, process the data 124 | self.stdin:close() 125 | end 126 | end)) 127 | end 128 | 129 | ---Init the udp socket to receive requests 130 | ---@private 131 | function ProxyImpl:init_socket() 132 | log.debug({"ProxyImpl:init_socket"}) 133 | if arg[1] ~= '-a' then 134 | return 135 | end 136 | self.sock = assert(uv.new_udp()) 137 | assert(self.sock:bind("127.0.0.1", 0)) 138 | local sockname = self.sock:getsockname() 139 | log.info({"Socket port", port = sockname.port}) 140 | local f = assert(io.open(arg[2], 'w')) 141 | f:write(sockname.port) 142 | f:close() 143 | 144 | -- The argument has been consumed, shift by two 145 | for i = 3, #arg do 146 | arg[i - 2] = arg[i] 147 | end 148 | arg[#arg] = nil 149 | arg[#arg] = nil 150 | log.debug({"shift arg", arg = arg}) 151 | end 152 | 153 | ---Start receiving requests via the udp socket 154 | ---@private 155 | function ProxyImpl:start_socket() 156 | log.debug({"ProxyImpl:start_socket"}) 157 | if self.sock == nil then 158 | return 159 | end 160 | self.sock:recv_start(function(err, data, addr) 161 | log.debug({"recv request", err = err, data = data, addr = addr}) 162 | assert(not err, err) 163 | if data then 164 | self.request_queue[self.request_queue_tail] = {request = data, addr = addr} 165 | self.request_queue_tail = self.request_queue_tail + 1 166 | -- If the user doesn't type anymore, can process the request immediately, 167 | -- otherwise, the request will be picked up upon the stdout timer elapse. 168 | if self.stdout_timer:get_due_in() == 0 then 169 | vim.schedule(function() 170 | self:process_request() 171 | end) 172 | end 173 | end 174 | end) 175 | end 176 | 177 | ---Process debugger output: either print on the screen or capture as a response to a request 178 | ---@private 179 | ---@param data1 string part 1 (""|"\n") 180 | ---@param data2 string part 2 181 | function ProxyImpl:on_stdout(data1, data2) 182 | log.debug({"ProxyImpl:on_stdout", data1 = data1, data2 = data2}) 183 | if self.current_request ~= nil then 184 | self.buffer = self.buffer .. data1 .. data2 185 | -- First substitute cursor movement with new lines: \27[16;9H 186 | local plain_buffer = self.buffer:gsub('%[%d+;%d+H', '\n') 187 | -- Get rid of the other CSEQ 188 | plain_buffer = plain_buffer:gsub('%[[^a-zA-Z]*[a-zA-Z]', '') 189 | local start_index = plain_buffer:find(self.prompt) 190 | if start_index then 191 | local request = self.current_request 192 | self.current_request = nil 193 | local response = plain_buffer:sub(#request.command + 1, start_index):match('^%s*(.-)%s*$') 194 | log.info({"Collected response", response = response}) 195 | self.request_timer:stop() 196 | self:send_response(request.req_id, response, request.addr) 197 | self.buffer = '' 198 | -- Resume taking user input 199 | self:start_stdin() 200 | self:process_request() 201 | end 202 | else 203 | io.stdout:write(data1, data2) 204 | end 205 | local function start_stdout_timer() 206 | self.stdout_timer:stop() 207 | self.stdout_timer:start(100, 0, vim.schedule_wrap(function() 208 | if self:process_request() then 209 | self.stdout_timer:stop() 210 | else 211 | -- There're still requests to be scheduled 212 | -- Note, that interval timer returns deceptional get_due_in(), 213 | -- which is >0 after the timer has been stopped until the first interval elapses. 214 | -- Therefore no intervals, restarting the timer manually 215 | start_stdout_timer() 216 | end 217 | end)) 218 | start_stdout_timer() 219 | end 220 | end 221 | 222 | ---Check if there's an outstanding request and start executing it 223 | ---@private 224 | ---@return boolean false if there's an outstanding request, but it can't be scheduled at the moment 225 | function ProxyImpl:process_request() 226 | log.debug({"ProxyImpl:process_request"}) 227 | if self.current_request ~= nil then 228 | return false 229 | end 230 | if self.request_queue_tail == self.request_queue_head then 231 | return true 232 | end 233 | local command = self.request_queue[self.request_queue_head] 234 | self.request_queue[self.request_queue_head] = nil 235 | self.request_queue_head = self.request_queue_head + 1 236 | local req_id, _, cmd = command.request:match('(%d+) ([a-z-]+) (.+)') 237 | self.current_request = {req_id = assert(tonumber(req_id)), command = cmd, addr = command.addr} 238 | -- Going to execute a command, suppress user input 239 | self.stdin:read_stop() 240 | log.info({"Send request", cmd = cmd}) 241 | -- \r\n for win32 242 | vim.fn.chansend(self.job_id, cmd .. cmd_nl) 243 | self.request_timer:start(500, 0, vim.schedule_wrap(function() 244 | self.request_timer:stop() 245 | self:send_response(req_id, "Timed out", command.addr) 246 | self.current_request = nil 247 | if self.buffer ~= nil then 248 | self:on_stdout(self.buffer, '') 249 | self.buffer = nil 250 | end 251 | -- Resume taking user input 252 | self:start_stdin() 253 | self:process_request() 254 | end)) 255 | return true 256 | end 257 | 258 | ---Send a response back to the requester 259 | ---@private 260 | ---@param req_id integer request identifier from the requester 261 | ---@param response any to be encoded in JSON 262 | ---@param addr Address the request origin -- the response destination 263 | function ProxyImpl:send_response(req_id, response, addr) 264 | log.debug({"ProxyImpl:send_response", req_id = req_id, response = response, addr = addr}) 265 | local data = vim.fn.json_encode({request = req_id, response = response}) 266 | self.sock:send(data, addr.ip, addr.port, function(err) 267 | assert(not err, err) 268 | end) 269 | end 270 | 271 | return ProxyImpl 272 | -------------------------------------------------------------------------------- /lib/proxy/pdb.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | 3 | local dir = uv.fs_realpath(arg[0]:match('^(.*)[/\\]')) 4 | package.path = dir .. '/?.lua;' .. dir .. '/../../lua/?.lua;' .. package.path 5 | 6 | local log = require'nvimgdb.log' 7 | log.set_filename('pdb.log') 8 | local ProxyImpl = require'impl' 9 | 10 | local proxy = ProxyImpl.new('[\n\r]%(Pdb%+*%) *') 11 | proxy:start() 12 | vim.wait(10^9, function() return false end) 13 | -------------------------------------------------------------------------------- /lib/rr-replay.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import sys 5 | import shlex 6 | import re 7 | 8 | # rr replay refuses to run outside of a pty. But it allows 9 | # attaching a gdb remotely. 10 | 11 | 12 | async def run_gdb(cmd): 13 | parts = shlex.split(cmd) 14 | gdb_idx = parts.index('gdb') 15 | if gdb_idx != -1: 16 | parts = parts[:gdb_idx+1] + sys.argv[1:] + parts[gdb_idx+1:] 17 | gdb_proc = await asyncio.create_subprocess_exec(*parts) 18 | await gdb_proc.communicate() 19 | return gdb_proc.returncode 20 | 21 | 22 | async def continue_rr(rr_proc): 23 | # Copy the rest of `rr replay` stderr to the terminal 24 | while not rr_proc.stderr.at_eof(): 25 | line = await rr_proc.stderr.readline() 26 | print(line.decode(), file=sys.stderr) 27 | return await rr_proc.wait() 28 | 29 | 30 | async def run(cmd): 31 | # First run the command `rr replay` 32 | rr_proc = await asyncio.create_subprocess_shell( 33 | cmd, 34 | stderr=asyncio.subprocess.PIPE) 35 | 36 | header_regex = re.compile(b'Launch \\w+ with$') 37 | 38 | # Check it launched 39 | header = await rr_proc.stderr.readline() 40 | if not header_regex.match(header): 41 | rest = await rr_proc.stderr.read() 42 | raise RuntimeError(f"Unexpected: {header.decode()}{rest.decode()}") 43 | 44 | # Get the advertised gdb command from the stderr 45 | gdb_cmd = await rr_proc.stderr.readline() 46 | 47 | # Continue to running both rr and gdb 48 | return await asyncio.gather(continue_rr(rr_proc), 49 | run_gdb(gdb_cmd.decode())) 50 | 51 | 52 | # Run `rr replay` selecting a random TCP port for GDB 53 | asyncio.run(run("rr replay -s0")) 54 | -------------------------------------------------------------------------------- /lua/nvimgdb/backend.lua: -------------------------------------------------------------------------------- 1 | -- vim: set et ts=2 sw=2: 2 | 3 | ---@class Backend 4 | local C = {} 5 | C.__index = C 6 | 7 | ---Create a parser to recognize state changes and code jumps 8 | ---@param actions ParserActions callbacks for the parser 9 | ---@param proxy Proxy side channel connection to the debugger 10 | ---@return ParserImpl new parser instance 11 | function C.create_parser(actions, proxy) 12 | local _ = actions 13 | local _ = proxy 14 | return assert(nil, "Not implemented") 15 | end 16 | 17 | ---@async 18 | ---@param fname string full path to the source 19 | ---@param proxy Proxy connection to the side channel 20 | ---@return FileBreakpoints collection of actual breakpoints 21 | function C.query_breakpoints(fname, proxy) 22 | local _ = fname 23 | local _ = proxy 24 | return assert(nil, "Not implemented") 25 | end 26 | 27 | ---@alias CommandMap table 28 | ---@type CommandMap map from generic commands to specific commands 29 | C.command_map = {} 30 | 31 | ---Adapt command if necessary. 32 | ---@param command string generic debugger command 33 | ---@return string translated command for a specific backend 34 | function C:translate_command(command) 35 | local cmd = self.command_map[command] 36 | if cmd ~= nil then 37 | return cmd 38 | end 39 | return command 40 | end 41 | 42 | ---@return string[] errorformats to setup for this backend 43 | function C.get_error_formats() 44 | return {} 45 | end 46 | 47 | ---@param client_cmd string[] original debugger command 48 | ---@param tmp_dir string path to the session state directory 49 | ---@param proxy_addr string full path to the file with the udp port in the session state directory 50 | ---@return string[] command to launch the debugger with termopen() 51 | function C.get_launch_cmd(client_cmd, tmp_dir, proxy_addr) 52 | local _ = client_cmd 53 | local _ = tmp_dir 54 | local _ = proxy_addr 55 | return {} 56 | end 57 | 58 | return C 59 | -------------------------------------------------------------------------------- /lua/nvimgdb/backend/bashdb.lua: -------------------------------------------------------------------------------- 1 | -- BashDB specifics 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | local Backend = require'nvimgdb.backend' 6 | local ParserImpl = require'nvimgdb.parser_impl' 7 | local utils = require'nvimgdb.utils' 8 | 9 | ---@class BackendBashdb: Backend specifics of BashDB 10 | local C = {} 11 | C.__index = C 12 | setmetatable(C, {__index = Backend}) 13 | 14 | ---@return BackendBashdb new instance 15 | function C.new() 16 | local self = setmetatable({}, C) 17 | return self 18 | end 19 | 20 | ---Create a parser to recognize state changes and code jumps 21 | ---@param actions ParserActions callbacks for the parser 22 | ---@param proxy Proxy side channel connection to the debugger 23 | ---@return ParserImpl new parser instance 24 | function C.create_parser(actions, proxy) 25 | local _ = proxy 26 | local P = {} 27 | P.__index = P 28 | setmetatable(P, {__index = ParserImpl}) 29 | 30 | local self = setmetatable({}, P) 31 | self:_init(actions) 32 | 33 | local re_jump = '[\r\n]%(([^:]+):(%d+)%):[\r\n]' 34 | local re_prompt = '[\r\n]bashdb<%(?%d+%)?> $' 35 | local re_term = '[\r\n]Debugged program terminated ' 36 | self.add_trans(self.paused, re_jump, self._paused_jump) 37 | 38 | function P:_handle_terminated(_) 39 | self.actions:continue_program() 40 | return self.paused 41 | end 42 | 43 | self.add_trans(self.paused, re_term, self._handle_terminated) 44 | -- Make sure the prompt is matched in the last turn to exhaust 45 | -- every other possibility while parsing delayed. 46 | self.add_trans(self.paused, re_prompt, self._query_b) 47 | 48 | -- Let's start the backend in the running state for the tests 49 | -- to be able to determine when the launch finished. 50 | -- It'll transition to the paused state once and will remain there. 51 | 52 | function P:_running_jump(fname, line) 53 | log.info("_running_jump " .. fname .. ":" .. line) 54 | self.actions:jump_to_source(fname, tonumber(line)) 55 | return self.running 56 | end 57 | 58 | self.add_trans(self.running, re_jump, self._running_jump) 59 | self.add_trans(self.running, re_prompt, self._query_b) 60 | self.state = self.running 61 | 62 | return self 63 | end 64 | 65 | ---@async 66 | ---@param fname string full path to the source 67 | ---@param proxy Proxy connection to the side channel 68 | ---@return FileBreakpoints collection of actual breakpoints 69 | function C.query_breakpoints(fname, proxy) 70 | log.info("Query breakpoints for " .. fname) 71 | local response = proxy:query('handle-command info breakpoints') 72 | if response == nil or type(response) ~= 'string' or response == "" then 73 | return {} 74 | end 75 | 76 | -- Select lines in the current file with enabled breakpoints. 77 | local breaks = {} 78 | for line in response:gmatch("[^\r\n]+") do 79 | local fields = {} 80 | for field in line:gmatch("[^%s]+") do 81 | fields[#fields + 1] = field 82 | end 83 | if fields[4] == 'y' then -- Is enabled? 84 | local bpfname, lnum = fields[#fields]:match("^([^:]+):(%d+)$") -- file.cpp:line 85 | if bpfname ~= nil then 86 | if bpfname == fname or vim.loop.fs_realpath(fname) == vim.loop.fs_realpath(bpfname) then 87 | local br_id = fields[1] 88 | local list = breaks[lnum] 89 | if list == nil then 90 | breaks[lnum] = {br_id} 91 | else 92 | list[#list + 1] = br_id 93 | end 94 | end 95 | end 96 | end 97 | end 98 | return breaks 99 | end 100 | 101 | ---@type CommandMap 102 | C.command_map = { 103 | delete_breakpoints = 'delete', 104 | breakpoint = 'break', 105 | ['info breakpoints'] = 'info breakpoints', 106 | } 107 | 108 | ---@return string[] 109 | function C.get_error_formats() 110 | -- Return the list of errorformats for backtrace, breakpoints. 111 | return {[[%m\ in\ file\ `%f'\ at\ line\ %l]], 112 | [[%m\ called\ from\ file\ `%f'\ at\ line\ %l]], 113 | [[%m\ %f:%l]]} 114 | 115 | -- bashdb<18> bt 116 | -- ->0 in file `main.sh' at line 8 117 | -- ##1 Foo("1") called from file `main.sh' at line 18 118 | -- ##2 Main() called from file `main.sh' at line 22 119 | -- ##3 source("main.sh") called from file `/sbin/bashdb' at line 107 120 | -- ##4 main("main.sh") called from file `/sbin/bashdb' at line 0 121 | -- bashdb<22> info breakpoints 122 | -- Num Type Disp Enb What 123 | -- 1 breakpoint keep y /tmp/nvim-gdb/test/main.sh:16 124 | -- breakpoint already hit 1 time 125 | -- 2 breakpoint keep y /tmp/nvim-gdb/test/main.sh:7 126 | -- breakpoint already hit 1 time 127 | -- 3 breakpoint keep y /tmp/nvim-gdb/test/main.sh:3 128 | -- 4 breakpoint keep y /tmp/nvim-gdb/test/main.sh:8 129 | end 130 | 131 | ---@param client_cmd string[] original debugger command 132 | ---@param tmp_dir string path to the session state directory 133 | ---@param proxy_addr string full path to the file with the udp port in the session state directory 134 | ---@return string[] command to launch the debugger with termopen() 135 | function C.get_launch_cmd(client_cmd, tmp_dir, proxy_addr) 136 | local _ = tmp_dir 137 | local cmd = { 138 | utils.is_windows and 'nvim.exe' or 'nvim', '--clean', '-u', 'NONE', 139 | '-l', utils.get_plugin_file_path('lib', 'proxy', 'bashdb.lua'), 140 | '-a', proxy_addr 141 | } 142 | -- Append the rest of arguments 143 | for i = 1, #client_cmd do 144 | cmd[#cmd + 1] = client_cmd[i] 145 | end 146 | return cmd 147 | end 148 | 149 | return C 150 | -------------------------------------------------------------------------------- /lua/nvimgdb/backend/gdb.lua: -------------------------------------------------------------------------------- 1 | -- GDB specifics 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | local Backend = require'nvimgdb.backend' 6 | local ParserImpl = require'nvimgdb.parser_impl' 7 | local utils = require'nvimgdb.utils' 8 | 9 | ---@class BackendGdb: Backend @specifics of GDB 10 | local C = {} 11 | C.__index = C 12 | setmetatable(C, {__index = Backend}) 13 | 14 | ---@return BackendGdb new instance 15 | function C.new() 16 | local self = setmetatable({}, C) 17 | return self 18 | end 19 | 20 | ---Create a parser to recognize state changes and code jumps 21 | ---@param actions ParserActions callbacks for the parser 22 | ---@param proxy Proxy side channel connection to the debugger 23 | ---@return ParserImpl new parser instance 24 | function C.create_parser(actions, proxy) 25 | local P = {} 26 | P.__index = P 27 | setmetatable(P, {__index = ParserImpl}) 28 | 29 | local self = setmetatable({}, P) 30 | self:_init(actions) 31 | 32 | P.prev_fname = nil 33 | P.prev_line = nil 34 | 35 | function P:query_paused() 36 | log.debug({"P:query_paused"}) 37 | coroutine.resume(coroutine.create(function() 38 | local process_state = proxy:query('get-process-state') 39 | log.debug({"process state", process_state}) 40 | if process_state == 'stopped' then 41 | local location = proxy:query('get-current-frame-location') 42 | log.debug({"current frame location", location}) 43 | if #location == 2 then 44 | local fname = location[1] 45 | local line = location[2] 46 | if (fname ~= self.prev_fname or line ~= self.prev_line) or 47 | proxy:query('has-exited-or-ran') then 48 | self.prev_line = line 49 | self.prev_fname = fname 50 | self.actions:jump_to_source(fname, line) 51 | end 52 | end 53 | end 54 | self.actions:query_breakpoints() 55 | self.state = process_state == 'running' and self.running or self.paused 56 | end)) 57 | -- Don't change the state yet 58 | return self.state 59 | end 60 | 61 | local re_prompt = '$' 62 | self.add_trans(self.paused, '[\r\n]Continuing%.', self._paused_continue) 63 | self.add_trans(self.paused, '[\r\n]Starting program:', self._paused_continue) 64 | self.add_trans(self.paused, re_prompt, self.query_paused) 65 | self.add_trans(self.running, re_prompt, self.query_paused) 66 | 67 | self.state = self.running 68 | 69 | return self 70 | end 71 | 72 | ---@async 73 | ---@param fname string full path to the source 74 | ---@param proxy Proxy connection to the side channel 75 | ---@return FileBreakpoints collection of actual breakpoints 76 | function C.query_breakpoints(fname, proxy) 77 | log.info("Query breakpoints for " .. fname) 78 | local breaks = proxy:query('info-breakpoints ' .. fname) 79 | if breaks == nil or next(breaks) == nil then 80 | return {} 81 | end 82 | -- We expect the proxies to send breakpoints for a given file 83 | -- as a map of lines to array of breakpoint ids set in those lines. 84 | local err = breaks._error 85 | if err ~= nil then 86 | log.error("Can't get breakpoints: " .. err) 87 | return {} 88 | end 89 | return breaks 90 | end 91 | 92 | ---@type CommandMap 93 | C.command_map = { 94 | delete_breakpoints = 'delete', 95 | breakpoint = 'break', 96 | ['info breakpoints'] = 'info breakpoints', 97 | } 98 | 99 | ---@return string[] 100 | function C.get_error_formats() 101 | -- Return the list of errorformats for backtrace, breakpoints. 102 | return {[[%m\ at\ %f:%l]], [[%m\ %f:%l]]} 103 | end 104 | 105 | ---@param client_cmd string[] original debugger command 106 | ---@param tmp_dir string path to the session state directory 107 | ---@param proxy_addr string full path to the file with the udp port in the session state directory 108 | ---@return string[] command to launch the debugger with termopen() 109 | function C.get_launch_cmd(client_cmd, tmp_dir, proxy_addr) 110 | 111 | -- Assuming the first argument is path to gdb, the rest are arguments. 112 | -- We'd like to ensure gdb is launched with our custom initialization 113 | -- injected. 114 | 115 | local cmd_arg = "-ix" 116 | local rest_arg_idx = 2 117 | local cmd = {client_cmd[1]} 118 | if cmd[1] == "rr-replay.py" then 119 | -- Check for rr-replay.py 120 | cmd = {utils.get_plugin_file_path("lib", "rr-replay.py")} 121 | elseif cmd[1] == "cargo-debug" then 122 | -- Check for cargo 123 | cmd_arg = "--command-file" 124 | elseif cmd[1] == "cargo" then 125 | -- the 2nd arg is the cargo's subcommand, should be 'debug' here 126 | cmd = {'cargo', client_cmd[2]} 127 | cmd_arg = "--command-file" 128 | rest_arg_idx = 3 129 | end 130 | 131 | local gdb_init = utils.path_join(tmp_dir, "gdb_init") 132 | local file = io.open(gdb_init, "w") 133 | assert(file, "Failed to open gdb_init for writing") 134 | if file then 135 | file:write([[ 136 | set confirm off 137 | set pagination off 138 | ]]) 139 | file:write("source " .. utils.get_plugin_file_path("lib", "gdb_commands.py") .. "\n") 140 | file:write("nvim-gdb-init " .. proxy_addr .. "\n") 141 | if utils.is_windows then 142 | -- Change code page to UTF-8 in Windows, required to avoid distortion of characters like \x1a (^Z) 143 | file:write("!chcp 65001\n") 144 | end 145 | file:close() 146 | end 147 | 148 | table.insert(cmd, cmd_arg) 149 | table.insert(cmd, gdb_init) 150 | -- Append the rest of arguments 151 | for i = rest_arg_idx, #client_cmd do 152 | cmd[#cmd + 1] = client_cmd[i] 153 | end 154 | return cmd 155 | end 156 | 157 | return C 158 | -------------------------------------------------------------------------------- /lua/nvimgdb/backend/lldb.lua: -------------------------------------------------------------------------------- 1 | -- LLDB specifics 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | local Backend = require'nvimgdb.backend' 6 | local ParserImpl = require'nvimgdb.parser_impl' 7 | local utils = require'nvimgdb.utils' 8 | 9 | ---@class BackendLldb: Backend specifics of LLDB 10 | local C = {} 11 | C.__index = C 12 | setmetatable(C, {__index = Backend}) 13 | 14 | ---@return BackendLldb new instance 15 | function C.new() 16 | local self = setmetatable({}, C) 17 | return self 18 | end 19 | 20 | ---Create a parser to recognize state changes and code jumps 21 | ---@param actions ParserActions callbacks for the parser 22 | ---@param proxy Proxy side channel connection to the debugger 23 | ---@return ParserImpl new parser instance 24 | function C.create_parser(actions, proxy) 25 | local P = {} 26 | P.__index = P 27 | setmetatable(P, {__index = ParserImpl}) 28 | 29 | local self = setmetatable({}, P) 30 | self:_init(actions) 31 | 32 | function P:query_paused() 33 | coroutine.resume(coroutine.create(function() 34 | local process_state = proxy:query('get-process-state') 35 | log.debug({"process state", process_state}) 36 | if process_state == 'stopped' then 37 | -- A frame and thread are selected when the process gets stopped 38 | local location = proxy:query('get-current-frame-location') 39 | log.debug({"current frame location", location}) 40 | if #location == 2 then 41 | local fname = location[1] 42 | local line = location[2] 43 | self.actions:jump_to_source(fname, line) 44 | end 45 | end 46 | self.actions:query_breakpoints() 47 | self.state = process_state == 'running' and self.running or self.paused 48 | end)) 49 | -- Don't change the state yet 50 | return self.state 51 | end 52 | 53 | local re_prompt = '$' 54 | self.add_trans(self.paused, 'Process %d+ resuming', self._paused_continue) 55 | self.add_trans(self.paused, 'Process %d+ launched', self._paused_continue) 56 | self.add_trans(self.paused, 'Process %d+ exited', self._paused_continue) 57 | self.add_trans(self.paused, re_prompt, self.query_paused) 58 | self.add_trans(self.running, 'Process %d+ stopped', self._paused) 59 | self.add_trans(self.running, re_prompt, self.query_paused) 60 | 61 | self.state = self.running 62 | 63 | return self 64 | end 65 | 66 | ---@async 67 | ---@param fname string full path to the source 68 | ---@param proxy Proxy connection to the side channel 69 | ---@return FileBreakpoints collection of actual breakpoints 70 | function C.query_breakpoints(fname, proxy) 71 | log.info("Query breakpoints for " .. fname) 72 | local breaks = proxy:query('info-breakpoints ' .. fname) 73 | if breaks == nil or next(breaks) == nil then 74 | return {} 75 | end 76 | -- We expect the proxies to send breakpoints for a given file 77 | -- as a map of lines to array of breakpoint ids set in those lines. 78 | local err = breaks._error 79 | if err ~= nil then 80 | log.error("Can't get breakpoints: " .. err) 81 | return {} 82 | end 83 | return breaks 84 | end 85 | 86 | ---@type CommandMap 87 | C.command_map = { 88 | delete_breakpoints = 'breakpoint delete', 89 | breakpoint = 'b', 90 | ['until %s'] = 'thread until %s', 91 | ['info breakpoints'] = 'nvim-gdb-info-breakpoints', 92 | } 93 | 94 | ---@return string[] 95 | function C.get_error_formats() 96 | -- Return the list of errorformats for backtrace, breakpoints. 97 | -- Breakpoint list is queried specifically with a custom command 98 | -- nvim-gdb-info-breakpoints, which is only implemented in the proxy. 99 | return {[[%m\ at\ %f:%l]], [[%f:%l\ %m]]} 100 | end 101 | 102 | ---@param client_cmd string[] original debugger command 103 | ---@param tmp_dir string path to the session state directory 104 | ---@param proxy_addr string full path to the file with the udp port in the session state directory 105 | ---@return string[] command to launch the debugger with termopen() 106 | function C.get_launch_cmd(client_cmd, tmp_dir, proxy_addr) 107 | 108 | -- Assuming the first argument is path to lldb, the rest are arguments. 109 | -- We'd like to ensure gdb is launched with our custom initialization 110 | -- injected. 111 | 112 | local cmd_args = {'--source-quietly', '-S'} 113 | local rest_arg_idx = 2 114 | local cmd = {client_cmd[1]} 115 | if cmd[1] == "cargo-debug" then 116 | -- Check for cargo 117 | cmd_args = {'--debugger', 'lldb', '--command-file'} 118 | elseif cmd[1] == "cargo" then 119 | -- the 2nd arg is the cargo's subcommand, should be 'debug' here 120 | cmd = {'cargo', client_cmd[2]} 121 | cmd_args = {'--debugger', 'lldb', '--command-file'} 122 | rest_arg_idx = 3 123 | end 124 | 125 | local lldb_init = utils.path_join(tmp_dir, "lldb_init") 126 | local file = io.open(lldb_init, "w") 127 | assert(file, "Failed to open lldb_init for writing") 128 | if file then 129 | if utils.is_windows then 130 | -- Change code page to UTF-8 in Windows, required to avoid distortion of characters like \x1a (^Z) 131 | file:write("shell chcp 65001\n") 132 | end 133 | file:write("settings set auto-confirm true\n") 134 | file:write("settings set stop-line-count-before 1\n") 135 | file:write("settings set stop-line-count-after 1\n") 136 | file:write("command script import " .. utils.get_plugin_file_path("lib", "lldb_commands.py") .. "\n") 137 | file:write("command script add -f lldb_commands.init nvim-gdb-init\n") 138 | file:write("nvim-gdb-init " .. proxy_addr .. "\n") 139 | file:close() 140 | end 141 | 142 | -- Execute lldb finally with our custom initialization script 143 | for _, arg in ipairs(cmd_args) do 144 | table.insert(cmd, arg) 145 | end 146 | table.insert(cmd, lldb_init) 147 | 148 | -- Append the rest of arguments 149 | for i = rest_arg_idx, #client_cmd do 150 | cmd[#cmd + 1] = client_cmd[i] 151 | end 152 | return cmd 153 | end 154 | 155 | return C 156 | -------------------------------------------------------------------------------- /lua/nvimgdb/backend/pdb.lua: -------------------------------------------------------------------------------- 1 | -- PDB specifics 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | local Backend = require'nvimgdb.backend' 6 | local ParserImpl = require'nvimgdb.parser_impl' 7 | local utils = require'nvimgdb.utils' 8 | 9 | ---@class BackendPdb: Backend specifics of PDB 10 | local C = {} 11 | C.__index = C 12 | setmetatable(C, {__index = Backend}) 13 | 14 | ---@return BackendPdb new instance 15 | function C.new() 16 | local self = setmetatable({}, C) 17 | return self 18 | end 19 | 20 | local for_win32 = function(win32, other) 21 | if utils.is_windows then 22 | return win32 23 | end 24 | return other 25 | end 26 | 27 | local U = { 28 | re_jump = for_win32('> ([^(]+)%((%d+)%)[^(]+%(%)', '[\r\n ]> ([^(]+)%((%d+)%)[^(]+%(%)'), 29 | -- c:\full\path\test.py 30 | jump_regex = for_win32('^([^:]+:[^:]+):([0-9]+)$', '^([^:]+):([0-9]+)$'), 31 | strieq = for_win32(function(a, b) return a:lower() == b:lower() end, 32 | function(a, b) return a == b end) 33 | } 34 | 35 | ---Create a parser to recognize state changes and code jumps 36 | ---@param actions ParserActions callbacks for the parser 37 | ---@param proxy Proxy side channel connection to the debugger 38 | ---@return ParserImpl new parser instance 39 | function C.create_parser(actions, proxy) 40 | local _ = proxy 41 | 42 | local P = {} 43 | P.__index = P 44 | setmetatable(P, {__index = ParserImpl}) 45 | 46 | local self = setmetatable({}, P) 47 | self:_init(actions) 48 | 49 | local re_prompt = '[\r\n]%(Pdb%+?%+?%)[\r ]*$' 50 | self.add_trans(self.paused, U.re_jump, self._paused_jump) 51 | self.add_trans(self.paused, re_prompt, self._query_b) 52 | 53 | -- Let's start the backend in the running state for the tests 54 | -- to be able to determine when the launch finished. 55 | -- It'll transition to the paused state once and will remain there. 56 | function P:_running_jump(fname, line) 57 | log.info("_running_jump " .. fname .. ":" .. line) 58 | self.actions:jump_to_source(fname, tonumber(line)) 59 | return self.running 60 | end 61 | 62 | self.add_trans(self.running, U.re_jump, self._running_jump) 63 | self.add_trans(self.running, re_prompt, self._query_b) 64 | self.state = self.running 65 | return self 66 | end 67 | 68 | ---@async 69 | ---@param fname string full path to the source 70 | ---@param proxy Proxy connection to the side channel 71 | ---@return FileBreakpoints collection of actual breakpoints 72 | function C.query_breakpoints(fname, proxy) 73 | -- Query actual breakpoints for the given file. 74 | log.info("Query breakpoints for " .. fname) 75 | 76 | local response = proxy:query('handle-command break') 77 | if response == nil or type(response) ~= 'string' or response == '' then 78 | return {} 79 | end 80 | 81 | -- Num Type Disp Enb Where 82 | -- 1 breakpoint keep yes at /tmp/nvim-gdb/test/main.py:8 83 | 84 | local breaks = {} 85 | for line in response:gmatch('[^\r\n]+') do 86 | local tokens = {} 87 | for token in line:gmatch('[^%s]+') do 88 | tokens[#tokens+1] = token 89 | end 90 | local bid = tokens[1] 91 | if tokens[2] == 'breakpoint' and tokens[4] == 'yes' then 92 | local bpfname, lnum = tokens[#tokens]:match(U.jump_regex) 93 | if bpfname ~= nil and U.strieq(fname, bpfname) then 94 | local list = breaks[lnum] 95 | if list == nil then 96 | breaks[lnum] = {bid} 97 | else 98 | list[#list + 1] = bid 99 | end 100 | end 101 | end 102 | end 103 | return breaks 104 | end 105 | 106 | ---@type CommandMap 107 | C.command_map = { 108 | delete_breakpoints = 'clear', 109 | breakpoint = 'break', 110 | finish = 'return', 111 | ['print %s'] = 'print(%s)', 112 | ['info breakpoints'] = 'break', 113 | } 114 | 115 | ---@return string[] 116 | function C.get_error_formats() 117 | -- Return the list of errorformats for backtrace, breakpoints. 118 | return {[[%m\ at\ %f:%l]], [[[%[0-9]%#]%[>\ ]%#%f(%l)%m]], [[%[>\ ]%#%f(%l)%m]]} 119 | 120 | -- (Pdb) break 121 | -- Num Type Disp Enb Where 122 | -- 1 breakpoint keep yes at /tmp/nvim-gdb/test/main.py:14 123 | -- 2 breakpoint keep yes at /tmp/nvim-gdb/test/main.py:4 124 | -- (Pdb) bt 125 | -- /usr/lib/python3.9/bdb.py(580)run() 126 | -- -> exec(cmd, globals, locals) 127 | -- (1)() 128 | -- /tmp/nvim-gdb/test/main.py(22)() 129 | -- -> _main() 130 | -- /tmp/nvim-gdb/test/main.py(16)_main() 131 | -- -> _foo(i) 132 | -- /tmp/nvim-gdb/test/main.py(11)_foo() 133 | -- -> return num + _bar(num - 1) 134 | -- > /tmp/nvim-gdb/test/main.py(5)_bar() 135 | 136 | -- Pdb++ may produce a different backtrace: 137 | -- (Pdb++) bt 138 | -- [0] /usr/lib/python3.9/bdb.py(580)run() 139 | -- -> exec(cmd, globals, locals) 140 | -- [1] (1)() 141 | -- [2] /tmp/nvim-gdb/test/main.py(22)() 142 | -- -> _main() 143 | -- [3] /tmp/nvim-gdb/test/main.py(16)_main() 144 | -- -> _foo(i) 145 | -- [4] /tmp/nvim-gdb/test/main.py(11)_foo() 146 | -- -> return num + _bar(num - 1) 147 | -- [5] > /tmp/nvim-gdb/test/main.py(5)_bar() 148 | -- -> return i * 2 149 | end 150 | 151 | ---@param client_cmd string[] original debugger command 152 | ---@param tmp_dir string path to the session state directory 153 | ---@param proxy_addr string full path to the file with the udp port in the session state directory 154 | ---@return string[] command to launch the debugger with termopen() 155 | function C.get_launch_cmd(client_cmd, tmp_dir, proxy_addr) 156 | local _ = tmp_dir 157 | local cmd = { 158 | utils.is_windows and 'nvim.exe' or 'nvim', '--clean', '-u', 'NONE', 159 | '-l', utils.get_plugin_file_path('lib', 'proxy', 'pdb.lua'), 160 | '-a', proxy_addr 161 | } 162 | -- Append the rest of arguments 163 | for i = 1, #client_cmd do 164 | cmd[#cmd + 1] = client_cmd[i] 165 | end 166 | return cmd 167 | end 168 | 169 | return C 170 | -------------------------------------------------------------------------------- /lua/nvimgdb/breakpoint.lua: -------------------------------------------------------------------------------- 1 | -- Handle breakpoint signs. 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | 6 | ---@alias FileBreakpoints table # breakpoint collection for a file {line -> [id]} 7 | ---@alias QueryBreakpoints function(fname: string, proxy: Proxy): FileBreakpoints # Function to obtain a breakpoint collection 8 | 9 | ---@class Breakpoint breakpoint signs handler 10 | ---@field private config Config resolved configuration 11 | ---@field private proxy Proxy connection to the side channel 12 | ---@field private query_impl QueryBreakpoints function to query breakpoints for a given file 13 | ---@field private breaks table discovered breakpoints so far: {file -> {line -> [id]}} 14 | ---@field private max_sign_id number biggest sign identifier for the breakpoints in use 15 | local Breakpoint = {} 16 | Breakpoint.__index = Breakpoint 17 | 18 | ---Constructor 19 | ---@param config Config resolved configuration 20 | ---@param proxy Proxy @connection to the side channel 21 | ---@param query_impl QueryBreakpoints @function to query breakpoints 22 | ---@return Breakpoint @new instance 23 | function Breakpoint.new(config, proxy, query_impl) 24 | log.debug({"Breakpoint.new", query_impl = query_impl}) 25 | local self = setmetatable({}, Breakpoint) 26 | self.config = config 27 | self.proxy = proxy 28 | self.query_impl = query_impl 29 | self.breaks = {} 30 | self.max_sign_id = 0 31 | return self 32 | end 33 | 34 | ---Clear all breakpoint signs in all buffers 35 | function Breakpoint:clear_signs() 36 | log.debug({"Breakpoint:clear_signs"}) 37 | -- Clear all breakpoint signs. 38 | for i = 5000, self.max_sign_id do 39 | vim.fn.sign_unplace('NvimGdb', {id = i}) 40 | end 41 | self.max_sign_id = 0 42 | end 43 | 44 | ---Set a breakpoint sign in the given buffer 45 | ---@param buf number @buffer number 46 | function Breakpoint:_set_signs(buf) 47 | log.debug({"Breakpoint:_set_signs", buf = buf}) 48 | if buf ~= -1 then 49 | local sign_id = 5000 - 1 50 | -- Breakpoints need full path to the buffer (at least in lldb) 51 | local bpath = vim.fn.expand('#' .. tostring(buf) .. ':p') 52 | 53 | local function _get_sign_name(idx) 54 | local max_count = #self.config:get('sign_breakpoint') 55 | if idx > max_count then 56 | idx = max_count 57 | end 58 | return "GdbBreakpoint" .. tostring(idx) 59 | end 60 | 61 | local priority = self.config:get('sign_breakpoint_priority') 62 | local for_file = self.breaks[bpath] 63 | if for_file ~= nil then 64 | for line, ids in pairs(for_file) do 65 | if type(line) == "string" then 66 | sign_id = sign_id + 1 67 | local sign_name = _get_sign_name(#ids) 68 | vim.fn.sign_place(sign_id, 'NvimGdb', sign_name, buf, 69 | {lnum = line, priority = priority}) 70 | end 71 | end 72 | self.max_sign_id = sign_id 73 | end 74 | end 75 | end 76 | 77 | ---Query actual breakpoints for the given file. 78 | ---@param buf_num number buffer number 79 | ---@param fname string full path to the source code file 80 | function Breakpoint:query(buf_num, fname) 81 | log.info({"Breakpoint:query(", buf_num = buf_num, fname = fname}) 82 | self.breaks[fname] = self.query_impl(fname, self.proxy) 83 | self:clear_signs() 84 | self:_set_signs(buf_num) 85 | end 86 | 87 | ---Reset all known breakpoints and their signs. 88 | function Breakpoint:reset_signs() 89 | log.debug({"Breakpoint:reset_signs"}) 90 | self.breaks = {} 91 | self:clear_signs() 92 | end 93 | 94 | ---Get breakpoints for the given position in a file. 95 | ---@param fname string full path to the source code file 96 | ---@param line number|string line number 97 | ---@return string[] list of breakpoint identifiers 98 | function Breakpoint:get_for_file(fname, line) 99 | log.debug({"Breakpoint:get_for_file", fname = fname, line = line}) 100 | local breaks = self.breaks[fname] 101 | if breaks == nil then 102 | return {} 103 | end 104 | local ids = breaks[tostring(line)] -- make sure the line is a string 105 | if ids == nil then 106 | return {} 107 | end 108 | return ids 109 | end 110 | 111 | return Breakpoint 112 | -------------------------------------------------------------------------------- /lua/nvimgdb/client.lua: -------------------------------------------------------------------------------- 1 | -- The class to maintain connection to the debugger client. 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | local uv = vim.loop 6 | local utils = require'nvimgdb.utils' 7 | 8 | ---@class Client spawned debugger manager 9 | ---@field private config Config resolved configuration 10 | ---@field public win number terminal window handler 11 | ---@field private client_id number terminal job handler 12 | ---@field private is_active boolean true if the debugger has been launched 13 | ---@field private has_interacted boolean true if the debugger was interactive 14 | ---@field private tmp_dir string temporary directory for the proxy address 15 | ---@field private proxy_addr string path to the file with proxy port 16 | ---@field private command string[] complete command to launch the debugger (including proxy) 17 | ---@field private client_buf number terminal buffer handler 18 | ---@field private buf_hidden_auid number autocmd id of the BufHidden handler 19 | local Client = {} 20 | Client.__index = Client 21 | 22 | ---Constructor 23 | ---@param config Config resolved configuration for this session 24 | ---@param backend Backend debugger backend 25 | ---@param client_cmd string[] command to launch the debugger 26 | ---@return Client new instance 27 | function Client.new(config, backend, client_cmd) 28 | log.debug({"Client.new", client_cmd = client_cmd}) 29 | local self = setmetatable({}, Client) 30 | self.config = config 31 | log.info("termwin_command", config:get('termwin_command')) 32 | vim.api.nvim_command(config:get('termwin_command')) 33 | self.win = vim.api.nvim_get_current_win() 34 | self.client_id = nil 35 | self.is_active = false 36 | self.has_interacted = false 37 | -- Create a temporary unique directory for all the sockets. 38 | self.tmp_dir = uv.fs_mkdtemp(uv.os_tmpdir() .. '/nvimgdb-XXXXXX') 39 | self.proxy_addr = utils.path_join(self.tmp_dir, 'port') 40 | 41 | -- Prepare the debugger command to run 42 | self.command = backend.get_launch_cmd(client_cmd, self.tmp_dir, self.proxy_addr) 43 | log.info({"Debugger command", self.command}) 44 | 45 | vim.api.nvim_command "enew" 46 | self.client_buf = vim.api.nvim_get_current_buf() 47 | self.buf_hidden_auid = -1 48 | return self 49 | end 50 | 51 | ---Destructor 52 | function Client:cleanup() 53 | log.debug({"Client:cleanup"}) 54 | if vim.api.nvim_buf_is_valid(self.client_buf) and vim.fn.bufexists(self.client_buf) then 55 | self:_cleanup_buf_hidden() 56 | vim.api.nvim_buf_delete(self.client_buf, {force = true}) 57 | end 58 | 59 | if self.proxy_addr then 60 | os.remove(self.proxy_addr) 61 | end 62 | vim.fn.delete(self.tmp_dir, "rf") 63 | end 64 | 65 | ---Get client buffer 66 | ---@return number client buffer 67 | function Client:get_client_buf() 68 | return self.client_buf 69 | end 70 | 71 | ---Return true if the client is active 72 | ---@return boolean 73 | function Client:get_is_active() 74 | return self.is_active 75 | end 76 | 77 | function Client:_cleanup_buf_hidden() 78 | log.debug({"Client:_cleanup_buf_hidden"}) 79 | if self.buf_hidden_auid ~= -1 then 80 | vim.api.nvim_del_autocmd(self.buf_hidden_auid) 81 | self.buf_hidden_auid = -1 82 | end 83 | end 84 | 85 | ---Launch the debugger (when all the parsers are ready) 86 | function Client:start() 87 | log.debug({"Client:start"}) 88 | -- Open a terminal window with the debugger client command. 89 | -- Go to the yet-to-be terminal window 90 | vim.api.nvim_set_current_win(self.win) 91 | self.is_active = true 92 | 93 | local cur_tabpage = vim.api.nvim_get_current_tabpage() 94 | -- TODO: fix app access 95 | local app = assert(NvimGdb.apps[cur_tabpage]) 96 | 97 | self.client_id = vim.fn.termopen(self.command, { 98 | on_stdout = function(--[[j]]_, lines, --[[name]]_) 99 | if NvimGdb ~= nil then 100 | app.parser:feed(lines) 101 | end 102 | end, 103 | on_exit = function(--[[j]]_, code, --[[name]]_) 104 | if self.has_interacted and code == 0 then 105 | -- TODO: fix app access 106 | local cur_app = NvimGdb.apps[cur_tabpage] 107 | -- Deal with the race, check that this client is still working in the same tabpage 108 | if app == cur_app then 109 | vim.api.nvim_command("sil! bw!") 110 | NvimGdb.cleanup(cur_tabpage) 111 | end 112 | end 113 | end 114 | }) 115 | 116 | vim.bo.filetype = "nvimgdb" 117 | -- Allow detaching the terminal from its window 118 | vim.bo.bufhidden = "hide" 119 | -- Prevent the debugger buffer from being listed 120 | vim.bo.buflisted = false 121 | -- Finish the debugging session when the terminal is closed 122 | -- Left the remains of the code intentionally to remind that there is no need 123 | -- to close the debugger terminal automatically. 124 | --local cur_tabpage = vim.api.nvim_get_current_tabpage() 125 | --vim.cmd("au TermClose lua NvimGdb.cleanup(" .. cur_tabpage .. ")") 126 | 127 | -- Check whether the terminal buffer should always be shown 128 | local sticky = self.config:get_or('sticky_dbg_buf', true) 129 | if sticky then 130 | self.buf_hidden_auid = vim.api.nvim_create_autocmd("BufHidden", { 131 | buffer = self.client_buf, 132 | callback = vim.schedule_wrap(function() 133 | self:_check_sticky() 134 | end), 135 | }) 136 | vim.api.nvim_create_autocmd("TermClose", { 137 | buffer = self.client_buf, 138 | callback = function() 139 | self:_cleanup_buf_hidden() 140 | end 141 | }) 142 | end 143 | end 144 | 145 | ---Make the debugger window sticky. If closed accidentally, 146 | ---resurrect it. 147 | function Client:_check_sticky() 148 | log.debug({"Client:_check_sticky"}) 149 | local prev_win = vim.api.nvim_get_current_win() 150 | vim.api.nvim_command(self.config:get('termwin_command')) 151 | local buf = vim.api.nvim_get_current_buf() 152 | if vim.api.nvim_buf_is_valid(self.client_buf) then 153 | vim.api.nvim_command('b ' .. self.client_buf) 154 | end 155 | vim.api.nvim_buf_delete(buf, {}) 156 | self.win = vim.api.nvim_get_current_win() 157 | vim.api.nvim_set_current_win(prev_win) 158 | end 159 | 160 | ---Interrupt running program by sending ^c. 161 | function Client:interrupt() 162 | log.debug({"Client:interrupt"}) 163 | vim.fn.chansend(self.client_id, "\x03") 164 | end 165 | 166 | ---Execute one command on the debugger interpreter. 167 | ---@param data string send a command to the debugger 168 | function Client:send_line(data) 169 | log.info({"Client:send_line", data = data}) 170 | local cr = "\n" 171 | if utils.is_windows then 172 | cr = "\r" 173 | end 174 | vim.fn.chansend(self.client_id, data .. cr) 175 | end 176 | 177 | ---Get the client terminal buffer. 178 | ---@return number terminal buffer handle 179 | function Client:get_buf() 180 | log.debug({"Client:get_buf"}) 181 | return self.client_buf 182 | end 183 | 184 | ---Get the side-channel address. 185 | ---@return string file with proxy port 186 | function Client:get_proxy_addr() 187 | log.debug({"Client:get_proxy_addr"}) 188 | return self.proxy_addr 189 | end 190 | 191 | ---Remember this debugger reached the interactive state 192 | ---This means we can close the terminal whenever the debugger quits 193 | ---Otherwise, keep the terminal to show the output to the user. 194 | function Client:mark_has_interacted() 195 | self.has_interacted = true 196 | end 197 | 198 | return Client 199 | -------------------------------------------------------------------------------- /lua/nvimgdb/config.lua: -------------------------------------------------------------------------------- 1 | -- Handle configuration settings 2 | -- vim: et sw=2 ts=2: 3 | 4 | local Keymaps = require 'nvimgdb.keymaps' 5 | local log = require 'nvimgdb.log' 6 | 7 | ---@class Config resolved configuration instance 8 | ---@field private config ConfDict configuration entries 9 | local Config = {} 10 | Config.__index = Config 11 | 12 | ---@class ConfDict @default configuration 13 | local default = { 14 | key_until = '', 15 | key_continue = '', 16 | key_next = '', 17 | key_step = '', 18 | key_finish = '', 19 | key_breakpoint = '', 20 | key_frameup = '', 21 | key_framedown = '', 22 | key_eval = '', 23 | key_quit = nil, 24 | set_tkeymaps = Keymaps.set_t, 25 | set_keymaps = Keymaps.set, 26 | unset_keymaps = Keymaps.unset, 27 | sign_current_line = '▶', 28 | sign_breakpoint = {'●', '●²', '●³', '●⁴', '●⁵', '●⁶', '●⁷', '●⁸', '●⁹', '●ⁿ'}, 29 | sign_breakpoint_priority = 10, 30 | termwin_command = 'belowright new', -- Assign a window for the debugging terminal 31 | codewin_command = 'new', -- Assign a window for the source code 32 | set_scroll_off = 5, 33 | jump_bottom_gdb_buf = true, 34 | sticky_dbg_buf = true, 35 | } 36 | 37 | ---Turn a string into a funcref looking up a Vim function. 38 | ---@param key string callback name 39 | ---@param val any parameter value expected to be a function reference 40 | ---@return any 41 | local function filter_funcref(key, val) 42 | -- Lookup the key in the default config. 43 | local def_val = default[key] 44 | -- Check whether the key should be a function. 45 | if type(def_val) ~= "function" then 46 | return val 47 | end 48 | if type(val) == "function" then 49 | return val 50 | end 51 | -- Finally, turn the value into a Vim function call. 52 | return function(_) vim.api.nvim_call_function(val, {}) end 53 | end 54 | 55 | ---Get a value of a user-defined variable nvimgdb_, 56 | ---probing the scope in succession: buffer, window, tabpage, global. 57 | ---@param var string variable name like nvimgdb_ 58 | ---@return any? variable value if defined, nil otherwise 59 | local function _get_from_user_variable(var) 60 | for scope in ("bwtg"):gmatch'.' do 61 | local cmd = "return vim." .. scope .. ".nvimgdb_" .. var 62 | local val = loadstring(cmd)() 63 | if val ~= nil then 64 | return val 65 | end 66 | end 67 | end 68 | 69 | ---@return ConfDict? copy and process the configuration supplied 70 | local function copy_user_config() 71 | -- Make a copy of the supplied configuration if defined 72 | local config = _get_from_user_variable("config") 73 | if config == nil then 74 | return nil 75 | end 76 | 77 | for key, val in pairs(config) do 78 | local filtered_val = filter_funcref(key, val) 79 | if filtered_val ~= nil then 80 | config[key] = filtered_val 81 | end 82 | end 83 | 84 | -- Make sure the essential keys are present even if not supplied. 85 | for _, must_have in pairs({'sign_current_line', 'sign_breakpoint', 86 | 'termwin_command', 'codewin_command', 'set_scroll_off'}) do 87 | if config[must_have] == nil then 88 | config[must_have] = default[must_have] 89 | end 90 | end 91 | 92 | return config 93 | end 94 | 95 | ---@return Config create a new configuration instance 96 | function Config.new() 97 | log.debug({"Config.new"}) 98 | local self = setmetatable({}, Config) 99 | -- Prepare actual configuration with overrides resolved. 100 | local key_to_func = {} 101 | 102 | -- Make a copy of the supplied configuration if defined 103 | local config = copy_user_config() 104 | if config ~= nil then 105 | self.config = config 106 | else 107 | self.config = {} 108 | for key, val in pairs(default) do 109 | self.config[key] = val 110 | end 111 | end 112 | 113 | for func, key in pairs(self.config) do 114 | self:_check_keymap_conflicts(key, func, key_to_func, true) 115 | end 116 | 117 | self:_apply_overrides(key_to_func) 118 | self:_define_signs() 119 | 120 | log.info({"Resolved configuration", self.config}) 121 | return self 122 | end 123 | 124 | ---Apply to the configuration user overrides taken from global variables 125 | ---@param key_to_func table map of keystrokes to their meaning 126 | function Config:_apply_overrides(key_to_func) 127 | log.debug({"Config:_apply_overrides", key_to_func = key_to_func}) 128 | -- If there is config override defined, add it 129 | local override = _get_from_user_variable("config_override") 130 | if override ~= nil then 131 | for key, val in pairs(override) do 132 | local key_val = filter_funcref(key, val) 133 | if key_val ~= nil then 134 | self:_check_keymap_conflicts(key_val, key, key_to_func, true) 135 | self.config[key] = key_val 136 | end 137 | end 138 | end 139 | 140 | -- See whether an override for a specific configuration 141 | -- key exists. If so, update the config. 142 | for key, _ in pairs(default) do 143 | local val = _get_from_user_variable(key) 144 | if val ~= nil then 145 | local key_val = filter_funcref(key, val) 146 | if key_val ~= nil then 147 | self:_check_keymap_conflicts(key_val, key, key_to_func, false) 148 | self.config[key] = key_val 149 | end 150 | end 151 | end 152 | end 153 | 154 | ---Check for keymap configuration sanity. 155 | ---@param key string configuration parameter 156 | ---@param func string configuration parameter value 157 | ---@param key_to_func table disambiguation dictionary 158 | ---@param verbose boolean produce messages if true 159 | function Config:_check_keymap_conflicts(key, func, key_to_func, verbose) 160 | log.debug({"Config:_check_keymap_conflicts", key = key, func = func, key_to_func = key_to_func, verbose = verbose}) 161 | if func:match('^key_.*') ~= nil then 162 | local prev_func = key_to_func[key] 163 | if prev_func ~= nil and prev_func ~= func then 164 | if verbose then 165 | print('Overriding conflicting keymap "' .. key .. '" for ' 166 | .. func .. ' (was ' .. prev_func .. ')') 167 | end 168 | key_to_func[self.config[func]] = nil 169 | self.config[prev_func] = nil 170 | end 171 | key_to_func[key] = func 172 | end 173 | end 174 | 175 | ---Define the cursor and breakpoint signs 176 | function Config:_define_signs() 177 | log.debug({"Config:_define_signs"}) 178 | -- Define the sign for current line the debugged program is executing. 179 | vim.fn.sign_define('GdbCurrentLine', {text = self.config.sign_current_line}) 180 | -- Define signs for the breakpoints. 181 | for i, brk in ipairs(self.config.sign_breakpoint) do 182 | vim.fn.sign_define('GdbBreakpoint' .. i, {text = brk}) 183 | end 184 | end 185 | 186 | ---Get the configuration value or return nil 187 | ---@param key string configuration parameter 188 | ---@return any configuration parameter value 189 | function Config:get(key) 190 | log.debug({"Config:get", key = key}) 191 | return self.config[key] 192 | end 193 | 194 | ---Get the configuration value by key or return the val if missing. 195 | ---@param key string configuration parameter 196 | ---@param val any suggested default value 197 | ---@return any parameter value or default value 198 | function Config:get_or(key, val) 199 | log.debug({"Config:get_or", key = key, val = val}) 200 | local v = self:get(key) 201 | if v == nil then v = val end 202 | return v 203 | end 204 | 205 | return Config 206 | -------------------------------------------------------------------------------- /lua/nvimgdb/cursor.lua: -------------------------------------------------------------------------------- 1 | -- Manipulating the current line sign. 2 | -- vim: set et sw=2 ts=2: 3 | 4 | local log = require 'nvimgdb.log' 5 | 6 | ---@class Cursor current line handler 7 | ---@field private config Config resolved configuration 8 | ---@field private buf number buffer number 9 | ---@field private line number line number 10 | ---@field private sign_id number sign identifier 11 | local Cursor = {} 12 | Cursor.__index = Cursor 13 | 14 | ---Constructor 15 | ---@param config Config resolved configuration 16 | ---@return Cursor new instance 17 | function Cursor.new(config) 18 | log.debug({"Cursor.new"}) 19 | local self = setmetatable({}, Cursor) 20 | self.config = config 21 | self.buf = -1 22 | self.line = -1 23 | self.sign_id = -1 24 | return self 25 | end 26 | 27 | ---Hide the current line sign 28 | function Cursor:hide() 29 | log.debug({"Cursor:hide"}) 30 | if self.sign_id ~= -1 and vim.api.nvim_buf_is_loaded(self.buf) then 31 | vim.fn.sign_unplace('NvimGdb', {id = self.sign_id, buffer = self.buf}) 32 | self.sign_id = -1 33 | end 34 | end 35 | 36 | ---Show the current line sign 37 | function Cursor:show() 38 | log.debug({"Cursor:show"}) 39 | -- To avoid flicker when removing/adding the sign column(due to 40 | -- the change in line width), we switch ids for the line sign 41 | -- and only remove the old line sign after marking the new one. 42 | local old_sign_id = self.sign_id 43 | if old_sign_id == -1 or old_sign_id == 4998 then 44 | self.sign_id = 4999 45 | else 46 | self.sign_id = 4998 47 | end 48 | if self.buf ~= -1 then 49 | if self.line ~= -1 then 50 | local priority = self.config:get('sign_breakpoint_priority') + 1 51 | vim.fn.sign_place(self.sign_id, 'NvimGdb', 'GdbCurrentLine', self.buf, 52 | {lnum = self.line, priority = priority}) 53 | end 54 | if old_sign_id ~= -1 then 55 | vim.fn.sign_unplace('NvimGdb', {id = old_sign_id, buffer = self.buf}) 56 | end 57 | end 58 | end 59 | 60 | ---Set the current line sign number. 61 | ---@param buf number buffer number 62 | ---@param line number|string line number 63 | function Cursor:set(buf, line) 64 | log.debug({"Cursor:set", buf = buf, line = line}) 65 | self.buf = buf 66 | self.line = assert(tonumber(line)) 67 | end 68 | 69 | return Cursor 70 | -------------------------------------------------------------------------------- /lua/nvimgdb/efmmgr.lua: -------------------------------------------------------------------------------- 1 | -- Manager for the 'errorformat'. 2 | -- vim: set et ts=2 sw=2: 3 | 4 | ---@class EfmMgr errorformat manager 5 | ---@field private counters table specific errorformat counter 6 | local efmmgr = { 7 | counters = {} 8 | } 9 | 10 | ---Destructor 11 | function efmmgr.cleanup() 12 | for f, _ in pairs(efmmgr.counters) do 13 | vim.api.nvim_command("set efm-=" .. f) 14 | end 15 | end 16 | 17 | ---Add 'efm' for some backend. 18 | ---@param formats string[] 19 | function efmmgr.setup(formats) 20 | for _, f in ipairs(formats) do 21 | local c = efmmgr.counters[f] 22 | if c == nil then 23 | c = 0 24 | vim.api.nvim_command("set efm+=" .. f) 25 | end 26 | efmmgr.counters[f] = c + 1 27 | end 28 | end 29 | 30 | ---Remove 'efm' entries for some backend. 31 | ---@param formats string[] 32 | function efmmgr.teardown(formats) 33 | for _, f in ipairs(formats) do 34 | local c = efmmgr.counters[f] - 1 35 | if c <= 0 then 36 | vim.api.nvim_command("set efm-=" .. f) 37 | efmmgr.counters[f] = nil 38 | else 39 | efmmgr.counters[f] = c 40 | end 41 | end 42 | end 43 | 44 | return efmmgr 45 | -------------------------------------------------------------------------------- /lua/nvimgdb/health.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local health = vim.health 4 | local utils = require'nvimgdb.utils' 5 | 6 | -- "report_" prefix has been deprecated, use the recommended replacements if they exist. 7 | local _start = health.start or health.report_start 8 | local _ok = health.ok or health.report_ok 9 | local _warn = health.warn or health.report_warn 10 | local _error = health.error or health.report_error 11 | local _info = health.info or health.report_info 12 | 13 | local Tests = {} 14 | Tests.__index = Tests 15 | 16 | 17 | function Tests:execute_command(job_name, cmd) 18 | local on_data = function(_, data, name) 19 | local res = self.results[job_name] 20 | res[name] = data 21 | if res == nil then 22 | res = {} 23 | self.results[job_name] = res 24 | end 25 | end 26 | 27 | local opts = { 28 | stderr_buffered = true, 29 | stdout_buffered = true, 30 | on_stdout = on_data, 31 | on_stderr = on_data, 32 | } 33 | 34 | self.results[job_name] = {} 35 | local success, job_id = pcall(vim.fn.jobstart, cmd, opts) 36 | if not success then 37 | self.results[job_name].error = '`' .. cmd[1] .. '` is not executable' 38 | return -1 39 | end 40 | return job_id 41 | end 42 | 43 | local function get_version(output) 44 | return vim.split(output, "[\r\n]")[1] 45 | end 46 | 47 | function Tests:get_result(name) 48 | return self.results[name] 49 | end 50 | 51 | local function stdout_getter(line) 52 | return function(result) 53 | if result.stdout == nil then 54 | return nil 55 | end 56 | return result.stdout[line] 57 | end 58 | end 59 | 60 | local function stderr_getter(line) 61 | return function(result) 62 | if result.stderr == nil then 63 | return nil 64 | end 65 | return result.stderr[line] 66 | end 67 | end 68 | 69 | local function get_message(prefix, message) 70 | if prefix ~= "" then 71 | return prefix .. " " .. message 72 | end 73 | return message 74 | end 75 | 76 | function Tests:check_version(name, message, getter) 77 | local result = self.results[name] 78 | if result.error ~= nil then 79 | _error(get_message(message, result.error)) 80 | return false 81 | end 82 | 83 | local output = getter(result) 84 | if output == nil then 85 | _error(get_message(message, "failed")) 86 | return false 87 | end 88 | 89 | _ok(get_message(message, get_version(output))) 90 | return true 91 | end 92 | 93 | function Tests.execute_commands(commands) 94 | local self = setmetatable({}, Tests) 95 | self.results = {} 96 | local job_ids = {} 97 | for name, cmd in pairs(commands) do 98 | local job_id = self:execute_command(name, cmd) 99 | if job_id > 0 then 100 | table.insert(job_ids, job_id) 101 | end 102 | end 103 | vim.fn.jobwait(job_ids, -1) 104 | return self 105 | end 106 | 107 | M.check = function() 108 | 109 | local commands = { 110 | gdb = {"gdb", "--version"}, 111 | gdb_py = {"gdb", "--batch", "-ex", "python import sys; print(sys.version)"}, 112 | lldb = {"lldb", "--version"}, 113 | lldb_py = {"lldb", "--batch", "-o", "script import lldb, sys; print(sys.version)", "-o", "quit"}, 114 | python = {"python", "--version"}, 115 | pynvim = {"python", "-c", "import pynvim; v=pynvim.VERSION; print(f'{pynvim.__name__} {v.major}.{v.minor}.{v.patch}{v.prerelease}')"}, 116 | } 117 | if utils.is_linux then 118 | commands.rr = {"rr", "--version"} 119 | end 120 | if not utils.is_windows then 121 | commands.bashdb = {"bashdb", "--version"} 122 | end 123 | 124 | local tests = Tests.execute_commands(commands) 125 | local results = tests.results 126 | 127 | _start "GDB backend" 128 | local has_gdb = tests:check_version("gdb", "", stdout_getter(1)) 129 | if has_gdb then 130 | tests:check_version("gdb_py", "GNU gdb Python", stdout_getter(1)) 131 | end 132 | 133 | _start "LLDB backend" 134 | local has_lldb = tests:check_version("lldb", "", stdout_getter(1)) 135 | if has_lldb then 136 | tests:check_version("lldb_py", "lldb Python", stdout_getter(2)) 137 | end 138 | 139 | if results.rr ~= nil then 140 | _start "RR executable" 141 | local has_rr = tests:check_version("rr", "", stdout_getter(1)) 142 | if has_rr then 143 | tests:check_version("gdb", "", stdout_getter(1)) 144 | end 145 | end 146 | 147 | _start "PDB backend" 148 | tests:check_version("python", "", stdout_getter(1)) 149 | if results.bashdb ~= nil then 150 | _start "BashDB backend" 151 | tests:check_version("bashdb", "", stderr_getter(1)) 152 | end 153 | _start "Test suite" 154 | tests:check_version("python", "", stdout_getter(1)) 155 | tests:check_version("pynvim", "", stdout_getter(1)) 156 | end 157 | 158 | return M 159 | -------------------------------------------------------------------------------- /lua/nvimgdb/keymaps.lua: -------------------------------------------------------------------------------- 1 | -- Manipulate keymaps: define and undefined when needed. 2 | -- vim: set et sw=2 ts=2: 3 | 4 | local log = require 'nvimgdb.log' 5 | 6 | ---@class Keymaps @dynamic keymaps manager 7 | ---@field private config Config supplied configuration 8 | local Keymaps = {} 9 | Keymaps.__index = Keymaps 10 | 11 | ---@param config Config resolved configuration 12 | ---@return Keymaps new instance of Keymaps 13 | function Keymaps.new(config) 14 | log.debug({"Keymaps.new"}) 15 | local self = setmetatable({}, Keymaps) 16 | self.config = config 17 | self.dispatch_active = true 18 | return self 19 | end 20 | 21 | ---Turn on/off keymaps manipulation. 22 | ---@param state boolean true to enable keymaps dispatching, false to supress 23 | function Keymaps:set_dispatch_active(state) 24 | log.debug({"Keymaps:set_dispatch_active", state = state}) 25 | self.dispatch_active = state 26 | end 27 | 28 | local default = { 29 | { mode = 'n', term = false, key = 'key_until', cmd = ':GdbUntil' }, 30 | { mode = 'n', term = true, key = 'key_continue', cmd = ':GdbContinue' }, 31 | { mode = 'n', term = true, key = 'key_next', cmd = ':GdbNext' }, 32 | { mode = 'n', term = true, key = 'key_step', cmd = ':GdbStep' }, 33 | { mode = 'n', term = true, key = 'key_finish', cmd = ':GdbFinish' }, 34 | { mode = 'n', term = false, key = 'key_breakpoint', cmd = ':GdbBreakpointToggle' }, 35 | { mode = 'n', term = true, key = 'key_frameup', cmd = ':GdbFrameUp' }, 36 | { mode = 'n', term = true, key = 'key_framedown', cmd = ':GdbFrameDown' }, 37 | { mode = 'n', term = false, key = 'key_eval', cmd = ':GdbEvalWord' }, 38 | { mode = 'v', term = false, key = 'key_eval', cmd = ':GdbEvalRange' }, 39 | { mode = 'n', term = true, key = 'key_quit', cmd = ':GdbDebugStop' }, 40 | } 41 | 42 | ---Define buffer-local keymaps for the jump window 43 | function Keymaps:set() 44 | log.debug({"Keymaps:set"}) 45 | -- Terminal keymaps are only set once per session, so there's 46 | -- no need to unset them properly (no `is_term` in Keymaps:unset()). 47 | local bufname = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()) 48 | local is_term = bufname:match("^term://") ~= nil 49 | for _, m in ipairs(default) do 50 | local keystroke = self.config:get(m.key) 51 | if keystroke ~= nil then 52 | if not is_term or m.term then 53 | vim.api.nvim_buf_set_keymap(vim.api.nvim_get_current_buf(), m.mode, 54 | keystroke, m.cmd .. '', {silent = true}) 55 | end 56 | end 57 | end 58 | end 59 | 60 | ---Undefine buffer-local keymaps for the jump window 61 | function Keymaps:unset() 62 | log.debug({"Keymaps:unset"}) 63 | for _, m in ipairs(default) do 64 | local keystroke = self.config:get(m.key) 65 | if keystroke ~= nil then 66 | pcall(vim.api.nvim_buf_del_keymap, vim.api.nvim_get_current_buf(), m.mode, keystroke) 67 | end 68 | end 69 | end 70 | 71 | local default_t = { 72 | { key = 'key_until', cmd = ':GdbUntil' }, 73 | { key = 'key_continue', cmd = ':GdbContinue' }, 74 | { key = 'key_next', cmd = ':GdbNext' }, 75 | { key = 'key_step', cmd = ':GdbStep' }, 76 | { key = 'key_finish', cmd = ':GdbFinish' }, 77 | { key = 'key_quit', cmd = ':GdbDebugStop' }, 78 | } 79 | 80 | ---Define term-local keymaps. 81 | function Keymaps:set_t() 82 | log.debug({"Keymaps:set_t"}) 83 | for _, m in ipairs(default_t) do 84 | local keystroke = self.config:get(m.key) 85 | if keystroke ~= nil then 86 | vim.api.nvim_buf_set_keymap(vim.api.nvim_get_current_buf(), 't', 87 | keystroke, [[]] .. m.cmd .. [[i]], {silent = true}) 88 | end 89 | end 90 | vim.api.nvim_buf_set_keymap(vim.api.nvim_get_current_buf(), 't', 91 | '', [[G]], {silent = true}) 92 | end 93 | 94 | ---Run by the configuration and call the appropriate keymap handler 95 | ---@param key string keymap routine like set_keymaps 96 | function Keymaps:_dispatch(key) 97 | log.debug({"Keymaps:_dispatch", key = key}) 98 | if self.dispatch_active then 99 | self.config:get_or(key, function(_) end)(self) 100 | end 101 | end 102 | 103 | ---Call the hook to set the keymaps. 104 | function Keymaps:dispatch_set() 105 | log.debug({"Keymaps:dispatch_set"}) 106 | self:_dispatch 'set_keymaps' 107 | end 108 | 109 | ---Call the hook to unset the keymaps. 110 | function Keymaps:dispatch_unset() 111 | log.debug({"Keymaps:dispatch_unset"}) 112 | self:_dispatch 'unset_keymaps' 113 | end 114 | 115 | ---Call the hook to set the terminal keymaps. 116 | function Keymaps:dispatch_set_t() 117 | log.debug({"Keymaps:dispatch_set_t"}) 118 | self:_dispatch 'set_tkeymaps' 119 | end 120 | 121 | return Keymaps 122 | -------------------------------------------------------------------------------- /lua/nvimgdb/log.lua: -------------------------------------------------------------------------------- 1 | local log = {} 2 | 3 | -- Log level dictionary with reverse lookup as well. 4 | -- 5 | -- Can be used to lookup the number from the name or the name from the number. 6 | -- Levels by name: 'trace', 'debug', 'info', 'warn', 'error' 7 | -- Level numbers begin with 'trace' at 0 8 | log.levels = { 9 | TRACE = 0; 10 | DEBUG = 1; 11 | INFO = 2; 12 | WARN = 3; 13 | ERROR = 4; 14 | CRIT = 5; 15 | } 16 | 17 | -- Default log level. 18 | log.current_log_level = (vim.env.CI == nil) and log.levels.CRIT or log.levels.DEBUG 19 | 20 | local logfilename = 'nvimgdb.log' 21 | local logfile = nil 22 | 23 | --- Returns the log filename. 24 | ---@return string log filename 25 | function log.get_filename() 26 | return logfilename 27 | end 28 | 29 | local get_logfile = function() 30 | if logfile == nil then 31 | logfile = assert(io.open(logfilename, "a+")) 32 | end 33 | return logfile 34 | end 35 | 36 | ---Set log file name 37 | ---@param filename string new log filename 38 | function log.set_filename(filename) 39 | if filename ~= logfilename then 40 | logfilename = filename 41 | if logfile ~= nil then 42 | logfile:close() 43 | logfile = nil 44 | end 45 | end 46 | end 47 | 48 | local log_date_format = "%F %H:%M:%S" 49 | 50 | do 51 | local function get_timestamp() 52 | local sec, usec = vim.loop.gettimeofday() 53 | return os.date(log_date_format, sec) .. "," .. string.format("%03d", math.floor(usec / 1000)) 54 | end 55 | 56 | for level, levelnr in pairs(log.levels) do 57 | -- Also export the log level on the root object. 58 | log[level] = levelnr 59 | -- FIXME: DOC 60 | -- Should be exposed in the vim docs. 61 | -- 62 | -- Set the lowercase name as the main use function. 63 | -- If called without arguments, it will check whether the log level is 64 | -- greater than or equal to this one. When called with arguments, it will 65 | -- log at that level (if applicable, it is checked either way). 66 | -- 67 | -- Recommended usage: 68 | -- ``` 69 | -- local _ = log.warn() and log.warn("123") 70 | -- ``` 71 | -- 72 | -- This way you can avoid string allocations if the log level isn't high enough. 73 | log[level:lower()] = function(...) 74 | local argc = select("#", ...) 75 | if levelnr < log.current_log_level then return false end 76 | if argc == 0 then return true end 77 | local info = debug.getinfo(2, "Sl") 78 | local src = info.short_src 79 | -- Chop off the long path prefix, just keep everything relative to lua/ or lib/ 80 | local suffix = src:match("l[ui][ab][/\\].+") 81 | if suffix ~= nil then 82 | src = suffix 83 | end 84 | 85 | local fileinfo = string.format("%s:%s", src, info.currentline) 86 | local parts = { table.concat({get_timestamp(), " [", level, "] ", fileinfo, ": "}, "") } 87 | for i = 1, argc do 88 | local arg = select(i, ...) 89 | if arg == nil then 90 | table.insert(parts, "nil") 91 | else 92 | table.insert(parts, vim.inspect(arg, {newline=''})) 93 | end 94 | end 95 | get_logfile():write(table.concat(parts, '\t'), "\n") 96 | get_logfile():flush() 97 | end 98 | end 99 | end 100 | 101 | -- This is put here on purpose after the loop above so that it doesn't 102 | -- interfere with iterating the levels 103 | -- vim.tbl_add_reverse_lookup(log.levels) 104 | log.levels[0] = "TRACE" 105 | log.levels[1] = "DEBUG" 106 | log.levels[2] = "INFO" 107 | log.levels[3] = "WARN" 108 | log.levels[4] = "ERROR" 109 | log.levels[5] = "CRIT" 110 | 111 | --- Sets the current log level. 112 | --@param level string|number One of `vim.lsp.log.levels` 113 | function log.set_level(level) 114 | if type(level) == 'string' then 115 | log.current_log_level = assert(log.levels[level:upper()], string.format("Invalid log level: %q", level)) 116 | else 117 | assert(type(level) == 'number', "level must be a number or string") 118 | assert(log.levels[level], string.format("Invalid log level: %d", level)) 119 | log.current_log_level = level 120 | end 121 | end 122 | 123 | --- Checks whether the level is sufficient for logging. 124 | --@param level number log level 125 | --@returns (bool) true if would log, false if not 126 | function log.should_log(level) 127 | return level >= log.current_log_level 128 | end 129 | 130 | return log 131 | -- vim:sw=2 ts=2 et 132 | -------------------------------------------------------------------------------- /lua/nvimgdb/parser_actions.lua: -------------------------------------------------------------------------------- 1 | -- Common FSM implementation for the integrated backends. 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require 'nvimgdb.log' 5 | 6 | ---@class ParserActions @parser callbacks handler 7 | ---@field private cursor Cursor @current line sign handler 8 | ---@field private win Win @jump window manager 9 | local ParserActions = {} 10 | ParserActions.__index = ParserActions 11 | 12 | ---Constructor 13 | ---@param cursor Cursor 14 | ---@param win Win 15 | ---@return ParserActions 16 | function ParserActions.new(cursor, win) 17 | log.debug({"ParserActions.new"}) 18 | local self = setmetatable({}, ParserActions) 19 | self.cursor = cursor 20 | self.win = win 21 | return self 22 | end 23 | 24 | ---Handle the program continued execution. Hide the cursor. 25 | function ParserActions:continue_program() 26 | log.debug({"ParserActions:continue_program"}) 27 | self.cursor:hide() 28 | vim.api.nvim_command("doautocmd User NvimGdbContinue") 29 | end 30 | 31 | ---Handle the program breaked. Show the source code. 32 | ---@param fname string full path to the source file 33 | ---@param line number line number 34 | function ParserActions:jump_to_source(fname, line) 35 | log.debug({"ParserActions:jump_to_source", fname = fname, line = line}) 36 | self.win:jump(fname, line) 37 | vim.api.nvim_command("doautocmd User NvimGdbBreak") 38 | end 39 | 40 | ---It's high time to query actual breakpoints. 41 | ---@async 42 | function ParserActions:query_breakpoints() 43 | log.debug({"ParserActions:query_breakpoints"}) 44 | self.win:query_breakpoints() 45 | -- Execute the rest of custom commands 46 | vim.api.nvim_command("doautocmd User NvimGdbQuery") 47 | end 48 | 49 | return ParserActions 50 | -------------------------------------------------------------------------------- /lua/nvimgdb/parser_impl.lua: -------------------------------------------------------------------------------- 1 | -- Common FSM implementation for the integrated backends. 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | 6 | ---@class ParserImpl base parser implementation 7 | ---@field protected actions ParserActions parser callbacks 8 | ---@field protected running ParserState running state transitions 9 | ---@field protected paused ParserState paused state transitions 10 | ---@field protected state ParserState current state (either running or paused) 11 | ---@field private buffer string debugger output collected so far 12 | ---@field private byte_count number monotonously increasing processed byte counter 13 | ---@field private parsing_progress number[] ordered byte counters to ensure parsing in the right order 14 | ---@field private timers table scheduled timers 15 | ---@field private output_is_still boolean true if no new output when parsing is delayed 16 | local ParserImpl = {} 17 | ParserImpl.__index = ParserImpl 18 | 19 | ---Initialization 20 | ---@param actions ParserActions parser callbacks 21 | function ParserImpl:_init(actions) 22 | log.debug({"ParserImpl:_init"}) 23 | self.actions = actions 24 | self.running = {} 25 | self.paused = {} 26 | -- Current state (either self.running or self.paused) 27 | self.state = self.paused 28 | self.buffer = '\n' 29 | self.byte_count = 1 30 | self.parsing_progress = {} 31 | self.timers = {} 32 | self.output_is_still = false 33 | end 34 | 35 | ---Destructor 36 | function ParserImpl:cleanup() 37 | -- Stop the remaining timers 38 | for timer, _ in pairs(self.timers) do 39 | timer:stop() 40 | timer:close() 41 | end 42 | self.timers = {} 43 | end 44 | 45 | ---@alias ParserState ParserTransition[] 46 | ---@alias ParserHandler function(m1:string, m2:string): ParserState 47 | 48 | ---@class ParserTransition 49 | ---@field public matcher string pattern to match in the debugger output 50 | ---@field public handler ParserHandler 51 | 52 | ---Add a new transition for a given state. 53 | ---@param state ParserState state to add a transition to 54 | ---@param matcher string pattern to look for in the buffer 55 | ---@param handler ParserHandler handler to invoke when a match is found 56 | function ParserImpl.add_trans(state, matcher, handler) 57 | log.debug({"ParserImpl.add_trans", state = state, matcher = matcher, handler = handler}) 58 | state[#state + 1] = {matcher = matcher, handler = handler} 59 | end 60 | 61 | ---Test whether the FSM is in the paused state. 62 | ---@return boolean true if parser is in the paused state 63 | function ParserImpl:is_paused() 64 | log.debug({"ParserImpl:is_paused"}) 65 | return self.state == self.paused 66 | end 67 | 68 | ---Test whether the FSM is in the running state. 69 | ---@return boolean true if parser is in the running state 70 | function ParserImpl:is_running() 71 | log.debug({"ParserImpl:is_running"}) 72 | return self.state == self.running 73 | end 74 | 75 | ---@return string current parser state name 76 | function ParserImpl:_get_state_name() 77 | log.debug({"ParserImpl:_get_state_name"}) 78 | if self.state == self.running then 79 | return "running" 80 | end 81 | if self.state == self.paused then 82 | return "paused" 83 | end 84 | return tostring(self.state) 85 | end 86 | 87 | ---From paused to running 88 | ---@return ParserState new parser state 89 | function ParserImpl:_paused_continue() 90 | log.info({"ParserImpl:_paused_continue"}) 91 | self.actions:continue_program() 92 | return self.running 93 | end 94 | 95 | ---In paused, show the source code 96 | ---@param fname string file name 97 | ---@param line string line number 98 | ---@return ParserState 99 | function ParserImpl:_paused_jump(fname, line) 100 | log.debug({"ParserImpl:_paused_jump", fname = fname, line = line}) 101 | -- Remove \r in case if path was too long and split by the backend 102 | local fname1 = fname:gsub("\r", "") 103 | if fname1 ~= fname then 104 | log.info({"Removing \\r from the file name", fname1}) 105 | fname = fname1 106 | end 107 | log.info("_paused_jump " .. fname .. ":" .. line) 108 | self.actions:jump_to_source(fname, assert(tonumber(line))) 109 | return self.paused 110 | end 111 | 112 | ---To paused 113 | ---@return ParserState 114 | function ParserImpl:_paused() 115 | log.info({"ParserImpl:_paused"}) 116 | return self.paused 117 | end 118 | 119 | ---Query breakpoints, to paused 120 | ---@return ParserState 121 | function ParserImpl:_query_b() 122 | log.info({"ParserImpl:_query_b"}) 123 | coroutine.resume(coroutine.create(function() 124 | self.actions:query_breakpoints() 125 | end)) 126 | return self.paused 127 | end 128 | 129 | ---Process a line of the debugger output through the FSM. 130 | ---It may be hard to guess when the backend started waiting for input, 131 | ---therefore parsing should be done asynchronously after a bit of delay. 132 | ---@param lines string[] input lines 133 | function ParserImpl:feed(lines) 134 | log.debug({"ParserImpl:feed", line = lines}) 135 | self.output_is_still = false 136 | local nl = '' 137 | for i, line in ipairs(lines) do 138 | if i == 1 and line == '' then 139 | nl = '\n' 140 | end 141 | -- Filter out control sequences 142 | line = line:gsub('\x1B[@-_][0-?]*[ -/]*[@-~]', '') 143 | 144 | self.buffer = self.buffer .. nl .. line 145 | self.byte_count = self.byte_count + #nl + #line 146 | nl = '\n' 147 | log.debug({"buffer", self.buffer}) 148 | end 149 | self.parsing_progress[#self.parsing_progress + 1] = self.byte_count 150 | self:_delay_parsing(50, self.byte_count) 151 | end 152 | 153 | ---@param delay_ms number number of milliseconds to wait before parsing 154 | ---@param byte_count number byte mark to allow parsing up to when the delay elapses 155 | function ParserImpl:_delay_parsing(delay_ms, byte_count) 156 | log.debug({"ParserImpl:_delay_parsing", delay_ms = delay_ms, byte_count = byte_count}) 157 | local timer = vim.loop.new_timer() 158 | self.timers[timer] = true 159 | timer:start(delay_ms, 0, vim.schedule_wrap(function() 160 | if self.timers[timer] ~= nil then 161 | self.timers[timer] = nil 162 | timer:stop() 163 | timer:close() 164 | self:delay_elapsed(byte_count) 165 | end 166 | end)) 167 | end 168 | 169 | ---Search through the buffer to find a match for a transition from the current state. 170 | ---@param ignore_tail_bytes number number of bytes at the end to ignore (grace period) 171 | ---@return boolean true if a match was found, means repeat searching immediately from a new state 172 | function ParserImpl:_search(ignore_tail_bytes) 173 | log.debug({"ParserImpl:_search", ignore_tail_bytes = ignore_tail_bytes}) 174 | -- If no new lines to parse have been received, mark that the output stabilized 175 | if ignore_tail_bytes == 0 then 176 | self.output_is_still = true 177 | end 178 | if #self.buffer <= ignore_tail_bytes then 179 | return false 180 | end 181 | -- If there is a matcher matching the line, call its handler. 182 | for _, mf in ipairs(self.state) do 183 | local b, e, m1, m2 = self.buffer:find(mf.matcher) 184 | if b ~= nil then 185 | if #self.buffer - e < ignore_tail_bytes then 186 | -- Wait a bit longer, the next timer is pending 187 | return false 188 | end 189 | self.buffer = self.buffer:sub(e + 1) 190 | log.debug("prev state: " .. self:_get_state_name()) 191 | self.state = mf.handler(self, m1, m2) 192 | log.info("new state: " .. self:_get_state_name()) 193 | return true 194 | end 195 | end 196 | return false 197 | end 198 | 199 | ---Grace period elapsed, can search for a transition from the current state. 200 | ---@param byte_count number byte mark up to which can search 201 | function ParserImpl:delay_elapsed(byte_count) 202 | log.debug({"ParserImpl:delay_elapsed", byte_count = byte_count}) 203 | if self.parsing_progress[1] ~= byte_count then 204 | -- Another parsing is already in progress, return to this mark later 205 | self:_delay_parsing(1, byte_count) 206 | return 207 | end 208 | -- Detect whether new input has been received before the previous 209 | -- delay elapsed. 210 | local ignore_tail_bytes = self.byte_count - byte_count 211 | while self:_search(ignore_tail_bytes) do 212 | end 213 | -- Pop the current mark allowing parsing the next chunk 214 | table.remove(self.parsing_progress, 1) 215 | end 216 | 217 | ---Return true if the output has paused for a while 218 | ---@return boolean true if no new output to parse during the parser delay 219 | function ParserImpl:is_still() 220 | return self.output_is_still 221 | end 222 | 223 | return ParserImpl 224 | -------------------------------------------------------------------------------- /lua/nvimgdb/proxy.lua: -------------------------------------------------------------------------------- 1 | -- Connection to the side channel. 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | local uv = vim.loop 6 | 7 | ---@class Proxy proxy to the side channel 8 | ---@field private client Client debugger terminal job 9 | ---@field private proxy_addr string path to the file with proxy port 10 | ---@field private sock any UDP socket used to communicate with the proxy 11 | ---@field private server_port number UDP port of the proxy 12 | ---@field private request_id number sequential request number 13 | ---@field private responses table received responses 14 | ---@field private responses_size number count of responses being waited 15 | local Proxy = {} 16 | Proxy.__index = Proxy 17 | 18 | ---Constructor 19 | ---@param client Client debugger terminal job 20 | ---@return Proxy 21 | function Proxy.new(client) 22 | log.debug({"Proxy.new"}) 23 | local self = setmetatable({}, Proxy) 24 | self.client = client 25 | self.proxy_addr = client:get_proxy_addr() 26 | 27 | self.sock = assert(uv.new_udp()) 28 | assert(self.sock:bind("127.0.0.1", 0)) 29 | -- Will connect to the socket later, when the first query is needed 30 | -- to be issued. 31 | self.server_port = nil 32 | 33 | self.request_id = 0 34 | self.responses = {} 35 | self.responses_size = 0 36 | 37 | return self 38 | end 39 | 40 | ---Destructor 41 | function Proxy:cleanup() 42 | log.debug({"Proxy:cleanup"}) 43 | if self.port ~= nil then 44 | self.sock:recv_stop() 45 | self.port = nil 46 | end 47 | if self.sock ~= nil then 48 | self.sock:close() 49 | self.sock = nil 50 | end 51 | end 52 | 53 | function Proxy:respond(response, is_async) 54 | local context = self.responses[response.request] 55 | if context ~= nil then 56 | self.responses_size = self.responses_size - 1 57 | self.responses[response.request] = nil 58 | context.timer:stop() 59 | context.timer:close() 60 | if is_async then 61 | vim.schedule(function() 62 | coroutine.resume(context.co, response.response) 63 | end) 64 | else 65 | return response.response 66 | end 67 | else 68 | log.warn({"Unexpected/outdated response", response = response, is_async = is_async}) 69 | end 70 | end 71 | 72 | ---Get the proxy port to prepare for communication 73 | ---@return boolean true if the port is available -- the proxy is ready 74 | function Proxy:_ensure_connected() 75 | log.debug({"function Proxy:_ensure_connected()"}) 76 | if self.server_port ~= nil then 77 | return true 78 | end 79 | local success, lines = pcall(io.lines, self.proxy_addr) 80 | if not success then 81 | log.warn({self.proxy_addr, 'not available yet', lines}) 82 | return false 83 | end 84 | local line = assert(lines()) 85 | self.server_port = assert(tonumber(line)) 86 | local res, errmsg = self.sock:recv_start(function(err, data, --[[addr]]_, --[[flags]]_) 87 | if err ~= nil then 88 | log.error({"Failed to receive response", err}) 89 | elseif data ~= nil then 90 | local response = vim.json.decode(data) 91 | self:respond(response, true) 92 | end 93 | end) 94 | if res == nil then 95 | log.error({"Failed to start receiving from proxy", errmsg}) 96 | self.server_port = nil 97 | return false 98 | end 99 | return true 100 | end 101 | 102 | ---Send a request to the proxy and wait for the response. 103 | ---@async 104 | ---@param request string command to the debugger proxy 105 | ---@return any response from the debugger proxy 106 | function Proxy:query(request) 107 | log.info({"Proxy:query", request = request}) 108 | 109 | if not self.client:get_is_active() then 110 | return nil 111 | end 112 | 113 | -- It takes time for the proxy to open a side channel. 114 | -- So we're connecting to the socket lazily during 115 | -- the first query. 116 | if not self:_ensure_connected() then 117 | log.error("Server port isn't known yet") 118 | return nil 119 | end 120 | 121 | if self.sock == nil then 122 | log.error({"No socket, likely a bug"}) 123 | return nil 124 | end 125 | 126 | if self.responses_size > 16 then 127 | log.debug({"Cleaning obsolete responses count=", #self.responses}) 128 | local cleaned_responses = {} 129 | local cleaned_responses_size = 0 130 | local deadline_id = self.request_id - 16 131 | for id, resp in pairs(self.responses) do 132 | if id >= deadline_id then 133 | cleaned_responses[id] = resp 134 | cleaned_responses_size = cleaned_responses_size + 1 135 | end 136 | end 137 | self.responses = cleaned_responses 138 | self.responses_size = cleaned_responses_size 139 | log.debug({"Responses after cleanup count=", self.responses_size}) 140 | end 141 | 142 | local request_id = self.request_id 143 | self.request_id = self.request_id + 1 144 | 145 | local co = coroutine.running() 146 | if co == nil then 147 | log.error({"Proxy should be used from a coroutine!", trace = debug.traceback()}) 148 | end 149 | 150 | local timer = uv.new_timer() 151 | timer:start(500, 0, function() 152 | log.warn({"Request timed out", request_id = request_id}) 153 | self:respond({request = request_id, response = {}}, true) 154 | end) 155 | 156 | self.responses[request_id] = {co = co, timer = timer} 157 | self.responses_size = self.responses_size + 1 158 | 159 | local res, errmsg = self.sock:send(request_id .. " " .. request, '127.0.0.1', self.server_port, function(err) 160 | if err ~= nil then 161 | log.warn({"Request failed", request_id = request_id, err = err}) 162 | self:respond({request = request_id, response = {}}, true) 163 | return 164 | end 165 | end) 166 | if res == nil then 167 | log.error({"Failed to send to proxy", errmsg}) 168 | return self:respond({request = request_id, response = {}}, false) 169 | end 170 | 171 | local response = coroutine.yield() 172 | return response 173 | end 174 | 175 | return Proxy 176 | -------------------------------------------------------------------------------- /lua/nvimgdb/utils.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | 3 | ---@class Utils 4 | ---@field public plugin_dir string Full path to the plugin directory 5 | ---@field public is_windows boolean 6 | ---@field public is_linux boolean 7 | ---@field public is_darwin boolean 8 | ---@field public fs_separator string path component separator in the file system 9 | 10 | local Utils = {} 11 | Utils.__index = Utils 12 | 13 | local function get_plugin_dir() 14 | local path = debug.getinfo(1).source:match("@(.*/)") 15 | return uv.fs_realpath(path .. '/../..') 16 | end 17 | 18 | Utils.plugin_dir = get_plugin_dir() 19 | 20 | -- true if in Windows, false otherwise 21 | Utils.is_windows = vim.loop.os_uname().sysname:find('Windows') ~= nil 22 | Utils.is_linux = vim.loop.os_uname().sysname:find('Linux') ~= nil 23 | Utils.is_darwin = vim.loop.os_uname().sysname:find('Darwin') ~= nil 24 | 25 | local function get_path_separator() 26 | local sep = '/' 27 | if Utils.is_windows then 28 | sep = '\\' 29 | end 30 | return sep 31 | end 32 | 33 | Utils.fs_separator = get_path_separator() 34 | 35 | ---Join path components 36 | ---@param path string path prefix 37 | ---@param ... string path components 38 | ---@return string @path with components separating according to the platform conventions 39 | Utils.path_join = function(path, ...) 40 | for _, name in ipairs({...}) do 41 | path = path .. Utils.fs_separator .. name 42 | end 43 | return path 44 | end 45 | 46 | ---Get full path of a file in the plugin directory 47 | ---@param ... string path components 48 | ---@return string full path to the file given its path components 49 | Utils.get_plugin_file_path = function(...) 50 | return Utils.path_join(Utils.plugin_dir, ...) 51 | end 52 | 53 | return Utils 54 | -------------------------------------------------------------------------------- /lua/nvimgdb/win.lua: -------------------------------------------------------------------------------- 1 | -- Jump window management. 2 | -- vim: set et ts=2 sw=2: 3 | 4 | local log = require'nvimgdb.log' 5 | 6 | ---@class Win jump window management 7 | ---@field private config Config resolved configuration 8 | ---@field private keymaps Keymaps dynamic keymap manager 9 | ---@field private cursor Cursor current line sign manager 10 | ---@field private client Client debugger terminal job 11 | ---@field private breakpoint Breakpoint breakpoint sign manager 12 | ---@field private jump_win number? window handle that will be displaying the current file 13 | ---@field private buffers table set of opened buffers to close automatically 14 | local Win = {} 15 | Win.__index = Win 16 | 17 | ---Constructor 18 | ---@param config Config resolved configuration 19 | ---@param keymaps Keymaps dynamic keymap manager 20 | ---@param cursor Cursor current line sign manager 21 | ---@param client Client debugger terminal job 22 | ---@param breakpoint Breakpoint breakpoint sign manager 23 | ---@param start_win number? window handle that could be used as the jump window 24 | ---@param edited_buf number? buffer handle that needs to be loaded by default 25 | ---@return Win new instance 26 | function Win.new(config, keymaps, cursor, client, breakpoint, start_win, edited_buf) 27 | log.debug({"Win.new", start_win = start_win, edited_buf = edited_buf}) 28 | local self = setmetatable({}, Win) 29 | self.config = config 30 | self.keymaps = keymaps 31 | self.cursor = cursor 32 | self.client = client 33 | self.breakpoint = breakpoint 34 | self.jump_win = start_win 35 | self.buffers = {} -- {buf -> true} 36 | 37 | self.last_jump_line = 0 38 | 39 | -- Create the default jump window 40 | self:_ensure_jump_window() 41 | 42 | -- The originally edited buffer may have been a new "[No Name]". 43 | -- The terminal buffer may be created with the same number. 44 | if edited_buf ~= nil and edited_buf ~= client:get_client_buf() then 45 | -- Load the originally edited buffer 46 | vim.api.nvim_win_set_buf(self.jump_win, edited_buf) 47 | end 48 | return self 49 | end 50 | 51 | ---Only makes sense if NvimGdb.global_cleanup() is called 52 | function Win:unset_keymaps() 53 | log.debug({"Win:unset_keymaps"}) 54 | if self:_has_jump_win() then 55 | self:_with_saved_win(true, function() 56 | vim.api.nvim_set_current_win(self.jump_win) 57 | pcall(self.keymaps.dispatch_unset, self.keymaps) 58 | end) 59 | end 60 | end 61 | 62 | ---Cleanup the windows and buffers. 63 | function Win:cleanup() 64 | log.debug({"Win:cleanup"}) 65 | for buf, _ in pairs(self.buffers) do 66 | vim.api.nvim_buf_delete(buf, {force = true}) 67 | end 68 | end 69 | 70 | ---Check whether the jump window is displayed. 71 | ---@return boolean true if jump window is visible 72 | function Win:_has_jump_win() 73 | log.debug({"Win:_has_jump_win"}) 74 | local wins = vim.api.nvim_tabpage_list_wins(vim.api.nvim_get_current_tabpage()) 75 | for _, w in ipairs(wins) do 76 | if w == self.jump_win then 77 | return true 78 | end 79 | end 80 | return false 81 | end 82 | 83 | ---Check whether the current buffer is displayed in the jump window. 84 | ---@return boolean 85 | function Win:is_jump_window_active() 86 | log.debug({"Win:is_jump_window_active"}) 87 | if not self:_has_jump_win() then 88 | return false 89 | end 90 | return vim.api.nvim_get_current_buf() == vim.api.nvim_win_get_buf(self.jump_win) 91 | end 92 | 93 | ---Execute function and return the cursor back. 94 | ---We're going to jump to another window and return. 95 | ---There may be no need to change keymaps forth and back. 96 | ---@param dispatch_keymaps boolean true to dispatch keymaps, false if not necessary 97 | ---@param func function() action to execute 98 | function Win:_with_saved_win(dispatch_keymaps, func) 99 | log.debug({"Win:_with_saved_win", dispatch_keymaps = dispatch_keymaps, func = func}) 100 | if not dispatch_keymaps then 101 | self.keymaps:set_dispatch_active(false) 102 | end 103 | local prev_win = vim.api.nvim_get_current_win() 104 | func() 105 | -- The window may disappear after func() 106 | if pcall(vim.api.nvim_set_current_win, prev_win) then 107 | if not dispatch_keymaps then 108 | self.keymaps:set_dispatch_active(true) 109 | end 110 | end 111 | end 112 | 113 | ---Execute function and restore the previous mode afterwards 114 | ---@param func function() action to execute 115 | function Win:_with_saved_mode(func) 116 | log.debug({"Win:_with_saved_mode", func = func}) 117 | local mode = vim.api.nvim_get_mode() 118 | func() 119 | if mode.mode:match("^[ti]$") ~= nil then 120 | vim.api.nvim_command("startinsert!") 121 | end 122 | end 123 | 124 | ---Ensure that the jump window is available. 125 | function Win:_ensure_jump_window() 126 | log.debug({"Win:_ensure_jump_window"}) 127 | if not self:_has_jump_win() then 128 | -- The jump window needs to be created first 129 | self:_with_saved_win(false, function() 130 | vim.api.nvim_command(self.config:get('codewin_command')) 131 | self.jump_win = vim.api.nvim_get_current_win() 132 | -- Remember the '[No name]' buffer for later cleanup 133 | self.buffers[vim.api.nvim_get_current_buf()] = true 134 | end) 135 | end 136 | end 137 | 138 | ---Ensure the scroll_off config parameter is observed in the jump window 139 | ---@param line number buffer line with the cursor 140 | ---@param scroll_off number number of the lines to keep off the window edge 141 | function Win:_adjust_jump_win_view(line, scroll_off) 142 | log.debug({"Win:_adjust_jump_win_view", line = line, scroll_off = scroll_off}) 143 | local wininfo = vim.fn.getwininfo(self.jump_win)[1] 144 | local botline = wininfo.botline 145 | local topline = wininfo.topline 146 | 147 | -- Try adjusting the scroll off value if the window is too low 148 | local win_height = botline - topline 149 | local max_scroll_off = (win_height - win_height % 2) / 2 150 | if max_scroll_off < scroll_off then 151 | scroll_off = max_scroll_off 152 | end 153 | 154 | if botline - topline <= scroll_off then 155 | return 156 | end 157 | 158 | -- Check for potential scroll off adjustments 159 | local new_topline = topline 160 | -- line - topline > scroll_off 161 | local top_gap = line - topline 162 | if top_gap < scroll_off then 163 | new_topline = new_topline - scroll_off + top_gap 164 | end 165 | 166 | -- botline - line > scroll_off 167 | local bottom_gap = botline - line 168 | if bottom_gap < scroll_off then 169 | new_topline = new_topline + scroll_off - (botline - line) 170 | end 171 | if new_topline < 1 then 172 | new_topline = 1 173 | end 174 | 175 | if new_topline ~= topline then 176 | vim.fn.winrestview({topline = new_topline}) 177 | end 178 | end 179 | 180 | ---Show the file and the current line in the jump window. 181 | ---@param file string full path to the source code 182 | ---@param line number line number 183 | function Win:jump(file, line) 184 | log.info({"Win:jump", file = file, line = line}) 185 | -- Check whether the file is already loaded or load it 186 | local target_buf = vim.fn.bufnr(file, 1) 187 | 188 | -- Ensure the jump window is available 189 | self:_with_saved_mode(function() 190 | self:_ensure_jump_window() 191 | end) 192 | 193 | -- The terminal buffer may contain the name of the source file 194 | -- (in pdb, for instance). 195 | if target_buf == self.client:get_buf() then 196 | self:_with_saved_win(true, function() 197 | vim.api.nvim_set_current_win(self.jump_win) 198 | target_buf = self:_open_file("noswapfile view " .. file) 199 | end) 200 | end 201 | 202 | if vim.api.nvim_win_get_buf(self.jump_win) ~= target_buf then 203 | self:_with_saved_mode(function() 204 | self:_with_saved_win(true, function() 205 | if self.jump_win ~= vim.api.nvim_get_current_win() then 206 | vim.api.nvim_set_current_win(self.jump_win) 207 | end 208 | -- Hide the current line sign when navigating away. 209 | self.cursor:hide() 210 | target_buf = self:_open_file("noswap e " .. file) 211 | end) 212 | end) 213 | end 214 | 215 | -- Goto the proper line and set the cursor on it 216 | self:_with_saved_win(false, function() 217 | vim.api.nvim_command(string.format("noa call nvim_set_current_win(%d)", self.jump_win)) 218 | 219 | -- If there is no required file or it has fewer lines, avoid settings cursor 220 | -- below the last line 221 | local max_line = vim.fn.line('$', self.jump_win) 222 | if line > max_line then 223 | line = max_line 224 | end 225 | 226 | -- Debounce jumping because of asynchronous querying 227 | if line ~= self.last_jump_line then 228 | vim.api.nvim_win_set_cursor(self.jump_win, {line, 0}) 229 | self.last_jump_line = line 230 | end 231 | 232 | self.cursor:set(target_buf, line) 233 | self.cursor:show() 234 | 235 | -- &scrolloff seems to have effect only in the interactive mode. 236 | -- So we'll have to adjust the view manually. 237 | local scroll_off = self.config:get_or('set_scroll_off', 1) 238 | self:_adjust_jump_win_view(line, scroll_off) 239 | vim.api.nvim_command("normal! zv") 240 | end) 241 | vim.api.nvim_command("redraw") 242 | end 243 | 244 | ---Test whether an item is in the list 245 | ---@param it any needle 246 | ---@param list any[] haystack 247 | ---@return boolean 248 | local function contains(it, list) 249 | for _, i in ipairs(list) do 250 | if i == it then 251 | return true 252 | end 253 | end 254 | return false 255 | end 256 | 257 | ---@param cmd string vim command to execute 258 | ---@return number newly opened buffer handle 259 | function Win:_open_file(cmd) 260 | log.debug({"Win:_open_file", cmd = cmd}) 261 | local open_buffers = vim.api.nvim_list_bufs() 262 | vim.api.nvim_command(cmd) 263 | local new_buffer = vim.api.nvim_get_current_buf() 264 | if not contains(new_buffer, open_buffers) then 265 | -- A new buffer was open specifically for debugging, 266 | -- remember it to close later. 267 | self.buffers[new_buffer] = true 268 | end 269 | return new_buffer 270 | end 271 | 272 | ---Show actual breakpoints in the current window. 273 | ---@async 274 | function Win:query_breakpoints() 275 | log.debug({"Win:query_breakpoints"}) 276 | -- Just notify the client that the breakpoints are being queried 277 | self.client:mark_has_interacted() 278 | 279 | if not self:_has_jump_win() then 280 | return 281 | end 282 | 283 | -- Get the source code buffer number 284 | local buf_num = vim.api.nvim_win_get_buf(self.jump_win) 285 | 286 | -- Get the source code file name 287 | local fname = vim.fn.expand('#' .. buf_num .. ':p') 288 | 289 | -- If no file name or a weird name with spaces, ignore it (to avoid 290 | -- misinterpretation) 291 | if fname ~= '' and fname:find(' ') == nil then 292 | -- Query the breakpoints for the shown file 293 | self.breakpoint:query(buf_num, fname) 294 | vim.api.nvim_command("redraw") 295 | end 296 | end 297 | 298 | ---Populate the location list with the result of debugger cmd. 299 | ---@param cmd string debugger command to execute 300 | ---@param mods string command modifiers like 'leftabove' 301 | function Win:lopen(cmd, mods) 302 | log.debug({"Win:lopen", cmd = cmd, mods = mods}) 303 | coroutine.resume(coroutine.create(function() 304 | local llist = NvimGdb.here:get_for_llist(cmd) 305 | self:_with_saved_mode(function() 306 | self:_with_saved_win(false, function() 307 | self:_ensure_jump_window() 308 | if self.jump_win ~= vim.api.nvim_get_current_win() then 309 | vim.api.nvim_set_current_win(self.jump_win) 310 | end 311 | vim.fn.setloclist(self.jump_win, {}, ' ', {lines = llist}) 312 | vim.api.nvim_command("exe 'normal ' | " .. mods .. " lopen") 313 | end) 314 | end) 315 | end)) 316 | end 317 | 318 | return Win 319 | -------------------------------------------------------------------------------- /plugin/nvimgdb.vim: -------------------------------------------------------------------------------- 1 | if 1 != has("nvim-0.9.0") 2 | nvim_err_writeln("nvimgdb requires at least nvim-0.9.0") 3 | finish 4 | endif 5 | 6 | if exists("g:loaded_nvimgdb") || !has("nvim") 7 | finish 8 | endif 9 | let g:loaded_nvimgdb = 1 10 | 11 | lua require'nvimgdb'.setup() 12 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | backends.txt 3 | -------------------------------------------------------------------------------- /test/02_cmake_spec.lua: -------------------------------------------------------------------------------- 1 | local eng = require'engine' 2 | local utils = require'nvimgdb.utils' 3 | local conftest = require'conftest' 4 | 5 | local function check_skip() 6 | if conftest.backend_names.cmake == nil then 7 | pending("CMake not configured") 8 | end 9 | end 10 | 11 | local cmake_test_exec = 'build/cmake_test_exec' 12 | if utils.is_windows then 13 | cmake_test_exec = 'build\\cmake_test_exec.exe' 14 | end 15 | 16 | describe("cmake", function() 17 | setup(function() 18 | eng.exe("cd src") 19 | eng.exe("e test.cpp") 20 | end) 21 | 22 | teardown(function() 23 | eng.exe("bw!") 24 | eng.exe("cd ..") 25 | end) 26 | 27 | it("guess", function() 28 | check_skip() 29 | local test_exec = {[cmake_test_exec] = true} 30 | local executables_of_buffer = require'nvimgdb.cmake'.executables_of_buffer 31 | local execs = executables_of_buffer('') 32 | assert.are.same(test_exec, execs) 33 | execs = executables_of_buffer('bu') 34 | assert.are.same(test_exec, execs) 35 | execs = executables_of_buffer('./bu') 36 | assert.are.same(test_exec, execs) 37 | execs = executables_of_buffer('./build/') 38 | assert.are.same(test_exec, execs) 39 | execs = executables_of_buffer('./build/cm') 40 | assert.are.same(test_exec, execs) 41 | execs = executables_of_buffer('./../src/build/cm') 42 | assert.are.same(test_exec, execs) 43 | end) 44 | 45 | it("find executables", function() 46 | local execs = require'nvimgdb.cmake'.find_executables('../') 47 | assert.are.same({['../' .. conftest.aout] = true}, execs) 48 | end) 49 | 50 | it("get executables", function() 51 | check_skip() 52 | local execs = require'nvimgdb.cmake'.get_executables('../') 53 | assert.are.same({[cmake_test_exec] = true, ['../' .. conftest.aout] = true}, execs) 54 | end) 55 | end) 56 | 57 | 58 | -------------------------------------------------------------------------------- /test/05_quit_spec.lua: -------------------------------------------------------------------------------- 1 | local thr = require'thread' 2 | local eng = require'engine' 3 | local conf = require'conftest' 4 | local utils = require'nvimgdb.utils' 5 | 6 | 7 | local function mysetup(backend, action) 8 | eng.feed(string.format(backend.launchF, "")) 9 | assert.is_true(eng.wait_paused()) 10 | eng.feed("") 11 | 12 | action(backend) 13 | 14 | assert.are.equal(1, vim.fn.tabpagenr('$'), "No rogue tabpages") 15 | end 16 | 17 | local function mysetup_bufcheck(backend, action) 18 | local buffers = eng.get_buffers() 19 | 20 | mysetup(backend, action) 21 | 22 | eng.wait_for( 23 | function() return eng.get_buffers() end, 24 | function(r) return vim.deep_equal(buffers, r) end 25 | ) 26 | assert.are.same(buffers, eng.get_buffers(), "No new rogue buffers") 27 | end 28 | 29 | describe("quit", function() 30 | conf.backend(function(backend) 31 | it(backend.name .. " using command GdbDebugStop", function() 32 | mysetup_bufcheck(backend, function() 33 | eng.exe("GdbDebugStop") 34 | end) 35 | end) 36 | 37 | it(backend.name .. " when EOF with ctrl-d", function() 38 | mysetup_bufcheck(backend, function() 39 | if utils.is_windows and backend.name == 'lldb' then 40 | -- lldb doesn't like ^D in Windows 41 | eng.feed("iquit") 42 | else 43 | eng.feed("i") 44 | end 45 | eng.feed("") 46 | end) 47 | end) 48 | 49 | it(backend.name .. " terminal survives closing", function() 50 | mysetup_bufcheck(backend, function() 51 | assert.equal(2, #vim.api.nvim_list_wins()) 52 | eng.feed(":q") 53 | assert.equal(2, #vim.api.nvim_list_wins()) 54 | eng.feed(":GdbDebugStop") 55 | end) 56 | end) 57 | 58 | it(backend.name .. " terminal can be closed", function() 59 | -- Disable terminal stickiness. 60 | vim.g.nvimgdb_sticky_dbg_buf = false 61 | mysetup_bufcheck(backend, function() 62 | assert.equal(2, #vim.api.nvim_list_wins()) 63 | eng.feed(":q") 64 | assert.equal(1, #vim.api.nvim_list_wins()) 65 | eng.feed(":GdbDebugStop") 66 | end) 67 | vim.g.nvimgdb_sticky_dbg_buf = nil 68 | end) 69 | 70 | it(backend.name .. " when tabpage is closed", function() 71 | mysetup(backend, function() 72 | eng.feed(string.format(backend.launchF, "")) 73 | if utils.is_windows and backend.name == 'lldb' then 74 | thr.y(500) 75 | end 76 | assert.is_true(eng.wait_paused()) 77 | eng.feed('') 78 | eng.feed(":tabclose") 79 | eng.feed(":GdbDebugStop") 80 | end) 81 | end) 82 | end) 83 | end) 84 | -------------------------------------------------------------------------------- /test/10_generic_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | local thr = require'thread' 4 | local utils = require'nvimgdb.utils' 5 | 6 | 7 | describe("generic", function() 8 | conf.backend(function(backend) 9 | it(backend.name .. ' smoke', function() 10 | conf.post_terminal_end(function() 11 | eng.feed(backend.launch) 12 | assert.is_true(eng.wait_paused()) 13 | eng.feed(backend.tbreak_main) 14 | eng.feed('run') 15 | eng.feed('') 16 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 17 | 18 | eng.feed('') 19 | assert.is_true(eng.wait_signs({cur = 'test.cpp:19'})) 20 | 21 | eng.feed('') 22 | assert.is_true(eng.wait_signs({cur = 'test.cpp:10'})) 23 | 24 | eng.feed('') 25 | assert.is_true(eng.wait_signs({cur = 'test.cpp:19'})) 26 | 27 | eng.feed('') 28 | assert.is_true(eng.wait_signs({cur = 'test.cpp:10'})) 29 | 30 | eng.feed('') 31 | 32 | local function check_signs(signs) 33 | -- different for different compilers 34 | local lines = {17, 19, 20} 35 | for _, line in ipairs(lines) do 36 | if vim.deep_equal(signs, {cur = 'test.cpp:' .. line}) then 37 | return true 38 | end 39 | end 40 | return false 41 | end 42 | assert.is_true(eng.wait_for(eng.get_signs, check_signs)) 43 | 44 | eng.feed('') 45 | assert.is_true(eng.wait_signs({})) 46 | end) 47 | end) 48 | 49 | it(backend.name .. ' breaks', function() 50 | conf.post_terminal_end(function() 51 | eng.feed(backend.launch) 52 | assert.is_true(eng.wait_paused()) 53 | eng.feed('w') 54 | eng.feed(":e src/test.cpp\n") 55 | eng.feed(':5') 56 | eng.feed('') 57 | assert.is_true(eng.wait_signs({brk = {[1] = {5}}})) 58 | 59 | eng.exe("GdbRun") 60 | assert.is_true(eng.wait_signs({cur = 'test.cpp:5', brk = {[1] = {5}}})) 61 | 62 | eng.feed('') 63 | assert.is_true(eng.wait_signs({cur = 'test.cpp:5'})) 64 | end) 65 | end) 66 | 67 | it(backend.name .. ' interrupt', function() 68 | conf.post_terminal_end(function() 69 | eng.feed(backend.launch) 70 | assert.is_true(eng.wait_paused()) 71 | if utils.is_windows and backend.name == 'lldb' then 72 | thr.y(0, vim.cmd("GdbDebugStop")) 73 | pending("LLDB shows prompt even while the target is running") 74 | end 75 | eng.feed('run 4294967295') 76 | eng.feed('') 77 | assert.is_true(eng.wait_running(5000)) 78 | eng.feed(':GdbInterrupt\n') 79 | if utils.is_linux then 80 | assert.is_true(eng.wait_signs({cur = 'test.cpp:22'})) 81 | else 82 | -- Most likely to break in the kernel code 83 | end 84 | assert.is_true(eng.wait_paused()) 85 | end) 86 | end) 87 | 88 | it(backend.name .. ' until', function() 89 | conf.post_terminal_end(function() 90 | eng.feed(backend.launch) 91 | assert.is_true(eng.wait_paused()) 92 | eng.feed(backend.tbreak_main) 93 | eng.feed('run') 94 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 95 | eng.feed('') 96 | eng.feed('w') 97 | eng.feed(':21') 98 | assert.is_true(eng.wait_cursor(21)) 99 | eng.feed('') 100 | assert.is_true(eng.wait_signs({cur = 'test.cpp:21'})) 101 | end) 102 | end) 103 | 104 | it(backend.name .. ' program exit', function() 105 | conf.post_terminal_end(function() 106 | eng.feed(backend.launch) 107 | assert.is_true(eng.wait_paused()) 108 | eng.feed(backend.break_main) 109 | eng.feed('') 110 | eng.feed(':Gdb run\n') 111 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 112 | eng.feed('') 113 | if utils.is_windows and backend.name == 'lldb' then 114 | -- Llldb can't resolve the breakpoint's location anymore 115 | assert.is_true(eng.wait_signs({})) 116 | else 117 | assert.is_true(eng.wait_signs({brk = {[1] = {17}}})) 118 | end 119 | 120 | -- One more time 121 | eng.feed(':Gdb run\n') 122 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 123 | eng.feed('') 124 | if utils.is_windows and backend.name == 'lldb' then 125 | -- Llldb can't resolve the breakpoint's location anymore 126 | assert.is_true(eng.wait_signs({})) 127 | else 128 | assert.is_true(eng.wait_signs({brk = {[1] = {17}}})) 129 | end 130 | end) 131 | end) 132 | 133 | it(backend.name .. ' eval ', function() 134 | conf.post_terminal_end(function() 135 | eng.feed(backend.launch) 136 | assert.is_true(eng.wait_paused()) 137 | eng.feed(backend.tbreak_main) 138 | eng.feed('run') 139 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 140 | eng.feed('') 141 | eng.feed('w') 142 | assert.is_true(eng.wait_cursor(17)) 143 | eng.feed('') 144 | assert.is_true(eng.wait_signs({cur = 'test.cpp:19'})) 145 | 146 | eng.feed('^') 147 | assert.equals('print Foo', NvimGdb.here._last_command) 148 | 149 | eng.feed('/Lib::Baz\n') 150 | assert.is_true(eng.wait_cursor(21)) 151 | eng.feed('vt(') 152 | eng.feed(':GdbEvalRange\n') 153 | assert.equals('print Lib::Baz', NvimGdb.here._last_command) 154 | end) 155 | end) 156 | 157 | it(backend.name .. ' navigating to another file', function() 158 | conf.post_terminal_end(function() 159 | eng.feed(backend.launch) 160 | assert.is_true(eng.wait_paused()) 161 | eng.feed(backend.tbreak_main) 162 | eng.feed('run') 163 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 164 | eng.feed('') 165 | eng.feed('w') 166 | eng.feed('/Lib::Baz\n') 167 | assert.is_true(eng.wait_cursor(21)) 168 | eng.feed('') 169 | assert.is_true(eng.wait_signs({cur = 'test.cpp:21'})) 170 | eng.feed('') 171 | assert.is_true(eng.wait_signs({cur = 'lib.hpp:7'})) 172 | 173 | eng.feed('') 174 | assert.is_true(eng.wait_signs({cur = 'lib.hpp:8'})) 175 | end) 176 | end) 177 | 178 | it(backend.name .. ' repeat last command on empty input', function() 179 | conf.post_terminal_end(function() 180 | eng.feed(backend.launch) 181 | assert.is_true(eng.wait_paused()) 182 | eng.feed(backend.tbreak_main) 183 | eng.feed('run') 184 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 185 | 186 | eng.feed('n') 187 | assert.is_true(eng.wait_signs({cur = 'test.cpp:19'})) 188 | eng.feed('') 189 | 190 | if utils.is_darwin then 191 | local function check_signs(signs) 192 | -- different for different compilers 193 | local lines = {17, 20} 194 | for _, line in ipairs(lines) do 195 | if vim.deep_equal(signs, {cur = 'test.cpp:' .. line}) then 196 | return true 197 | end 198 | end 199 | return false 200 | end 201 | assert.is_true(eng.wait_for(eng.get_signs, check_signs)) 202 | else 203 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 204 | end 205 | end) 206 | end) 207 | 208 | it(backend.name .. ' scrolloff is respected in the jump window', function() 209 | conf.post_terminal_end(function() 210 | conf.count_stops(function(count_stops) 211 | eng.feed(backend.launch) 212 | assert.is_true(eng.wait_paused()) 213 | count_stops.reset() 214 | eng.feed(backend.tbreak_main) 215 | eng.feed('run') 216 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 217 | eng.feed('') 218 | 219 | local function check_margin() 220 | local jump_win = NvimGdb.here.win.jump_win 221 | local wininfo = vim.fn.getwininfo(jump_win)[1] 222 | local curline = vim.api.nvim_win_get_cursor(jump_win)[1] 223 | local signline = tonumber(vim.split(eng.get_signs().cur, ':')[2]) 224 | assert.equals(signline, curline) 225 | local botline = wininfo.botline - 3 226 | assert.is_true(curline <= botline, string.format("curline=%d <= botline=%d", curline, botline)) 227 | local topline = wininfo.topline + 3 228 | assert.is_true(curline >= topline, string.format("curline=%d >= topline=%d", curline, topline)) 229 | end 230 | 231 | check_margin() 232 | count_stops.reset() 233 | eng.feed('') 234 | assert.is_true(count_stops.wait(1)) 235 | check_margin() 236 | eng.feed('') 237 | assert.is_true(count_stops.wait(2)) 238 | check_margin() 239 | end) 240 | end) 241 | end) 242 | 243 | end) 244 | end) 245 | -------------------------------------------------------------------------------- /test/15_multiview_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | 4 | 5 | describe("generic", function() 6 | 7 | local backends = {} 8 | if conf.backends.gdb ~= nil then 9 | table.insert(backends, conf.backends.gdb) 10 | end 11 | if conf.backends.lldb ~= nil then 12 | table.insert(backends, conf.backends.lldb) 13 | end 14 | if #backends < 2 then 15 | table.insert(backends, backends[1]) 16 | end 17 | if #backends == 0 then 18 | pending("No usable C++ debugger backends") 19 | end 20 | 21 | it('multiple views ' .. backends[1].name .. "+" .. backends[2].name, function() 22 | conf.post_terminal_end(function() 23 | local back1, back2 = unpack(backends) 24 | 25 | -- Launch the first backend 26 | eng.feed(back1.launch) 27 | assert.is_true(eng.wait_paused()) 28 | eng.feed(back1.tbreak_main) 29 | eng.feed('run') 30 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 31 | eng.feed('') 32 | eng.feed('w') 33 | eng.feed(':11') 34 | assert.is_true(eng.wait_cursor(11)) 35 | eng.feed('') 36 | eng.feed('') 37 | eng.feed('') 38 | 39 | assert.is_true(eng.wait_signs({cur = 'test.cpp:10', brk = {[1] = {11}}})) 40 | 41 | -- Then launch the second backend 42 | eng.feed(back2.launch) 43 | assert.is_true(eng.wait_paused()) 44 | eng.feed(back2.tbreak_main) 45 | eng.feed('run') 46 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 47 | eng.feed('') 48 | eng.feed('w') 49 | eng.feed(':5') 50 | assert.is_true(eng.wait_cursor(5)) 51 | eng.feed('') 52 | eng.feed(':12') 53 | assert.is_true(eng.wait_cursor(12)) 54 | eng.feed('') 55 | eng.feed('') 56 | 57 | assert.is_true(eng.wait_signs({cur = 'test.cpp:19', brk = {[1] = {5, 12}}})) 58 | 59 | -- Switch to the first backend 60 | eng.feed('1gt') 61 | assert.is_true(eng.wait_signs({cur = 'test.cpp:10', brk = {[1] = {11}}})) 62 | 63 | -- Quit 64 | eng.feed(':GdbDebugStop') 65 | 66 | -- Switch back to the second backend 67 | eng.feed('2gt') 68 | assert.is_true(eng.wait_signs({cur = 'test.cpp:19', brk = {[1] = {5, 12}}})) 69 | 70 | -- The last debugger is quit automatically 71 | end) 72 | end) 73 | 74 | end) 75 | -------------------------------------------------------------------------------- /test/20_breakpoint_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | local utils = require'nvimgdb.utils' 4 | 5 | local uv = vim.loop 6 | 7 | describe("breakpoint", function() 8 | conf.backend(function(backend) 9 | 10 | it(backend.name .. ' manual breakpoint is detected', function() 11 | conf.post_terminal_end(function() 12 | eng.feed(backend.launch) 13 | assert.is_true(eng.wait_paused()) 14 | eng.feed(backend.break_main) 15 | eng.feed('run') 16 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 17 | end) 18 | end) 19 | 20 | ---Fixture to change directory temporarily. 21 | local function cd_tmp(action) 22 | local old_dir = uv.fs_realpath('.') 23 | local tmp_dir = uv.fs_mkdtemp(uv.os_tmpdir() .. '/nvimgdb-test-XXXXXX') 24 | vim.loop.chdir(tmp_dir) 25 | action(utils.path_join(old_dir, conf.aout)) 26 | uv.chdir(old_dir) 27 | uv.fs_rmdir(tmp_dir) 28 | end 29 | 30 | it(backend.name .. ' manual breakpoint is detected from a random directory', function() 31 | conf.post_terminal_end(function() 32 | cd_tmp(function(aout_path) 33 | eng.feed(string.format(backend.launchF, aout_path)) 34 | assert.is_true(eng.wait_paused()) 35 | eng.feed(backend.break_main) 36 | eng.feed('run') 37 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 38 | end) 39 | end) 40 | end) 41 | 42 | it(backend.name .. ' breakpoints stay when source code is navigated', function() 43 | -- Verify that breakpoints stay when source code is navigated. 44 | conf.post_terminal_end(function() 45 | eng.feed(backend.launch) 46 | assert.is_true(eng.wait_paused()) 47 | eng.feed(backend.break_bar) 48 | eng.feed(":wincmd w") 49 | eng.feed(":e src/test.cpp\n") 50 | eng.feed(":10") 51 | eng.feed("") 52 | 53 | assert.is_true(eng.wait_signs({brk = {[1] = {5, 10}}})) 54 | 55 | -- Go to another file 56 | eng.feed(":e src/lib.hpp\n") 57 | assert.is.same({}, eng.get_signs()) 58 | eng.feed(":8\n") 59 | eng.feed("") 60 | assert.is_true(eng.wait_signs({brk = {[1] = {8}}})) 61 | 62 | -- Return to the first file 63 | eng.feed(":e src/test.cpp\n") 64 | assert.is_true(eng.wait_signs({brk = {[1] = {5, 10}}})) 65 | end) 66 | end) 67 | 68 | it(backend.name .. ' can clear all breakpoints', function() 69 | conf.post_terminal_end(function() 70 | eng.feed(backend.launch) 71 | assert.is_true(eng.wait_paused()) 72 | eng.feed(backend.break_bar) 73 | eng.feed(backend.break_main) 74 | eng.feed(":wincmd w") 75 | eng.feed(":e src/test.cpp\n") 76 | eng.feed(":10") 77 | eng.feed("") 78 | 79 | assert.is_true(eng.wait_signs({brk = {[1] = {5, 10, 17}}})) 80 | 81 | eng.feed(":GdbBreakpointClearAll\n") 82 | assert.is_true(eng.wait_signs({})) 83 | end) 84 | end) 85 | 86 | it(backend.name .. ' duplicate breakpoints are displayed distinctively', function() 87 | conf.post_terminal_end(function() 88 | eng.feed(backend.launch) 89 | assert.is_true(eng.wait_paused()) 90 | eng.feed(backend.break_main) 91 | eng.feed('run') 92 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 93 | eng.feed(backend.break_main) 94 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[2] = {17}}})) 95 | eng.feed(backend.break_main) 96 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[3] = {17}}})) 97 | eng.feed(":wincmd w") 98 | eng.feed(":17") 99 | eng.feed("") 100 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[2] = {17}}})) 101 | eng.feed("") 102 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 103 | eng.feed("") 104 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 105 | end) 106 | end) 107 | 108 | it(backend.name .. ' watchpoint transitions to paused', function() 109 | conf.post_terminal_end(function() 110 | if vim.env.GITHUB_WORKFLOW ~= nil and backend.name == 'lldb' then 111 | pending("Known to fail in GitHub actions") 112 | end 113 | eng.feed(backend.launch) 114 | assert.is_true(eng.wait_paused()) 115 | eng.feed(backend.break_main) 116 | eng.feed('run') 117 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 118 | eng.feed(backend.watchF:format('i')) 119 | eng.feed('cont') 120 | assert.is_true(eng.wait_paused()) 121 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17', brk = {[1] = {17}}})) 122 | end) 123 | end) 124 | 125 | end) 126 | end) 127 | -------------------------------------------------------------------------------- /test/30_pdb_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | 4 | describe("pdb", function() 5 | 6 | it('generic use case', function() 7 | conf.post_terminal_end(function() 8 | eng.feed(' dp') 9 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 10 | eng.feed('tbreak _main') 11 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {14}}})) 12 | eng.feed('cont') 13 | assert.is_true(eng.wait_signs({cur = 'main.py:15'})) 14 | eng.feed('') 15 | 16 | eng.feed('') 17 | assert.is_true(eng.wait_signs({cur = 'main.py:16'})) 18 | 19 | eng.feed('') 20 | assert.is_true(eng.wait_signs({cur = 'main.py:8'})) 21 | 22 | eng.feed('') 23 | assert.is_true(eng.wait_signs({cur = 'main.py:16'})) 24 | 25 | eng.feed('') 26 | assert.is_true(eng.wait_signs({cur = 'main.py:8'})) 27 | 28 | eng.feed('') 29 | assert.is_true(eng.wait_signs({cur = 'main.py:10'})) 30 | 31 | eng.feed('') 32 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 33 | end) 34 | end) 35 | 36 | it('toggling breakpoints', function() 37 | conf.post_terminal_end(function() 38 | eng.feed(' dp') 39 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 40 | eng.feed('') 41 | 42 | eng.feed('k') 43 | eng.feed(':5') 44 | assert.is_true(eng.wait_cursor(5)) 45 | eng.feed('') 46 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {5}}})) 47 | 48 | eng.exe('GdbContinue') 49 | assert.is_true(eng.wait_signs({cur = 'main.py:5', brk = {[1] = {5}}})) 50 | 51 | eng.feed('') 52 | assert.is_true(eng.wait_signs({cur = 'main.py:5'})) 53 | end) 54 | end) 55 | 56 | it('toggling breakpoints while navigating', function() 57 | conf.post_terminal_end(function() 58 | eng.feed(' dp') 59 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 60 | eng.feed('') 61 | 62 | eng.feed('w') 63 | eng.feed(':5') 64 | assert.is_true(eng.wait_cursor(5)) 65 | eng.feed('') 66 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {5}}})) 67 | 68 | -- Go to another file 69 | eng.feed(':e lib.py') 70 | eng.feed(':5') 71 | assert.is_true(eng.wait_cursor(5)) 72 | eng.feed('') 73 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {5}}})) 74 | eng.feed(':7') 75 | assert.is_true(eng.wait_cursor(7)) 76 | eng.feed('') 77 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {5, 7}}})) 78 | 79 | -- Return to the original file 80 | eng.feed(':e main.py') 81 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {5}}})) 82 | end) 83 | end) 84 | 85 | it('run until line', function() 86 | conf.post_terminal_end(function() 87 | eng.feed(' dp') 88 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 89 | eng.feed('tbreak _main') 90 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {14}}})) 91 | eng.feed('cont') 92 | assert.is_true(eng.wait_signs({cur = 'main.py:15'})) 93 | eng.feed('') 94 | 95 | eng.feed('w') 96 | eng.feed(':18') 97 | assert.is_true(eng.wait_cursor(18)) 98 | eng.feed('') 99 | assert.is_true(eng.wait_signs({cur = 'main.py:18'})) 100 | end) 101 | end) 102 | 103 | it('eval ', function() 104 | conf.post_terminal_end(function() 105 | eng.feed(' dp') 106 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 107 | eng.feed('tbreak _main') 108 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {14}}})) 109 | eng.feed('cont') 110 | assert.is_true(eng.wait_signs({cur = 'main.py:15'})) 111 | eng.feed('') 112 | eng.feed('w') 113 | eng.feed('') 114 | assert.is_true(eng.wait_signs({cur = 'main.py:16'})) 115 | 116 | eng.feed('^') 117 | assert.equals('print(_foo)', NvimGdb.here._last_command) 118 | 119 | eng.feed('viW') 120 | eng.feed(':GdbEvalRange') 121 | assert.equals('print(_foo(i))', NvimGdb.here._last_command) 122 | end) 123 | end) 124 | 125 | it('launch expand()', function() 126 | conf.post_terminal_end(function() 127 | eng.feed(':e main.py') -- Open a file to activate % 128 | eng.feed(' dp') 129 | -- Substitute main.py by % and launch 130 | eng.feed('%') 131 | -- Ensure a debugging session has started 132 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 133 | -- Clean up the main tabpage 134 | eng.feed('w') 135 | eng.exe('bw!') 136 | end) 137 | end) 138 | 139 | it('the last command is repeated on empty input', function() 140 | conf.post_terminal_end(function() 141 | eng.feed(' dp') 142 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 143 | eng.feed('tbreak _main') 144 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {14}}})) 145 | eng.feed('cont') 146 | assert.is_true(eng.wait_signs({cur = 'main.py:15'})) 147 | 148 | eng.feed('n') 149 | assert.is_true(eng.wait_signs({cur = 'main.py:16'})) 150 | eng.feed('') 151 | assert.is_true(eng.wait_signs({cur = 'main.py:15'})) 152 | end) 153 | end) 154 | 155 | end) 156 | -------------------------------------------------------------------------------- /test/40_keymap_spec.lua: -------------------------------------------------------------------------------- 1 | local eng = require'engine' 2 | local conf = require'conftest' 3 | 4 | describe("keymaps", function() 5 | 6 | local backend = nil 7 | if conf.backends.gdb ~= nil then 8 | backend = conf.backends.gdb 9 | elseif conf.backends.lldb ~= nil then 10 | backend = conf.backends.lldb 11 | end 12 | if backend == nil then 13 | pending("No usable C++ debugger backends") 14 | end 15 | 16 | local config_test = conf.config_test 17 | 18 | local function keymap_hooks() 19 | -- This function can be used as an example of how to add custom keymaps 20 | -- Flags for test keymaps 21 | vim.g.test_tkeymap = 0 22 | vim.g.test_keymap = 0 23 | 24 | -- A hook function to set keymaps in the terminal window 25 | local function my_set_tkeymaps() 26 | NvimGdb.here.keymaps:set_t() 27 | vim.cmd([[tnoremap ~tkm :let g:test_tkeymap = 1i]]) 28 | end 29 | 30 | -- A hook function to key keymaps in the code window. 31 | -- Will be called every time the code window is entered 32 | local function my_set_keymaps() 33 | -- First set up the stock keymaps 34 | NvimGdb.here.keymaps:set() 35 | 36 | -- Then there can follow any additional custom keymaps. For example, 37 | -- One custom programmable keymap needed in some tests 38 | vim.cmd([[nnoremap ~tn :let g:test_keymap = 1]]) 39 | end 40 | 41 | -- A hook function to unset keymaps in the code window 42 | -- Will be called every time the code window is left 43 | local function my_unset_keymaps() 44 | -- Unset the custom programmable keymap created in MySetKeymap 45 | vim.cmd([[nunmap ~tn]]) 46 | 47 | -- Then unset the stock keymaps 48 | NvimGdb.here.keymaps:unset() 49 | end 50 | 51 | -- Declare in the configuration that there are custom keymap handlers 52 | vim.g.nvimgdb_config_override = { 53 | set_tkeymaps = my_set_tkeymaps, 54 | set_keymaps = my_set_keymaps, 55 | unset_keymaps = my_unset_keymaps, 56 | } 57 | end 58 | 59 | it("custom programmable keymaps", function() 60 | config_test(function() 61 | keymap_hooks() 62 | eng.feed(backend.launchF:format("")) 63 | 64 | assert.equals(0, vim.g.test_tkeymap) 65 | eng.feed('~tkm') 66 | assert.is_true(eng.wait_for(function() return vim.g.test_tkeymap end, function(v) return v == 1 end)) 67 | eng.feed('') 68 | assert.equals(0, vim.g.test_keymap) 69 | eng.feed('~tn') 70 | assert.is_true(eng.wait_for(function() return vim.g.test_keymap end, function(v) return v == 1 end)) 71 | vim.g.test_tkeymap = 0 72 | vim.g.test_keymap = 0 73 | eng.feed('w') 74 | assert.equals(0, vim.g.test_keymap) 75 | eng.feed('~tn') 76 | assert.is_true(eng.wait_for(function() return vim.g.test_keymap end, function(v) return v == 1 end)) 77 | eng.exe('let g:test_keymap = 0') 78 | end) 79 | end) 80 | 81 | it("conflicting keymap", function() 82 | config_test(function() 83 | vim.g.nvimgdb_config = {key_next = '', key_prev = ''} 84 | eng.feed(backend.launchF:format("")) 85 | 86 | local count = 0 87 | for key, _ in pairs(NvimGdb.here.config.config) do 88 | if key:match("^key_.*") ~= nil then 89 | count = count + 1 90 | end 91 | end 92 | 93 | assert.equals(1, count) 94 | -- Check that the cursor is moving freely without stucking 95 | eng.feed('') 96 | eng.feed('w') 97 | eng.feed('w') 98 | end) 99 | end) 100 | 101 | it("override a key", function() 102 | config_test(function() 103 | vim.g.nvimgdb_config_override = {key_next = ''} 104 | eng.feed(backend.launchF:format("")) 105 | local key = NvimGdb.here.config:get("key_next") 106 | assert.equals('', key) 107 | end) 108 | end) 109 | 110 | it("override assumes priority in a conflict", function() 111 | config_test(function() 112 | vim.g.nvimgdb_config_override = {key_next = ''} 113 | eng.feed(backend.launchF:format("")) 114 | local res = NvimGdb.here.config:get_or("key_breakpoint", 0) 115 | assert.equals(0, res) 116 | end) 117 | end) 118 | 119 | it("override a single key", function() 120 | config_test(function() 121 | vim.g.nvimgdb_key_next = '' 122 | eng.feed(backend.launchF:format("")) 123 | local key = NvimGdb.here.config:get_or("key_next", 0) 124 | assert.equals('', key) 125 | end) 126 | end) 127 | 128 | it("override a single key, priority", function() 129 | config_test(function() 130 | vim.g.nvimgdb_key_next = '' 131 | eng.feed(backend.launchF:format("")) 132 | local res = NvimGdb.here.config:get_or("key_breakpoint", 0) 133 | assert.equals(0, res) 134 | end) 135 | end) 136 | 137 | it("smoke", function() 138 | config_test(function() 139 | vim.g.nvimgdb_config_override = {key_next = ''} 140 | vim.g.nvimgdb_key_step = '' 141 | eng.feed(backend.launchF:format("")) 142 | assert.equals(0, NvimGdb.here.config:get_or("key_continue", 0)) 143 | assert.equals(0, NvimGdb.here.config:get_or("key_next", 0)) 144 | assert.equals('', NvimGdb.here.config:get_or("key_step", 0)) 145 | end) 146 | end) 147 | 148 | end) 149 | -------------------------------------------------------------------------------- /test/45_layout_spec.lua: -------------------------------------------------------------------------------- 1 | local eng = require'engine' 2 | local conf = require'conftest' 3 | 4 | describe("layout", function() 5 | 6 | it("terminal window above", function() 7 | conf.config_test(function() 8 | vim.w.nvimgdb_termwin_command = "aboveleft new" 9 | eng.exe('e config.py') 10 | eng.feed(' dp') 11 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 12 | eng.feed('') 13 | eng.feed('j') 14 | assert.equals('main.py', vim.fn.expand("%:t")) 15 | end) 16 | end) 17 | 18 | it("terminal window to the right", function() 19 | conf.config_test(function() 20 | vim.w.nvimgdb_termwin_command = "belowright vnew" 21 | eng.exe('e config.py') 22 | eng.feed(' dp') 23 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 24 | eng.feed('') 25 | eng.feed('h') 26 | assert.equals('main.py', vim.fn.expand("%:t")) 27 | end) 28 | end) 29 | 30 | it("terminal window in the current window below the jump window", function() 31 | conf.config_test(function() 32 | vim.w.nvimgdb_termwin_command = "" 33 | eng.exe('e config.py') 34 | eng.feed(' dp') 35 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 36 | eng.feed('') 37 | eng.feed('k') 38 | assert.equals('main.py', vim.fn.expand("%:t")) 39 | end) 40 | end) 41 | 42 | it("terminal window in the current window below the jump window", function() 43 | conf.config_test(function() 44 | vim.w.nvimgdb_termwin_command = "" 45 | vim.t.nvimgdb_codewin_command = "belowright new" 46 | eng.exe('e config.py') 47 | eng.feed(' dp') 48 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 49 | eng.feed('') 50 | eng.feed('j') 51 | assert.equals('main.py', vim.fn.expand("%:t")) 52 | end) 53 | end) 54 | 55 | it("terminal window in the current window left of the jump window", function() 56 | conf.config_test(function() 57 | vim.w.nvimgdb_termwin_command = "" 58 | vim.t.nvimgdb_codewin_command = "rightbelow vnew" 59 | eng.exe('e config.py') 60 | eng.feed(' dp') 61 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 62 | eng.feed('') 63 | eng.feed('l') 64 | assert.equals('main.py', vim.fn.expand("%:t")) 65 | end) 66 | end) 67 | 68 | end) 69 | -------------------------------------------------------------------------------- /test/50_command_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | local thr = require'thread' 4 | local utils = require'nvimgdb.utils' 5 | 6 | describe("command", function() 7 | 8 | local tests = { 9 | gdb = {{"print i", '$1 = 0'}, 10 | {"info locals", 'i = 0'}}, 11 | lldb = {{"frame var argc", "(int) argc = 1"}, 12 | {"frame var i", "(int) i = 0"}}, 13 | } 14 | 15 | local function custom_command(cmd, result) 16 | coroutine.resume(coroutine.create(function() 17 | local output = require'nvimgdb'.i(0):custom_command_async(cmd) 18 | result.output = output 19 | end)) 20 | end 21 | 22 | conf.backend(function(backend) 23 | it(backend.name .. ' custom command in C++', function() 24 | conf.post_terminal_end(function() 25 | eng.feed(backend.launch) 26 | assert.is_true(eng.wait_paused()) 27 | eng.feed(backend.tbreak_main) 28 | eng.feed('run') 29 | assert.is_true(eng.wait_signs({cur = 'test.cpp:17'})) 30 | eng.feed('') 31 | eng.feed('') 32 | if utils.is_windows and backend.name == 'lldb' then 33 | thr.y(300) 34 | end 35 | for _, test in ipairs(tests[backend.name]) do 36 | local cmd, exp = unpack(test) 37 | local result = {} 38 | custom_command(cmd, result) 39 | assert.is_true( 40 | eng.wait_for( 41 | function() return result.output end, 42 | function(out) return exp == out end 43 | ) 44 | ) 45 | end 46 | end) 47 | end) 48 | end) 49 | 50 | it('custom command in pdb', function() 51 | conf.post_terminal_end(function() 52 | eng.feed(' dp') 53 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 54 | eng.feed('b _foo') 55 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {8}}})) 56 | eng.feed('cont') 57 | assert.is_true(eng.wait_signs({cur = 'main.py:9', brk = {[1] = {8}}})) 58 | 59 | local result = {} 60 | custom_command('print(num)', result) 61 | assert.is_true( 62 | eng.wait_for( 63 | function() return result.output end, 64 | function(out) return "0" == out end 65 | ) 66 | ) 67 | 68 | eng.feed('cont') 69 | assert.is_true(eng.wait_signs({cur = 'main.py:9', brk = {[1] = {8}}})) 70 | 71 | result = {} 72 | custom_command('print(num)', result) 73 | assert.is_true( 74 | eng.wait_for( 75 | function() return result.output end, 76 | function(out) return "1" == out end 77 | ) 78 | ) 79 | end) 80 | end) 81 | 82 | local watch_tests = { 83 | gdb = {'info locals', {'i = 0'}}, 84 | lldb = {'frame var i', {'(int) i = 0'}}, 85 | } 86 | 87 | conf.backend(function(backend) 88 | 89 | it(backend.name .. ' watch window with custom command in C++', function() 90 | conf.post_terminal_end(function() 91 | eng.feed(backend.launch) 92 | assert.is_true(eng.wait_paused()) 93 | eng.feed(backend.tbreak_main) 94 | eng.feed('run') 95 | assert.is_true(eng.wait_paused()) 96 | eng.feed('') 97 | local cmd, res = unpack(watch_tests[backend.name]) 98 | eng.feed(':GdbCreateWatch ' .. cmd .. '\n') 99 | eng.feed(':GdbNext\n') 100 | local function query() 101 | return vim.fn.getbufline(cmd, 1) 102 | end 103 | assert.is_true(eng.wait_for(query, function(out) return vim.deep_equal(out, res) end)) 104 | end) 105 | end) 106 | 107 | it(backend.name .. ' cleanup of watch window with custom command in C++', function() 108 | conf.post_terminal_end(function() 109 | eng.feed(backend.launch) 110 | assert.is_true(eng.wait_paused()) 111 | eng.feed(backend.tbreak_main) 112 | eng.feed('run') 113 | assert.is_true(eng.wait_paused()) 114 | eng.feed('') 115 | local cmd, res = unpack(watch_tests[backend.name]) 116 | eng.feed(':GdbCreateWatch ' .. cmd .. '\n') 117 | local bufname = cmd:gsub(" ", "\\ ") 118 | -- If a user wants to get rid of the watch window manually, 119 | -- the plugin should take care of properly getting rid of autocommands 120 | -- in the backend. 121 | local auid = vim.api.nvim_create_autocmd('User', {pattern='NvimGdbCleanup', command='bwipeout! ' .. bufname}) 122 | 123 | eng.feed(':GdbDebugStop\n') 124 | 125 | -- Start and test another time to check that no error is raised 126 | eng.feed(backend.launch) 127 | assert.is_true(eng.wait_paused()) 128 | eng.feed(backend.tbreak_main) 129 | eng.feed('run') 130 | assert.is_true(eng.wait_paused()) 131 | eng.feed('') 132 | eng.feed(':GdbCreateWatch ' .. cmd .. '\n') 133 | eng.feed(':GdbNext\n') 134 | local function query() 135 | return vim.fn.getbufline(cmd, 1) 136 | end 137 | assert.is_true(eng.wait_for(query, function(out) return vim.deep_equal(out, res) end)) 138 | 139 | vim.api.nvim_del_autocmd(auid) 140 | end) 141 | end) 142 | 143 | end) 144 | 145 | end) 146 | -------------------------------------------------------------------------------- /test/60_bashdb_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | 4 | describe("bashdb", function() 5 | 6 | if conf.backend_names.bashdb == nil then 7 | return 8 | end 9 | 10 | it('generic use case', function() 11 | conf.post_terminal_end(function() 12 | eng.feed(' db') 13 | assert.is_true(eng.wait_signs({cur = 'main.sh:22'})) 14 | 15 | eng.feed('tbreak Main') 16 | eng.feed('') 17 | eng.feed('') 18 | assert.is_true(eng.wait_signs({cur = 'main.sh:16'})) 19 | 20 | eng.feed('') 21 | assert.is_true(eng.wait_signs({cur = 'main.sh:17'})) 22 | 23 | eng.feed('') 24 | assert.is_true(eng.wait_signs({cur = 'main.sh:18'})) 25 | 26 | eng.feed('') 27 | assert.is_true(eng.wait_signs({cur = 'main.sh:7'})) 28 | 29 | eng.feed('') 30 | assert.is_true(eng.wait_signs({cur = 'main.sh:18'})) 31 | 32 | eng.feed('') 33 | assert.is_true(eng.wait_signs({cur = 'main.sh:7'})) 34 | 35 | eng.feed('') 36 | assert.is_true(eng.wait_signs({cur = 'main.sh:17'})) 37 | 38 | eng.feed('') 39 | assert.is_true(eng.wait_signs({})) 40 | end) 41 | end) 42 | 43 | it('toggling breakpoints', function() 44 | conf.post_terminal_end(function() 45 | eng.feed(' db') 46 | assert.is_true(eng.wait_signs({cur = 'main.sh:22'})) 47 | eng.feed('k') 48 | eng.feed(':4') 49 | assert.is_true(eng.wait_cursor(4)) 50 | eng.feed('') 51 | assert.is_true(eng.wait_signs({cur = 'main.sh:22', brk = {[1] = {4}}})) 52 | 53 | eng.exe('GdbContinue') 54 | assert.is_true(eng.wait_signs({cur = 'main.sh:4', brk = {[1] = {4}}})) 55 | 56 | eng.feed('') 57 | assert.is_true(eng.wait_signs({cur = 'main.sh:4'})) 58 | end) 59 | end) 60 | 61 | it('last command is repeated on empty input', function() 62 | conf.post_terminal_end(function() 63 | eng.feed(' db') 64 | assert.is_true(eng.wait_signs({cur = 'main.sh:22'})) 65 | 66 | eng.feed('tbreak Main') 67 | eng.feed('cont') 68 | assert.is_true(eng.wait_signs({cur = 'main.sh:16'})) 69 | 70 | eng.feed('n') 71 | assert.is_true(eng.wait_signs({cur = 'main.sh:17'})) 72 | eng.feed('') 73 | assert.is_true(eng.wait_signs({cur = 'main.sh:18'})) 74 | eng.feed('') 75 | assert.is_true(eng.wait_signs({cur = 'main.sh:17'})) 76 | end) 77 | end) 78 | 79 | end) 80 | -------------------------------------------------------------------------------- /test/70_quickfix_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | local thr = require'thread' 4 | 5 | describe("quickfix", function() 6 | conf.backend(function(backend) 7 | 8 | it(backend.name .. ' breakpoint location list in C++', function() 9 | conf.post_terminal_end(function() 10 | conf.count_stops(function(count_stops) 11 | eng.feed(backend.launch) 12 | assert.is_true(eng.wait_paused()) 13 | eng.feed('b main') 14 | eng.feed('b Foo') 15 | count_stops.reset() 16 | eng.feed('b Bar') 17 | assert.is_true(count_stops.wait(1)) 18 | eng.feed('') 19 | eng.feed('w') 20 | eng.feed(':aboveleft GdbLopenBreakpoints\n') 21 | assert.is_true( 22 | eng.wait_for( 23 | function() return #vim.fn.getloclist(0) end, 24 | function(r) return r > 0 end 25 | ) 26 | ) 27 | 28 | eng.feed('k') 29 | eng.feed(':ll') 30 | assert.is_true( 31 | eng.wait_for( 32 | function() return vim.fn.line('.') end, 33 | function(r) return r == 17 end 34 | ) 35 | ) 36 | eng.feed(':lnext') 37 | assert.equals(10, vim.fn.line('.')) 38 | eng.feed(':lnext') 39 | assert.equals(5, vim.fn.line('.')) 40 | eng.feed(':lnext') 41 | assert.equals(5, vim.fn.line('.')) 42 | end) 43 | end) 44 | end) 45 | 46 | it(backend.name .. ' backtrace location list in C++', function() 47 | conf.post_terminal_end(function() 48 | eng.feed(backend.launch) 49 | assert.is_true(eng.wait_paused()) 50 | eng.feed('b Bar') 51 | eng.feed('run') 52 | assert.is_true(eng.wait_signs({cur = 'test.cpp:5', brk = {[1] = {5}}})) 53 | eng.feed('') 54 | eng.feed('w') 55 | eng.feed(':belowright GdbLopenBacktrace') 56 | assert.is_true( 57 | eng.wait_for( 58 | function() return #vim.fn.getloclist(0) end, 59 | function(r) return r > 0 end 60 | ) 61 | ) 62 | eng.feed('j') 63 | eng.feed(':ll') 64 | assert.is_true( 65 | eng.wait_for( 66 | function() return vim.fn.line('.') end, 67 | function(r) return r == 5 end 68 | ) 69 | ) 70 | eng.feed(':lnext') 71 | assert.equals(12, vim.fn.line('.')) 72 | eng.feed(':lnext') 73 | assert.equals(19, vim.fn.line('.')) 74 | end) 75 | end) 76 | 77 | 78 | end) 79 | 80 | it('breakpoint location list in pdb', function() 81 | conf.post_terminal_end(function() 82 | eng.feed(' dp') 83 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 84 | eng.feed('b _main') 85 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {14}}})) 86 | eng.feed('b _foo') 87 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {8, 14}}})) 88 | eng.feed('b _bar') 89 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {4, 8, 14}}})) 90 | eng.feed('') 91 | eng.feed('w') 92 | eng.feed(':GdbLopenBreakpoints') 93 | assert.is_true( 94 | eng.wait_for( 95 | function() return #vim.fn.getloclist(0) end, 96 | function(r) return r > 0 end 97 | ) 98 | ) 99 | eng.feed('j') 100 | eng.feed(':ll') 101 | assert.is_true( 102 | eng.wait_for( 103 | function() return vim.fn.line('.') end, 104 | function(r) return r == 14 end 105 | ) 106 | ) 107 | eng.feed(':lnext') 108 | assert.equals(8, vim.fn.line('.')) 109 | eng.feed(':lnext') 110 | assert.equals(4, vim.fn.line('.')) 111 | eng.feed(':lnext') 112 | assert.equals(4, vim.fn.line('.')) 113 | end) 114 | end) 115 | 116 | it('backtrace location list in pdb', function() 117 | conf.post_terminal_end(function() 118 | eng.feed(' dp') 119 | assert.is_true(eng.wait_signs({cur = 'main.py:1'})) 120 | eng.feed('b _bar') 121 | assert.is_true(eng.wait_signs({cur = 'main.py:1', brk = {[1] = {4}}})) 122 | eng.feed('cont') 123 | assert.is_true(eng.wait_signs({cur = 'main.py:5', brk = {[1] = {4}}})) 124 | eng.feed('') 125 | eng.feed('w') 126 | eng.feed(':GdbLopenBacktrace') 127 | assert.is_true( 128 | eng.wait_for( 129 | function() return #vim.fn.getloclist(0) end, 130 | function(r) return r > 0 end 131 | ) 132 | ) 133 | eng.feed('j') 134 | eng.feed(':lnext') 135 | eng.feed(':lnext') 136 | assert.is_true( 137 | eng.wait_for( 138 | function() return vim.fn.line('.') end, 139 | function(r) return r == 22 end 140 | ) 141 | ) 142 | eng.feed(':lnext') 143 | assert.equals(16, vim.fn.line('.')) 144 | eng.feed(':lnext') 145 | assert.equals(11, vim.fn.line('.')) 146 | eng.feed(':lnext') 147 | assert.equals(5, vim.fn.line('.')) 148 | end) 149 | end) 150 | 151 | if conf.backend_names.bashdb ~= nil then 152 | 153 | it('breakpoint location list in BashDB', function() 154 | conf.post_terminal_end(function() 155 | eng.feed(' db\n') 156 | assert.is_true(eng.wait_paused()) 157 | eng.feed('b Main\n') 158 | eng.feed('b Foo\n') 159 | eng.feed('b Bar\n') 160 | eng.feed('') 161 | eng.feed(':GdbLopenBreakpoints\n') 162 | thr.y(300) 163 | eng.feed('k') 164 | eng.feed(':ll\n') 165 | assert.is_true( 166 | eng.wait_for( 167 | function() return vim.fn.line('.') end, 168 | function(r) return r == 16 end 169 | ) 170 | ) 171 | eng.feed(':lnext\n') 172 | assert.equals(7, vim.fn.line('.')) 173 | eng.feed(':lnext\n') 174 | assert.equals(3, vim.fn.line('.')) 175 | eng.feed(':lnext\n') 176 | assert.equals(3, vim.fn.line('.')) 177 | end) 178 | end) 179 | 180 | it('breakpoint location list in BashDB', function() 181 | conf.post_terminal_end(function() 182 | eng.feed(' db\n') 183 | assert.is_true(eng.wait_paused()) 184 | eng.feed('b Bar\n') 185 | eng.feed('cont\n') 186 | assert.is_true(eng.wait_signs({cur = 'main.sh:3', brk = {[1] = {3}}})) 187 | eng.feed('') 188 | eng.feed(':GdbLopenBacktrace\n') 189 | thr.y(300) 190 | eng.feed('k') 191 | eng.feed(':ll\n') 192 | assert.is_true( 193 | eng.wait_for( 194 | function() return vim.fn.line('.') end, 195 | function(r) return r == 3 end 196 | ) 197 | ) 198 | eng.feed(':lnext\n') 199 | assert.equals(11, vim.fn.line('.')) 200 | eng.feed(':lnext\n') 201 | assert.equals(18, vim.fn.line('.')) 202 | eng.feed(':lnext\n') 203 | assert.equals(22, vim.fn.line('.')) 204 | end) 205 | end) 206 | 207 | end 208 | 209 | end) 210 | -------------------------------------------------------------------------------- /test/90_misc_spec.lua: -------------------------------------------------------------------------------- 1 | local conf = require'conftest' 2 | local eng = require'engine' 3 | 4 | describe("misc", function() 5 | 6 | it('ensure that keymaps are defined in the jump window when navigating', function() 7 | conf.post_terminal_end(function() 8 | 9 | local function get_map() 10 | return vim.fn.execute("map ") 11 | end 12 | local function contains(b) 13 | return function(a) return a:find(b) ~= nil end 14 | end 15 | 16 | eng.feed(":e main.py\n") 17 | assert.is_true(eng.wait_for(get_map, contains("No mapping found"))) 18 | eng.feed(' dp') 19 | assert.is_true(eng.wait_signs({cur = "main.py:1"})) 20 | eng.feed('') 21 | assert.is_true(eng.wait_for(get_map, contains("GdbFrameDown"))) 22 | eng.feed('w') 23 | assert.is_true(eng.wait_for(get_map, contains("GdbFrameDown"))) 24 | eng.feed(':tabnew\n') 25 | eng.feed(':e main.py\n') 26 | assert.is_true(eng.wait_for(get_map, contains("No mapping found"))) 27 | eng.feed('gt') 28 | assert.is_true(eng.wait_for(get_map, contains("GdbFrameDown"))) 29 | eng.exe("bw!") 30 | end) 31 | end) 32 | 33 | end) 34 | -------------------------------------------------------------------------------- /test/all.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | import tempfile 7 | 8 | 9 | # It seems to be impossible to insert into PATH in GitHub Actions for now. 10 | # Let's allow accessing gdb in the scope of the process. 11 | if sys.platform == 'win32' and os.getenv('GITHUB_WORKFLOW'): 12 | path = os.environ["PATH"].split(';') 13 | # Hopefully after Python 14 | path = path[:10] + ["C:\\tools\\msys64\\mingw64\\bin"] + path[10:] 15 | os.environ["PATH"] = ";".join(path) 16 | 17 | os.chdir(os.path.join(os.path.dirname(__file__), '..')) 18 | root_dir = os.getcwd() 19 | 20 | # Deliberately test that the tests pass from a random symlink 21 | # to the source directory. 22 | with tempfile.TemporaryDirectory() as tmp_dir: 23 | if sys.platform != 'win32': 24 | os.symlink(root_dir, os.path.join(tmp_dir, 'src'), 25 | target_is_directory=True) 26 | os.chdir(os.path.join(tmp_dir, 'src', 'test')) 27 | else: 28 | os.chdir(os.path.join(root_dir, "test")) 29 | 30 | print("Test neovim is usable") 31 | res = subprocess.run(["nvim", "--headless", "+qa"]) 32 | if res.returncode != 0: 33 | raise RuntimeError("Neovim check failed") 34 | 35 | from prerequisites import Prerequisites 36 | Prerequisites() 37 | 38 | test_cmd = ["nvim", "-l", "run-tests.lua", ".", "--no-keep-going"] 39 | # Use the following command to see neovim screen 40 | # test_cmd = ["python", "nvim.py", "+luafile main.lua"] 41 | print(f"Run `{' '.join(test_cmd)}`") 42 | res = subprocess.run(test_cmd) 43 | if res.returncode != 0: 44 | raise RuntimeError("Lua tests failed") 45 | -------------------------------------------------------------------------------- /test/config.lua: -------------------------------------------------------------------------------- 1 | local C = { 2 | exit_after_tests = false, 3 | send_output_to_tcp = false, 4 | } 5 | return C 6 | -------------------------------------------------------------------------------- /test/config.py: -------------------------------------------------------------------------------- 1 | '''Read backend configuration.''' 2 | 3 | import os 4 | 5 | 6 | BACKEND_NAMES = ['XXX'] 7 | with open(os.path.join(os.path.dirname(__file__), 8 | 'backends.txt'), 'r') as fback: 9 | BACKEND_NAMES = [s.strip() for s in fback.readlines()] 10 | -------------------------------------------------------------------------------- /test/config_ci.lua: -------------------------------------------------------------------------------- 1 | local config = require'config' 2 | config.exit_after_tests = true 3 | config.send_output_to_tcp = true 4 | -------------------------------------------------------------------------------- /test/conftest.lua: -------------------------------------------------------------------------------- 1 | local utils = require'nvimgdb.utils' 2 | local thr = require'thread' 3 | local eng = require'engine' 4 | local busted = require'busted' 5 | 6 | ---@class Conf 7 | ---@field aout string Executable file name 8 | local C = {} 9 | 10 | C.aout = utils.is_windows and 'a.exe' or 'a.out' 11 | 12 | C.backend_names = {} 13 | for name in io.lines("backends.txt") do 14 | C.backend_names[name] = true 15 | end 16 | 17 | C.backends = {} 18 | if C.backend_names.gdb ~= nil then 19 | C.backends.gdb = { 20 | name = 'gdb', 21 | launch = ' dd '.. C.aout .. '', 22 | tbreak_main = 'tbreak main', 23 | break_main = 'break main', 24 | break_bar = 'break Bar', 25 | launchF = ':GdbStart gdb -q %s', 26 | watchF = 'watch %s', 27 | } 28 | end 29 | if C.backend_names.lldb ~= nil then 30 | C.backends.lldb = { 31 | name = 'lldb', 32 | launch = ' dl ' .. C.aout .. '', 33 | tbreak_main = 'breakpoint set -o true -n main', 34 | break_main = 'breakpoint set -n main', 35 | break_bar = 'breakpoint set --fullname Bar', 36 | launchF = ':GdbStartLLDB lldb %s', 37 | watchF = 'watchpoint set variable %s', 38 | } 39 | end 40 | 41 | function C.terminal_end(action) 42 | action() 43 | local cursor_line = vim.api.nvim_win_get_cursor(NvimGdb.here.client.win)[1] 44 | local last_line = vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(NvimGdb.here.client.win)) 45 | local win_height = vim.api.nvim_win_get_height(NvimGdb.here.client.win) 46 | busted.assert.is_true(cursor_line >= last_line - win_height, "cursor in the terminal window should be visible") 47 | end 48 | 49 | function C.post(action) 50 | -- Prepare and check tabpages for every test. 51 | -- Quit debugging and do post checks. 52 | 53 | local mode = vim.api.nvim_get_mode() 54 | if mode.mode == 'i' then 55 | eng.feed("") 56 | elseif mode.mode == 't' then 57 | eng.feed("") 58 | end 59 | 60 | while vim.fn.tabpagenr('$') > 1 do 61 | thr.y(0, vim.cmd('tabclose $')) 62 | end 63 | 64 | action() 65 | 66 | thr.y(0, vim.cmd("GdbDebugStop")) 67 | busted.assert.equals(1, vim.fn.tabpagenr('$'), "No rogue tabpages") 68 | busted.assert.are.same({}, eng.get_signs(), "No rogue signs") 69 | busted.assert.are.same({}, eng.get_termbuffers(), "No rogue terminal buffers") 70 | 71 | for _, buf in ipairs(vim.api.nvim_list_bufs()) do 72 | if buf ~= 1 and vim.api.nvim_buf_is_loaded(buf) then 73 | thr.y(0, vim.cmd("bdelete! " .. buf)) 74 | -- TODO investigate why 75 | --vim.api.nvim_buf_delete(buf, {force = true}) 76 | end 77 | end 78 | end 79 | 80 | function C.post_terminal_end(action) 81 | C.post(function() 82 | C.terminal_end(action) 83 | end) 84 | end 85 | 86 | function C.backend(action) 87 | for _, backend in pairs(C.backends) do 88 | action(backend) 89 | end 90 | end 91 | 92 | ---Allow waiting for the specific count of debugger prompts appeared 93 | ---@param action function(prompt) @test actions 94 | function C.count_stops(action) 95 | local prompt_count = 0 96 | local auid = vim.api.nvim_create_autocmd('User', { 97 | pattern = 'NvimGdbQuery', 98 | callback = function() 99 | prompt_count = prompt_count + 1 100 | end 101 | }) 102 | 103 | local prompt = { 104 | reset = function() prompt_count = 0 end, 105 | wait = function(count, timeout_ms) 106 | return eng.wait_for( 107 | function() return prompt_count end, 108 | function(val) return val >= count end, 109 | timeout_ms 110 | ) 111 | end 112 | } 113 | 114 | action(prompt) 115 | 116 | vim.api.nvim_del_autocmd(auid) 117 | end 118 | 119 | function C.config_test(action) 120 | C.post_terminal_end(action) 121 | for scope in ("bwtg"):gmatch'.' do 122 | for k, _ in pairs(vim.fn.eval(scope .. ':')) do 123 | if type(k) == "string" and k:find('^nvimgdb_') then 124 | vim.api.nvim_command('unlet ' .. scope .. ':' .. k) 125 | end 126 | end 127 | end 128 | end 129 | 130 | return C 131 | -------------------------------------------------------------------------------- /test/engine.lua: -------------------------------------------------------------------------------- 1 | local thr = require'thread' 2 | local utils = require'nvimgdb.utils' 3 | local nvimgdb = require'nvimgdb' 4 | 5 | local E = {} 6 | 7 | E.common_timeout = (vim.env.GITHUB_WORKFLOW ~= nil or utils.is_windows) and 10000 or 5000 8 | 9 | ---Feed keys to Neovim 10 | ---@param keys string @keystrokes 11 | ---@param timeout? number @delay in milliseconds after the input 12 | function E.feed(keys, timeout) 13 | vim.api.nvim_input(keys) 14 | thr.y(timeout == nil and 200 or timeout) 15 | end 16 | 17 | ---Execute a command 18 | ---@param cmd string neovim command 19 | function E.exe(cmd) 20 | thr.y(0, vim.cmd(cmd)) 21 | end 22 | 23 | function E.get_time_ms() 24 | return vim.loop.hrtime() * 1e-6 25 | end 26 | 27 | ---Wait until the query passes the check 28 | ---@param query function 29 | ---@param check function 30 | ---@param timeout_ms? number timeout in milliseconds (E.common_timeout if omitted) 31 | ---@return boolean|any true if the check function returned true, or the result of the query function otherwise 32 | function E.wait_for(query, check, timeout_ms) 33 | if timeout_ms == nil then 34 | timeout_ms = E.common_timeout 35 | end 36 | local deadline = E.get_time_ms() + timeout_ms 37 | local value = nil 38 | while E.get_time_ms() < deadline do 39 | value = query() 40 | if check(value) then 41 | return true 42 | end 43 | thr.y(100) 44 | end 45 | return value 46 | end 47 | 48 | ---Wait until the debugger gets into the desired state 49 | ---@param state boolean true for paused, false for running 50 | ---@param timeout_ms? number Timeout in milliseconds 51 | ---@return boolean 52 | function E.wait_state(state, timeout_ms) 53 | if timeout_ms == nil then 54 | timeout_ms = 5000 55 | end 56 | local query = function() 57 | local parser = nvimgdb.here.parser 58 | return type(parser) == 'table' and parser:is_paused() 59 | end 60 | return E.wait_for(query, function(is_paused) return is_paused == state end, timeout_ms) 61 | end 62 | 63 | ---Wait until the debugger doesn't have new output 64 | ---@param timeout_ms? number Timeout in milliseconds 65 | ---@return boolean 66 | function E.wait_is_still(timeout_ms) 67 | local query = function() 68 | local parser = nvimgdb.here.parser 69 | return type(parser) == 'table' and parser:is_still() 70 | end 71 | return E.wait_for(query, function(is_still) return is_still end, timeout_ms) 72 | end 73 | 74 | ---Wait until the debugger gets into the paused state 75 | ---@param timeout_ms? number Timeout in milliseconds 76 | ---@return boolean 77 | function E.wait_paused(timeout_ms) 78 | return E.wait_is_still(timeout_ms) and E.wait_state(true, timeout_ms) 79 | end 80 | 81 | ---Wait until the debugger gets into the running state 82 | ---@param timeout_ms? number Timeout in milliseconds 83 | ---@return boolean 84 | function E.wait_running(timeout_ms) 85 | return E.wait_state(false, timeout_ms) 86 | end 87 | 88 | ---Get buffers satisfying the predicate 89 | ---@param pred function(buf: integer): boolean condition for a buffer to be reported 90 | ---@return table map of buffer number to name 91 | function E.get_buffers_impl(pred) 92 | local buffers = {} 93 | for _, buf in ipairs(vim.api.nvim_list_bufs()) do 94 | if pred(buf) then 95 | buffers[buf] = vim.api.nvim_buf_get_name(buf) 96 | end 97 | end 98 | return buffers 99 | end 100 | 101 | ---Get all the loaded buffers 102 | ---@return table map of buffer number to name 103 | function E.get_buffers() 104 | -- Determine how many terminal buffers are there. 105 | return E.get_buffers_impl(function(buf) 106 | return vim.api.nvim_buf_is_loaded(buf) 107 | end) 108 | end 109 | 110 | ---Get all the terminal buffers 111 | ---@return table map of buffer number to name 112 | function E.get_termbuffers() 113 | -- Determine how many terminal buffers are there. 114 | return E.get_buffers_impl(function(buf) 115 | return vim.api.nvim_buf_is_loaded(buf) and vim.api.nvim_buf_get_option(buf, 'buftype') == 'terminal' 116 | end) 117 | end 118 | 119 | ---@alias BreakpointInfo table # breakpoint ID -> list of lines 120 | ---@alias SignInfo {cur: string, brk: BreakpointInfo} # information about signs 121 | 122 | ---Get current signs: current line and breakpoints 123 | ---@return SignInfo 124 | function E.get_signs() 125 | -- Get pointer position and list of breakpoints. 126 | local ret = {} 127 | 128 | for _, buf in ipairs(vim.api.nvim_list_bufs()) do 129 | if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then 130 | local breaks = {} 131 | for _, bsigns in ipairs(vim.fn.sign_getplaced(buf, {group = "NvimGdb"})) do 132 | for _, signs in ipairs(bsigns.signs) do 133 | local sname = signs.name 134 | if sname == 'GdbCurrentLine' then 135 | local bname = vim.api.nvim_buf_get_name(buf):match("[^/\\]+$") 136 | if bname == nil then 137 | bname = vim.api.nvim_buf_get_name(buf) 138 | end 139 | if ret.cur == nil then 140 | ret.cur = bname .. ':' .. signs.lnum 141 | else 142 | if ret.curs == nil then 143 | ret.curs = {} 144 | end 145 | table.insert(ret.curs, bname .. ':' .. signs.lnum) 146 | end 147 | end 148 | if sname:match('^GdbBreakpoint') then 149 | local num = assert(tonumber(sname:sub(1 + string.len('GdbBreakpoint')))) 150 | if breaks[num] == nil then 151 | breaks[num] = {} 152 | end 153 | table.insert(breaks[num], signs.lnum) 154 | end 155 | end 156 | end 157 | if next(breaks) ~= nil then 158 | ret.brk = breaks 159 | end 160 | end 161 | end 162 | return ret 163 | end 164 | 165 | ---Wait until the sign configuration is as expected 166 | ---@param expected_signs SignInfo 167 | ---@param timeout_ms number? 168 | ---@return boolean|SignInfo true if expected_signs realized, actual signs otherwise 169 | function E.wait_signs(expected_signs, timeout_ms) 170 | local function query() 171 | return E.get_signs() 172 | end 173 | local function is_expected(signs) 174 | return vim.deep_equal(expected_signs, signs) 175 | end 176 | return E.wait_is_still(timeout_ms) and E.wait_for(query, is_expected, timeout_ms) 177 | end 178 | 179 | ---Wait until cursor in the current window gets to the given line 180 | ---@param line integer 1-based line number 181 | ---@param timeout_ms? number timeout in milliseconds 182 | ---@return true|integer true if successful, the actual line number otherwise 183 | function E.wait_cursor(line, timeout_ms) 184 | return E.wait_for( 185 | function() return vim.api.nvim_win_get_cursor(0)[1] end, 186 | function(row) return row == line end, 187 | timeout_ms 188 | ) 189 | end 190 | 191 | return E 192 | -------------------------------------------------------------------------------- /test/init.lua: -------------------------------------------------------------------------------- 1 | if vim.loop.os_uname().sysname:find('Darwin') == nil then 2 | -- Command not available in nvim-macos 3 | vim.cmd("language C") 4 | end 5 | local plugin_dir = vim.loop.fs_realpath('..') 6 | vim.o.rtp = vim.env.VIMRUNTIME .. ',' .. plugin_dir 7 | vim.g.mapleader = ' ' 8 | vim.g.loaded_matchparen = 1 -- Don't load stock plugins to simplify debugging 9 | vim.g.loaded_netrwPlugin = 1 10 | vim.o.shortmess = 'a' 11 | vim.o.cmdheight = 5 12 | vim.o.hidden = true 13 | vim.o.ruler = false 14 | vim.o.showcmd = false 15 | 16 | vim.cmd("runtime! plugin/*.vim") 17 | 18 | local rocks_dir = plugin_dir .. '/lua_modules/share/lua/5.1' 19 | package.path = rocks_dir .. '/?.lua;' .. rocks_dir .. '/?/init.lua;' .. package.path 20 | 21 | local utils = require'nvimgdb.utils' 22 | local so = utils.is_windows and '.dll' or '.so' 23 | package.cpath = plugin_dir .. '/lua_modules/lib/lua/5.1/?' .. so .. ';' .. package.cpath 24 | -------------------------------------------------------------------------------- /test/lib.py: -------------------------------------------------------------------------------- 1 | '''.''' 2 | 3 | 4 | def _factorial(num): 5 | if num <= 1: 6 | return 1 7 | return num * _factorial(num - 1) 8 | -------------------------------------------------------------------------------- /test/main.lua: -------------------------------------------------------------------------------- 1 | local thr = require'thread' 2 | local runner = require'busted.runner' 3 | local result = require'result' 4 | local config = require'config' 5 | 6 | arg = {'.'} 7 | if vim.g.busted_arg ~= nil and #vim.g.busted_arg > 0 then 8 | arg = vim.g.busted_arg 9 | end 10 | 11 | local M = {} 12 | 13 | local function report_result() 14 | local test_log = table.concat(result.test_output, "") 15 | local f = io.open('test.log', 'w') 16 | if f ~= nil then 17 | f:write(test_log) 18 | f:close() 19 | end 20 | if config.exit_after_tests then 21 | os.exit(result.failures > 0 and 1 or 0) 22 | end 23 | vim.cmd("noswap tabnew test.log") 24 | vim.cmd([[ 25 | syntax match DiagnosticOk /+/ 26 | syntax match DiagnosticWarn /-/ 27 | syntax match DiagnosticOk /\d\+ success[^ ]*/ 28 | syntax match DiagnosticWarn /\d\+ failure[^ ]*/ 29 | syntax match DiagnosticError /\d\+ error[^ ]*/ 30 | syntax match DiagnosticInfo /\d\+ pending[^ ]*/ 31 | syntax match Float /[0-9]\+\.[0-9]\+/ 32 | syntax match DiagnosticWarn /Failure ->/ 33 | syntax match DiagnosticError /Error ->/ 34 | ]]) 35 | end 36 | 37 | local function main() 38 | -- busted will try to end the process in case of failure, so disable os.exit() for now 39 | local exit_orig = os.exit 40 | os.exit = function() end 41 | local ok, err = pcall(runner, {standalone = false, output = 'output.lua'}) 42 | if not ok then 43 | print(err) 44 | end 45 | os.exit = exit_orig 46 | report_result() 47 | M.thr:cleanup() 48 | end 49 | 50 | local function on_stuck() 51 | print("Thread stuck") 52 | report_result() 53 | M.thr:cleanup() 54 | end 55 | 56 | M.thr = thr.create(main, on_stuck) 57 | -------------------------------------------------------------------------------- /test/main.py: -------------------------------------------------------------------------------- 1 | """Test program.""" 2 | 3 | 4 | def _bar(i): 5 | return i * 2 6 | 7 | 8 | def _foo(num): 9 | if num == 0: 10 | return 0 11 | return num + _bar(num - 1) 12 | 13 | 14 | def _main(): 15 | for i in range(10): 16 | _foo(i) 17 | for i in range(0xffffff): 18 | pass 19 | 20 | 21 | if __name__ == "__main__": 22 | _main() 23 | -------------------------------------------------------------------------------- /test/main.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function Bar() { 4 | echo $(( $1 * 2 )) 5 | } 6 | 7 | function Foo() { 8 | if [[ $1 -eq 0 ]]; then 9 | echo 0 10 | else 11 | b=$(Bar $(($1 - 1))) 12 | echo $(( $1 + b )) 13 | fi 14 | } 15 | 16 | function Main() { 17 | for i in $(seq 1 5); do 18 | Foo "$i" 19 | done 20 | } 21 | 22 | Main 23 | -------------------------------------------------------------------------------- /test/nvim.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import shutil 5 | import socket 6 | import subprocess 7 | import sys 8 | import threading 9 | import time 10 | from spy_ui import SpyUI 11 | 12 | 13 | class Nvim: 14 | def __init__(self): 15 | self.spy = None 16 | self.thread = None 17 | 18 | def wait_for_port(self, host, port): 19 | for i in range(10): 20 | try: 21 | with socket.create_connection((host, port), timeout=0.1): 22 | return 23 | except (socket.timeout, ConnectionRefusedError): 24 | time.sleep(0.1) 25 | raise TimeoutError(f"Timeout waiting for port {port} on {host}") 26 | 27 | def run_spy_ui(self): 28 | self.wait_for_port('127.0.0.1', 44444) 29 | terminal_size = shutil.get_terminal_size() 30 | rows, columns = terminal_size.lines, terminal_size.columns 31 | self.spy = SpyUI(width=columns, height=rows) 32 | self.spy.run() 33 | 34 | def run(self, args): 35 | if os.getenv("CI") or "--embed" in args: 36 | self.thread = threading.Thread(target=self.run_spy_ui) 37 | self.thread.start() 38 | 39 | command = ['nvim', '--clean', '-u', 'NONE', '+luafile init.lua', 40 | '--listen', '127.0.0.1:44444'] 41 | command.extend(args) 42 | 43 | result = subprocess.run(command) 44 | if self.thread and self.thread.is_alive(): 45 | self.thread.join() 46 | return result.returncode 47 | 48 | 49 | if __name__ == "__main__": 50 | # The script can be launched as `python3 script.py` 51 | args_to_skip = 1 if os.path.basename(__file__) in sys.argv[0] else 0 52 | sys.exit(Nvim().run(sys.argv[args_to_skip:])) 53 | -------------------------------------------------------------------------------- /test/output.lua: -------------------------------------------------------------------------------- 1 | -- custom_output.lua 2 | 3 | local hook = require'output_hook' 4 | hook.init() 5 | 6 | local old_io_write = io.write 7 | io.write = hook.write 8 | local old_io_flush = io.flush 9 | io.flush = hook.flush 10 | 11 | local outputHandler = require'busted.outputHandlers.gtest' 12 | 13 | local ret = function(options) 14 | local handler = outputHandler(options) 15 | -- Update the failure count at the suite end 16 | require'busted'.subscribe({ 'suite', 'end' }, function() 17 | require'result'.failures = #handler.failures + #handler.errors 18 | end) 19 | return handler 20 | end 21 | 22 | io.write = old_io_write 23 | io.flush = old_io_flush 24 | 25 | return ret 26 | -------------------------------------------------------------------------------- /test/output_hook.lua: -------------------------------------------------------------------------------- 1 | local H = {} 2 | 3 | H.result = require'result' 4 | H.tcp_client = nil 5 | 6 | 7 | function H.init() 8 | H.result.test_output = {} 9 | H.result.failures = 0 10 | 11 | if require'config'.send_output_to_tcp then 12 | local uv = vim.loop 13 | local client = uv.new_tcp() 14 | client:nodelay(true) 15 | client:connect("127.0.0.1", 11111, function(err) 16 | assert(not err, err) 17 | H.tcp_client = client 18 | end) 19 | assert(vim.wait(1000, function() return H.tcp_client ~= nil end), "Failed to connect") 20 | end 21 | end 22 | 23 | function H.write(...) 24 | for _, msg in ipairs({...}) do 25 | table.insert(H.result.test_output, msg) 26 | if H.tcp_client ~= nil then 27 | H.tcp_client:write(msg, function(err) 28 | assert(not err, err) 29 | end) 30 | end 31 | end 32 | end 33 | 34 | function H.flush() 35 | end 36 | 37 | return H 38 | -------------------------------------------------------------------------------- /test/prerequisites.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | import subprocess 6 | import sys 7 | from packaging import version 8 | 9 | 10 | class Prerequisites(): 11 | def __init__(self): 12 | # Check the prerequisites 13 | self.check_exe_required('nvim', '0.7.2') 14 | self.check_exe_required('python', '3.9') 15 | 16 | with open("backends.txt", "w") as bf: 17 | gdb = self.check_exe('gdb', '9') 18 | if gdb: 19 | bf.write("gdb\n") 20 | lldb = self.check_exe('lldb', '9') 21 | if lldb: 22 | bf.write("lldb\n") 23 | bashdb = self.check_exe('bashdb', '5') 24 | if bashdb: 25 | bf.write("bashdb\n") 26 | cmake = self.check_exe('cmake', '3.14.7') # need File API 27 | if cmake: 28 | bf.write("cmake\n") 29 | self.echo("Running CMake\n") 30 | generator = [] 31 | if sys.platform == 'win32': 32 | # Prefer ninja to Visual Studio 33 | generator = ['-G', 'Ninja'] 34 | subprocess.run(['cmake'] + generator + 35 | ['src', '-B', 'src/build']) 36 | 37 | self.compile_src() 38 | 39 | def echo(self, msg: str): 40 | sys.stdout.write(msg) 41 | sys.stdout.flush() 42 | 43 | def check_exe(self, exe: str, min_version: str) -> str: 44 | self.echo(f"Check for {exe} (>={min_version})".ljust(32)) 45 | try: 46 | result = subprocess.run([exe, "--version"], 47 | stdout=subprocess.PIPE, 48 | stderr=subprocess.STDOUT) 49 | if result.returncode != 0: 50 | self.echo("Failed to execute\n") 51 | return None 52 | except FileNotFoundError: 53 | self.echo("Not found\n") 54 | return None 55 | output = result.stdout.splitlines()[0].decode('utf-8') 56 | self.echo(f"{output}\n") 57 | v = next(re.finditer(r"\d+\.\d+(\.\d+)?", output)).group(0) 58 | min_version = version.parse(min_version) 59 | v = version.parse(v) 60 | if v < min_version: 61 | return None 62 | return v 63 | 64 | def check_exe_required(self, exe: str, min_version: str): 65 | path = self.check_exe(exe, min_version) 66 | if not path: 67 | raise RuntimeError(f"{exe} not found") 68 | 69 | def is_file_newer(self, file1, file2): 70 | try: 71 | return os.path.getmtime(file1) > os.path.getmtime(file2) 72 | except FileNotFoundError: 73 | return True 74 | 75 | def compile_src(self): 76 | cxx = 'g++' if sys.platform != 'darwin' else 'clang++' 77 | aout = 'a.out' if sys.platform != 'win32' else 'a.exe' 78 | 79 | self.echo("Compiling test.cpp".ljust(32)) 80 | 81 | if self.is_file_newer('src/test.cpp', aout) \ 82 | or self.is_file_newer('src/lib.hpp', aout): 83 | # Debuggers may be confused if non-absolute paths are used during 84 | # compilation. 85 | subprocess.run([cxx, '-g', '-gdwarf-2', '-std=c++11', 86 | os.path.realpath('src/test.cpp')]) 87 | self.echo(f"{aout}\n") 88 | else: 89 | self.echo(f"(cached {aout})\n") 90 | 91 | 92 | def main(): 93 | Prerequisites() 94 | 95 | 96 | if __name__ == "__main__": 97 | main() 98 | -------------------------------------------------------------------------------- /test/result.lua: -------------------------------------------------------------------------------- 1 | ---@class Result 2 | ---@field test_output string[] @table of output chunks 3 | ---@field failures number @count of failures and errors 4 | local R = { 5 | test_output = {}, 6 | failures = 0, 7 | } 8 | 9 | return R 10 | -------------------------------------------------------------------------------- /test/run-tests.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | 3 | -- Get rid of the script, leave the arguments only 4 | arg[0] = nil 5 | 6 | local server = uv.new_tcp() 7 | server:bind("127.0.0.1", 11111) 8 | server:listen(5, function(err) 9 | assert(not err, err) 10 | local sock = vim.loop.new_tcp() 11 | sock:nodelay(true) 12 | server:accept(sock) 13 | sock:read_start(function(err2, chunk) 14 | if err2 == "ECONNRESET" then 15 | sock:close() 16 | return 17 | end 18 | assert(not err2, err2) -- Check for errors. 19 | if chunk then 20 | io.stdout:write(chunk) 21 | io.stdout:flush() 22 | else 23 | sock:close() 24 | end 25 | end) 26 | end) 27 | 28 | local function assemble_output(_, d, _) 29 | -- :help channel-lines: the first and the last chunk of the data 30 | -- may belong to the same line 31 | local nl = '' 32 | for i, chunk in ipairs(d) do 33 | if i == 1 and chunk == '' then 34 | nl = '\n' 35 | end 36 | io.stdout:write(nl, chunk) 37 | nl = '\n' 38 | end 39 | end 40 | 41 | local opts = { 42 | on_stdout = assemble_output, 43 | on_stderr = assemble_output, 44 | on_exit = function(_, c, _) 45 | os.exit(c) 46 | end 47 | } 48 | 49 | local job_nvim = assert( 50 | vim.fn.jobstart({ 51 | "python", "nvim.py", "--embed", "--headless", "-n", 52 | "+let g:busted_arg=json_decode('" .. vim.fn.json_encode(arg) .. "')", 53 | "+luafile config_ci.lua", 54 | "+luafile main.lua" 55 | }, opts)) 56 | 57 | local signal = uv.new_signal() 58 | signal:start("sigint", vim.schedule_wrap(function(_) 59 | vim.fn.jobstop(job_nvim) 60 | os.exit(1) 61 | end)) 62 | 63 | --local channel = nil; 64 | --assert(vim.wait(1000, function() 65 | -- local ok, ch = pcall(vim.fn.sockconnect, 'tcp', 'localhost:44444', {rpc = true}) 66 | -- channel = ch 67 | -- return ok 68 | --end)) 69 | -- 70 | --vim.fn.rpcrequest(channel, "nvim_ui_attach", 80, 25, {ext_linegrid = true}) 71 | 72 | vim.wait(30 * 60 * 1000, function() return false end) 73 | -------------------------------------------------------------------------------- /test/spy_ui.py: -------------------------------------------------------------------------------- 1 | """A UI client for neovim used to fetch screen logs for testing.""" 2 | 3 | import logging 4 | import os 5 | from pynvim import attach 6 | 7 | 8 | class SpyUI: 9 | """Spy UI is a UI client for neovim. 10 | 11 | It will attach to a running instance of neovim and capture its screen. 12 | """ 13 | 14 | def __init__(self, width=80, height=25): 15 | """Constructor.""" 16 | self.logger = logging.getLogger("SpyUI") 17 | self.logger.setLevel(logging.DEBUG) 18 | lhandl = logging.NullHandler() if not os.environ.get('CI') \ 19 | else logging.FileHandler("spy_ui.log", encoding='utf-8') 20 | fmt = "%(asctime)s [%(levelname)s]: %(message)s" 21 | lhandl.setFormatter(logging.Formatter(fmt)) 22 | self.logger.addHandler(lhandl) 23 | 24 | self.nvim = attach('tcp', address='localhost', port=44444) 25 | self.width = int(width) 26 | self.height = int(height) 27 | self.logger.info("Starting SpyUI %dx%d", self.width, self.height) 28 | self.nvim.ui_attach(self.width, self.height, rgb=True, 29 | ext_linegrid=True) 30 | self.grid = [] 31 | for _ in range(self.height): 32 | self.grid.append([' '] * self.width) 33 | self.screen = self.to_str() 34 | 35 | def run(self): 36 | """Run the loop.""" 37 | def _req(name, arg): 38 | # print("req", name, arg, file=sys.stderr) 39 | pass 40 | self.nvim.run_loop(_req, self._not) 41 | 42 | def close(self): 43 | """Break from the UI loop.""" 44 | self.nvim.stop_loop() 45 | 46 | IGNORE_REDRAW = frozenset( 47 | ("mode_info_set", "mode_change", "mouse_on", "mouse_off", 48 | "option_set", "default_colors_set", 49 | "hl_group_set", "hl_attr_define", 50 | "cursor_goto", "grid_cursor_goto", "busy_start", "busy_stop", 51 | "update_fg", "update_bg", "update_sp", "highlight_set", 52 | "win_viewport" 53 | ) 54 | ) 55 | 56 | def _not(self, name, args): 57 | if name == "redraw": 58 | self._redraw(args) 59 | else: 60 | # print(name, args, file=sys.stderr) 61 | pass 62 | 63 | def _redraw(self, args): 64 | for arg in args: 65 | cmd = arg[0] 66 | 67 | def for_each_param(handler): 68 | for i in range(1, len(arg)): 69 | handler(*arg[i]) 70 | 71 | par = arg[1] 72 | if cmd in self.IGNORE_REDRAW: 73 | continue 74 | if cmd == "grid_resize": 75 | for_each_param(self._grid_resize) 76 | elif cmd == "grid_clear": 77 | for_each_param(self._grid_clear) 78 | elif cmd == "grid_line": 79 | for_each_param(self._grid_line) 80 | elif cmd == "grid_scroll": 81 | for_each_param(self._grid_scroll) 82 | elif cmd == "flush": 83 | screen = self.to_str() 84 | if screen != self.screen: 85 | # print(screen) 86 | self.logger.info("\n%s", self.screen) 87 | self.screen = screen 88 | else: 89 | # print(cmd, par, file=sys.stderr) 90 | pass 91 | 92 | def _grid_resize(self, gr, width, height): 93 | assert gr == 1 94 | new_grid = [] 95 | for _ in range(height): 96 | new_grid.append([' '] * width) 97 | for row in range(min(self.height, height)): 98 | for col in range(min(self.width, width)): 99 | new_grid[row][col] = self.grid[row][col] 100 | self.grid = new_grid 101 | self.width = width 102 | self.height = height 103 | 104 | def _grid_clear(self, gr): 105 | assert gr == 1 106 | for row in range(self.height): 107 | for col in range(self.width): 108 | self.grid[row][col] = ' ' 109 | 110 | def _grid_line(self, gr, row, col, cells, wrap, *args): 111 | assert gr == 1 112 | for cell in cells: 113 | text = cell[0] 114 | repeat = 1 115 | if len(cell) > 2: 116 | repeat = int(cell[2]) 117 | for i in range(repeat): 118 | self.grid[row][col + i] = text 119 | col += repeat 120 | 121 | def _grid_scroll(self, gr, top, bot, left, right, rows, cols): 122 | assert gr == 1 123 | assert cols == 0 124 | if rows > 0: 125 | for row in range(top, bot - rows): 126 | rfrom = row + rows 127 | for col in range(left, right): 128 | self.grid[row][col] = self.grid[rfrom][col] 129 | else: 130 | for row in range(bot - 1, top - rows - 1, -1): 131 | rfrom = row + rows 132 | for col in range(left, right): 133 | self.grid[row][col] = self.grid[rfrom][col] 134 | 135 | def to_str(self): 136 | """Render the grid into a string.""" 137 | lines = [] 138 | lines.append("+" + "-" * self.width + "+") 139 | for row in self.grid: 140 | lines.append('|' + ''.join(row) + '|') 141 | lines.append("+" + "-" * self.width + "+") 142 | return "\n".join(lines) 143 | 144 | 145 | if __name__ == "__main__": 146 | width = os.environ.get("COLUMNS") 147 | if not width: 148 | width = 80 149 | height = os.environ.get("LINES") 150 | if not height: 151 | height = 25 152 | SpyUI(width=width, height=height).run() 153 | -------------------------------------------------------------------------------- /test/src/.gitignore: -------------------------------------------------------------------------------- 1 | build/* 2 | -------------------------------------------------------------------------------- /test/src/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | project("NVIM-GDB CMake Test") 3 | 4 | add_executable(cmake_test_exec 5 | test.cpp) 6 | 7 | -------------------------------------------------------------------------------- /test/src/lib.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | namespace Lib { 4 | 5 | int Baz() 6 | { 7 | int ret{0}; 8 | for (int i = 0; i <= 100; ++i) 9 | { 10 | ret += i; 11 | } 12 | return ret; 13 | } 14 | 15 | } //namespace Lib; 16 | 17 | #include 18 | 19 | namespace Lib { 20 | 21 | unsigned GetLoopCount(int argc, char *argv[]) 22 | { 23 | if (argc > 1) 24 | { 25 | return strtoul(argv[1], nullptr, 10); 26 | } 27 | return 0xffff; 28 | } 29 | 30 | } //namespace Lib; 31 | -------------------------------------------------------------------------------- /test/src/test.cpp: -------------------------------------------------------------------------------- 1 | #include "lib.hpp" 2 | 3 | unsigned Bar(unsigned i) 4 | { 5 | return i * 2; 6 | } 7 | 8 | unsigned Foo(unsigned n) 9 | { 10 | if (!n) 11 | return 0; 12 | return n + Bar(n - 1); 13 | } 14 | 15 | int main(int argc, char *argv[]) 16 | { 17 | for (int i = 0; i < 10; ++i) 18 | { 19 | Foo(i); 20 | } 21 | Lib::Baz(); 22 | for (unsigned i = 0, n = Lib::GetLoopCount(argc, argv); i < n; ++i); 23 | return 0; 24 | } 25 | -------------------------------------------------------------------------------- /test/thread.lua: -------------------------------------------------------------------------------- 1 | local Thread = {} 2 | Thread.__index = Thread 3 | 4 | function Thread.create(func, on_stuck) 5 | local co = setmetatable({}, Thread) 6 | co.watchdog_flag = false 7 | co.watchdog_timer = vim.loop.new_timer() 8 | co.watchdog_timer:start(5000, 5000, function () 9 | if co.watchdog_flag then 10 | co.watchdog_timer:stop() 11 | co.watchdog_timer:close() 12 | vim.schedule(on_stuck) 13 | end 14 | co.watchdog_flag = true 15 | end) 16 | Thread.it = co 17 | co.co = coroutine.create(function() func(co) end) 18 | co:step() 19 | return co 20 | end 21 | 22 | function Thread:cleanup() 23 | self.watchdog_timer:stop() 24 | self.watchdog_timer:close() 25 | end 26 | 27 | function Thread:step() 28 | self.watchdog_flag = false 29 | local success, ms = coroutine.resume(self.co) 30 | if not success or coroutine.status(self.co) == "dead" then 31 | return 32 | end 33 | if type(ms) == 'number' and ms > 0 then 34 | vim.defer_fn(function() self:step() end, ms) 35 | else 36 | vim.schedule(function() self:step() end) 37 | end 38 | end 39 | 40 | function Thread.y(ms) 41 | coroutine.yield(ms) 42 | end 43 | 44 | return Thread 45 | -------------------------------------------------------------------------------- /utils/setup-testenv.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | NVIM_RELEASE_URL = 'https://github.com/neovim/neovim/releases/latest/download' 4 | 5 | 6 | if __name__ == "__main__": 7 | if sys.platform == "win32": 8 | from testenv_win32 import Setup 9 | elif sys.platform == "darwin": 10 | from testenv_darwin import Setup 11 | else: 12 | from testenv_linux import Setup 13 | Setup(NVIM_RELEASE_URL) 14 | -------------------------------------------------------------------------------- /utils/testenv_darwin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import subprocess 4 | import urllib.request 5 | 6 | 7 | class Setup: 8 | def __init__(self, url: str): 9 | bindir = os.path.join(os.getenv('HOME'), 'bin') 10 | subprocess.run(f'mkdir -p {bindir}', shell=True, check=True) 11 | github_path = os.getenv("GITHUB_PATH") 12 | if github_path: 13 | with open(github_path, 'a') as f: 14 | f.write(f'{bindir}\n') 15 | else: 16 | print(f"Ensure {bindir} is in PATH") 17 | 18 | subprocess.run('pip install --user six', shell=True, check=True) 19 | 20 | # Macos may be running on arm64 21 | machine = platform.machine() 22 | 23 | urllib.request.urlretrieve(f"{url}/nvim-macos-{machine}.tar.gz", 24 | f"nvim-macos-{machine}.tar.gz") 25 | subprocess.run( 26 | f''' 27 | tar -xf nvim-macos-{machine}.tar.gz 28 | cat >"$HOME/bin/nvim" <"$HOME/bin/lldb" <<'EOF' 40 | #!/bin/bash 41 | PATH=/usr/bin /usr/bin/lldb "$@" 42 | EOF 43 | chmod +x "$HOME/bin/lldb" 44 | ''', 45 | shell=True, check=True 46 | ) 47 | 48 | subprocess.run( 49 | r''' 50 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 51 | brew install luarocks luajit 52 | luarocks --lua-version=5.1 init 53 | luarocks --lua-version=5.1 install busted 54 | ''', 55 | shell=True, check=True 56 | ) 57 | -------------------------------------------------------------------------------- /utils/testenv_linux.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import urllib.request 4 | 5 | 6 | class Setup: 7 | def __init__(self, url: str): 8 | urllib.request.urlretrieve(f"{url}/nvim.appimage", "nvim.appimage") 9 | os.chmod("nvim.appimage", 0o755) 10 | bindir = os.path.join(os.getenv('HOME'), 'bin') 11 | os.mkdir(bindir) 12 | os.symlink(os.path.realpath("nvim.appimage"), 13 | os.path.join(bindir, "nvim")) 14 | 15 | with open(os.getenv("GITHUB_PATH"), 'a') as f: 16 | f.write(f'{bindir}\n') 17 | 18 | subprocess.run( 19 | r''' 20 | sudo apt-get update 21 | sudo apt-get install libfuse2 gdb lldb python3-lldb-14 cmake file --no-install-recommends 22 | # Fix lldb python path mismatch 23 | sudo mkdir -p /usr/lib/local/lib/python3.10 24 | sudo ln -s /usr/lib/llvm-14/lib/python3.10/dist-packages /usr/lib/local/lib/python3.10/dist-packages 25 | ''', 26 | shell=True, check=True 27 | ) 28 | 29 | subprocess.run( 30 | r''' 31 | # Install bashdb 32 | ver=$(curl -sL "https://sourceforge.net/projects/bashdb/rss" \ 33 | | grep -oP '(?<=bashdb-)[0-9.-]+(?=\.tar\.bz2)' \ 34 | | head -1) 35 | bashdb_url="https://sourceforge.net/projects/bashdb/files/bashdb" 36 | wget -qc "$bashdb_url/${ver}/bashdb-${ver}.tar.bz2" 37 | tar -xvf bashdb-${ver}.tar.bz2 38 | cd bashdb-${ver} 39 | sed -e "/^\s\+'5.0' / s:): | '5.1'&:g" -i configure 40 | ./configure 41 | make 42 | sudo make install 43 | 44 | command -v bashdb 45 | ''', 46 | shell=True, check=True 47 | ) 48 | 49 | subprocess.run( 50 | r''' 51 | sudo apt-get install luarocks luajit 52 | luarocks --lua-version=5.1 init 53 | luarocks --lua-version=5.1 install busted 54 | ''', 55 | shell=True, check=True 56 | ) 57 | -------------------------------------------------------------------------------- /utils/testenv_win32.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import subprocess 5 | import urllib.request 6 | import zipfile 7 | 8 | 9 | class Setup: 10 | def __init__(self, url: str): 11 | urllib.request.urlretrieve(f"{url}/nvim-win64.zip", "nvim-win64.zip") 12 | with zipfile.ZipFile("nvim-win64.zip", 'r') as zip_ref: 13 | zip_ref.extractall("nvim") 14 | nvimbin = os.path.join(os.path.realpath("nvim"), "nvim-win64", "bin") 15 | with open(os.getenv("GITHUB_PATH"), 'a') as f: 16 | f.write(f'{nvimbin}\n') 17 | 18 | subprocess.run("choco install --no-progress -y msys2", 19 | shell=True, check=True) 20 | subprocess.run('c:\\tools\\msys64\\usr\\bin\\pacman.exe -S --noconfirm' 21 | ' mingw-w64-x86_64-gcc' 22 | ' mingw-w64-x86_64-gdb' 23 | ' mingw-w64-x86_64-lldb' 24 | ' mingw-w64-x86_64-cmake' 25 | ' mingw-w64-x86_64-ninja' 26 | ' mingw-w64-x86_64-lua51', 27 | shell=True, check=True) 28 | # c:\tools\msys64\mingw64\bin is appended to PATH in all.py 29 | # And we need it here too: 30 | current_path = os.environ['PATH'] 31 | mingw_bin = r'c:\tools\msys64\mingw64\bin' 32 | os.environ['PATH'] = current_path + os.pathsep + mingw_bin 33 | 34 | luarocks_url = 'https://luarocks.github.io/luarocks/releases/' 35 | response = urllib.request.urlopen(luarocks_url) 36 | content = response.read().decode("utf-8") 37 | matches = re.findall(r'href="(.*windows-64)\.zip"', content) 38 | urllib.request.urlretrieve(f"{luarocks_url}{matches[0]}.zip", 39 | "luarocks.zip") 40 | with zipfile.ZipFile('luarocks.zip', 'r') as zip_ref: 41 | zip_ref.extractall("luarocks") 42 | shutil.move(f"luarocks/{matches[0]}/luarocks.exe", "luarocks/") 43 | 44 | subprocess.run([r'.\luarocks\luarocks.exe', '--lua-version=5.1', 45 | 'init'], check=True) 46 | subprocess.run([r'.\luarocks\luarocks.exe', '--lua-version=5.1', 47 | 'install', 'busted'], check=True) 48 | --------------------------------------------------------------------------------