How to deal with filenames containing a single quote inside a zsh completion function

autocompletequotingzsh

I'm using the zsh shell and have the following code inside my .zshrc:

fpath=(~/.zsh/completion $fpath)
autoload -Uz _vc

autoload -Uz compinit
compinit

The first line add the path ~/.zsh/completion to the array stored inside the environment variable fpath.

The 2nd line loads a completion function, called _vc, for a custom shell function called vc. The purpose of vc is simply to edit a file inside a particular folder (~/.cheat). _vc is defined in the file ~/.zsh/completion/_vc.

The last 2 lines enable a completion system.

Here's the code for my completion function _vc:

#compdef vc

declare -a cheatsheets
cheatsheets="$(ls ~/.cheat)"
_arguments "1:cheatsheets:(${cheatsheets})" && return 0

I copied it from this address, and adapted it for my needs.

As long as the directory ~/.cheat doesn't have a file whose name contains a single quote, the completion works. But if there's one, such as foo'bar, the completion fails with this error message:

(eval):56: unmatched '
(eval):56: unmatched '
(eval):56: unmatched '
(eval):56: unmatched '

I found a solution, by replacing the double quotes in the line cheatsheets="$(ls ~/.cheat)", with single quotes cheatsheets='$(ls ~/.cheat)'.

Now, when I hit Tab after my vc command, zsh suggests files inside ~/.cheat, including foo\'bar (zsh seems to have escaped the single quote automatically).

However, I don't understand how or why it works. With single quotes, the variable cheatsheets sould contain a literal string. So the $(...) command substitution shouldn't be expanded. For example, if I execute the following commands:

myvar='$(ls)'
echo $myvar    →    outputs `$(ls)` literally

So why is '$(ls ~/.cheat)' expanded inside _arguments "1:cheatsheets:(${cheatsheets})" && return 0? And why does it automatically escape the single quote inside foo'bar?

Best Answer

If we insist on doing things The Wrong Way™

#compdef vc

declare -a cheatsheets
cheatsheets=(${(f)"$(ls ~/.cheat/)"})
_arguments '1:cheatsheets:(${cheatsheets})' && return 0

Yuck! This of course will break should a filename contain a newline, as (f) splits on those. Parsing ls is anyways a bad idea; ZSH can glob files directly into an array:

% mkdir ~/.lojban
% touch ~/.lojban/{go\'i,na\ go\'i}
% words=(~/.lojban/*) 
% print -l ${words:t}
go'i
na go'i
% words=(~/.lojban/*(On))
% print -l ${words:t}    
na go'i
go'i
% 

But we probably don't need a local array; _files can complete on a glob:

#compdef vc
_arguments '1:cheatsheets:_files -g "~/.cheat/*"' && return 0

This returns fully qualified file paths; if only the bare filename is required from a search directory we can instead use:

#compdef vc
_arguments '1:cheatsheets:_files -W ~/.cheat' && return 0