]> git.ipfire.org Git - thirdparty/vim.git/commitdiff
runtime(termdebug): Add remote debugging capabilities
authorMiguel Barro <miguel.barro@live.com>
Wed, 8 Oct 2025 18:15:51 +0000 (18:15 +0000)
committerChristian Brabandt <cb@256bit.org>
Wed, 8 Oct 2025 18:15:51 +0000 (18:15 +0000)
closes: #18429

Co-authored-by: Christian Brabandt <cb@256bit.org>
Signed-off-by: Miguel Barro <miguel.barro@live.com>
Signed-off-by: Christian Brabandt <cb@256bit.org>
runtime/doc/tags
runtime/doc/terminal.txt
runtime/pack/dist/opt/termdebug/plugin/termdebug.vim
src/testdir/test_plugin_termdebug.vim

index cfb54e16f61afb3e8e21f525146078b554d9f1dc..4f4a24d77ed47158df3c58224178d58f301cbdf9 100644 (file)
@@ -10952,8 +10952,12 @@ termdebug-example      terminal.txt    /*termdebug-example*
 termdebug-frames       terminal.txt    /*termdebug-frames*
 termdebug-mappings     terminal.txt    /*termdebug-mappings*
 termdebug-prompt       terminal.txt    /*termdebug-prompt*
+termdebug-remote       terminal.txt    /*termdebug-remote*
+termdebug-remote-example       terminal.txt    /*termdebug-remote-example*
+termdebug-remote-window        terminal.txt    /*termdebug-remote-window*
 termdebug-starting     terminal.txt    /*termdebug-starting*
 termdebug-stepping     terminal.txt    /*termdebug-stepping*
+termdebug-substitute-path      terminal.txt    /*termdebug-substitute-path*
 termdebug-timeout      terminal.txt    /*termdebug-timeout*
 termdebug-variables    terminal.txt    /*termdebug-variables*
 termdebug_contributing terminal.txt    /*termdebug_contributing*
index b667832f05cbb57d62ffbff41a9e995d638dff36..cbb997dc3a80752730213eab60d9234cf79ca499 100644 (file)
@@ -1,4 +1,4 @@
-*terminal.txt* For Vim version 9.1.  Last change: 2025 Sep 15
+*terminal.txt* For Vim version 9.1.  Last change: 2025 Oct 08
 
 
                  VIM REFERENCE MANUAL    by Bram Moolenaar
@@ -44,6 +44,7 @@ If the result is "1" you have it.
       Prompt mode                              |termdebug-prompt|
       Mappings                                 |termdebug-mappings|
       Communication                            |termdebug-communication|
+      Remote Debugging                         |termdebug-remote|
       Customizing                              |termdebug-customizing|
 
 {only available when compiled with the |+terminal| feature}
@@ -1635,12 +1636,103 @@ interrupt the running program.  But after using the MI command
 communication channel.
 
 
+Remote debugging ~
+                                                       *termdebug-remote*
+One of the main issues of remote debugging is the access to the debuggee's
+source files.  The plugin can profit from system and vim's networking
+capabilities to workaround this.
+                                               *termdebug-remote-example*
+The |termdebug-example| can be replicated by running the `gdb` debugger to
+debug Vim on a remote Linux machine accessible via `ssh`.
+
+- Build Vim as explained in the local example.
+
+- If "socat" is not available in the remote machine 'terminal' mode will not
+  work properly. Fall back to |termdebug_use_prompt|: >
+    :let g:termdebug_config = {}
+    :let g:termdebug_config['use_prompt'] = v:true
+
+- Specify the command line to run the remote `gdb` instance: >
+    :let g:termdebug_config['command'] = ['ssh', 'hostname', 'gdb']
+<  Explaining `ssh` is beyond the scope of this example, but notice the
+  command line can be greatly simplified by specifying the user, keys and
+  other options into the `$HOME/.ssh/config` file.
+
+- Provide a hint for translating remote paths into |netrw| paths: >
+    :let g:termdebug_config['substitute_path'] = { '/': 'scp://hostname//' }
+
+- Load the termdebug plugin and start debugging Vim: >
+    :packadd termdebug
+    :Termdebug vim
+
+You now have the same three windows of the local example and can follow the
+very same steps.  The only difference is that the source windows displays a
+netrw buffer instead of a local one.
+
+                                               *termdebug-substitute-path*
+Use the `g:termdebug_config['substitute_path']` entry to map remote to local
+files using the same strategy that gdb's `substitute-path` command uses.
+For example:
+- Use |netrw| to access files remoting via ssh: >
+    let g:termdebug_config['command'] = ['ssh', 'hostname', 'gdb']
+    let g:termdebug_config['substitute_path'] = { '/': 'scp://hostname//' }
+< Note: that the key specifies the remote machine root path and the value
+ the local one.
+- Use Windows' `UNC` paths to access `WSL2` sources: >
+    let g:termdebug_config['command'] = ['wsl', 'gdb']
+    let g:termdebug_config['substitute_path'] = {
+       \ '/': '\\wsl.localhost\Ubuntu-22.04\',
+       \ '/mnt/c/': 'C:/' }
+< Note: that several mappings are required: one for each drive unit
+ and one for the linux filesystem (queried via `wslpath`).
+
+In this mode any `ssh` or `wsl` command would be detected and a similar
+command would be used to launch `socat` in a remote `tty` terminal session
+and connect it to `gdb`.
+If `socat` is not available a plain remote terminal would be used as
+fallback.
+The next session shows how to override this default behaviour.
+
+                                               *termdebug-remote-window*
+In order to use another remote terminal client, set "remote_window" entry
+in `g:termdebug_config` variable before invoking `:Termdebug`.  For example:
+- Debugging inside a docker container using "prompt" mode: >
+    let g:termdebug_config['use_prompt'] = v:true
+    let g:termdebug_config['command'] = ['docker', 'run', '-i',
+       \ '--rm', '--name', 'container-name', 'image-name', 'gdb']
+    let g:termdebug_config['remote_window'] =
+       \ ['docker', 'exec', '-ti', 'container-name'
+       \ ,'socat', '-dd', '-', 'PTY,raw,echo=0']
+
+- Debugging inside a docker container using a "terminal buffer".
+  The container should be already running because unlike the previous
+  case for `terminal mode` "program" and "communication" ptys are created
+  before the gdb one: >
+    $ docker run -ti --rm --name container-name immage-name
+
+<  Then, launch the debugger: >
+    let g:termdebug_config['use_prompt'] = v:false " default
+    let g:termdebug_config['command'] =
+       \ ['docker', 'exec', '-ti', 'container-name', 'gdb']
+    let g:termdebug_config['remote_window'] =
+       \ ['docker', 'exec', '-ti', 'container-name'
+       \ ,'socat', '-dd', '-', 'PTY,raw,echo=0']
+
+Note: "command" cannot use `-t` on |termdebug-prompt| mode because prompt
+buffers cannot handle `tty` connections.
+The "remote_window" command must use `-t` because otherwise it will lack
+a `pty slave device` for gdb to connect.
+Note: "socat" must be available in the remote machine on "terminal" mode.
+Note: docker container sources can be accessible combining `volumes`
+with mappings (see |termdebug-substitute-path|).
+
 GDB command ~
                                                        *g:termdebugger*
 To change the name of the gdb command, set "debugger" entry in
 g:termdebug_config or the "g:termdebugger" variable before invoking
 `:Termdebug`: >
        let g:termdebug_config['command'] = "mygdb"
+
 If there is no g:termdebug_config you can use: >
        let g:termdebugger = "mygdb"
 
@@ -1648,6 +1740,7 @@ However, the latter form will be deprecated in future releases.
 
 If the command needs an argument use a List: >
        let g:termdebug_config['command'] = ['rr', 'replay', '--']
+
 If there is no g:termdebug_config you can use: >
        let g:termdebugger = ['rr', 'replay', '--']
 
@@ -1655,6 +1748,13 @@ Several arguments will be added to make gdb work well for the debugger.
 If you want to modify them, add a function to filter the argument list: >
        let g:termdebug_config['command_filter'] = MyDebugFilter
 
+A "command_filter" scenario is solving escaping issues on remote debugging
+over "ssh".  For convenience a default filter is provided for escaping
+whitespaces inside the arguments.  It is automatically configured for "ssh",
+but can be employed in other use cases like this: >
+       let g:termdebug_config['command_filter'] =
+       /   function('g:Termdebug_escape_whitespace')
+
 If you do not want the arguments to be added, but you do need to set the
 "pty", use a function to add the necessary arguments: >
        let g:termdebug_config['command_add_args'] = MyAddArguments
@@ -1717,7 +1817,8 @@ than 99 will be displayed as "9+".
 If you want to customize the breakpoint signs to show `>>` in the signcolumn: >
        let g:termdebug_config['sign'] = '>>'
 You can also specify individual signs for the first several breakpoints: >
-       let g:termdebug_config['signs'] = ['>1', '>2', '>3', '>4', '>5', '>6', '>7', '>8', '>9']
+       let g:termdebug_config['signs'] = ['>1', '>2', '>3', '>4', '>5',
+         \ '>6', '>7', '>8', '>9']
        let g:termdebug_config['sign'] = '>>'
 If you would like to use decimal (base 10) breakpoint signs: >
        let g:termdebug_config['sign_decimal'] = 1
index a4183c27495c7e7849c9e7fbce469878e16e2fd3..cefa1a08554b836b63633beb5f8afd3ee2eecd61 100644 (file)
@@ -4,7 +4,7 @@ vim9script
 
 # Author: Bram Moolenaar
 # Copyright: Vim license applies, see ":help license"
-# Last Change: 2025 Sep 15
+# Last Change: 2025 Oct 08
 # Converted to Vim9: Ubaldo Tiberi <ubaldo.tiberi@gmail.com>
 
 # WORK IN PROGRESS - The basics works stable, more to come
@@ -34,6 +34,7 @@ vim9script
 # Gdb is run as a job with callbacks for I/O.
 # On Unix another terminal window is opened to run the debugged program
 # On MS-Windows a separate console is opened to run the debugged program
+# but a terminal window is used to run remote debugged programs.
 
 # The communication with gdb uses GDB/MI.  See:
 # https://sourceware.org/gdb/current/onlinedocs/gdb/GDB_002fMI.html
@@ -435,8 +436,73 @@ def IsGdbStarted(): bool
   return true
 enddef
 
-def CreateProgramPty(): string
-  ptybufnr = term_start('NONE', {
+# Check if the debugger is running remotely and return a suitable command to pty remotely
+def GetRemotePtyCmd(gdb_cmd: list<string>): list<string>
+  # Check if the user provided a command to launch the program window
+  var term_cmd = null_list
+  if exists('g:termdebug_config') && has_key(g:termdebug_config, 'remote_window')
+    term_cmd = g:termdebug_config['remote_window']
+    term_cmd = type(term_cmd) == v:t_list ? copy(term_cmd) : [term_cmd]
+  else
+    # Check if it is a remote gdb, the program terminal should be started
+    # on the remote machine.
+    const remote_pattern = '^\(ssh\|wsl\)'
+    if gdb_cmd[0] =~? remote_pattern
+      var gdb_pos = indexof(gdb_cmd, $'v:val =~? "^{GetCommand()[-1]}"')
+      if gdb_pos > 0
+        # strip debugger call
+        term_cmd = gdb_cmd[0 : gdb_pos - 1]
+        # roundtrip to check if socat is available on the remote side
+        silent call system(join(term_cmd, ' ') .. ' socat -h')
+        if v:shell_error
+          Echowarn('Install socat on the remote machine for a program window better experience')
+        else
+          # create a devoted tty slave device and link to stdin/stdout
+          term_cmd += ['socat', '-dd', '-', 'PTY,raw,echo=0']
+          ch_log($'launching remote ttys using "{join(term_cmd)}"')
+        endif
+      endif
+    endif
+  endif
+  return term_cmd
+enddef
+
+# Retrieve the remote pty device from a remote terminal
+# If interact is true, use remote tty command to get the pty device
+def GetRemotePtyDev(bufnr: number, interact: bool): string
+  var pty: string = null_string
+  var line = null_string
+
+  for j in range(5)
+
+    if interact
+      term_sendkeys(bufnr, "tty\<CR>")
+    endif
+
+    for i in range(0, term_getsize(bufnr)[0])
+      line = term_getline(bufnr, i)
+      if line =~? "/dev/pts"
+          pty = line
+          break
+      endif
+      term_wait(bufnr, 100)
+    endfor # i
+
+    if pty != null_string
+      # Clear the terminal window
+      if interact
+        term_sendkeys(bufnr, "clear\<CR>")
+      endif
+      break
+    endif
+
+  endfor # j
+
+  return pty
+enddef
+
+def CreateProgramPty(cmd: list<string> = null_list): string
+  ptybufnr = term_start(!cmd ? 'NONE' : cmd, {
     term_name: ptybufname,
     vertical: vvertical})
   if ptybufnr == 0
@@ -454,20 +520,76 @@ def CreateProgramPty(): string
     endif
   endif
 
-  return job_info(term_getjob(ptybufnr))['tty_out']
+  if !cmd
+    return job_info(term_getjob(ptybufnr))['tty_out']
+  else
+    var interact = indexof(cmd, 'v:val =~? "^socat"') < 0
+    var pty = GetRemotePtyDev(ptybufnr, interact)
+
+    if pty !~? "/dev/pts"
+      Echoerr('Failed to get the program window tty')
+      exe $'bwipe! {ptybufnr}'
+      pty = null_string
+    elseif pty !~? "^/dev/pts"
+      # remove the prompt
+      pty = pty->matchstr('/dev/pts/\d\+')
+    endif
+
+    return pty
+  endif
 enddef
 
-def CreateCommunicationPty(): string
+def CreateCommunicationPty(cmd: list<string> = null_list): string
   # Create a hidden terminal window to communicate with gdb
-  commbufnr = term_start('NONE', {
-    term_name: commbufname,
-    out_cb: CommOutput,
-    hidden: 1
-  })
+  var options: dict<any> = { term_name: commbufname, out_cb: CommOutput, hidden: 1 }
+
+  if !cmd
+    commbufnr = term_start('NONE', options)
+  else
+    # avoid message wrapping that prevents proper parsing
+    options['term_cols'] = 500
+    commbufnr = term_start(cmd, options)
+  endif
+
   if commbufnr == 0
     return null_string
   endif
-  return job_info(term_getjob(commbufnr))['tty_out']
+
+  if !cmd
+    return job_info(term_getjob(commbufnr))['tty_out']
+  else
+    # CommunicationPty only will be reliable with socat
+    if indexof(cmd, 'v:val =~? "^socat"') < 0
+      Echoerr('Communication window should be started with socat')
+      exe $'bwipe! {commbufnr}'
+      return null_string
+    endif
+
+    var pty = GetRemotePtyDev(commbufnr, false)
+
+    if pty !~? "/dev/pts"
+      Echoerr('Failed to get the communication window tty')
+      exe $'bwipe! {commbufnr}'
+      pty = null_string
+    elseif pty !~? "^/dev/pts"
+      # remove the prompt
+      pty = pty->matchstr('/dev/pts/\d\+')
+    endif
+
+    return pty
+  endif
+enddef
+
+# Convenient filter to workaround remote escaping issues.
+# For example, ssh doesn't escape spaces for the gdb arguments.
+# Workaround doing:
+# let g:termdebug_config['command_filter'] = function('g:Termdebug_escape_whitespace')
+def g:Termdebug_escape_whitespace(args: list<string>): list<string>
+  var new_args: list<string> = []
+  for arg in args
+    new_args += [substitute(arg, ' ', '\\ ', 'g')]
+  endfor
+  return new_args
 enddef
 
 def CreateGdbConsole(dict: dict<any>, pty: string, commpty: string): string
@@ -497,6 +619,12 @@ def CreateGdbConsole(dict: dict<any>, pty: string, commpty: string): string
     gdb_cmd += ['-ex', 'echo startupdone\n']
   endif
 
+  # Escape whitespaces in the gdb arguments for ssh remoting
+  if exists('g:termdebug_config') && !has_key(g:termdebug_config, 'command_filter') &&
+      gdb_cmd[0] =~? '^ssh'
+    g:termdebug_config['command_filter'] = function('g:Termdebug_escape_whitespace')
+  endif
+
   if exists('g:termdebug_config') && has_key(g:termdebug_config, 'command_filter')
     gdb_cmd = g:termdebug_config.command_filter(gdb_cmd)
   endif
@@ -599,14 +727,18 @@ enddef
 # Open a terminal window without a job, to run the debugged program in.
 def StartDebug_term(dict: dict<any>)
 
-  var programpty = CreateProgramPty()
+  # Retrieve command if remote pty is needed
+  var gdb_cmd = GetCommand()
+  var term_cmd = GetRemotePtyCmd(gdb_cmd)
+
+  var programpty = CreateProgramPty(term_cmd)
   if programpty is null_string
     Echoerr('Failed to open the program terminal window')
     CloseBuffers()
     return
   endif
 
-  var commpty = CreateCommunicationPty()
+  var commpty = CreateCommunicationPty(term_cmd)
   if commpty is null_string
     Echoerr('Failed to open the communication terminal window')
     CloseBuffers()
@@ -656,16 +788,27 @@ def StartDebug_prompt(dict: dict<any>)
   var gdb_args = get(dict, 'gdb_args', [])
   var proc_args = get(dict, 'proc_args', [])
 
-  # Add -quiet to avoid the intro message causing a hit-enter prompt.
-  gdb_cmd += ['-quiet']
+  # directly communicate via mi2. This option must precede any -iex options for proper
+  # interpretation.
+  gdb_cmd += ['--interpreter=mi2']
   # Disable pagination, it causes everything to stop at the gdb, needs to be run early
   gdb_cmd += ['-iex', 'set pagination off']
   # Interpret commands while the target is running.  This should usually only
   # be exec-interrupt, since many commands don't work properly while the
   # target is running (so execute during startup).
   gdb_cmd += ['-iex', 'set mi-async on']
-  # directly communicate via mi2
-  gdb_cmd += ['--interpreter=mi2']
+  # Add -quiet to avoid the intro message causing a hit-enter prompt.
+  gdb_cmd += ['-quiet']
+
+  # Escape whitespaces in the gdb arguments for ssh remoting
+  if exists('g:termdebug_config') && !has_key(g:termdebug_config, 'command_filter') &&
+      gdb_cmd[0] =~? '^ssh'
+    g:termdebug_config['command_filter'] = function('g:Termdebug_escape_whitespace')
+  endif
+
+  if exists('g:termdebug_config') && has_key(g:termdebug_config, 'command_filter')
+    gdb_cmd = g:termdebug_config.command_filter(gdb_cmd)
+  endif
 
   # Adding arguments requested by the user
   gdb_cmd += gdb_args
@@ -686,24 +829,61 @@ def StartDebug_prompt(dict: dict<any>)
   set modified
   gdb_channel = job_getchannel(gdbjob)
 
-  ptybufnr = 0
-  if has('win32')
-    # MS-Windows: run in a new console window for maximum compatibility
-    SendCommand('set new-console on')
-  elseif has('terminal')
-    # Unix: Run the debugged program in a terminal window.  Open it below the
-    # gdb window.
-    belowright ptybufnr = term_start('NONE', {
-      term_name: 'debugged program',
-      vertical: vvertical
-    })
-    if ptybufnr == 0
-      Echoerr('Failed to open the program terminal window')
+  # Retrieve command if remote pty is needed
+  var term_cmd = GetRemotePtyCmd(gdb_cmd)
+
+  # If we are not using socat maybe is a shell:
+  var interact = indexof(term_cmd, 'v:val =~? "^socat"') < 0
+
+  if has('terminal') && (term_cmd != null || !has('win32'))
+
+    # Try open terminal twice because sync with gdbjob may not succeed
+    # the first time (docker daemon for example)
+    var trials: number = 2
+    var pty: string = null_string
+
+    while trials > 0
+
+      # Run the debugged program in a window.  Open it below the
+      # gdb window.
+      belowright ptybufnr = term_start(
+        term_cmd != null ? term_cmd : 'NONE', {
+        term_name: 'debugged program',
+        vertical: vvertical
+      })
+
+      if ptybufnr == 0
+        Echoerr('Failed to open the program terminal window')
+        job_stop(gdbjob)
+        return
+      endif
+
+      ptywin = win_getid()
+
+      if term_cmd is null
+        pty = job_info(term_getjob(ptybufnr))['tty_out']
+      else
+        # Retrieve remote pty value
+        pty = GetRemotePtyDev(ptybufnr, interact)
+      endif
+
+      if pty !~? "/dev/pts"
+        exe $'bwipe! {ptybufnr}'
+        --trials
+        pty = null_string
+      else
+        break
+      endif
+    endwhile
+
+    if pty !~? "/dev/pts"
+      Echoerr('Failed to get the program windows tty')
       job_stop(gdbjob)
-      return
+    elseif pty !~? "^/dev/pts"
+      # remove the prompt
+      pty = pty->matchstr('/dev/pts/\d\+')
     endif
-    ptywin = win_getid()
-    var pty = job_info(term_getjob(ptybufnr))['tty_out']
+
     SendCommand($'tty {pty}')
 
     # Since GDB runs in a prompt window, the environment has not been set to
@@ -714,6 +894,9 @@ def StartDebug_prompt(dict: dict<any>)
     SendCommand($'set env COLUMNS = {winwidth(ptywin)}')
     SendCommand($'set env COLORS = {&t_Co}')
     SendCommand($'set env VIM_TERMINAL = {v:version}')
+  elseif has('win32')
+    # MS-Windows: run in a new console window for maximum compatibility
+    SendCommand('set new-console on')
   else
     # TODO: open a new terminal, get the tty name, pass on to gdb
     SendCommand('show inferior-tty')
@@ -930,7 +1113,7 @@ enddef
 const NullRepl = 'XXXNULLXXX'
 
 # Extract the "name" value from a gdb message with fullname="name".
-def GetFullname(msg: string): string
+def GetLocalFullname(msg: string): string
   if msg !~ 'fullname'
     return ''
   endif
@@ -944,6 +1127,50 @@ def GetFullname(msg: string): string
   return name
 enddef
 
+# Turn a remote machine local path into a remote one.
+def Local2RemotePath(path: string): string
+  # If no mappings are provided keep the path.
+  if !exists('g:termdebug_config') || !has_key(g:termdebug_config, 'substitute_path')
+    return path
+  endif
+
+  var mappings: list<any> = items(g:termdebug_config['substitute_path'])
+
+  # Try to match the longest local path first.
+  sort(mappings, (a, b) => len(b[0]) - len(a[0]))
+
+  for [local, remote] in mappings
+    const pattern = '^' .. escape(local, '\.*~()')
+    if path =~ pattern
+      return substitute(path, pattern, escape(remote, '\.*~()'), '')
+    endif
+  endfor
+
+  return path
+enddef
+
+# Turn a remote path into a local one to the remote machine.
+def Remote2LocalPath(path: string): string
+  # If no mappings are provided keep the path.
+  if !exists('g:termdebug_config') || !has_key(g:termdebug_config, 'substitute_path')
+    return path
+  endif
+
+  var mappings: list<any> = items(g:termdebug_config['substitute_path'])
+
+  # Try to match the longest remote path first.
+  sort(mappings, (a, b) => len(b[1]) - len(a[1]))
+
+  for [local, remote] in mappings
+    const pattern = '^' .. escape(substitute(remote, '[\/]', '[\\/]', 'g'), '.*~()')
+    if path =~ pattern
+      return substitute(path, pattern, local, '')
+    endif
+  endfor
+
+  return path
+enddef
+
 # Extract the "addr" value from a gdb message with addr="0x0001234".
 def GetAsmAddr(msg: string): string
   if msg !~ 'addr='
@@ -1159,7 +1386,7 @@ def CommOutput(chan: channel, message: string)
 enddef
 
 def GotoProgram()
-  if has('win32')
+  if has('win32') && !ptywin
     if executable('powershell')
       system(printf('powershell -Command "add-type -AssemblyName microsoft.VisualBasic;[Microsoft.VisualBasic.Interaction]::AppActivate(%d);"', pid))
     endif
@@ -1374,7 +1601,8 @@ def Until(at: string)
     ch_log('assume that program is running after this command')
 
     # Use the fname:lnum format
-    var AT = empty(at) ? QuoteArg($"{expand('%:p')}:{line('.')}") : at
+    var fname = Remote2LocalPath(expand('%:p'))
+    var AT = empty(at) ? QuoteArg($"{fname}:{line('.')}") : at
     SendCommand($'-exec-until {AT}')
   else
     ch_log('dropping command, program is running: exec-until')
@@ -1393,7 +1621,8 @@ def SetBreakpoint(at: string, tbreak=false)
   endif
 
   # Use the fname:lnum format, older gdb can't handle --source.
-  var AT = empty(at) ? QuoteArg($"{expand('%:p')}:{line('.')}") : at
+  var fname = Remote2LocalPath(expand('%:p'))
+  var AT = empty(at) ? QuoteArg($"{fname}:{line('.')}") : at
   var cmd = ''
   if tbreak
     cmd = $'-break-insert -t {AT}'
@@ -1407,7 +1636,8 @@ def SetBreakpoint(at: string, tbreak=false)
 enddef
 
 def ClearBreakpoint()
-  var fname = fnameescape(expand('%:p'))
+  var fname = Remote2LocalPath(expand('%:p'))
+  fname = fnameescape(fname)
   var lnum = line('.')
   var bploc = printf('%s:%d', fname, lnum)
   var nr = 0
@@ -1444,7 +1674,8 @@ def ClearBreakpoint()
 enddef
 
 def ToggleBreak()
-  var fname = fnameescape(expand('%:p'))
+  var fname = Remote2LocalPath(expand('%:p'))
+  fname = fnameescape(fname)
   var lnum = line('.')
   var bploc = printf('%s:%d', fname, lnum)
   if has_key(breakpoint_locations, bploc)
@@ -1859,7 +2090,7 @@ def HandleCursor(msg: string)
 
   var fname = ''
   if msg =~ 'fullname='
-    fname = GetFullname(msg)
+    fname = GetLocalFullname(msg)
   endif
 
   if msg =~ 'addr='
@@ -1887,12 +2118,15 @@ def HandleCursor(msg: string)
     SendCommand('-stack-list-variables 2')
   endif
 
-  if msg =~ '^\(\*stopped\|=thread-selected\)' && filereadable(fname)
+  # Translate to remote file name if needed.
+  const fremote = Local2RemotePath(fname)
+
+  if msg =~ '^\(\*stopped\|=thread-selected\)' && (fremote != fname || filereadable(fname))
     var lnum = substitute(msg, '.*line="\([^"]*\)".*', '\1', '')
     if lnum =~ '^[0-9]*$'
       GotoSourcewinOrCreateIt()
-      if expand('%:p') != fnamemodify(fname, ':p')
-        echomsg $"different fname: '{expand('%:p')}' vs '{fnamemodify(fname, ':p')}'"
+      if expand('%:p') != fnamemodify(fremote, ':p')
+        echomsg $"different fname: '{expand('%:p')}' vs '{fnamemodify(fremote, ':p')}'"
         augroup Termdebug
           # Always open a file read-only instead of showing the ATTENTION
           # prompt, since it is unlikely we want to edit the file.
@@ -1904,11 +2138,11 @@ def HandleCursor(msg: string)
         augroup END
         if &modified
           # TODO: find existing window
-          exe $'split {fnameescape(fname)}'
+          exe $'split {fnameescape(fremote)}'
           sourcewin = win_getid()
           InstallWinbar(false)
         else
-          exe $'edit {fnameescape(fname)}'
+          exe $'edit {fnameescape(fremote)}'
         endif
         augroup Termdebug
           au! SwapExists
@@ -1917,7 +2151,7 @@ def HandleCursor(msg: string)
       exe $":{lnum}"
       normal! zv
       sign_unplace('TermDebug', {id: pc_id})
-      sign_place(pc_id, 'TermDebug', 'debugPC', fname,
+      sign_place(pc_id, 'TermDebug', 'debugPC', fremote,
         {lnum: str2nr(lnum), priority: 110})
       if !exists('b:save_signcolumn')
         b:save_signcolumn = &signcolumn
@@ -1991,10 +2225,11 @@ def HandleNewBreakpoint(msg: string, modifiedFlag: bool)
   endif
 
   for mm in SplitMsg(msg)
-    var fname = GetFullname(mm)
+    var fname = GetLocalFullname(mm)
     if empty(fname)
       continue
     endif
+    var fremote = Local2RemotePath(fname)
     nr = substitute(mm, '.*number="\([0-9.]*\)\".*', '\1', '')
     if empty(nr)
       return
@@ -2034,11 +2269,11 @@ def HandleNewBreakpoint(msg: string, modifiedFlag: bool)
     endif
 
     var posMsg = ''
-    if bufloaded(fname)
+    if bufloaded(fremote)
       PlaceSign(id, subid, entry)
       posMsg = $' at line {lnum}.'
     else
-      posMsg = $' in {fname} at line {lnum}.'
+      posMsg = $' in {fremote} at line {lnum}.'
     endif
     var actionTaken = ''
     if !modifiedFlag
@@ -2055,8 +2290,9 @@ enddef
 
 def PlaceSign(id: number, subid: number, entry: dict<any>)
   var nr = printf('%d.%d', id, subid)
+  var remote = Local2RemotePath(entry['fname'])
   sign_place(Breakpoint2SignNumber(id, subid), 'TermDebug',
-    $'debugBreakpoint{nr}', entry['fname'],
+    $'debugBreakpoint{nr}', remote,
     {lnum: entry['lnum'], priority: 110})
   entry['placed'] = 1
 enddef
index 79455ba08833e67a7fbe1ac2046e7a5edf57c4c7..1f6fd36e4ba1b516784905851bded003dbb05795 100644 (file)
@@ -689,4 +689,86 @@ func Test_termdebug_toggle_break()
   %bw!
 endfunc
 
+" Check substitution capabilities and simulate remote debugging
+func Test_termdebug_remote_basic()
+  let bin_name = 'XTD_basicremote'
+  let src_name = bin_name .. '.c'
+  call s:generate_files(bin_name)
+  defer s:cleanup_files(bin_name)
+
+  " Duplicate sources to test the mapping
+  const pwd = getcwd()
+  const src_shadow_dir = "shadow"
+  call mkdir(src_shadow_dir)
+  const src_shadow_file = $"{src_shadow_dir}/{src_name}"
+  call filecopy(src_name, src_shadow_file)
+  defer delete(src_shadow_dir, 'rf')
+
+  let modes = [v:true]
+  " termdebug only wokrs fine if socat is available on the remote machine
+  " otherwise the communication pty will be unstable
+  if executable('socat')
+    let modes += [v:false]
+  endif
+
+  for use_prompt in modes
+    " Set up mock remote and mapping
+    let g:termdebug_config = {}
+
+    let g:termdebug_config['use_prompt'] = use_prompt
+    " favor socat if available
+    if executable('socat')
+      let g:termdebug_config['remote_window'] =
+            \ ['socat', '-d', '-d', '-', 'PTY,raw,echo=0']
+    else
+      let g:termdebug_config['remote_window'] = ['sh']
+    endif
+
+    let g:termdebug_config['substitute_path'] = {}
+    let g:termdebug_config['substitute_path'][pwd] = pwd . '/' . src_shadow_dir
+    defer execute("unlet g:termdebug_config")
+
+    " Launch the debugger and set breakpoints in the shadow file instead
+    exe $"edit {src_shadow_file}"
+    exe $"Termdebug ./{bin_name}"
+    call WaitForAssert({-> assert_true(get(g:, "termdebug_is_running", v:false))})
+    call WaitForAssert({-> assert_equal(3, winnr('$'))})
+    let gdb_buf = winbufnr(1)
+    wincmd b
+    Break 9
+    sleep 100m
+    redraw!
+    call assert_equal([
+          \ {'lnum': 9, 'id': 1014, 'name': 'debugBreakpoint1.0',
+          \  'priority': 110, 'group': 'TermDebug'}],
+          \ sign_getplaced('', #{group: 'TermDebug'})[0].signs)
+    Run
+    call term_wait(gdb_buf, 400)
+    redraw!
+    call WaitForAssert({-> assert_equal([
+          \ {'lnum': 9, 'id': 12, 'name': 'debugPC', 'priority': 110,
+          \  'group': 'TermDebug'},
+          \ {'lnum': 9, 'id': 1014, 'name': 'debugBreakpoint1.0',
+          \  'priority': 110, 'group': 'TermDebug'}],
+          \ sign_getplaced('', #{group: 'TermDebug'})[0].signs)})
+    Finish
+    call term_wait(gdb_buf)
+    redraw!
+    call WaitForAssert({-> assert_equal([
+          \ {'lnum': 9, 'id': 1014, 'name': 'debugBreakpoint1.0',
+          \  'priority': 110, 'group': 'TermDebug'},
+          \ {'lnum': 20, 'id': 12, 'name': 'debugPC',
+          \  'priority': 110, 'group': 'TermDebug'}],
+          \ sign_getplaced('', #{group: 'TermDebug'})[0].signs)})
+
+    " Cleanup, make sure the gdb job is terminated before return
+    " otherwise may interfere with next test
+    Gdb
+    bw!
+    call WaitForAssert({-> assert_equal(1, winnr('$'))})
+  endfor
+
+  %bw!
+endfunc
+
 " vim: shiftwidth=2 sts=2 expandtab