fix(hooks): leave here-document bodies out of the shell segments

A body attached to the segment of its command was still searched by the dev-server check. For npm --version <<EOF && cat, the body line npm run dev made the npm segment look like a dev server. A body is data, so it is no longer part of any segment. An empty delimiter (<<'') is a valid opener again, and its body ends at the first empty line.
This commit is contained in:
L4XB committed 2026-10-23 02:47:32 +02:00
1 parent d583576552
commit de87e6ec74
3 files changed
+30 -46

No files matched your search

+12 -17
View File
@@ -1,7 +1,8 @@
'use strict';
// A here-document opener at `index`, but not a here-string (<<<): `<<` or `<<-`, then the
// delimiter, a shell word whose quotes are removed (`<<'EOF'`, `<<E"OF"`, `<<EOF/END`, `<<123`).
// delimiter, a shell word whose quotes are removed (`<<'EOF'`, `<<E"OF"`, `<<EOF/END`, `<<123`,
// and `<<''`, whose body ends at the first empty line).
function heredocAt(command, index) {
if (command[index - 1] === '<' || command[index + 2] === '<') return null;
let i = index + 2;
@@ -10,6 +11,7 @@ function heredocAt(command, index) {
while (command[i] === ' ' || command[i] === '\t') i++;
let delimiter = '';
let quote = null;
let quoted = false;
for (; i < command.length; i++) {
const ch = command[i];
if (quote) {
@@ -17,6 +19,7 @@ function heredocAt(command, index) {
else delimiter += ch;
} else if (ch === "'" || ch === '"') {
quote = ch;
quoted = true;
} else if (ch === '\\' && i + 1 < command.length) {
delimiter += command[++i];
} else if (/[\s|&;()<>]/.test(ch)) {
@@ -25,7 +28,7 @@ function heredocAt(command, index) {
delimiter += ch;
}
}
return delimiter && !quote ? { text: command.slice(index, i), delimiter, stripTabs } : null;
return (delimiter || quoted) && !quote ? { text: command.slice(index, i), delimiter, stripTabs } : null;
}
// The index after the `))` that closes the arithmetic `((` at `index`, or -1.
@@ -64,27 +67,19 @@ function endOfHeredocBodies(command, start, heredocs) {
return Math.min(end, command.length);
}
// At the newline at `index` the command ends, after the bodies of the here-documents opened on
// its line. The bodies are data, so they go with the command that opened the first of them, even
// when an operator after it (`cat <<EOF &&`) already ended its segment. Returns the index after
// the bodies.
// At the newline at `index` the command ends. The bodies of the here-documents opened on its
// line follow it. They are data, not commands, so they are left out of the segments. Returns the
// index after the bodies.
function endLine(command, index, current, heredocs, segments) {
const end = endOfHeredocBodies(command, index + 1, heredocs);
let bodies = command.slice(index, end);
if (heredocs.length > 0 && heredocs[0].segment < segments.length) {
segments[heredocs[0].segment] += bodies.trimEnd();
bodies = '';
}
const rest = (current + bodies).trim();
if (rest) segments.push(rest);
return end;
if (current.trim()) segments.push(current.trim());
return endOfHeredocBodies(command, index + 1, heredocs);
}
/**
* Split a shell command into segments by operators (&&, ||, ;, &) and by
* newlines, while respecting quoting (single/double) and escaped characters.
* Redirection operators (&>, >&, 2>&1) are NOT treated as separators, and the
* body of a here-document stays in the segment of the command that reads it.
* body of a here-document, which is data, is left out of the segments.
*/
function splitShellSegments(command) {
const segments = [];
@@ -134,7 +129,7 @@ function splitShellSegments(command) {
const heredoc = ch === '<' && next === '<' ? heredocAt(command, i) : null;
if (heredoc) {
heredocs.push({ ...heredoc, segment: segments.length });
heredocs.push(heredoc);
current += heredoc.text;
i += heredoc.text.length - 1;
continue;
@@ -234,6 +234,11 @@ function runTests() {
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
}) ? passed++ : failed++);
(test('allows a here-document body fed to a package manager command', () => {
const result = runScript('npm --version <<EOF && cat\nnpm run dev\nEOF');
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
}) ? passed++ : failed++);
// --- Edge cases ---
(test('empty/invalid input passes through (exit code 0)', () => {
+13 -29
View File
@@ -120,16 +120,10 @@ test('an escaped or quoted newline does not split', () => {
assert.deepStrictEqual(splitShellSegments('npm run \\\ndev'), ['npm run \\\ndev']);
assert.deepStrictEqual(splitShellSegments('echo "a\nb"'), ['echo "a\nb"']);
});
test('a here-document body stays with its command', () => {
assert.deepStrictEqual(
splitShellSegments('cat <<EOF > notes.md\na && b\nnpm run dev\nEOF\nls'),
['cat <<EOF > notes.md\na && b\nnpm run dev\nEOF', 'ls']
);
assert.deepStrictEqual(
splitShellSegments("cat <<-'END'\n\tnpm run dev\n\tEND\nls"),
["cat <<-'END'\n\tnpm run dev\n\tEND", 'ls']
);
assert.deepStrictEqual(splitShellSegments('cat <<A <<B\na\nA\nb\nB\nnext'), ['cat <<A <<B\na\nA\nb\nB', 'next']);
test('a here-document body is data, left out of the segments', () => {
assert.deepStrictEqual(splitShellSegments('cat <<EOF > notes.md\na && b\nnpm run dev\nEOF\nls'), ['cat <<EOF > notes.md', 'ls']);
assert.deepStrictEqual(splitShellSegments("cat <<-'END'\n\tnpm run dev\n\tEND\nls"), ["cat <<-'END'", 'ls']);
assert.deepStrictEqual(splitShellSegments('cat <<A <<B\na\nA\nb\nB\nnext'), ['cat <<A <<B', 'next']);
});
test('a here-string or a shift is not a here-document', () => {
assert.deepStrictEqual(splitShellSegments('cat <<< x && y'), ['cat <<< x', 'y']);
@@ -143,28 +137,18 @@ test('a << in a comment is not a here-document, and a comment is not split', ()
assert.deepStrictEqual(splitShellSegments('echo a#b ${#x} && c'), ['echo a#b ${#x}', 'c']);
});
test('a here-document delimiter is a shell word, read without its quotes', () => {
assert.deepStrictEqual(
splitShellSegments('cat <<EOF/END\nnpm run dev\nEOF/END\nls'),
['cat <<EOF/END\nnpm run dev\nEOF/END', 'ls']
);
assert.deepStrictEqual(splitShellSegments('cat <<E"OF"\nnpm run dev\nEOF\nls'), ['cat <<E"OF"\nnpm run dev\nEOF', 'ls']);
assert.deepStrictEqual(splitShellSegments('cat <<123\nnpm run dev\n123\nls'), ['cat <<123\nnpm run dev\n123', 'ls']);
assert.deepStrictEqual(splitShellSegments('cat <<EOF/END\nnpm run dev\nEOF/END\nls'), ['cat <<EOF/END', 'ls']);
assert.deepStrictEqual(splitShellSegments('cat <<E"OF"\nnpm run dev\nEOF\nls'), ['cat <<E"OF"', 'ls']);
assert.deepStrictEqual(splitShellSegments('cat <<123\nnpm run dev\n123\nls'), ['cat <<123', 'ls']);
// An empty delimiter ends the body at the first empty line
assert.deepStrictEqual(splitShellSegments("cat <<''\nnpm run dev\n\nls"), ["cat <<''", 'ls']);
});
test('a here-document body stays with its command after an operator ends the segment', () => {
assert.deepStrictEqual(
splitShellSegments('cat <<EOF &&\nnpm run dev\nEOF\necho done'),
['cat <<EOF\nnpm run dev\nEOF', 'echo done']
);
assert.deepStrictEqual(
splitShellSegments('cat <<EOF && npm run build\nnpm run dev\nEOF'),
['cat <<EOF\nnpm run dev\nEOF', 'npm run build']
);
test('a here-document body after an operator is left out too', () => {
assert.deepStrictEqual(splitShellSegments('cat <<EOF &&\nnpm run dev\nEOF\necho done'), ['cat <<EOF', 'echo done']);
assert.deepStrictEqual(splitShellSegments('npm --version <<EOF && cat\nnpm run dev\nEOF'), ['npm --version <<EOF', 'cat']);
});
test('a here-document delimiter on a CRLF line still ends the body', () => {
assert.deepStrictEqual(
splitShellSegments('cat <<EOF\r\ntext\r\nEOF\r\nnpm run dev'),
['cat <<EOF\r\ntext\r\nEOF', 'npm run dev']
);
assert.deepStrictEqual(splitShellSegments('cat <<EOF\r\ntext\r\nEOF\r\nnpm run dev'), ['cat <<EOF', 'npm run dev']);
});
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);