@marcomigozzi/trailhead 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +28 -3
- package/README.md +38 -3
- package/bin/lib/codex-projection.js +16 -9
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -42,87 +42,512 @@ function stripQuotes(t) {
|
|
|
42
42
|
|
|
43
43
|
// --- heredoc bodies ------------------------------------------------------------
|
|
44
44
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
// A
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
45
|
+
// What follows a `<<` introducer: an optional `-` (the terminator line may then
|
|
46
|
+
// be indented with leading tabs), then the tag, quoted or bare. Captures the
|
|
47
|
+
// `-` and the tag (single-quoted, double-quoted or bare) separately. Sticky, so
|
|
48
|
+
// it only matches right at the operator.
|
|
49
|
+
const HEREDOC_TAG_RE = /(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|([A-Za-z0-9_]+))/y;
|
|
50
|
+
|
|
51
|
+
// A `#` starts a comment, and a `((` an arithmetic command, only at a word start.
|
|
52
|
+
const WORD_START_PREV_RE = /[\s;&|()]/;
|
|
53
|
+
|
|
54
|
+
// The ONE lexer behind heredoc detection and statement splitting. It scans a
|
|
55
|
+
// line with the shell context a bare regex lacks: a `<<` inside quotes, a
|
|
56
|
+
// comment, `$((..))`, `$[..]`, `((..))` or after a backslash is not an
|
|
57
|
+
// introducer, and `<<<` is a herestring. `st.stack` holds the open contexts as
|
|
58
|
+
// uniform { k, ... } frames: k is 'sq', 'dq', 'ansi' (`$'..'`), 'br' (`${..}`),
|
|
59
|
+
// 'sub' (`$(..)`, with a paren count n), 'ar' (an arithmetic span) or 'hd' (the
|
|
60
|
+
// body of an unquoted heredoc, only ever the bottom frame of a dedicated scan,
|
|
61
|
+
// see heredocSubstitutionBodies); `st.cont` is set when the line
|
|
62
|
+
// ends in a backslash-newline continuation. It is mutated and carried to the next line, so a quote or arithmetic
|
|
63
|
+
// span that runs across lines keeps suppressing introducers. A `$(..)` is code
|
|
64
|
+
// again, even inside double quotes, so its heredocs count. Returns
|
|
65
|
+
// [{ at, depth, dash, tag, quoted }] (`at` is the index of the `<<`, `depth` the
|
|
66
|
+
// number of open contexts there). When `seps` is given, the statement separators found
|
|
67
|
+
// in splittable code (top level or inside `$(..)`, never inside a quote,
|
|
68
|
+
// arithmetic span or comment) are pushed to it as { at, len, op }, so the
|
|
69
|
+
// splitters cut exactly where this lexer says code is. Best-effort: not a full
|
|
70
|
+
// shell parser (`$(( (a) ))` nesting tricks and `<<\TAG` are not modelled,
|
|
71
|
+
// nor is a backtick nested inside a backtick span other than as an escaped
|
|
72
|
+
// char; nor is the body order of a heredoc nested in another heredoc's pending `"$(..)"`, e.g. `cat <<E "$(cat <<A ..)"`).
|
|
73
|
+
// When `subs` is given ({ open(at, bq), close(at) }), it is told where each
|
|
74
|
+
// OUTERMOST `$(..)` / `<(..)` / `>(..)` / backtick body starts (the index after
|
|
75
|
+
// the opener; `bq` is true for a backtick) and where it ends (the index of the
|
|
76
|
+
// closing `)` or backtick), so substitutionBodies reads the bodies off this
|
|
77
|
+
// lexer instead of running a second one.
|
|
78
|
+
// A backtick span is NOT a stack frame: it lives in `st.bq` ({ outer } or unset)
|
|
79
|
+
// and is opened by an unescaped backtick in code, `dq`, `br` or `sub` context
|
|
80
|
+
// (never in `sq`, `ansi`, arithmetic or a comment) and closed by the next
|
|
81
|
+
// unescaped one. The span is OPAQUE: while it is open only a backslash (skips
|
|
82
|
+
// the next char) and the closing backtick are looked at, as in the shell, which
|
|
83
|
+
// finds the closing backtick first and parses the body separately (so a quote
|
|
84
|
+
// inside it cannot desync this lexer). Its separators and heredocs therefore
|
|
85
|
+
// do not count; the guards recurse into the body via substitutionBodies. It is
|
|
86
|
+
// kept off the stack so heredoc depth is untouched. `st.bq.dq` records whether
|
|
87
|
+
// it sits in double quotes (nearest `dq`/`sub` frame is `dq`), where the shell
|
|
88
|
+
// also unescapes `\"` in the body. A span that never closes runs to the end of
|
|
89
|
+
// the input.
|
|
90
|
+
function scanLine(line, st, seps, subs) {
|
|
91
|
+
const found = [];
|
|
92
|
+
const stack = st.stack;
|
|
93
|
+
const parens = {}; // parenIsArith's per-line match cache
|
|
94
|
+
const cont0 = st.cont; // the previous line ended in a backslash: this one continues it
|
|
95
|
+
let escAt = -2; // index of the last escaped char
|
|
96
|
+
// Index of the last bare `$` (not escaped, not the 2nd of `$$`). A bare `$` that
|
|
97
|
+
// ended the previous line before its `\<nl>` is still adjacent: it counts as index -1.
|
|
98
|
+
let bareAt = cont0 && st.bareEnd ? -1 : -2;
|
|
99
|
+
st.cont = false;
|
|
100
|
+
st.bareEnd = false;
|
|
101
|
+
// Is the char before index i a word boundary? An escaped char (`\ #x`) is not.
|
|
102
|
+
// The `)` that closes an expansion (`$(..)`, `<(..)`, `$((..))`) ends no word
|
|
103
|
+
// either: `$(x)# y` is not a comment, `(x)# y` is.
|
|
104
|
+
let expCloseAt = -2;
|
|
105
|
+
// Push a `$(..)` / `<(..)` frame; the outermost one is reported to `subs`.
|
|
106
|
+
const openSub = (bodyAt) => {
|
|
107
|
+
const outer = !stack.some((f) => f.k === 'sub');
|
|
108
|
+
stack.push({ k: 'sub', n: 0, outer });
|
|
109
|
+
if (subs && outer) subs.open(bodyAt);
|
|
110
|
+
};
|
|
111
|
+
const isBoundary = (i) => WORD_START_PREV_RE.test(line[i - 1]) && escAt !== i - 1 && expCloseAt !== i - 1;
|
|
112
|
+
for (let i = 0; i < line.length; i++) {
|
|
113
|
+
const c = line[i];
|
|
114
|
+
if (st.bq) { // opaque backtick span: only a backslash and the closing backtick matter
|
|
115
|
+
if (c === '\\') i++;
|
|
116
|
+
else if (c === '`') { if (subs && st.bq.outer) subs.close(i); st.bq = null; }
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const top = stack[stack.length - 1];
|
|
120
|
+
if (top && top.k === 'sq') { if (c === "'") stack.pop(); continue; }
|
|
121
|
+
if (top && top.k === 'ansi') { // $'..': a backslash escapes the next char, even a quote
|
|
122
|
+
if (c === '\\') i++;
|
|
123
|
+
else if (c === "'") stack.pop();
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (top && top.k === 'ar') {
|
|
127
|
+
// A `$(..)` inside arithmetic runs: push a sub frame (its own parens
|
|
128
|
+
// balance there), unless it is itself an arithmetic `$((..))`.
|
|
129
|
+
if (c === '$' && line[i + 1] === '(' && !(line[i + 2] === '(' && parenIsArith(line, i + 3, parens))) { openSub(i + 2); i++; continue; }
|
|
130
|
+
if (top.mode === 'bracket') { if (c === ']') stack.pop(); continue; }
|
|
131
|
+
if (c === '(') top.depth++;
|
|
132
|
+
else if (c === ')' && --top.depth === 0) { stack.pop(); if (top.exp) expCloseAt = i; }
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (c === '\\') { // escaped char stays literal
|
|
136
|
+
if (i + 1 >= line.length) { // backslash-newline: the line continues
|
|
137
|
+
st.cont = true;
|
|
138
|
+
// The next line is glued to this text: it starts a word only if the
|
|
139
|
+
// char before the backslash was a boundary (`a \<nl>#x` is a comment,
|
|
140
|
+
// `a\<nl>#x` is not). A line of just `\` inherits the earlier answer.
|
|
141
|
+
st.contBoundary = i === 0 ? (cont0 ? st.contBoundary : true) : isBoundary(i);
|
|
142
|
+
st.bareEnd = i === 0 ? bareAt === -1 : bareAt === i - 1; // carry the bare-dollar bit over
|
|
143
|
+
if (st.onCont) st.onCont(i, st.contBoundary, top); // optional observer (inWordContinuations)
|
|
144
|
+
}
|
|
145
|
+
i++;
|
|
146
|
+
escAt = i;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (c === '`') { // a backtick substitution opens here (its close is handled above)
|
|
150
|
+
const outer = !stack.some((f) => f.k === 'sub');
|
|
151
|
+
const near = stack.slice().reverse().find((f) => f.k === 'dq' || f.k === 'sub');
|
|
152
|
+
const dq = !!near && near.k === 'dq';
|
|
153
|
+
st.bq = { outer, dq };
|
|
154
|
+
if (subs && outer) subs.open(i + 1, true, dq);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// Only a bare `$` opens `$'..'`, `${..}`, `$[..]`, `$(..)` or `$((..))`: not an escaped one
|
|
158
|
+
// (skipped above) and not the 2nd of `$$` (the PID, so `$${` is PID + a literal brace).
|
|
159
|
+
const bare = c === '$' && bareAt !== i - 1;
|
|
160
|
+
if (bare) bareAt = i;
|
|
161
|
+
if (bare && line[i + 1] === '(' && line[i + 2] === '(' && parenIsArith(line, i + 3, parens)) { stack.push({ k: 'ar', mode: 'paren', depth: 2, exp: true }); i += 2; continue; }
|
|
162
|
+
if (bare && line[i + 1] === '(') { openSub(i + 2); i++; continue; }
|
|
163
|
+
if (bare && line[i + 1] === '{') {
|
|
164
|
+
// `${ ` / `${<tab>` / `${|` / `${` at the end of a line is a bash 5.3 function
|
|
165
|
+
// substitution (it runs code); the optional observer `st.funsubs` records it.
|
|
166
|
+
const funsub = st.funsubs && (i + 2 >= line.length || ' \t|'.includes(line[i + 2]));
|
|
167
|
+
const entry = funsub ? { line: st.lineNo, at: i } : null;
|
|
168
|
+
if (entry) st.funsubs.push(entry);
|
|
169
|
+
stack.push(entry ? { k: 'br', fs: entry } : { k: 'br' });
|
|
170
|
+
i++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (top && top.k === 'dq') {
|
|
174
|
+
if (c === '"') stack.pop();
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// An unquoted heredoc body is like double quotes without a closing `"`: the
|
|
178
|
+
// expansions above stay live, but quotes, comments, separators and
|
|
179
|
+
// introducers are literal text.
|
|
180
|
+
if (top && top.k === 'hd') continue;
|
|
181
|
+
if (bare && line[i + 1] === "'") { stack.push({ k: 'ansi' }); i++; continue; }
|
|
182
|
+
// A `'` glued to a bare `$` that ended the previous line (`$\<nl>'..'`) is ANSI-C too.
|
|
183
|
+
if (c === "'" && i === 0 && bareAt === -1) { stack.push({ k: 'ansi' }); continue; }
|
|
184
|
+
if (c === "'") { stack.push({ k: 'sq' }); continue; }
|
|
185
|
+
if (c === '"') { stack.push({ k: 'dq' }); continue; }
|
|
186
|
+
if (bare && line[i + 1] === '[') { stack.push({ k: 'ar', mode: 'bracket' }); i++; continue; }
|
|
187
|
+
if (top && top.k === 'br') { // ${..}: no code until the matching }
|
|
188
|
+
if (c === '}') {
|
|
189
|
+
stack.pop();
|
|
190
|
+
if (top.fs) { top.fs.endLine = st.lineNo; top.fs.endAt = i; }
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const wordStart = i === 0 ? (cont0 ? st.contBoundary : true) : isBoundary(i);
|
|
195
|
+
if (c === '#' && wordStart) break; // comment to end of line
|
|
196
|
+
if (c === '(' && line[i + 1] === '(' && wordStart && parenIsArith(line, i + 2, parens)) { stack.push({ k: 'ar', mode: 'paren', depth: 2 }); i++; continue; }
|
|
197
|
+
if (top && top.k === 'sub') {
|
|
198
|
+
if (c === '(') { top.n++; continue; }
|
|
199
|
+
if (c === ')') { if (top.n === 0) { stack.pop(); expCloseAt = i; if (subs && top.outer) subs.close(i); } else top.n--; continue; }
|
|
200
|
+
}
|
|
201
|
+
// A process substitution `<(..)` / `>(..)` is code, like `$(..)`.
|
|
202
|
+
if ((c === '<' || c === '>') && line[i + 1] === '(') { openSub(i + 2); i++; continue; }
|
|
203
|
+
if (seps && splittable(stack)) {
|
|
204
|
+
const two = c + line[i + 1];
|
|
205
|
+
if (two === '&&' || two === '||') { seps.push({ at: i, len: 2, op: two }); i++; continue; }
|
|
206
|
+
if (c === ';' || c === '|') { seps.push({ at: i, len: 1, op: c }); continue; }
|
|
207
|
+
// A lone `&` backgrounds, unless it is part of a redirection (`2>&1`, `>&2`, `&>f`).
|
|
208
|
+
if (c === '&' && line[i - 1] !== '>' && line[i - 1] !== '<' && line[i + 1] !== '>') { seps.push({ at: i, len: 1, op: '&' }); continue; }
|
|
209
|
+
}
|
|
210
|
+
if (c === '<' && line[i + 1] === '<') {
|
|
211
|
+
if (line[i + 2] === '<') { i += 2; continue; } // herestring
|
|
212
|
+
HEREDOC_TAG_RE.lastIndex = i + 2;
|
|
213
|
+
const m = HEREDOC_TAG_RE.exec(line);
|
|
214
|
+
if (m) {
|
|
215
|
+
found.push({ at: i, depth: stack.length, dash: m[1] === '-', tag: m[2] || m[3] || m[4], quoted: m[2] !== undefined || m[3] !== undefined });
|
|
216
|
+
i = HEREDOC_TAG_RE.lastIndex - 1;
|
|
217
|
+
} else {
|
|
218
|
+
i++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// `((` / `$((` open arithmetic only when the paren that closes the inner `(` is
|
|
226
|
+
// directly followed by `)`; `((a); b)` is nested subshells (and `$((a); b)` a
|
|
227
|
+
// substitution). `from` is the index just after the two opening parens. A span
|
|
228
|
+
// that runs past the end of the line is assumed to be arithmetic. `cache` is one
|
|
229
|
+
// object per line: a single left-to-right pass (skipping quoted spans and
|
|
230
|
+
// escapes) matches every `(` it visits to its `)`, so the many calls on a line
|
|
231
|
+
// full of `((` stay linear instead of each rescanning the rest of the line.
|
|
232
|
+
function parenIsArith(line, from, cache) {
|
|
233
|
+
const open = from - 1; // the inner `(`
|
|
234
|
+
if (!cache.close || !cache.close.has(open)) cache.close = matchParens(line, from);
|
|
235
|
+
const close = cache.close.get(open);
|
|
236
|
+
return close < 0 ? true : line[close + 1] === ')';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Match every `(` visited from `from` on to its `)`: Map(open -> close, or -1
|
|
240
|
+
// when it never closes or an unclosed quote hides the rest of the line). The
|
|
241
|
+
// paren just before `from` is the inner one of a `((`, so it is seeded as open.
|
|
242
|
+
function matchParens(line, from) {
|
|
243
|
+
const close = new Map([[from - 1, -1]]);
|
|
244
|
+
const opens = [from - 1];
|
|
245
|
+
for (let j = from; j < line.length; j++) {
|
|
246
|
+
const c = line[j];
|
|
247
|
+
if (c === '\\') { j++; continue; }
|
|
248
|
+
if (c === "'" || c === '"') { // skip a quoted span on this line
|
|
249
|
+
const end = line.indexOf(c, j + 1);
|
|
250
|
+
if (end < 0) break;
|
|
251
|
+
j = end;
|
|
252
|
+
} else if (c === '(') { close.set(j, -1); opens.push(j); }
|
|
253
|
+
else if (c === ')' && opens.length > 0) close.set(opens.pop(), j);
|
|
254
|
+
}
|
|
255
|
+
return close;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Code that can be split into statements: the innermost open context is a
|
|
259
|
+
// `$(..)` substitution (or nothing). Frames are uniform { k, ... } objects, so
|
|
260
|
+
// no string can ever hit a String.prototype property by accident.
|
|
261
|
+
const splittable = (stack) => stack.length === 0 || stack[stack.length - 1].k === 'sub';
|
|
262
|
+
|
|
263
|
+
// The one walk shared by stripHeredocBodies, heredocBodies, countHeredocs and
|
|
264
|
+
// both splitters, so they always agree (secret-guard pairs heredocBodies with
|
|
265
|
+
// per-statement countHeredocs by position). A heredoc BODY is literal data
|
|
266
|
+
// (e.g. a script being written to a file), not shell statements run in the
|
|
267
|
+
// current shell. Whenever a line introduces heredocs, every following line up
|
|
268
|
+
// to and including each terminator is body. Returns { lines, keep, docs, seps,
|
|
269
|
+
// nl, cont, hd }: `keep[i]` is false for body and terminator lines; `docs` is
|
|
270
|
+
// [{ quoted, body }] in order of appearance (`hd[i]` holds the introducer positions on line i) (`quoted` is true for a quoted
|
|
271
|
+
// delimiter, whose body is literal text); `seps[i]` are the separators inside
|
|
272
|
+
// line i and `nl[i]` says whether the newline after it separates statements;
|
|
273
|
+
// `cont[i]` says whether line i ends in a live backslash-newline continuation.
|
|
274
|
+
function scanHeredocs(cmd, initialStack) {
|
|
58
275
|
const lines = String(cmd).split('\n');
|
|
59
|
-
const
|
|
276
|
+
const keep = new Array(lines.length).fill(true);
|
|
277
|
+
const docs = [];
|
|
278
|
+
const seps = lines.map(() => []);
|
|
279
|
+
const nl = new Array(lines.length).fill(false);
|
|
280
|
+
const cont = new Array(lines.length).fill(false);
|
|
281
|
+
const hd = lines.map(() => []); // hd[i]: indexes of the heredoc introducers on line i
|
|
282
|
+
const st = { stack: (initialStack || []).map((f) => ({ ...f })) }; // a copy: never shared with the caller
|
|
60
283
|
for (let i = 0; i < lines.length; i++) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
284
|
+
const found = scanLine(lines[i], st, seps[i]);
|
|
285
|
+
hd[i] = found.map((f) => f.at);
|
|
286
|
+
nl[i] = !st.cont && !st.bq && splittable(st.stack);
|
|
287
|
+
cont[i] = st.cont;
|
|
288
|
+
// A line ending in a backslash continues on the next: that is ONE logical
|
|
289
|
+
// line, and any heredoc body starts after its last physical line. So does a
|
|
290
|
+
// quote opened after the introducer (`cat <<E 'a<nl>b'`): bash starts the
|
|
291
|
+
// body after the logical line, which ends only when the quote closes.
|
|
292
|
+
// A heredoc introduced inside the innermost open `$(..)` (the `"$(cat <<EOF`
|
|
293
|
+
// idiom) starts its body on the next physical line; one introduced at an
|
|
294
|
+
// outer depth waits until every context opened after it has closed.
|
|
295
|
+
while ((st.cont || (st.bq && found.length > 0) || found.some((f) => st.stack.length > f.depth)) && i + 1 < lines.length) {
|
|
296
|
+
i++;
|
|
297
|
+
const more = scanLine(lines[i], st, seps[i]);
|
|
298
|
+
hd[i] = more.map((f) => f.at);
|
|
299
|
+
found.push(...more);
|
|
300
|
+
nl[i] = !st.cont && !st.bq && splittable(st.stack);
|
|
301
|
+
cont[i] = st.cont;
|
|
302
|
+
}
|
|
303
|
+
// A single line can introduce more than one heredoc (`cmd <<A <<B`); the
|
|
304
|
+
// bodies follow in order.
|
|
305
|
+
for (const { dash, tag, quoted } of found) {
|
|
306
|
+
const body = [];
|
|
73
307
|
while (i + 1 < lines.length) {
|
|
74
308
|
i++;
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
// never become a candidate statement.
|
|
309
|
+
keep[i] = false; // body or terminator: never a candidate statement
|
|
310
|
+
const l = lines[i];
|
|
311
|
+
if ((dash ? l.replace(/^\t+/, '') : l) === tag) break; // exact match, or (for `<<-`) tabs then the tag
|
|
312
|
+
body.push(l);
|
|
80
313
|
}
|
|
314
|
+
docs.push({ quoted, body: body.join('\n') });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { lines, keep, docs, seps, nl, cont, hd };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Remove every heredoc body (and terminator line) before the statement splitter
|
|
321
|
+
// sees the command. Handles multiple heredocs on one line and quoted tags.
|
|
322
|
+
function stripHeredocBodies(cmd) {
|
|
323
|
+
const { lines, keep } = scanHeredocs(cmd);
|
|
324
|
+
return lines.filter((_, i) => keep[i]).join('\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The heredoc bodies of a command, in order of appearance: [{ quoted, body }].
|
|
328
|
+
function heredocBodies(cmd) {
|
|
329
|
+
return scanHeredocs(cmd).docs;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function lexCount(text) {
|
|
333
|
+
const st = { stack: [] };
|
|
334
|
+
return String(text).split('\n').reduce((n, line) => n + scanLine(line, st).length, 0);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// How many heredoc introducers a statement holds. A splitter entry carries the
|
|
338
|
+
// count from the single whole-command scan (`heredocs`), which is what callers
|
|
339
|
+
// pairing statements with heredocBodies must use. A plain string is lexed
|
|
340
|
+
// standalone from an empty stack: pure, no memory of any earlier split, so a
|
|
341
|
+
// fragment cut out of a larger command may count differently from its entry.
|
|
342
|
+
function countHeredocs(stmt) {
|
|
343
|
+
if (stmt && typeof stmt === 'object' && typeof stmt.heredocs === 'number') return stmt.heredocs;
|
|
344
|
+
return lexCount(stmt && typeof stmt === 'object' ? stmt.text : stmt);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The text of every outermost command substitution in a command: `$(..)`,
|
|
348
|
+
// `<(..)`, `>(..)` and backticks, including those inside double quotes (the
|
|
349
|
+
// shell runs them), never one in single quotes, `$'..'`, arithmetic, a comment or a
|
|
350
|
+
// heredoc body. Nested ones are not returned: callers recurse into a body to
|
|
351
|
+
// reach them. A backtick body is returned as the shell would run it: `\``,
|
|
352
|
+
// `\\` and `\$` (and `\"` inside double quotes) are unescaped, so a nested `\`..\`` becomes a real backtick
|
|
353
|
+
// span in the body. A body can span lines (joined with '\n'); one that never closes
|
|
354
|
+
// runs to the end of the input (fail toward scanning). Read off scanLine's
|
|
355
|
+
// `sub` frames, so it agrees with the splitters on what is code.
|
|
356
|
+
function substitutionBodies(cmd) {
|
|
357
|
+
// Cut from the ORIGINAL text: a heredoc inside a body keeps its body and
|
|
358
|
+
// terminator lines there (the caller's own scan strips them), while lines
|
|
359
|
+
// that are heredoc body are never lexed, so a `$(..)` in one is not returned.
|
|
360
|
+
const { lines, keep } = scanHeredocs(cmd);
|
|
361
|
+
return collectSubstitutionBodies(lines, keep, { stack: [] });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The outermost substitution bodies of an UNQUOTED heredoc body (#218): the
|
|
365
|
+
// shell runs `$(..)` and backticks there, though substitutionBodies never lexes
|
|
366
|
+
// a body line. The body is lexed as the shell reads a heredoc body, not as
|
|
367
|
+
// command text: quotes are literal (`it's $(cat .env)` still has its
|
|
368
|
+
// substitution), `\$` and `\`` stay escaped. Every line is lexed (a heredoc
|
|
369
|
+
// introducer in the body is text, not another heredoc). Same result shape and
|
|
370
|
+
// backtick unescaping as substitutionBodies. A quoted-delimiter body is literal
|
|
371
|
+
// and must not be passed here.
|
|
372
|
+
function heredocSubstitutionBodies(body) {
|
|
373
|
+
const hd = () => [{ k: 'hd' }];
|
|
374
|
+
// Scan once for the keep marks: a heredoc introduced inside a substitution in
|
|
375
|
+
// the body owns the lines after it (its body and terminator are data, so a `)`
|
|
376
|
+
// there does not close the substitution). Then collect from a fresh state.
|
|
377
|
+
const { lines, keep } = scanHeredocs(joinLineContinuations(body), hd());
|
|
378
|
+
return collectSubstitutionBodies(lines, keep, { stack: hd() });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Remove every backslash-newline pair whose backslash is itself unescaped, as
|
|
382
|
+
// the shell does in an unquoted heredoc body before it expands anything. A run
|
|
383
|
+
// of backslashes before the newline pairs up from the left: an odd run leaves
|
|
384
|
+
// one live backslash (joined), an even run is escaped backslashes plus a real
|
|
385
|
+
// newline (kept).
|
|
386
|
+
function joinLineContinuations(text) {
|
|
387
|
+
const s = String(text);
|
|
388
|
+
let out = '';
|
|
389
|
+
for (let i = 0; i < s.length; i++) {
|
|
390
|
+
if (s[i] === '\\') {
|
|
391
|
+
if (s[i + 1] === '\n') { i++; continue; } // live backslash-newline: dropped
|
|
392
|
+
out += s[i] + (s[i + 1] === undefined ? '' : s[i + 1]); // escaped pair: kept whole
|
|
393
|
+
i++;
|
|
394
|
+
continue;
|
|
81
395
|
}
|
|
396
|
+
out += s[i];
|
|
82
397
|
}
|
|
83
|
-
return out
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The one collector behind substitutionBodies and heredocSubstitutionBodies:
|
|
402
|
+
// lex the `keep`-marked lines from the start state `st` and read the outermost
|
|
403
|
+
// body texts off scanLine's `subs` callbacks.
|
|
404
|
+
function collectSubstitutionBodies(lines, keep, st) {
|
|
405
|
+
const out = [];
|
|
406
|
+
let cur = null; // text of the open body so far, or null
|
|
407
|
+
let from = 0; // where the open body's text starts on the current line
|
|
408
|
+
let bq = null; // the open body is a backtick span: null, or { dq } (in double quotes)
|
|
409
|
+
const unescape = (t) => (bq ? t.replace(bq.dq ? /\\([`\\$"])/g : /\\([`\\$])/g, '$1') : t);
|
|
410
|
+
const subs = {
|
|
411
|
+
open: (at, isBq, dq) => { cur = ''; from = at; bq = isBq ? { dq: !!dq } : null; },
|
|
412
|
+
close: (at) => { out.push(unescape(cur + lines[li].slice(from, at))); cur = null; },
|
|
413
|
+
};
|
|
414
|
+
let li = 0;
|
|
415
|
+
for (; li < lines.length; li++) {
|
|
416
|
+
if (!keep[li]) { if (cur !== null) cur += lines[li] + '\n'; continue; }
|
|
417
|
+
from = 0;
|
|
418
|
+
scanLine(lines[li], st, undefined, subs);
|
|
419
|
+
if (cur !== null) cur += lines[li].slice(from) + '\n';
|
|
420
|
+
}
|
|
421
|
+
if (cur !== null) out.push(unescape(cur.slice(0, -1))); // unterminated: the remainder
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Lex the `keep`-marked lines of a command from the start state `st`, telling the
|
|
426
|
+
// optional observers on `st` (funsubs, onCont) which line they are on.
|
|
427
|
+
function lexKeptLines(lines, keep, st) {
|
|
428
|
+
for (let li = 0; li < lines.length; li++) {
|
|
429
|
+
if (!keep[li]) continue;
|
|
430
|
+
st.lineNo = li;
|
|
431
|
+
scanLine(lines[li], st);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Every live bash 5.3 function substitution in a command: `${ cmd; }`, `${| cmd; }`
|
|
436
|
+
// and `${` at the end of a line. They run code the statement splitter treats as
|
|
437
|
+
// ordinary text. Read off scanLine, so a `${ ` in single quotes, `$'..'`, a comment,
|
|
438
|
+
// arithmetic or a quoted-delimiter heredoc body is not reported, while one in double
|
|
439
|
+
// quotes, `$(..)` or an unquoted heredoc body is. Returns [{ at, payload }]: `at`
|
|
440
|
+
// is the offset of the `${` in `cmd` (-1 inside a heredoc body), `payload` the code
|
|
441
|
+
// up to the closing brace (to the end of the text when it never closes). A backtick
|
|
442
|
+
// span is opaque to the lexer: callers recurse into substitutionBodies for those.
|
|
443
|
+
function functionSubstitutions(cmd) {
|
|
444
|
+
const out = [];
|
|
445
|
+
const lexText = (text, initial, absolute) => {
|
|
446
|
+
const { lines, keep } = scanHeredocs(text, initial);
|
|
447
|
+
const starts = [];
|
|
448
|
+
lines.reduce((n, l, i) => { starts[i] = n; return n + l.length + 1; }, 0);
|
|
449
|
+
const st = { stack: (initial || []).map((f) => ({ ...f })), funsubs: [] };
|
|
450
|
+
lexKeptLines(lines, keep, st);
|
|
451
|
+
for (const f of st.funsubs) {
|
|
452
|
+
const endLine = f.endLine === undefined ? lines.length - 1 : f.endLine;
|
|
453
|
+
const endAt = f.endAt === undefined ? lines[endLine].length : f.endAt;
|
|
454
|
+
const parts = lines.slice(f.line, endLine + 1);
|
|
455
|
+
parts[parts.length - 1] = parts[parts.length - 1].slice(0, endAt);
|
|
456
|
+
parts[0] = parts[0].slice(f.at + 2);
|
|
457
|
+
out.push({ at: absolute ? starts[f.line] + f.at : -1, payload: parts.join('\n') });
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
lexText(String(cmd), [], true);
|
|
461
|
+
for (const doc of heredocBodies(cmd)) {
|
|
462
|
+
if (!doc.quoted) lexText(joinLineContinuations(doc.body), [{ k: 'hd' }], false);
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Every live backslash-newline that splits a WORD: the char before the backslash is
|
|
468
|
+
// not a word boundary and the next line starts with a non-blank (`cat .e\<nl>nv`).
|
|
469
|
+
// A continuation between words (` \<nl> --body x`, the usual multi-line form) is
|
|
470
|
+
// not reported, nor is one inside quotes, a comment or a heredoc body. Returns
|
|
471
|
+
// [{ line, junction }]: `junction` is the glued text around the join (`.env`).
|
|
472
|
+
function inWordContinuations(cmd) {
|
|
473
|
+
const out = [];
|
|
474
|
+
const { lines, keep } = scanHeredocs(cmd);
|
|
475
|
+
const st = {
|
|
476
|
+
stack: [],
|
|
477
|
+
onCont: (at, boundary, top) => {
|
|
478
|
+
const next = lines[st.lineNo + 1];
|
|
479
|
+
if (boundary || next === undefined || !/^\S/.test(next)) return;
|
|
480
|
+
if (top && top.k !== 'sub') return; // inside quotes the existing emulation joins it
|
|
481
|
+
const before = /[^\s;&|()<>]{0,6}$/.exec(lines[st.lineNo].slice(0, at))[0];
|
|
482
|
+
out.push({ line: st.lineNo, junction: before + /^[^\s;&|()<>]{0,6}/.exec(next)[0] });
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
lexKeptLines(lines, keep, st);
|
|
486
|
+
return out;
|
|
84
487
|
}
|
|
85
488
|
|
|
86
489
|
// --- statement splitting -------------------------------------------------------
|
|
87
490
|
|
|
491
|
+
// Cut a command at the separators the lexer reports. Heredoc bodies are skipped
|
|
492
|
+
// (literal data, never statements). Returns [{ text, sep }] with the operator
|
|
493
|
+
// that ENDED each statement (null for the last) and the number of heredoc
|
|
494
|
+
// introducers it holds (counted from the scan positions), untrimmed and unfiltered.
|
|
495
|
+
// A live backslash-newline continuation is removed like bash does (the backslash
|
|
496
|
+
// and the newline both go), so a word split across lines is one word again.
|
|
497
|
+
function cutStatements(cmd, ops) {
|
|
498
|
+
const { lines, keep, seps, nl, cont, hd } = scanHeredocs(cmd);
|
|
499
|
+
const kept = lines.map((_, i) => i).filter((i) => keep[i]);
|
|
500
|
+
const out = [];
|
|
501
|
+
let cur = '';
|
|
502
|
+
let heredocs = 0;
|
|
503
|
+
kept.forEach((i, k) => {
|
|
504
|
+
let from = 0;
|
|
505
|
+
const cuts = seps[i].filter((s) => ops.includes(s.op));
|
|
506
|
+
let h = 0;
|
|
507
|
+
for (const { at, len, op } of cuts) {
|
|
508
|
+
while (h < hd[i].length && hd[i][h] < at) { heredocs++; h++; }
|
|
509
|
+
out.push({ text: cur + lines[i].slice(from, at), sep: op, heredocs });
|
|
510
|
+
cur = '';
|
|
511
|
+
heredocs = 0;
|
|
512
|
+
from = at + len;
|
|
513
|
+
}
|
|
514
|
+
heredocs += hd[i].length - h;
|
|
515
|
+
cur += lines[i].slice(from);
|
|
516
|
+
if (k === kept.length - 1) return;
|
|
517
|
+
if (cont[i] && cur.endsWith('\\')) { cur = cur.slice(0, -1); return; } // joined: no separator, no newline
|
|
518
|
+
if (nl[i]) { out.push({ text: cur, sep: '\n', heredocs }); cur = ''; heredocs = 0; } else cur += '\n';
|
|
519
|
+
});
|
|
520
|
+
out.push({ text: cur, sep: null, heredocs });
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
88
524
|
// Split a shell string into top-level statements on `&&`, `||`, `;`, `|`, and
|
|
89
|
-
// newlines, respecting
|
|
90
|
-
//
|
|
91
|
-
// bodies are stripped first
|
|
92
|
-
//
|
|
525
|
+
// newlines, respecting quotes, `$((..))`, `$[..]`, `((..))` and comments (so a
|
|
526
|
+
// separator INSIDE any of them, e.g. a grep pattern, does not fracture the
|
|
527
|
+
// statement). Heredoc bodies are stripped first so literal data written via
|
|
528
|
+
// `<<TAG` is never mistaken for statements. Splitting is purely
|
|
93
529
|
// character-driven (not whitespace-driven), so a glued separator with no
|
|
94
530
|
// surrounding spaces (`cat .env|grep KEY`, `cat .env;echo done`) still splits
|
|
95
531
|
// correctly. Best-effort: not a full shell parser, just enough to avoid the
|
|
96
532
|
// common false splits.
|
|
97
533
|
function splitStatements(cmd) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
if (c === "'") { inSingle = true; cur += c; continue; }
|
|
117
|
-
if (c === '"') { inDouble = true; cur += c; continue; }
|
|
118
|
-
if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
|
|
119
|
-
if (c === '&' && s[i + 1] === '&') { stmts.push(cur); cur = ''; i++; continue; }
|
|
120
|
-
if (c === '|' && s[i + 1] === '|') { stmts.push(cur); cur = ''; i++; continue; }
|
|
121
|
-
if (c === ';' || c === '|' || c === '\n') { stmts.push(cur); cur = ''; continue; }
|
|
122
|
-
cur += c;
|
|
123
|
-
}
|
|
124
|
-
stmts.push(cur);
|
|
125
|
-
return stmts.map((t) => t.trim()).filter(Boolean);
|
|
534
|
+
return cutStatements(cmd, [';', '|', '&&', '||'])
|
|
535
|
+
.map((x) => ({ text: x.text.trim(), heredocs: x.heredocs }))
|
|
536
|
+
.filter((x) => x.text)
|
|
537
|
+
.map((x) => x.text);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Like splitStatements, but each statement carries the operator that ENDED it
|
|
541
|
+
// (`;`, `\n`, `&&`, `||`, `|`, `&`, or null for the last one), and a lone `&`
|
|
542
|
+
// (background) also splits. Callers use the separators to tell which statements
|
|
543
|
+
// ran in the current shell and which in a subshell (pipeline or background).
|
|
544
|
+
// Same lexing and heredoc rules as splitStatements, which stays unchanged for
|
|
545
|
+
// its existing consumers. A `&` that is part of a redirection (`2>&1`, `>&2`,
|
|
546
|
+
// `&>f`) is not a separator.
|
|
547
|
+
function splitStatementsWithSeparators(cmd) {
|
|
548
|
+
return cutStatements(cmd, [';', '|', '&&', '||', '&'])
|
|
549
|
+
.map((x) => ({ text: x.text.trim(), sep: x.sep, heredocs: x.heredocs }))
|
|
550
|
+
.filter((x) => x.text);
|
|
126
551
|
}
|
|
127
552
|
|
|
128
553
|
// --- tokenizing ----------------------------------------------------------------
|
|
@@ -237,7 +662,14 @@ module.exports = {
|
|
|
237
662
|
basename,
|
|
238
663
|
stripQuotes,
|
|
239
664
|
stripHeredocBodies,
|
|
665
|
+
heredocBodies,
|
|
666
|
+
countHeredocs,
|
|
240
667
|
splitStatements,
|
|
668
|
+
splitStatementsWithSeparators,
|
|
669
|
+
substitutionBodies,
|
|
670
|
+
heredocSubstitutionBodies,
|
|
671
|
+
functionSubstitutions,
|
|
672
|
+
inWordContinuations,
|
|
241
673
|
tokenize,
|
|
242
674
|
skipEnvPrefix,
|
|
243
675
|
GREP_VALUE_FLAGS,
|