@gotgenes/pi-permission-system 35.0.0 → 35.0.2
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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/access-intent/bash/heredoc-free-lines.ts +153 -0
- package/src/access-intent/bash/heredoc-tails.ts +277 -0
- package/src/access-intent/bash/parse-view.ts +180 -0
- package/src/access-intent/bash/parser.ts +15 -6
- package/src/access-intent/bash/redirect-analysis.ts +34 -3
- package/src/access-intent/bash/redirect-arguments.ts +39 -153
- package/src/access-intent/bash/unresolved-salvage.ts +16 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [35.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.1...pi-permission-system-v35.0.2) (2026-09-28)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** deny a command written after a heredoc the grammar cannot parse ([8a01239](https://github.com/gotgenes/pi-packages/commit/8a012390b49605ec52ff4379a8f452d3ec48a69e)), closes [#985](https://github.com/gotgenes/pi-packages/issues/985)
|
|
14
|
+
|
|
15
|
+
## [35.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.0...pi-permission-system-v35.0.1) (2026-09-27)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **pi-permission-system:** check the words after a heredoc against the command's own rules ([0a88264](https://github.com/gotgenes/pi-packages/commit/0a882643bb5b1be1f546842e75ca3fac5cff1930)), closes [#979](https://github.com/gotgenes/pi-packages/issues/979)
|
|
21
|
+
* **pi-permission-system:** gate the commands and redirects written after a heredoc ([b7b2709](https://github.com/gotgenes/pi-packages/commit/b7b27095606c07f684eca353c253b59ebdec4cb4)), closes [#979](https://github.com/gotgenes/pi-packages/issues/979)
|
|
22
|
+
|
|
8
23
|
## [35.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v34.0.1...pi-permission-system-v35.0.0) (2026-09-26)
|
|
9
24
|
|
|
10
25
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
|
+
import { childrenOf } from "./parse-view";
|
|
3
|
+
import type { TSNode } from "./parser";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The heredoc-free spelling of each line whose heredoc sits in a statement the
|
|
7
|
+
* primary parse could not resolve, in source order.
|
|
8
|
+
*
|
|
9
|
+
* `tree-sitter-bash` 0.25.1's `heredoc_redirect` accepts one tail form after
|
|
10
|
+
* the delimiter, so valid bash such as `cat <<EOF ; rm -rf x` or
|
|
11
|
+
* `cat <<EOF arg > /tmp/o` yields an `ERROR` directly under the redirect. The
|
|
12
|
+
* innermost unresolved node is then the redirect itself, whose own text fails
|
|
13
|
+
* to re-parse too, so the region salvage recovers nothing and a `deny` on the
|
|
14
|
+
* command after the heredoc never fires. The same line with its heredoc
|
|
15
|
+
* operators cut out (`cat ; rm -rf x`, `cat arg > /tmp/o`) is what bash runs
|
|
16
|
+
* on that line, and it parses.
|
|
17
|
+
*
|
|
18
|
+
* A heredoc's host is its nearest `redirected_statement`, or, when no
|
|
19
|
+
* statement holds it, the nearest enclosing `ERROR` (`cat 0<<EOF | …` lexes
|
|
20
|
+
* `0<<EOF` as one delimiter inside a top-level `ERROR`). Only a host the parse
|
|
21
|
+
* could not resolve is spelled, so a clean heredoc beside an unrelated failure
|
|
22
|
+
* yields nothing. The spelling runs from the host statement's start, or the
|
|
23
|
+
* heredoc's line start for an `ERROR` host, to the end of the heredoc's line;
|
|
24
|
+
* the statement anchor is what recovers a heredoc inside a compound opened
|
|
25
|
+
* earlier on its line (`if true; then cat <<EOF ; rm x`).
|
|
26
|
+
*
|
|
27
|
+
* Each cut removes an operator the scanner tokenized: the `<<` / `<<-` token,
|
|
28
|
+
* a `file_descriptor` before it, the delimiter, and the blanks before them,
|
|
29
|
+
* so the result reads as its heredoc-free spelling (`cat arg`, not `cat arg`)
|
|
30
|
+
* that a rule is written against. It only removes text, and the caller admits
|
|
31
|
+
* a spelling only when it re-parses cleanly (`unresolved-salvage.ts`).
|
|
32
|
+
*/
|
|
33
|
+
export function heredocFreeLinesWithin(root: TSNode): string[] {
|
|
34
|
+
const source = root.text;
|
|
35
|
+
const offset = root.startIndex;
|
|
36
|
+
const spans = new Map<number, HeredocLine>();
|
|
37
|
+
for (const heredoc of unresolvedHeredocsWithin(root)) {
|
|
38
|
+
const start =
|
|
39
|
+
heredoc.host.type === "redirected_statement"
|
|
40
|
+
? heredoc.host.startIndex
|
|
41
|
+
: lineStartOf(source, offset, heredoc.delimiter.startIndex);
|
|
42
|
+
const end = lineEndOf(source, offset, heredoc.delimiter.endIndex);
|
|
43
|
+
const line = spans.get(start) ?? { start, end, cuts: [] };
|
|
44
|
+
line.cuts.push(operatorSpanOf(heredoc, source, offset, start));
|
|
45
|
+
spans.set(start, line);
|
|
46
|
+
}
|
|
47
|
+
return [...spans.values()].map((line) => spell(line, source, offset));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface UnresolvedHeredoc {
|
|
51
|
+
/** The `heredoc_start` token. */
|
|
52
|
+
readonly delimiter: TSNode;
|
|
53
|
+
/** The delimiter's siblings, in order, so its operator can be found. */
|
|
54
|
+
readonly siblings: readonly TSNode[];
|
|
55
|
+
readonly index: number;
|
|
56
|
+
readonly host: TSNode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface HeredocLine {
|
|
60
|
+
readonly start: number;
|
|
61
|
+
readonly end: number;
|
|
62
|
+
readonly cuts: [number, number][];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unresolvedHeredocsWithin(root: TSNode): UnresolvedHeredoc[] {
|
|
66
|
+
const found: UnresolvedHeredoc[] = [];
|
|
67
|
+
collectUnresolvedHeredocs(root, null, null, found);
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Descends every node, `ERROR`s included: the `heredoc_start` token is the
|
|
73
|
+
* scanner's rather than recovery's invention, and it can sit inside one.
|
|
74
|
+
*/
|
|
75
|
+
function collectUnresolvedHeredocs(
|
|
76
|
+
node: TSNode,
|
|
77
|
+
statement: TSNode | null,
|
|
78
|
+
error: TSNode | null,
|
|
79
|
+
found: UnresolvedHeredoc[],
|
|
80
|
+
): void {
|
|
81
|
+
const siblings = childrenOf(node);
|
|
82
|
+
siblings.forEach((child, index) => {
|
|
83
|
+
const host = statement ?? error;
|
|
84
|
+
if (
|
|
85
|
+
child.type === "heredoc_start" &&
|
|
86
|
+
host !== null &&
|
|
87
|
+
parseUnresolvedWithin(host)
|
|
88
|
+
) {
|
|
89
|
+
found.push({ delimiter: child, siblings, index, host });
|
|
90
|
+
}
|
|
91
|
+
collectUnresolvedHeredocs(
|
|
92
|
+
child,
|
|
93
|
+
child.type === "redirected_statement" ? child : statement,
|
|
94
|
+
child.type === "ERROR" ? child : error,
|
|
95
|
+
found,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The span of a heredoc's operator: its delimiter, the `<<` / `<<-` token and
|
|
102
|
+
* a `file_descriptor` before it, and the blanks before those, but never
|
|
103
|
+
* reaching before the line's own start.
|
|
104
|
+
*/
|
|
105
|
+
function operatorSpanOf(
|
|
106
|
+
heredoc: UnresolvedHeredoc,
|
|
107
|
+
source: string,
|
|
108
|
+
offset: number,
|
|
109
|
+
lineStart: number,
|
|
110
|
+
): [number, number] {
|
|
111
|
+
const { delimiter, siblings, index } = heredoc;
|
|
112
|
+
let start = delimiter.startIndex;
|
|
113
|
+
let before = index - 1;
|
|
114
|
+
const operator = siblings.at(before);
|
|
115
|
+
if (
|
|
116
|
+
before >= 0 &&
|
|
117
|
+
operator &&
|
|
118
|
+
!operator.isNamed &&
|
|
119
|
+
operator.text.startsWith("<<")
|
|
120
|
+
) {
|
|
121
|
+
start = operator.startIndex;
|
|
122
|
+
before -= 1;
|
|
123
|
+
const descriptor = siblings.at(before);
|
|
124
|
+
if (before >= 0 && descriptor?.type === "file_descriptor") {
|
|
125
|
+
start = descriptor.startIndex;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
while (start > lineStart && isBlank(source[start - 1 - offset])) start -= 1;
|
|
129
|
+
return [start, delimiter.endIndex];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function spell(line: HeredocLine, source: string, offset: number): string {
|
|
133
|
+
let text = "";
|
|
134
|
+
let at = line.start;
|
|
135
|
+
for (const [cutStart, cutEnd] of line.cuts.toSorted(([a], [b]) => a - b)) {
|
|
136
|
+
text += source.slice(at - offset, cutStart - offset);
|
|
137
|
+
at = cutEnd;
|
|
138
|
+
}
|
|
139
|
+
return text + source.slice(at - offset, line.end - offset);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function lineStartOf(source: string, offset: number, index: number): number {
|
|
143
|
+
return source.lastIndexOf("\n", index - offset - 1) + 1 + offset;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lineEndOf(source: string, offset: number, index: number): number {
|
|
147
|
+
const newline = source.indexOf("\n", index - offset);
|
|
148
|
+
return (newline === -1 ? source.length : newline) + offset;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isBlank(character: string | undefined): boolean {
|
|
152
|
+
return character === " " || character === "\t";
|
|
153
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
childrenOf,
|
|
3
|
+
rewriteStatements,
|
|
4
|
+
rewrittenNode,
|
|
5
|
+
type Source,
|
|
6
|
+
sourceOf,
|
|
7
|
+
} from "./parse-view";
|
|
8
|
+
import type { TSNode } from "./parser";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `tree-sitter-bash`'s parse with the rest of each heredoc's line moved out of
|
|
12
|
+
* the heredoc, to where the grammar puts it when the heredoc is written as a
|
|
13
|
+
* plain input redirect.
|
|
14
|
+
*
|
|
15
|
+
* The grammar (0.25.1) lets a `heredoc_redirect` carry whatever follows its
|
|
16
|
+
* delimiter on the same line: further redirects (`cat <<EOF > /tmp/o`), a
|
|
17
|
+
* `| …` statement, or an `&& …` / `|| …` statement. They parse as children of
|
|
18
|
+
* the heredoc, which every walker reads only for the substitutions it hosts, so
|
|
19
|
+
* `cat <<EOF | rm -rf /tmp/x` would enumerate as `cat` alone and
|
|
20
|
+
* `cat <<EOF > /tmp/o` would project no path (#979).
|
|
21
|
+
*
|
|
22
|
+
* The corrected tree is the grammar's own parse of the same line with `< in` in
|
|
23
|
+
* place of `<<EOF`: a redirect becomes a sibling after the heredoc, and a
|
|
24
|
+
* `| …` or `&& …` statement is joined to the redirected statement the way the
|
|
25
|
+
* grammar joins it to `cat < in`. That reproduces the grammar's groupings
|
|
26
|
+
* rather than bash's where the two differ (a redirect on a list's last command
|
|
27
|
+
* hangs off the whole list), because every walker is already built to read
|
|
28
|
+
* those groupings.
|
|
29
|
+
*
|
|
30
|
+
* Words after the delimiter (`git <<EOF push --force`) stay in the heredoc:
|
|
31
|
+
* `reattachRedirectArguments` hands them to the command, exactly as it does for
|
|
32
|
+
* the words after a file redirect's target. The heredoc keeps its body, which
|
|
33
|
+
* is written after the rest of the line, so it ends after the nodes moved out
|
|
34
|
+
* of it.
|
|
35
|
+
*
|
|
36
|
+
* Returns `root` itself when no heredoc carries such a tail.
|
|
37
|
+
*/
|
|
38
|
+
export function hoistHeredocTails(root: TSNode): TSNode {
|
|
39
|
+
return rewriteStatements(root, hoistStatement);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `statement` with its heredoc's tail moved out, or `undefined` when no
|
|
44
|
+
* heredoc among `children` carries one.
|
|
45
|
+
*/
|
|
46
|
+
function hoistStatement(
|
|
47
|
+
statement: TSNode,
|
|
48
|
+
children: readonly TSNode[],
|
|
49
|
+
): TSNode | undefined {
|
|
50
|
+
const heredocIndex = children.findIndex(
|
|
51
|
+
(child) => child.type === "heredoc_redirect" && tailOf(child) !== undefined,
|
|
52
|
+
);
|
|
53
|
+
const heredoc = children.at(heredocIndex);
|
|
54
|
+
const tail = heredoc && tailOf(heredoc);
|
|
55
|
+
if (!tail) return undefined;
|
|
56
|
+
|
|
57
|
+
const after = children.slice(heredocIndex + 1);
|
|
58
|
+
// A statement tail runs to the end of the line, so nothing of the statement
|
|
59
|
+
// can follow it; anything that does is a shape this correction does not know.
|
|
60
|
+
if (tail.joined && after.some((child) => child.isNamed)) return undefined;
|
|
61
|
+
|
|
62
|
+
const source = sourceOf(statement);
|
|
63
|
+
const redirected = rewrittenNode(
|
|
64
|
+
statement,
|
|
65
|
+
[
|
|
66
|
+
...children.slice(0, heredocIndex),
|
|
67
|
+
rewrittenNode(
|
|
68
|
+
heredoc,
|
|
69
|
+
tail.kept,
|
|
70
|
+
heredoc.startIndex,
|
|
71
|
+
heredoc.endIndex,
|
|
72
|
+
source,
|
|
73
|
+
),
|
|
74
|
+
...tail.redirects,
|
|
75
|
+
...after,
|
|
76
|
+
],
|
|
77
|
+
statement.startIndex,
|
|
78
|
+
statement.endIndex,
|
|
79
|
+
source,
|
|
80
|
+
);
|
|
81
|
+
if (!tail.joined) return redirected;
|
|
82
|
+
return join(
|
|
83
|
+
redirected,
|
|
84
|
+
tail.joined.operator,
|
|
85
|
+
withTrailingRedirectsOutermost(tail.joined.statement, source),
|
|
86
|
+
source,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `statement` with a redirect written at its end hung off all of it, as the
|
|
92
|
+
* grammar hangs it when the same text is not a heredoc's tail.
|
|
93
|
+
*
|
|
94
|
+
* At the top level `a | b | c > o` parses as `(a | b | c) > o`, but in a
|
|
95
|
+
* heredoc's tail the grammar parses it as `a | ((b | c) > o)`. Left that way,
|
|
96
|
+
* the joined write would not reach the heredoc's own command, so
|
|
97
|
+
* `xargs grep foo <<EOF && a | b | c > o` would keep a floor exemption that
|
|
98
|
+
* `xargs grep foo < in && a | b | c > o` withholds.
|
|
99
|
+
*/
|
|
100
|
+
function withTrailingRedirectsOutermost(
|
|
101
|
+
statement: TSNode,
|
|
102
|
+
source: Source,
|
|
103
|
+
): TSNode {
|
|
104
|
+
if (statement.type === "redirected_statement") return statement;
|
|
105
|
+
const detached = detachTrailingRedirects(statement, source);
|
|
106
|
+
if (!detached) return statement;
|
|
107
|
+
return rewrittenNode(
|
|
108
|
+
{ type: "redirected_statement", isNamed: true },
|
|
109
|
+
[detached.body, ...detached.redirects],
|
|
110
|
+
statement.startIndex,
|
|
111
|
+
statement.endIndex,
|
|
112
|
+
source,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `node` without the `redirected_statement` that ends it, plus that
|
|
118
|
+
* statement's redirects, or `undefined` when `node` does not end in one.
|
|
119
|
+
*
|
|
120
|
+
* The walk follows the last element of each `list` and `pipeline`, and a
|
|
121
|
+
* pipeline left holding a pipeline is flattened into one, as the grammar
|
|
122
|
+
* parses `a | b | c`.
|
|
123
|
+
*/
|
|
124
|
+
function detachTrailingRedirects(
|
|
125
|
+
node: TSNode,
|
|
126
|
+
source: Source,
|
|
127
|
+
): { body: TSNode; redirects: TSNode[] } | undefined {
|
|
128
|
+
const children = childrenOf(node);
|
|
129
|
+
if (node.type === "redirected_statement") {
|
|
130
|
+
const bodyIndex = children.findIndex((child) => child.isNamed);
|
|
131
|
+
return {
|
|
132
|
+
body: children[bodyIndex],
|
|
133
|
+
redirects: children.slice(bodyIndex + 1),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!GROUPING_TYPES.has(node.type)) return undefined;
|
|
137
|
+
const lastIndex = children.findLastIndex((child) => child.isNamed);
|
|
138
|
+
const inner = detachTrailingRedirects(children[lastIndex], source);
|
|
139
|
+
if (!inner) return undefined;
|
|
140
|
+
const replaced =
|
|
141
|
+
node.type === "pipeline" && inner.body.type === "pipeline"
|
|
142
|
+
? [...children.slice(0, lastIndex), ...childrenOf(inner.body)]
|
|
143
|
+
: children.with(lastIndex, inner.body);
|
|
144
|
+
return {
|
|
145
|
+
body: rewrittenNode(
|
|
146
|
+
node,
|
|
147
|
+
replaced,
|
|
148
|
+
node.startIndex,
|
|
149
|
+
inner.body.endIndex,
|
|
150
|
+
source,
|
|
151
|
+
),
|
|
152
|
+
redirects: inner.redirects,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The groupings whose last element a trailing redirect is written after. */
|
|
157
|
+
const GROUPING_TYPES: ReadonlySet<string> = new Set(["list", "pipeline"]);
|
|
158
|
+
|
|
159
|
+
/** What a heredoc carries after its delimiter, split by where each part goes. */
|
|
160
|
+
interface HeredocTail {
|
|
161
|
+
/** The heredoc's own children: operator, delimiter, any words, and body. */
|
|
162
|
+
readonly kept: readonly TSNode[];
|
|
163
|
+
/** Redirects written after the delimiter. */
|
|
164
|
+
readonly redirects: readonly TSNode[];
|
|
165
|
+
/** A `| …`, `|& …`, `&& …`, or `|| …` statement after them, if any. */
|
|
166
|
+
readonly joined?: {
|
|
167
|
+
readonly operator: TSNode;
|
|
168
|
+
readonly statement: TSNode;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `heredoc`'s tail, or `undefined` when it carries neither a redirect nor a
|
|
174
|
+
* statement after its delimiter.
|
|
175
|
+
*
|
|
176
|
+
* The grammar spells a `| …` tail as one `pipeline` child holding the operator
|
|
177
|
+
* and the statement, and an `&& …` / `|| …` tail as the operator token and the
|
|
178
|
+
* statement as two children of the heredoc itself. That statement can be a
|
|
179
|
+
* pipeline too (`cat <<EOF && ls | rm x`), so a `pipeline` child is the `| …`
|
|
180
|
+
* form only when no operator came before it.
|
|
181
|
+
*/
|
|
182
|
+
function tailOf(heredoc: TSNode): HeredocTail | undefined {
|
|
183
|
+
const kept: TSNode[] = [];
|
|
184
|
+
const redirects: TSNode[] = [];
|
|
185
|
+
let operator: TSNode | undefined;
|
|
186
|
+
let statement: TSNode | undefined;
|
|
187
|
+
for (const child of childrenOf(heredoc)) {
|
|
188
|
+
if (TAIL_REDIRECT_TYPES.has(child.type)) {
|
|
189
|
+
redirects.push(child);
|
|
190
|
+
} else if (child.type === "pipeline" && !operator) {
|
|
191
|
+
const [pipe] = childrenOf(child);
|
|
192
|
+
operator = pipe;
|
|
193
|
+
statement = childrenOf(child).find((node) => node.isNamed);
|
|
194
|
+
} else if (LIST_OPERATORS.has(child.type) && !child.isNamed) {
|
|
195
|
+
operator = child;
|
|
196
|
+
} else if (operator && !statement && child.isNamed) {
|
|
197
|
+
statement = child;
|
|
198
|
+
} else {
|
|
199
|
+
kept.push(child);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (operator && !statement) return undefined;
|
|
203
|
+
const joined = operator && statement ? { operator, statement } : undefined;
|
|
204
|
+
if (redirects.length === 0 && !joined) return undefined;
|
|
205
|
+
return joined ? { kept, redirects, joined } : { kept, redirects };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The redirects a heredoc can carry after its delimiter. */
|
|
209
|
+
const TAIL_REDIRECT_TYPES: ReadonlySet<string> = new Set([
|
|
210
|
+
"file_redirect",
|
|
211
|
+
"herestring_redirect",
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
/** The operators that join an `&& …` / `|| …` tail. */
|
|
215
|
+
const LIST_OPERATORS: ReadonlySet<string> = new Set(["&&", "||"]);
|
|
216
|
+
|
|
217
|
+
/** The operators that join a `| …` tail. */
|
|
218
|
+
const PIPE_OPERATORS: ReadonlySet<string> = new Set(["|", "|&"]);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `redirected` joined to `statement` by `operator`, grouped as the grammar
|
|
222
|
+
* groups `cat < in <operator> <statement>`.
|
|
223
|
+
*
|
|
224
|
+
* The grammar hangs a redirect off everything before it, so a joined
|
|
225
|
+
* `redirected_statement` keeps its redirects outermost. That holds inside a
|
|
226
|
+
* pipeline too: `cat < in && a > o | b` parses as `(cat < in && a > o) | b`,
|
|
227
|
+
* so a pipeline whose first stage is redirected is joined at that stage. A
|
|
228
|
+
* `list` is left-associative and binds looser than a pipe, so the join reaches
|
|
229
|
+
* its first element. A pipe tail onto any other pipeline extends it. Anything
|
|
230
|
+
* else becomes the operator's own two-element node.
|
|
231
|
+
*
|
|
232
|
+
* Each rebuilt node spans from `redirected`'s start to the furthest end beneath
|
|
233
|
+
* it, which is the heredoc's, since its body is written after the tail.
|
|
234
|
+
*/
|
|
235
|
+
function join(
|
|
236
|
+
redirected: TSNode,
|
|
237
|
+
operator: TSNode,
|
|
238
|
+
statement: TSNode,
|
|
239
|
+
source: Source,
|
|
240
|
+
): TSNode {
|
|
241
|
+
const isPipe = PIPE_OPERATORS.has(operator.type);
|
|
242
|
+
const children = childrenOf(statement);
|
|
243
|
+
const end = Math.max(redirected.endIndex, statement.endIndex);
|
|
244
|
+
const rebuild = (type: Pick<TSNode, "type" | "isNamed">, kids: TSNode[]) =>
|
|
245
|
+
rewrittenNode(type, kids, redirected.startIndex, end, source);
|
|
246
|
+
|
|
247
|
+
const first = children.findIndex((child) => child.isNamed);
|
|
248
|
+
if (joinsAtFirstElement(statement, children[first])) {
|
|
249
|
+
return rebuild(
|
|
250
|
+
statement,
|
|
251
|
+
children.with(first, join(redirected, operator, children[first], source)),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (isPipe && statement.type === "pipeline") {
|
|
255
|
+
return rebuild(statement, [redirected, operator, ...children]);
|
|
256
|
+
}
|
|
257
|
+
return rebuild({ type: isPipe ? "pipeline" : "list", isNamed: true }, [
|
|
258
|
+
redirected,
|
|
259
|
+
operator,
|
|
260
|
+
statement,
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Whether the grammar joins what precedes `statement` to its first element
|
|
266
|
+
* rather than to `statement` as a whole.
|
|
267
|
+
*/
|
|
268
|
+
function joinsAtFirstElement(
|
|
269
|
+
statement: TSNode,
|
|
270
|
+
first: TSNode | undefined,
|
|
271
|
+
): boolean {
|
|
272
|
+
if (statement.type === "redirected_statement") return true;
|
|
273
|
+
if (statement.type === "list") return true;
|
|
274
|
+
return (
|
|
275
|
+
statement.type === "pipeline" && first?.type === "redirected_statement"
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
|
+
import type { TSNode } from "./parser";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A view of `tree-sitter-bash`'s parse tree that a correction pass can rewrite.
|
|
6
|
+
*
|
|
7
|
+
* The grammar hangs command-line material on the wrong node in more than one
|
|
8
|
+
* production (#977, #979), and each correction is a pass that rewrites the
|
|
9
|
+
* `redirected_statement`s it recognizes. Each pass builds its nodes from the
|
|
10
|
+
* primitives here, so a later pass reads an earlier one's output as the same
|
|
11
|
+
* kind of view and can re-parent its nodes in turn.
|
|
12
|
+
*
|
|
13
|
+
* A view reads its text from the source by absolute offsets, so every caller's
|
|
14
|
+
* `startIndex`/`endIndex` slicing still holds.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `root` with each `redirected_statement` whose parse resolved replaced by what
|
|
19
|
+
* `rewrite` returns for it, or `root` itself when nothing beneath it changed.
|
|
20
|
+
*
|
|
21
|
+
* The walk is bottom-up: `rewrite` receives the statement's children already
|
|
22
|
+
* rewritten, and returns `undefined` to leave the statement as it is.
|
|
23
|
+
*
|
|
24
|
+
* A statement whose parse failed is never handed to `rewrite`. Its units are
|
|
25
|
+
* floored already, and moving a node out of an unresolvable region would hand it
|
|
26
|
+
* a proof in place of that region's refusal to prove one (#814).
|
|
27
|
+
*/
|
|
28
|
+
export function rewriteStatements(
|
|
29
|
+
root: TSNode,
|
|
30
|
+
rewrite: (
|
|
31
|
+
statement: TSNode,
|
|
32
|
+
children: readonly TSNode[],
|
|
33
|
+
) => TSNode | undefined,
|
|
34
|
+
): TSNode {
|
|
35
|
+
return rewriteBeneath(root, rewrite) ?? root;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The rewritten node, or `undefined` when nothing beneath `node` changed. */
|
|
39
|
+
function rewriteBeneath(
|
|
40
|
+
node: TSNode,
|
|
41
|
+
rewrite: (
|
|
42
|
+
statement: TSNode,
|
|
43
|
+
children: readonly TSNode[],
|
|
44
|
+
) => TSNode | undefined,
|
|
45
|
+
): TSNode | undefined {
|
|
46
|
+
const children: TSNode[] = [];
|
|
47
|
+
let changed = false;
|
|
48
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
49
|
+
const child = node.child(i);
|
|
50
|
+
if (!child) continue;
|
|
51
|
+
const rewritten = rewriteBeneath(child, rewrite);
|
|
52
|
+
if (rewritten) changed = true;
|
|
53
|
+
children.push(rewritten ?? child);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (node.type === "redirected_statement" && !parseUnresolvedWithin(node)) {
|
|
57
|
+
const rewritten = rewrite(node, children);
|
|
58
|
+
if (rewritten) return rewritten;
|
|
59
|
+
}
|
|
60
|
+
return changed
|
|
61
|
+
? adoptingView(node, children, parseUnresolvedWithin(node))
|
|
62
|
+
: undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A node a correction built, reading its text from the statement's source. */
|
|
66
|
+
export function rewrittenNode(
|
|
67
|
+
original: Pick<TSNode, "type" | "isNamed">,
|
|
68
|
+
children: readonly TSNode[],
|
|
69
|
+
startIndex: number,
|
|
70
|
+
endIndex: number,
|
|
71
|
+
source: Source,
|
|
72
|
+
): TSNode {
|
|
73
|
+
return adoptingView(
|
|
74
|
+
{
|
|
75
|
+
type: original.type,
|
|
76
|
+
isNamed: original.isNamed,
|
|
77
|
+
startIndex,
|
|
78
|
+
endIndex,
|
|
79
|
+
text: source(startIndex, endIndex),
|
|
80
|
+
},
|
|
81
|
+
children,
|
|
82
|
+
false,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A slice of the command by absolute offsets. */
|
|
87
|
+
export type Source = (startIndex: number, endIndex: number) => string;
|
|
88
|
+
|
|
89
|
+
/** The source slicer for every node within `statement`. */
|
|
90
|
+
export function sourceOf(statement: TSNode): Source {
|
|
91
|
+
return (startIndex, endIndex) =>
|
|
92
|
+
statement.text.slice(
|
|
93
|
+
startIndex - statement.startIndex,
|
|
94
|
+
endIndex - statement.startIndex,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function childrenOf(node: TSNode): TSNode[] {
|
|
99
|
+
const children: TSNode[] = [];
|
|
100
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
101
|
+
const child = node.child(i);
|
|
102
|
+
if (child) children.push(child);
|
|
103
|
+
}
|
|
104
|
+
return children;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function lastNamedChild(node: TSNode): TSNode | undefined {
|
|
108
|
+
return childrenOf(node).findLast((child) => child.isNamed);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A view whose children are views too, so each one's `previousSibling` is its
|
|
113
|
+
* neighbor in the rewritten tree rather than the one the grammar gave it.
|
|
114
|
+
*/
|
|
115
|
+
function adoptingView(
|
|
116
|
+
fields: NodeFields,
|
|
117
|
+
children: readonly TSNode[],
|
|
118
|
+
hasError: boolean,
|
|
119
|
+
): TSNode {
|
|
120
|
+
return new NodeView(fields, children.map(asView), hasError);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `node` as a view, so a new parent can give it a new previous sibling.
|
|
125
|
+
*
|
|
126
|
+
* Its own children stay the grammar's nodes: nothing beneath it moved, so their
|
|
127
|
+
* siblings are still the grammar's too.
|
|
128
|
+
*/
|
|
129
|
+
function asView(node: TSNode): TSNode {
|
|
130
|
+
return node instanceof NodeView
|
|
131
|
+
? node
|
|
132
|
+
: new NodeView(node, childrenOf(node), parseUnresolvedWithin(node));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The fields a {@link NodeView} copies from the node it stands for. */
|
|
136
|
+
interface NodeFields {
|
|
137
|
+
readonly type: string;
|
|
138
|
+
readonly isNamed: boolean;
|
|
139
|
+
readonly startIndex: number;
|
|
140
|
+
readonly endIndex: number;
|
|
141
|
+
readonly text: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A parse-tree node a correction presents in place of the grammar's.
|
|
146
|
+
*
|
|
147
|
+
* Adopting its children sets each view child's `previousSibling` to the child
|
|
148
|
+
* before it, which is what lets a redirect moved into a command ask
|
|
149
|
+
* `parseUnresolvedAt` about its new neighbor.
|
|
150
|
+
*/
|
|
151
|
+
class NodeView implements TSNode {
|
|
152
|
+
readonly type: string;
|
|
153
|
+
readonly isNamed: boolean;
|
|
154
|
+
readonly startIndex: number;
|
|
155
|
+
readonly endIndex: number;
|
|
156
|
+
readonly text: string;
|
|
157
|
+
readonly childCount: number;
|
|
158
|
+
previousSibling: TSNode | null = null;
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
fields: NodeFields,
|
|
162
|
+
private readonly children: readonly TSNode[],
|
|
163
|
+
readonly hasError: boolean,
|
|
164
|
+
) {
|
|
165
|
+
this.type = fields.type;
|
|
166
|
+
this.isNamed = fields.isNamed;
|
|
167
|
+
this.startIndex = fields.startIndex;
|
|
168
|
+
this.endIndex = fields.endIndex;
|
|
169
|
+
this.text = fields.text;
|
|
170
|
+
this.childCount = children.length;
|
|
171
|
+
children.forEach((child, i) => {
|
|
172
|
+
if (child instanceof NodeView)
|
|
173
|
+
child.previousSibling = children[i - 1] ?? null;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
child(index: number): TSNode | null {
|
|
178
|
+
return this.children[index] ?? null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { memoizeAsyncWithRetry } from "./async-cache";
|
|
3
|
+
import { hoistHeredocTails } from "./heredoc-tails";
|
|
3
4
|
import { reattachRedirectArguments } from "./redirect-arguments";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -69,11 +70,19 @@ async function initParser(): Promise<TSParser> {
|
|
|
69
70
|
/**
|
|
70
71
|
* The parser every consumer reads the bash grammar through.
|
|
71
72
|
*
|
|
72
|
-
* Its trees are the grammar's with
|
|
73
|
-
* package
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
73
|
+
* Its trees are the grammar's with two corrections applied where they enter the
|
|
74
|
+
* package, each moving command-line material `tree-sitter-bash` hangs on a
|
|
75
|
+
* redirect to where bash gives it:
|
|
76
|
+
*
|
|
77
|
+
* 1. `hoistHeredocTails` moves the redirects and the `| …` / `&& …` statement
|
|
78
|
+
* written after a heredoc's delimiter out of the heredoc (#979).
|
|
79
|
+
* 2. `reattachRedirectArguments` hands the words a redirect carries, a file
|
|
80
|
+
* redirect's or a heredoc's, back to the command (#977).
|
|
81
|
+
*
|
|
82
|
+
* The order matters: a redirect hoisted out of a heredoc can itself carry the
|
|
83
|
+
* command's words (`cat <<EOF 2>/dev/null arg`), which the second pass then
|
|
84
|
+
* hands back. Every walker, the salvage re-parse, and the log masker read the
|
|
85
|
+
* corrected tree, so none of them has to learn the grammar's quirks on its own.
|
|
77
86
|
*/
|
|
78
87
|
export const getParser = memoizeAsyncWithRetry(async () =>
|
|
79
88
|
correctingParser(await getGrammarParser()),
|
|
@@ -85,7 +94,7 @@ function correctingParser(grammar: TSParser): TSParser {
|
|
|
85
94
|
const tree = grammar.parse(input);
|
|
86
95
|
if (!tree) return null;
|
|
87
96
|
return {
|
|
88
|
-
rootNode: reattachRedirectArguments(tree.rootNode),
|
|
97
|
+
rootNode: reattachRedirectArguments(hoistHeredocTails(tree.rootNode)),
|
|
89
98
|
delete: () => {
|
|
90
99
|
tree.delete();
|
|
91
100
|
},
|
|
@@ -138,18 +138,49 @@ export function redirectTargetIndex(redirect: TSNode): number | undefined {
|
|
|
138
138
|
* The child index of the first word the grammar appended after `redirect`'s
|
|
139
139
|
* own target, or `undefined` when none follows.
|
|
140
140
|
*
|
|
141
|
-
* Every
|
|
142
|
-
* command rather than a destination of the redirect: `f.txt` in
|
|
141
|
+
* Every {@link LITERAL_NODE_TYPES} child from there on is a word bash passes to
|
|
142
|
+
* the redirected command rather than a destination of the redirect: `f.txt` in
|
|
143
143
|
* `grep pat 2>/dev/null f.txt`, and `arg` in `cmd >&- arg`, where the close
|
|
144
144
|
* operator has no target at all (#977).
|
|
145
|
+
*
|
|
146
|
+
* A heredoc carries them the same way: `git <<EOF push --force` hangs `push`
|
|
147
|
+
* and `--force` after its delimiter, where its own target would be. Its body
|
|
148
|
+
* follows them and is not a word, and neither is any other tail the grammar
|
|
149
|
+
* lets a heredoc carry (a redirect, a `| …` or `&& …` statement), so the first
|
|
150
|
+
* child after the target counts only when it is a literal (#979).
|
|
145
151
|
*/
|
|
146
152
|
export function trailingArgumentIndex(redirect: TSNode): number | undefined {
|
|
147
153
|
const operator = redirectOperatorIndex(redirect);
|
|
148
154
|
if (operator === undefined) return undefined;
|
|
149
155
|
const target = redirectTargetIndex(redirect);
|
|
150
|
-
|
|
156
|
+
const index = namedChildIndexAfter(redirect, target ?? operator);
|
|
157
|
+
if (index === undefined) return undefined;
|
|
158
|
+
return LITERAL_NODE_TYPES.has(redirect.child(index)?.type ?? "")
|
|
159
|
+
? index
|
|
160
|
+
: undefined;
|
|
151
161
|
}
|
|
152
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The node types `tree-sitter-bash` 0.25.1 parses a literal word as: its
|
|
165
|
+
* `_literal` rule, which a file redirect's destinations and a heredoc's
|
|
166
|
+
* trailing words are both declared as.
|
|
167
|
+
*/
|
|
168
|
+
export const LITERAL_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
169
|
+
"concatenation",
|
|
170
|
+
"word",
|
|
171
|
+
"string",
|
|
172
|
+
"raw_string",
|
|
173
|
+
"translated_string",
|
|
174
|
+
"ansi_c_string",
|
|
175
|
+
"number",
|
|
176
|
+
"expansion",
|
|
177
|
+
"simple_expansion",
|
|
178
|
+
"command_substitution",
|
|
179
|
+
"process_substitution",
|
|
180
|
+
"arithmetic_expansion",
|
|
181
|
+
"brace_expression",
|
|
182
|
+
]);
|
|
183
|
+
|
|
153
184
|
/** Operators that close a descriptor, naming no file (`>&-`, `<&-`). */
|
|
154
185
|
const CLOSE_OPERATORS: ReadonlySet<string> = new Set([">&-", "<&-"]);
|
|
155
186
|
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
childrenOf,
|
|
3
|
+
lastNamedChild,
|
|
4
|
+
rewriteStatements,
|
|
5
|
+
rewrittenNode,
|
|
6
|
+
type Source,
|
|
7
|
+
sourceOf,
|
|
8
|
+
} from "./parse-view";
|
|
2
9
|
import type { TSNode } from "./parser";
|
|
3
|
-
import { trailingArgumentIndex } from "./redirect-analysis";
|
|
10
|
+
import { LITERAL_NODE_TYPES, trailingArgumentIndex } from "./redirect-analysis";
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
13
|
* `tree-sitter-bash`'s parse with each word it hung on a redirect handed back
|
|
@@ -21,6 +28,11 @@ import { trailingArgumentIndex } from "./redirect-analysis";
|
|
|
21
28
|
* followed by its words; a redirect after the last word stays at the
|
|
22
29
|
* statement, and a statement left with no redirect gives way to its body.
|
|
23
30
|
*
|
|
31
|
+
* A heredoc carries words the same way (`git <<EOF push --force`), and they
|
|
32
|
+
* move into the command after it; its body stays in the heredoc, which is
|
|
33
|
+
* written after the words and so ends after them (#979). Every other tail a
|
|
34
|
+
* heredoc can carry is `heredoc-tails.ts`'s to move.
|
|
35
|
+
*
|
|
24
36
|
* Three kinds of statement are left exactly as the grammar produced them:
|
|
25
37
|
*
|
|
26
38
|
* - One whose parse failed. Its units are floored already, and moving a word
|
|
@@ -35,28 +47,7 @@ import { trailingArgumentIndex } from "./redirect-analysis";
|
|
|
35
47
|
* stay positions in the command string every caller already slices.
|
|
36
48
|
*/
|
|
37
49
|
export function reattachRedirectArguments(root: TSNode): TSNode {
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The corrected node, or `undefined` when nothing beneath `node` changed. */
|
|
42
|
-
function correct(node: TSNode): TSNode | undefined {
|
|
43
|
-
const children: TSNode[] = [];
|
|
44
|
-
let changed = false;
|
|
45
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
46
|
-
const child = node.child(i);
|
|
47
|
-
if (!child) continue;
|
|
48
|
-
const corrected = correct(child);
|
|
49
|
-
if (corrected) changed = true;
|
|
50
|
-
children.push(corrected ?? child);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (node.type === "redirected_statement" && !parseUnresolvedWithin(node)) {
|
|
54
|
-
const reattached = reattachStatement(node, children);
|
|
55
|
-
if (reattached) return reattached;
|
|
56
|
-
}
|
|
57
|
-
return changed
|
|
58
|
-
? adoptingView(node, children, parseUnresolvedWithin(node))
|
|
59
|
-
: undefined;
|
|
50
|
+
return rewriteStatements(root, reattachStatement);
|
|
60
51
|
}
|
|
61
52
|
|
|
62
53
|
/**
|
|
@@ -74,7 +65,7 @@ function reattachStatement(
|
|
|
74
65
|
|
|
75
66
|
const lastCarrier = children.findLastIndex(
|
|
76
67
|
(child) =>
|
|
77
|
-
child.type
|
|
68
|
+
WORD_CARRYING_TYPES.has(child.type) &&
|
|
78
69
|
trailingArgumentIndex(child) !== undefined,
|
|
79
70
|
);
|
|
80
71
|
if (lastCarrier === -1) return undefined;
|
|
@@ -112,23 +103,34 @@ function reachesCommand(body: TSNode): boolean {
|
|
|
112
103
|
/** The bodies whose last element a statement-level redirect belongs to. */
|
|
113
104
|
const GROUPING_TYPES: ReadonlySet<string> = new Set(["list", "pipeline"]);
|
|
114
105
|
|
|
106
|
+
/** The redirects the grammar can hang a command's words on. */
|
|
107
|
+
const WORD_CARRYING_TYPES: ReadonlySet<string> = new Set([
|
|
108
|
+
"file_redirect",
|
|
109
|
+
"heredoc_redirect",
|
|
110
|
+
]);
|
|
111
|
+
|
|
115
112
|
/**
|
|
116
|
-
* `redirect` as it belongs in the command:
|
|
117
|
-
*
|
|
118
|
-
*
|
|
113
|
+
* `redirect` as it belongs in the command: without the words the grammar
|
|
114
|
+
* appended to it, and followed by them. A redirect carrying no words, or a node
|
|
115
|
+
* that cannot carry any, moves as it is.
|
|
116
|
+
*
|
|
117
|
+
* A file redirect's words are the rest of its children, so it is truncated
|
|
118
|
+
* after its target. A heredoc keeps its body and delimiter, which follow the
|
|
119
|
+
* words in the source, so it keeps its own end.
|
|
119
120
|
*/
|
|
120
121
|
function splitRedirect(redirect: TSNode, source: Source): TSNode[] {
|
|
121
|
-
const trailing =
|
|
122
|
-
redirect
|
|
123
|
-
|
|
124
|
-
: undefined;
|
|
122
|
+
const trailing = WORD_CARRYING_TYPES.has(redirect.type)
|
|
123
|
+
? trailingArgumentIndex(redirect)
|
|
124
|
+
: undefined;
|
|
125
125
|
if (trailing === undefined) return [redirect];
|
|
126
126
|
|
|
127
127
|
const kept: TSNode[] = [];
|
|
128
128
|
const words: TSNode[] = [];
|
|
129
129
|
for (let i = 0; i < redirect.childCount; i++) {
|
|
130
130
|
const child = redirect.child(i);
|
|
131
|
-
if (child)
|
|
131
|
+
if (!child) continue;
|
|
132
|
+
const isWord = i >= trailing && LITERAL_NODE_TYPES.has(child.type);
|
|
133
|
+
(isWord ? words : kept).push(child);
|
|
132
134
|
}
|
|
133
135
|
const end = kept.at(-1)?.endIndex ?? redirect.startIndex;
|
|
134
136
|
return [
|
|
@@ -139,8 +141,9 @@ function splitRedirect(redirect: TSNode, source: Source): TSNode[] {
|
|
|
139
141
|
|
|
140
142
|
/**
|
|
141
143
|
* `body` with `moved` appended to its rightmost command: the command itself,
|
|
142
|
-
* or the last element of a `list` or `pipeline`, recursively. Each node on the
|
|
143
|
-
* the end
|
|
144
|
+
* or the last element of a `list` or `pipeline`, recursively. Each node on the
|
|
145
|
+
* way grows to the furthest end among the moved nodes, which is a heredoc's
|
|
146
|
+
* when one moved, since its body is written after the words that follow it.
|
|
144
147
|
*/
|
|
145
148
|
function appendToRightmostCommand(
|
|
146
149
|
body: TSNode,
|
|
@@ -148,7 +151,7 @@ function appendToRightmostCommand(
|
|
|
148
151
|
source: Source,
|
|
149
152
|
): TSNode {
|
|
150
153
|
const children = childrenOf(body);
|
|
151
|
-
const end = moved.
|
|
154
|
+
const end = Math.max(body.endIndex, ...moved.map((node) => node.endIndex));
|
|
152
155
|
if (body.type === "command") {
|
|
153
156
|
return rewrittenNode(
|
|
154
157
|
body,
|
|
@@ -166,120 +169,3 @@ function appendToRightmostCommand(
|
|
|
166
169
|
);
|
|
167
170
|
return rewrittenNode(body, replaced, body.startIndex, end, source);
|
|
168
171
|
}
|
|
169
|
-
|
|
170
|
-
/** A node the correction built, reading its text from the statement's source. */
|
|
171
|
-
function rewrittenNode(
|
|
172
|
-
original: TSNode,
|
|
173
|
-
children: readonly TSNode[],
|
|
174
|
-
startIndex: number,
|
|
175
|
-
endIndex: number,
|
|
176
|
-
source: Source,
|
|
177
|
-
): TSNode {
|
|
178
|
-
return adoptingView(
|
|
179
|
-
{
|
|
180
|
-
type: original.type,
|
|
181
|
-
isNamed: original.isNamed,
|
|
182
|
-
startIndex,
|
|
183
|
-
endIndex,
|
|
184
|
-
text: source(startIndex, endIndex),
|
|
185
|
-
},
|
|
186
|
-
children,
|
|
187
|
-
false,
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* A view whose children are views too, so each one's `previousSibling` is its
|
|
193
|
-
* neighbor in the corrected tree rather than the one the grammar gave it.
|
|
194
|
-
*/
|
|
195
|
-
function adoptingView(
|
|
196
|
-
fields: NodeFields,
|
|
197
|
-
children: readonly TSNode[],
|
|
198
|
-
hasError: boolean,
|
|
199
|
-
): TSNode {
|
|
200
|
-
return new NodeView(fields, children.map(asView), hasError);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** A slice of the command by absolute offsets. */
|
|
204
|
-
type Source = (startIndex: number, endIndex: number) => string;
|
|
205
|
-
|
|
206
|
-
/** The source slicer for every node within `statement`. */
|
|
207
|
-
function sourceOf(statement: TSNode): Source {
|
|
208
|
-
return (startIndex, endIndex) =>
|
|
209
|
-
statement.text.slice(
|
|
210
|
-
startIndex - statement.startIndex,
|
|
211
|
-
endIndex - statement.startIndex,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function childrenOf(node: TSNode): TSNode[] {
|
|
216
|
-
const children: TSNode[] = [];
|
|
217
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
218
|
-
const child = node.child(i);
|
|
219
|
-
if (child) children.push(child);
|
|
220
|
-
}
|
|
221
|
-
return children;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function lastNamedChild(node: TSNode): TSNode | undefined {
|
|
225
|
-
return childrenOf(node).findLast((child) => child.isNamed);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* `node` as a view, so a new parent can give it a new previous sibling.
|
|
230
|
-
*
|
|
231
|
-
* Its own children stay the grammar's nodes: nothing beneath it moved, so their
|
|
232
|
-
* siblings are still the grammar's too.
|
|
233
|
-
*/
|
|
234
|
-
function asView(node: TSNode): TSNode {
|
|
235
|
-
return node instanceof NodeView
|
|
236
|
-
? node
|
|
237
|
-
: new NodeView(node, childrenOf(node), parseUnresolvedWithin(node));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** The fields a {@link NodeView} copies from the node it stands for. */
|
|
241
|
-
interface NodeFields {
|
|
242
|
-
readonly type: string;
|
|
243
|
-
readonly isNamed: boolean;
|
|
244
|
-
readonly startIndex: number;
|
|
245
|
-
readonly endIndex: number;
|
|
246
|
-
readonly text: string;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* A parse-tree node the correction presents in place of the grammar's.
|
|
251
|
-
*
|
|
252
|
-
* Adopting its children sets each view child's `previousSibling` to the child
|
|
253
|
-
* before it, which is what lets a redirect moved into a command ask
|
|
254
|
-
* `parseUnresolvedAt` about its new neighbor.
|
|
255
|
-
*/
|
|
256
|
-
class NodeView implements TSNode {
|
|
257
|
-
readonly type: string;
|
|
258
|
-
readonly isNamed: boolean;
|
|
259
|
-
readonly startIndex: number;
|
|
260
|
-
readonly endIndex: number;
|
|
261
|
-
readonly text: string;
|
|
262
|
-
readonly childCount: number;
|
|
263
|
-
previousSibling: TSNode | null = null;
|
|
264
|
-
|
|
265
|
-
constructor(
|
|
266
|
-
fields: NodeFields,
|
|
267
|
-
private readonly children: readonly TSNode[],
|
|
268
|
-
readonly hasError: boolean,
|
|
269
|
-
) {
|
|
270
|
-
this.type = fields.type;
|
|
271
|
-
this.isNamed = fields.isNamed;
|
|
272
|
-
this.startIndex = fields.startIndex;
|
|
273
|
-
this.endIndex = fields.endIndex;
|
|
274
|
-
this.text = fields.text;
|
|
275
|
-
this.childCount = children.length;
|
|
276
|
-
children.forEach((child, i) => {
|
|
277
|
-
if (child instanceof NodeView)
|
|
278
|
-
child.previousSibling = children[i - 1] ?? null;
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
child(index: number): TSNode | null {
|
|
283
|
-
return this.children[index] ?? null;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { heredocFreeLinesWithin } from "./heredoc-free-lines";
|
|
1
2
|
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
3
|
import type { BashReparser, TSNode } from "./parser";
|
|
3
4
|
|
|
@@ -19,6 +20,12 @@ import type { BashReparser, TSNode } from "./parser";
|
|
|
19
20
|
* grammar gap is in the *combination* — `2>&1 | rm -rf /tmp/x` parses
|
|
20
21
|
* perfectly on its own.
|
|
21
22
|
*
|
|
23
|
+
* A heredoc tail the grammar cannot parse at all (`cat <<EOF ; rm -rf x`)
|
|
24
|
+
* leaves no region whose own text re-parses: the innermost unresolved node is
|
|
25
|
+
* the heredoc redirect, whose text fails the same way. So each such heredoc's
|
|
26
|
+
* line is offered too, spelled without its heredoc operators
|
|
27
|
+
* (`heredoc-free-lines.ts`), after the regions.
|
|
28
|
+
*
|
|
22
29
|
* The roots are handed to a callback rather than returned because each belongs
|
|
23
30
|
* to a tree that must outlive its use and be released afterwards, exactly as
|
|
24
31
|
* the primary parse's caller already does for its own tree.
|
|
@@ -33,14 +40,20 @@ export function withSalvagedRoots<T>(
|
|
|
33
40
|
): T {
|
|
34
41
|
const trees: { rootNode: TSNode; delete(): void }[] = [];
|
|
35
42
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const candidates = [
|
|
44
|
+
...unresolvedRegionsWithin(primary).map((region) => region.text),
|
|
45
|
+
...heredocFreeLinesWithin(primary),
|
|
46
|
+
];
|
|
47
|
+
for (const text of candidates) {
|
|
48
|
+
const tree = reparser.parse(text);
|
|
38
49
|
if (!tree) continue;
|
|
39
50
|
// The whole safety argument: tree-sitter's error recovery *invents* the
|
|
40
51
|
// structure inside an unresolved region (#742), and invented structure
|
|
41
52
|
// does not re-parse. Without this check `cat <> rw.txt` salvages a
|
|
42
53
|
// command unit whose text is `">"`, matched against the bash rules like
|
|
43
|
-
// any real command.
|
|
54
|
+
// any real command. A heredoc-free line is derived rather than sliced,
|
|
55
|
+
// but only by removing the operators the scanner tokenized, and it is
|
|
56
|
+
// held to the same condition.
|
|
44
57
|
if (parseUnresolvedWithin(tree.rootNode)) {
|
|
45
58
|
tree.delete();
|
|
46
59
|
continue;
|