-
-
Notifications
You must be signed in to change notification settings - Fork 39.2k
Expand file tree
/
Copy pathshell-quotes.js
More file actions
210 lines (193 loc) · 6.13 KB
/
Copy pathshell-quotes.js
File metadata and controls
210 lines (193 loc) · 6.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
'use strict';
/**
* Quoted-region scanner for shell command lines.
*
* One forward pass records every quoted region: where it starts and ends, the
* argv0 of the statement containing it (the first non-assignment, non-reserved
* word before the quote; '' when the quote is part of that first word) and
* whether the string carries a command substitution. A `$(` or backtick inside
* "..." suspends the string: the substitution body is top-level shell again
* (its own statements and quotes) until the matching `)` / backtick, after
* which the string resumes as a new region. Both halves are flagged
* `substitution`. Regions are appended only once complete, in order, so they
* are disjoint and sorted. The result is cached per input, so a command line
* holding thousands of quoted tokens is scanned once.
*/
// Words that open or structure a compound command; none of them receives the
// quoted string that follows, so none may become a statement's argv0.
const SHELL_RESERVED_WORDS = new Set([
'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'for',
'case', 'esac', 'in', 'select', 'function', 'coproc', '!', '{', '}', '[[', ']]',
]);
const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
// Unquoted characters that start a new statement (or a nested command).
const STATEMENT_SEPARATORS = new Set([';', '|', '&', '\n', '(', ')', '`']);
let cache = { input: null, regions: [] };
function createState() {
return {
regions: [],
suspended: [],
quote: null,
escaped: false,
open: null,
argv0: null,
word: '',
inWord: false,
};
}
function endWord(state) {
if (!state.inWord) return;
if (
state.argv0 === null &&
state.word !== '' &&
!SHELL_RESERVED_WORDS.has(state.word) &&
!ASSIGNMENT_WORD.test(state.word)
) {
state.argv0 = state.word;
}
state.word = '';
state.inWord = false;
}
function newStatement(state) {
endWord(state);
state.argv0 = null;
}
function openRegion(state, start, quote, argv0, substitution) {
state.quote = quote;
state.open = { start, quote, argv0, substitution };
}
function closeRegion(state, end, substitution) {
const region = state.open;
state.regions.push({ ...region, end, substitution: region.substitution || substitution });
state.open = null;
state.quote = null;
}
/**
* `$(` or a backtick inside "...": park the outer statement's word state (the
* outer word `FOO="pre$(cmd)post"` continues after the substitution as if it
* were a single character) and scan the body as a fresh statement. Returns
* the number of characters consumed.
*/
function suspendString(state, index, char) {
state.suspended.push({
backtick: char === '`',
depth: 0,
argv0: state.open.argv0,
outer: { word: state.word, inWord: state.inWord, argv0: state.argv0 },
});
closeRegion(state, index, true);
state.word = '';
state.inWord = false;
state.argv0 = null;
return char === '$' ? 2 : 1;
}
function resumeString(state, index, outer) {
state.suspended.pop();
state.word = outer.outer.word;
state.inWord = outer.outer.inWord;
state.argv0 = outer.outer.argv0;
openRegion(state, index, '"', outer.argv0, true);
}
/** A character inside a quoted string. Returns the number of characters consumed. */
function scanQuotedChar(state, input, index) {
const char = input.charAt(index);
if (state.quote === '"' && char === '\\') {
state.escaped = true;
return 1;
}
if (char === state.quote) {
closeRegion(state, index, false);
return 1;
}
if (state.quote === '"' && (char === '`' || (char === '$' && input.charAt(index + 1) === '('))) {
return suspendString(state, index, char);
}
state.word += char;
state.inWord = true;
return 1;
}
/** A character outside quotes. Returns the number of characters consumed. */
function scanBareChar(state, index, char) {
if (char === '\\') {
state.escaped = true;
state.inWord = true;
return 1;
}
if (char === '"' || char === "'") {
state.inWord = true;
openRegion(state, index, char, state.argv0 === null ? '' : state.argv0, false);
return 1;
}
const outer = state.suspended.length > 0 ? state.suspended[state.suspended.length - 1] : null;
if (outer !== null) {
const resumes = outer.backtick ? char === '`' : char === ')' && outer.depth === 0;
if (resumes) {
resumeString(state, index, outer);
return 1;
}
if (!outer.backtick && (char === '(' || char === ')')) {
const depth = outer.depth + (char === '(' ? 1 : -1);
state.suspended = [...state.suspended.slice(0, -1), { ...outer, depth }];
}
}
if (STATEMENT_SEPARATORS.has(char)) {
newStatement(state);
return 1;
}
if (/\s/.test(char)) {
endWord(state);
return 1;
}
state.word += char;
state.inWord = true;
return 1;
}
/**
* Every quoted region of `input`, sorted by start and disjoint.
*
* @param {string} input
* @returns {Array<{start: number, end: number, quote: string, argv0: string, substitution: boolean}>}
*/
function quotedRegions(input) {
if (cache.input === input) return cache.regions;
const state = createState();
for (let i = 0; i < input.length; ) {
const char = input.charAt(i);
if (state.escaped) {
state.escaped = false;
state.word += char;
state.inWord = true;
i += 1;
continue;
}
i += state.quote ? scanQuotedChar(state, input, i) : scanBareChar(state, i, char);
}
if (state.open !== null) state.regions.push({ ...state.open, end: input.length });
cache = { input, regions: state.regions };
return state.regions;
}
/**
* The quoted region that strictly contains `idx`, or null when `idx` is not
* inside a quote. Regions are disjoint and sorted, so this is a binary search.
*
* @param {string} input
* @param {number} idx
*/
function quotedRegionAt(input, idx) {
const regions = quotedRegions(input);
let lo = 0;
let hi = regions.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const region = regions[mid];
if (idx <= region.start) {
hi = mid - 1;
} else if (idx >= region.end) {
lo = mid + 1;
} else {
return region;
}
}
return null;
}
module.exports = { quotedRegions, quotedRegionAt, SHELL_RESERVED_WORDS };