Completed
Pull Request — master (#82)
by Stephen
01:08
created

CompletionContext   A

Complexity

Total Complexity 30

Size/Duplication

Total Lines 330
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 0

Importance

Changes 0
Metric Value
wmc 30
lcom 1
cbo 0
dl 0
loc 330
rs 10
c 0
b 0
f 0

13 Methods

Rating   Name   Duplication   Size   Complexity  
A setCommandLine() 0 5 1
A getCommandLine() 0 4 1
A getCurrentWord() 0 8 2
A getWordAtIndex() 0 8 2
A getWords() 0 8 2
A getWordIndex() 0 8 2
A getCharIndex() 0 4 1
A setCharIndex() 0 5 1
A setWordBreaks() 0 6 1
B splitCommand() 0 38 8
A getTokenValue() 0 14 2
B tokenizeString() 0 64 6
A reset() 0 5 1
1
<?php
2
3
4
namespace Stecman\Component\Symfony\Console\BashCompletion;
5
6
/**
7
 * Command line context for completion
8
 *
9
 * Represents the current state of the command line that is being completed
10
 */
11
class CompletionContext
12
{
13
    /**
14
     * The current contents of the command line as a single string
15
     *
16
     * Bash equivalent: COMP_LINE
17
     *
18
     * @var string
19
     */
20
    protected $commandLine;
21
22
    /**
23
     * The index of the user's cursor relative to the start of the command line.
24
     *
25
     * If the current cursor position is at the end of the current command,
26
     * the value of this variable is equal to the length of $this->commandLine
27
     *
28
     * Bash equivalent: COMP_POINT
29
     *
30
     * @var int
31
     */
32
    protected $charIndex = 0;
33
34
    /**
35
     * An array containing the individual words in the current command line.
36
     *
37
     * This is not set until $this->splitCommand() is called, when it is populated by
38
     * $commandLine exploded by $wordBreaks
39
     *
40
     * Bash equivalent: COMP_WORDS
41
     *
42
     * @var array|null
43
     */
44
    protected $words = null;
45
46
    /**
47
     * The index in $this->words containing the word at the current cursor position.
48
     *
49
     * This is not set until $this->splitCommand() is called.
50
     *
51
     * Bash equivalent: COMP_CWORD
52
     *
53
     * @var int|null
54
     */
55
    protected $wordIndex = null;
56
57
    /**
58
     * Characters that $this->commandLine should be split on to get a list of individual words
59
     *
60
     * Bash equivalent: COMP_WORDBREAKS
61
     *
62
     * @var string
63
     */
64
    protected $wordBreaks = "= \t\n";
65
66
    /**
67
     * Set the whole contents of the command line as a string
68
     *
69
     * @param string $commandLine
70
     */
71
    public function setCommandLine($commandLine)
72
    {
73
        $this->commandLine = $commandLine;
74
        $this->reset();
75
    }
76
77
    /**
78
     * Return the current command line verbatim as a string
79
     *
80
     * @return string
81
     */
82
    public function getCommandLine()
83
    {
84
        return $this->commandLine;
85
    }
86
87
    /**
88
     * Return the word from the command line that the cursor is currently in
89
     *
90
     * Most of the time this will be a partial word. If the cursor has a space before it,
91
     * this will return an empty string, indicating a new word.
92
     *
93
     * @return string
94
     */
95
    public function getCurrentWord()
96
    {
97
        if (isset($this->words[$this->wordIndex])) {
98
            return $this->words[$this->wordIndex];
99
        }
100
101
        return '';
102
    }
103
104
    /**
105
     * Return a word by index from the command line
106
     *
107
     * @see $words, $wordBreaks
108
     * @param int $index
109
     * @return string
110
     */
111
    public function getWordAtIndex($index)
112
    {
113
        if (isset($this->words[$index])) {
114
            return $this->words[$index];
115
        }
116
117
        return '';
118
    }
119
120
    /**
121
     * Get the contents of the command line, exploded into words based on the configured word break characters
122
     *
123
     * @see $wordBreaks, setWordBreaks
124
     * @return array
125
     */
126
    public function getWords()
127
    {
128
        if ($this->words === null) {
129
            $this->splitCommand();
130
        }
131
132
        return $this->words;
133
    }
134
135
    /**
136
     * Get the index of the word the cursor is currently in
137
     *
138
     * @see getWords, getCurrentWord
139
     * @return int
140
     */
141
    public function getWordIndex()
142
    {
143
        if ($this->wordIndex === null) {
144
            $this->splitCommand();
145
        }
146
147
        return $this->wordIndex;
148
    }
149
150
    /**
151
     * Get the character index of the user's cursor on the command line
152
     *
153
     * This is in the context of the full command line string, so includes word break characters.
154
     * Note that some shells can only provide an approximation for character index. Under ZSH for
155
     * example, this will always be the character at the start of the current word.
156
     *
157
     * @return int
158
     */
159
    public function getCharIndex()
160
    {
161
        return $this->charIndex;
162
    }
163
164
    /**
165
     * Set the cursor position as a character index relative to the start of the command line
166
     *
167
     * @param int $index
168
     */
169
    public function setCharIndex($index)
170
    {
171
        $this->charIndex = $index;
172
        $this->reset();
173
    }
174
175
    /**
176
     * Set characters to use as split points when breaking the command line into words
177
     *
178
     * This defaults to a sane value based on BASH's word break characters and shouldn't
179
     * need to be changed unless your completions contain the default word break characters.
180
     *
181
     * @deprecated This is becoming an internal setting that doesn't make sense to expose publicly.
182
     *
183
     * @see wordBreaks
184
     * @param string $charList - a single string containing all of the characters to break words on
185
     */
186
    public function setWordBreaks($charList)
187
    {
188
        // Drop quotes from break characters - strings are handled separately to word breaks now
189
        $this->wordBreaks = str_replace(array('"', '\''), '', $charList);;
190
        $this->reset();
191
    }
192
193
    /**
194
     * Split the command line into words using the configured word break characters
195
     *
196
     * @return string[]
197
     */
198
    protected function splitCommand()
199
    {
200
        $tokens = $this->tokenizeString($this->commandLine);
201
202
        foreach ($tokens as $token) {
203
            if ($token['type'] != 'break') {
204
                $this->words[] = $this->getTokenValue($token);
205
            }
206
207
            // Determine which word index the cursor is inside once we reach it's offset
208
            if ($this->wordIndex === null && $this->charIndex <= $token['offsetEnd']) {
209
                $this->wordIndex = count($this->words) - 1;
210
211
                if ($token['type'] == 'break') {
212
                    // Cursor is in the break-space after a word
213
                    // Push an empty word at the cursor to allow completion of new terms at the cursor, ignoring words ahead
214
                    $this->wordIndex++;
215
                    $this->words[] = '';
216
                    continue;
217
                }
218
219
                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)
222
                    $relativeOffset = $this->charIndex - $token['offset'];
223
                    $truncated = substr($token['value'], 0, $relativeOffset);
224
225
                    $this->words[$this->wordIndex] = $truncated;
226
                }
227
            }
228
        }
229
230
        // Cursor position is past the end of the command line string - consider it a new word
231
        if ($this->wordIndex === null) {
232
            $this->wordIndex = count($this->words);
233
            $this->words[] = '';
234
        }
235
    }
236
237
    /**
238
     * Return a token's value with escaping and quotes removed
239
     *
240
     * @see self::tokenizeString()
241
     * @param array $token
242
     * @return string
243
     */
244
    protected function getTokenValue($token)
245
    {
246
        $value = $token['value'];
247
248
        // Remove outer quote characters (or first quote if unclosed)
249
        if ($token['type'] == 'quoted') {
250
            $value = preg_replace('/^(?:[\'"])(.*?)(?:[\'"])?$/', '$1', $value);
251
        }
252
253
        // Remove escape characters
254
        $value = preg_replace('/\\\\(.)/', '$1', $value);
255
256
        return $value;
257
    }
258
259
    /**
260
     * Break a string into words, quoted strings and non-words (breaks)
261
     *
262
     * Returns an array of unmodified segments of $string with offset and type information.
263
     *
264
     * @param string $string
265
     * @return array as [ [type => string, value => string, offset => int], ... ]
266
     */
267
    protected function tokenizeString($string)
268
    {
269
        // Map capture groups to returned token type
270
        $typeMap = array(
271
            'double_quote_string' => 'quoted',
272
            'single_quote_string' => 'quoted',
273
            'word' => 'word',
274
            'break' => 'break',
275
        );
276
277
        // Escape every word break character including whitespace
278
        // preg_quote won't work here as it doesn't understand the ignore whitespace flag ("x")
279
        $breaks = preg_replace('/(.)/', '\\\$1', $this->wordBreaks);
280
281
        $pattern = <<<"REGEX"
282
            /(?:
283
                (?P<double_quote_string>
284
                    "(\\\\.|[^\"\\\\])*(?:"|$)
285
                ) |
286
                (?P<single_quote_string>
287
                    '(\\\\.|[^'\\\\])*(?:'|$)
288
                ) |
289
                (?P<word>
290
                    (?:\\\\.|[^$breaks])+
291
                ) |
292
                (?P<break>
293
                     [$breaks]+
294
                )
295
            )/x
296
REGEX;
297
298
        $tokens = array();
299
300
        if (!preg_match_all($pattern, $string, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
301
            return $tokens;
302
        }
303
304
        foreach ($matches as $set) {
305
            foreach ($set as $groupName => $match) {
306
307
                // Ignore integer indices preg_match outputs (duplicates of named groups)
308
                if (is_integer($groupName)) {
309
                    continue;
310
                }
311
312
                // Skip if the offset indicates this group didn't match
313
                if ($match[1] === -1) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison === seems to always evaluate to false as the types of $match[1] (string) and -1 (integer) can never be identical. Maybe you want to use a loose comparison == instead?
Loading history...
314
                    continue;
315
                }
316
317
                $tokens[] = array(
318
                    'type' => $typeMap[$groupName],
319
                    'value' => $match[0],
320
                    'offset' => $match[1],
321
                    'offsetEnd' => $match[1] + strlen($match[0])
322
                );
323
324
                // Move to the next set (only one group should match per set)
325
                continue;
326
            }
327
        }
328
329
        return $tokens;
330
    }
331
332
    /**
333
     * Reset the computed words so that $this->splitWords is forced to run again
334
     */
335
    protected function reset()
336
    {
337
        $this->words = null;
338
        $this->wordIndex = null;
339
    }
340
}
341