Skip to content

Instantly share code, notes, and snippets.

@equalsraf
Last active November 5, 2023 17:46
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save equalsraf/207596e00d44229d44da0aab5796d26b to your computer and use it in GitHub Desktop.
Save equalsraf/207596e00d44229d44da0aab5796d26b to your computer and use it in GitHub Desktop.
Notes on neovim system('...') in Windows for #6359

Vim's system('string') interface is not always straightforward in Windows, its behaviour can be downright unexpected involves an intricate set of options. Its primary purpose its to execute a command in the shell (see &shell) in simple terms we can think that calling system('some command') is equivalent to spawning a process with the following arguments

[&shell, &shellcmdflag, 'some command']

and sometimes this might be true but it also might not. Consider the following examples (I'm running in gVim 7.4/Windows 8)

set shell=powershell shellquote=\" shellpipe=\| shellredir=>
set shellcmdflag=-Command
let &shellxquote=' '

" this works
echo system('echo a')

" this is a valid powershell expression and should print the line
"     a b
" instead it prints them on separate lines
echo system('echo "a b"')

To help figure out what is going on I've grabbed the printargs-test.exe binary from the Neovim functional tests, it prints the command line arguments it gets, separated by ;.

" this a test program used by neovim to debug command arguments
set shell=c:\msys64\home\dummy\neovim\build\bin\printargs-test.exe

" this one prints arg1:-Command;arg2:echo;arg3:a;
echo system('echo a')
" this one prints arg1:-Command;arg2:echo;arg3:a b;
echo system('echo "a b"')

The first command works but it does not produce the expected command arguments it is called as

[&shell, -Command, echo, a]

The second case is even more surprising

[&shell, -Command, echo, a b]

and notice that the double quotes "a b" were lost, those were a meaningful part of the powershell expression. That is why the output does not match what we expect.

To understand what happened you first need to know that in windows when spawning a process, arguments are represented as a string. This makes it dificult to know where one argument stops and the other begins, but there a convention for this documented here, I assume most modern programs follow it by using this function but the cmd.exe shell does not.

Try this

" this is what we wanted arg1:-Command;arg2:echo "a b"
echo system('"echo \"a b\""')

i.e.

[&shell, -Command, echo "a b"]

Actually my initial examples were a bit disonest, since they went straight for powershell. To understand why Vim does it this way you need to understand that UNIX systems have ways to handle argv correctly, but since in Vim in Windows works mostly around cmd.exe, then it makes sense that system() does not immediately work with other shells. The specific problem in Windows is that system() not only needs to build a valid shell command it also needs to build a valid command according to whatever convention your shell uses, and cmd.exe is different from other windows programs.

Another way to put it is that in windows, shell command construction gets mixed up with process argument construction. There are several historical reasons for this, for example Vim redirects process output to temporary files when calling system().

Windows argument quoting according to convention

Vim has several options related to shell invocation

  • shellquote
  • shellxquote
  • shellxescape
  • shellpipe
  • shellredir
  • shellcmdflag
  • shellescape()

These help you define how system calls the shell process. For example as a test, I've tried to follow up the previous examples with this

" this is one step closer to correct quoting, but it only works in Neovim
" in Vim it fails to open the redirect tmp file
set shellxquote=\"
echo system('echo \"a b\"')

I don't think we can implement proper quoting through the Vim options alone, AFAIK the only option that influences the contents of the string passed to system() is shellxescape but this option is only used if shellxquote=( which we don't really want for this case.

Here is an attempt to write a function to build process arguments in vimscript. It definitely DOES NOT work for cmd.exe, it is meant for conformant shells in windows. It basically does in vimscript what libuv did for Neovim prior to #6359.

" ported from libuv/quote_cmd_arg - quotes a single argument for calling a
" process. This works well for quoting arguments for windows shells that
" follow the expected argument convention (cmd.exe DOES NOT), at least
" assuming that you want to pass your entire command as single argument to
" your shell e.g. powershell -Command <...Shell cmd>
"
" Usage:
"	QuoteW32Arg(str)
"	QuoteW32Arg(str, 0)
"
" If the optional second argument is not 1 it disables wrapping in double quotes.
"
" NOTE: its unclear to me if this clashes with &shellquote=" when used as
"       system(QuoteW32Arg('...))
function! QuoteW32Arg(arg, ...)
	let wrap = (a:0 >= 1) ? a:1 : 1
	if strlen(a:arg) == 0
		" empty arguments use double quotes
		return (wrap == 1) ? '""' : '' 
	endif

	if a:arg !~ '"' && a:arg !~ "\t" && a:arg !~ ' '
		" no quotation needed
		return a:arg
	endif

	if a:arg !~ '\' && a:arg !~ '"'
		" no inner double quotes or backslashes, wrap in double quotes
		return (wrap == 1) ? '"'.a:arg.'"' : a:arg
	endif

	let revcmd = reverse(split(a:arg, '.\zs'))
	let target = ''
	let quote_hit = 1
	for c in revcmd
		let target = target . c

		if quote_hit == 1 && c == '\'
			" double backslash
			let target = target . '\'
		elseif c == '"'
			let quote_hit = 1
			" double quote
			let target = target . '\'
		else
			let quote_hit = 0
		endif
	endfor
	let result = join(reverse(split(target, '.\zs')), '')
	return (wrap == 1) ? '"'.result.'"' : result
endfunction

let v:errors = []
call assert_equal('"hello\"world"', QuoteW32Arg('hello"world'))
call assert_equal('hello\"world', QuoteW32Arg('hello"world', 0))
call assert_equal('"hello\"\"world"', QuoteW32Arg('hello""world'))
call assert_equal('hello\world', QuoteW32Arg('hello\world'))
call assert_equal('hello\\world', QuoteW32Arg('hello\\world'))
call assert_equal('"hello\\\"world"', QuoteW32Arg('hello\"world'))
call assert_equal('"hello\\\\\"world"', QuoteW32Arg('hello\\"world'))
call assert_equal('"hello world\\"', QuoteW32Arg('hello world\'))

call assert_equal('""', QuoteW32Arg(''))
call assert_equal('', QuoteW32Arg('', 0))
call assert_equal('"hello world"', QuoteW32Arg('hello world'))
call assert_equal('hello world', QuoteW32Arg('hello world', 0))

for err in v:errors
	echoerr err 
endfor

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment