Skip to content

Commit 0c0b1b3

Browse files
committed
Escape completion results for BASH before writing to stdout
In the previous commit I used `printf '%q'` in the BASH hook to handle escaping unquoted strings, however this didn't handle escaping quote characters inside strings where needed. This commit moves the responsibility of escaping completion results into PHP to make the escaping logic testable and easier to maintain. BASH supports automatic escaping/quoting in completion results only when the `-o filenames` option is used to register a completion function. The `filenames` option has behaviour that is incompatible with a generic completion handler, such as appending a `/` on results that happen to match directory name in the CWD. Unfortuntately this means we cannot use this functionality. There is no API to change this behaviour as far as I've been able to see from the BASH docs and source code. ZSH handles escaping of completion functions perfectly fine out of the box.
1 parent ab585cf commit 0c0b1b3

File tree

4 files changed

+138
-14
lines changed

4 files changed

+138
-14
lines changed

src/CompletionCommand.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,54 @@ protected function execute(InputInterface $input, OutputInterface $output)
7676
$output->write($hook, true);
7777
} else {
7878
$handler->setContext(new EnvironmentCompletionContext());
79-
$output->write($this->runCompletion(), true);
79+
80+
// Get completion results
81+
$results = $this->runCompletion();
82+
83+
// Escape results for the current shell
84+
$shellType = $input->getOption('shell-type') ?: $this->getShellType();
85+
86+
foreach ($results as &$result) {
87+
$result = $this->escapeForShell($result, $shellType);
88+
}
89+
90+
$output->write($results, true);
91+
}
92+
}
93+
94+
/**
95+
* Escape each completion result for the specified shell
96+
*
97+
* @param string $result - Completion results that should appear in the shell
98+
* @param string $shellType - Valid shell type from HookFactory
99+
* @return string
100+
*/
101+
protected function escapeForShell($result, $shellType)
102+
{
103+
switch ($shellType) {
104+
// BASH requires special escaping for multi-word and special character results
105+
// This emulates registering completion with`-o filenames`, without side-effects like dir name slashes
106+
case 'bash':
107+
$context = $this->handler->getContext();
108+
$wordStart = substr($context->getRawCurrentWord(), 0, 1);
109+
110+
if ($wordStart == "'") {
111+
// If the current word is single-quoted, escape any single quotes in the result
112+
$result = str_replace("'", "\\'", $result);
113+
} else if ($wordStart == '"') {
114+
// If the current word is double-quoted, escape any double quotes in the result
115+
$result = str_replace('"', '\\"', $result);
116+
} else {
117+
// Otherwise assume the string is unquoted and word breaks should be escaped
118+
$result = preg_replace('/([\s\'"\\\\])/', '\\\\$1', $result);
119+
}
120+
121+
// Escape output to prevent special characters being lost when passing results to compgen
122+
return escapeshellarg($result);
123+
124+
// No transformation by default
125+
default:
126+
return $result;
80127
}
81128
}
82129

src/CompletionContext.php

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,27 @@ class CompletionContext
3232
protected $charIndex = 0;
3333

3434
/**
35-
* An array containing the individual words in the current command line.
35+
* An array of the individual words in the current command line.
3636
*
3737
* This is not set until $this->splitCommand() is called, when it is populated by
3838
* $commandLine exploded by $wordBreaks
3939
*
4040
* Bash equivalent: COMP_WORDS
4141
*
42-
* @var array|null
42+
* @var string[]|null
4343
*/
4444
protected $words = null;
4545

46+
/**
47+
* Words from the currently command-line before quotes and escaping is processed
48+
*
49+
* This is indexed the same as $this->words, but in their raw input terms are in their input form, including
50+
* quotes and escaping.
51+
*
52+
* @var string[]|null
53+
*/
54+
protected $rawWords = null;
55+
4656
/**
4757
* The index in $this->words containing the word at the current cursor position.
4858
*
@@ -101,6 +111,22 @@ public function getCurrentWord()
101111
return '';
102112
}
103113

114+
/**
115+
* Return the unprocessed string for the word under the cursor
116+
*
117+
* This preserves any quotes and escaping that are present in the input command line.
118+
*
119+
* @return string
120+
*/
121+
public function getRawCurrentWord()
122+
{
123+
if (isset($this->rawWords[$this->wordIndex])) {
124+
return $this->rawWords[$this->wordIndex];
125+
}
126+
127+
return '';
128+
}
129+
104130
/**
105131
* Return a word by index from the command line
106132
*
@@ -132,6 +158,22 @@ public function getWords()
132158
return $this->words;
133159
}
134160

161+
/**
162+
* Get the unprocessed/literal words from the command line
163+
*
164+
* This is indexed the same as getWords(), but preserves any quoting and escaping from the command line
165+
*
166+
* @return string[]
167+
*/
168+
public function getRawWords()
169+
{
170+
if ($this->rawWords === null) {
171+
$this->splitCommand();
172+
}
173+
174+
return $this->rawWords;
175+
}
176+
135177
/**
136178
* Get the index of the word the cursor is currently in
137179
*
@@ -202,6 +244,7 @@ protected function splitCommand()
202244
foreach ($tokens as $token) {
203245
if ($token['type'] != 'break') {
204246
$this->words[] = $this->getTokenValue($token);
247+
$this->rawWords[] = $token['value'];
205248
}
206249

207250
// Determine which word index the cursor is inside once we reach it's offset
@@ -213,16 +256,22 @@ protected function splitCommand()
213256
// Push an empty word at the cursor to allow completion of new terms at the cursor, ignoring words ahead
214257
$this->wordIndex++;
215258
$this->words[] = '';
259+
$this->rawWords[] = '';
216260
continue;
217261
}
218262

219263
if ($this->charIndex < $token['offsetEnd']) {
220-
// Cursor is inside the current word - truncate the word at the cursor
221-
// (This emulates normal BASH completion behaviour I've observed, though I'm not entirely sure if it's useful)
264+
// Cursor is inside the current word - truncate the word at the cursor to complete on
265+
// This emulates BASH completion's behaviour with COMP_CWORD
266+
267+
// Create a copy of the token with its value truncated
268+
$truncatedToken = $token;
222269
$relativeOffset = $this->charIndex - $token['offset'];
223-
$truncated = substr($token['value'], 0, $relativeOffset);
270+
$truncatedToken['value'] = substr($token['value'], 0, $relativeOffset);
224271

225-
$this->words[$this->wordIndex] = $truncated;
272+
// Replace the current word with the truncated value
273+
$this->words[$this->wordIndex] = $this->getTokenValue($truncatedToken);
274+
$this->rawWords[$this->wordIndex] = $truncatedToken['value'];
226275
}
227276
}
228277
}
@@ -231,6 +280,7 @@ protected function splitCommand()
231280
if ($this->wordIndex === null) {
232281
$this->wordIndex = count($this->words);
233282
$this->words[] = '';
283+
$this->rawWords[] = '';
234284
}
235285
}
236286

src/HookFactory.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,6 @@ function %%function_name%% {
6868
6969
COMPREPLY=(`compgen -W "$RESULT" -- $cur`);
7070
71-
# Escape any spaces in results if the current word doesn't begin with a quote
72-
if [[ ! -z $COMPREPLY ]] && [[ ! $cur =~ ^[\'\"] ]]; then
73-
COMPREPLY=($(printf '%q\n' "${COMPREPLY[@]}"));
74-
fi;
75-
7671
__ltrim_colon_completions "$cur";
7772
7873
MAILCHECK=mail_check_backup;
@@ -156,6 +151,9 @@ public function generateHook($type, $programPath, $programName = null, $multiple
156151
$completionCommand = $programPath . ' _completion';
157152
}
158153

154+
// Pass shell type during completion so output can be encoded if the shell requires it
155+
$completionCommand .= " --shell-type $type";
156+
159157
return str_replace(
160158
array(
161159
'%%function_name%%',

tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionContextTest.php

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ public function testQuotedStringWordBreaking()
9696
{
9797
$context = new CompletionContext();
9898
$context->setCharIndex(1000);
99-
$context->setCommandLine('make horse --legs=3 --name="Jeff the horse" --colour Extreme\ Blanc \'foo " bar\'');
99+
$context->setCommandLine('make horse --legs=3 --name="Jeff the horse" --colour Extreme\\ Blanc \'foo " bar\'');
100100

101-
// Ensure spaces and quotes
101+
// Ensure spaces and quotes are processed correctly
102102
$this->assertEquals(
103103
array(
104104
'make',
@@ -115,6 +115,23 @@ public function testQuotedStringWordBreaking()
115115
$context->getWords()
116116
);
117117

118+
// Confirm the raw versions of the words are indexed correctly
119+
$this->assertEquals(
120+
array(
121+
'make',
122+
'horse',
123+
'--legs',
124+
'3',
125+
'--name',
126+
'"Jeff the horse"',
127+
'--colour',
128+
'Extreme\\ Blanc',
129+
"'foo \" bar'",
130+
'',
131+
),
132+
$context->getRawWords()
133+
);
134+
118135
$context = new CompletionContext();
119136
$context->setCommandLine('console --tag=');
120137

@@ -130,6 +147,18 @@ public function testQuotedStringWordBreaking()
130147
);
131148
}
132149

150+
public function testGetRawCurrentWord()
151+
{
152+
$context = new CompletionContext();
153+
154+
$context->setCommandLine('cmd "double quoted" --option \'value\'');
155+
$context->setCharIndex(13);
156+
$this->assertEquals(1, $context->getWordIndex());
157+
158+
$this->assertEquals(array('cmd', '"double q', '--option', "'value'"), $context->getRawWords());
159+
$this->assertEquals('"double q', $context->getRawCurrentWord());
160+
}
161+
133162
public function testConfigureFromEnvironment()
134163
{
135164
putenv("CMDLINE_CONTENTS=beam up li");

0 commit comments

Comments
 (0)