@gotgenes/pi-permission-system 35.0.0 → 35.0.1

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 CHANGED
@@ -5,6 +5,14 @@ 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.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.0...pi-permission-system-v35.0.1) (2026-09-27)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+ * **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)
15
+
8
16
  ## [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
17
 
10
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "35.0.0",
3
+ "version": "35.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 one correction applied where they enter the
73
- * package: a word `tree-sitter-bash` hung on a redirect is handed back to the
74
- * command it belongs to (`reattachRedirectArguments`, #977). Every walker, the
75
- * salvage re-parse, and the log masker read that corrected tree, so none of
76
- * them has to learn the grammar's quirk on its own.
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 named child from there on is a word bash passes to the redirected
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
- return namedChildIndexAfter(redirect, target ?? operator);
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 { parseUnresolvedWithin } from "./parse-health";
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 correct(root) ?? root;
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 === "file_redirect" &&
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: truncated after its own target and
117
- * followed by the words the grammar appended to it. A redirect carrying no
118
- * words, or a node that is not a file redirect, moves as it is.
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.type === "file_redirect"
123
- ? trailingArgumentIndex(redirect)
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) (i < trailing ? kept : words).push(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 way grows to
143
- * the end of the last moved node.
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.at(-1)?.endIndex ?? body.endIndex;
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
- }