Skip to content

Commit 0661ff9

Browse files
committed
refactor(block-no-verify): move the quote scanner into scripts/lib/shell-quotes.js
The scanner grew past the 50-line function / 800-line file limits. It now lives in its own module as a handful of small state-machine helpers (openRegion / closeRegion / suspendString / resumeString / scanQuotedChar / scanBareChar) with the same behaviour and the same exported lookup (`quotedRegionAt`). block-no-verify.js is back to 650 lines.
1 parent 622b085 commit 0661ff9

2 files changed

Lines changed: 212 additions & 172 deletions

File tree

scripts/hooks/block-no-verify.js

Lines changed: 2 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
'use strict';
1717

18+
const { quotedRegionAt } = require('../lib/shell-quotes');
19+
1820
const MAX_STDIN = 1024 * 1024;
1921
let raw = '';
2022

@@ -344,178 +346,6 @@ function commandBasename(word) {
344346
.toLowerCase();
345347
}
346348

347-
// Words that open or structure a compound command; none of them receives the
348-
// quoted string that follows, so none may become a statement's argv0.
349-
const SHELL_RESERVED_WORDS = new Set([
350-
'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'for',
351-
'case', 'esac', 'in', 'select', 'function', 'coproc', '!', '{', '}', '[[', ']]',
352-
]);
353-
354-
/**
355-
* One forward scan over the input that records every quoted region: where it
356-
* starts and ends, the argv0 of the statement containing it (the first
357-
* non-assignment, non-reserved word before the quote; '' when the quote is
358-
* part of that first word) and whether the string carries a command
359-
* substitution. A `$(` or backtick inside "..." suspends the string: the
360-
* substitution body is top-level shell again (its own statements and quotes)
361-
* until the matching `)` / backtick, after which the string resumes as a new
362-
* region. Both halves are flagged `substitution`. Regions are appended only
363-
* once complete, in order, so they are disjoint and sorted. Cached per input
364-
* so a command line holding thousands of quoted `git` tokens is scanned once.
365-
*/
366-
let quotedRegionCache = { input: null, regions: [] };
367-
368-
function quotedRegions(input) {
369-
if (quotedRegionCache.input === input) return quotedRegionCache.regions;
370-
371-
const regions = [];
372-
const suspended = [];
373-
let quote = null;
374-
let escaped = false;
375-
let open = null;
376-
let argv0 = null;
377-
let word = '';
378-
let inWord = false;
379-
380-
const endWord = () => {
381-
if (!inWord) return;
382-
if (
383-
argv0 === null &&
384-
word !== '' &&
385-
!SHELL_RESERVED_WORDS.has(word) &&
386-
!/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
387-
) {
388-
argv0 = word;
389-
}
390-
word = '';
391-
inWord = false;
392-
};
393-
const newStatement = () => {
394-
endWord();
395-
argv0 = null;
396-
};
397-
const openRegion = (start, char, regionArgv0, substitution) => {
398-
quote = char;
399-
open = { start, quote: char, argv0: regionArgv0, substitution };
400-
};
401-
const closeRegion = (end, substitution) => {
402-
regions.push({ ...open, end, substitution: open.substitution || substitution });
403-
open = null;
404-
quote = null;
405-
};
406-
407-
for (let i = 0; i < input.length; i++) {
408-
const char = input.charAt(i);
409-
410-
if (escaped) {
411-
escaped = false;
412-
word += char;
413-
inWord = true;
414-
continue;
415-
}
416-
417-
if (quote) {
418-
if (quote === '"' && char === '\\') {
419-
escaped = true;
420-
continue;
421-
}
422-
if (char === quote) {
423-
closeRegion(i, false);
424-
continue;
425-
}
426-
if (quote === '"' && (char === '`' || (char === '$' && input.charAt(i + 1) === '('))) {
427-
// Park the outer statement's word state: the substitution body is a
428-
// fresh statement, and the outer word (`FOO="pre$(cmd)post"`)
429-
// continues after it as if the substitution were a single character.
430-
suspended.push({
431-
backtick: char === '`',
432-
depth: 0,
433-
argv0: open.argv0,
434-
outer: { word, inWord, argv0 },
435-
});
436-
closeRegion(i, true);
437-
word = '';
438-
inWord = false;
439-
argv0 = null;
440-
if (char === '$') i++;
441-
continue;
442-
}
443-
word += char;
444-
inWord = true;
445-
continue;
446-
}
447-
448-
if (char === '\\') {
449-
escaped = true;
450-
inWord = true;
451-
continue;
452-
}
453-
454-
if (char === '"' || char === "'") {
455-
inWord = true;
456-
openRegion(i, char, argv0 === null ? '' : argv0, false);
457-
continue;
458-
}
459-
460-
const outer = suspended.length > 0 ? suspended[suspended.length - 1] : null;
461-
if (outer !== null) {
462-
const resumes = outer.backtick ? char === '`' : char === ')' && outer.depth === 0;
463-
if (resumes) {
464-
suspended.pop();
465-
word = outer.outer.word;
466-
inWord = outer.outer.inWord;
467-
argv0 = outer.outer.argv0;
468-
openRegion(i, '"', outer.argv0, true);
469-
continue;
470-
}
471-
if (!outer.backtick && char === '(') outer.depth++;
472-
if (!outer.backtick && char === ')') outer.depth--;
473-
}
474-
475-
if (
476-
char === ';' || char === '|' || char === '&' || char === '\n' ||
477-
char === '(' || char === ')' || char === '`'
478-
) {
479-
newStatement();
480-
continue;
481-
}
482-
483-
if (/\s/.test(char)) {
484-
endWord();
485-
continue;
486-
}
487-
488-
word += char;
489-
inWord = true;
490-
}
491-
492-
if (open !== null) regions.push({ ...open, end: input.length });
493-
quotedRegionCache = { input, regions };
494-
return regions;
495-
}
496-
497-
/**
498-
* The quoted region that strictly contains `idx`, or null when `idx` is not
499-
* inside a quote. Regions are disjoint and sorted, so this is a binary search.
500-
*/
501-
function quotedRegionAt(input, idx) {
502-
const regions = quotedRegions(input);
503-
let lo = 0;
504-
let hi = regions.length - 1;
505-
while (lo <= hi) {
506-
const mid = (lo + hi) >> 1;
507-
const region = regions[mid];
508-
if (idx <= region.start) {
509-
hi = mid - 1;
510-
} else if (idx >= region.end) {
511-
lo = mid + 1;
512-
} else {
513-
return region;
514-
}
515-
}
516-
return null;
517-
}
518-
519349
/**
520350
* A `git` inside a quoted string is only a command when that string is
521351
* handed to something that executes it. Otherwise it is an argument of an

scripts/lib/shell-quotes.js

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

0 commit comments

Comments
 (0)