@gotgenes/pi-permission-system 27.1.1 → 27.1.3
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,39 @@ 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
|
+
## [27.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.2...pi-permission-system-v27.1.3) (2026-08-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** gate commands hosted by declarations, test commands, and assignments ([dfde822](https://github.com/gotgenes/pi-packages/commit/dfde8227d3bf11de5258dcbe376dd0b8b75045e0)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
14
|
+
* **pi-permission-system:** gate commands inside a for loop's body and word list ([6e96464](https://github.com/gotgenes/pi-packages/commit/6e9646486db849afdf80819150a203d293b3847c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
15
|
+
* **pi-permission-system:** gate commands inside control-flow bodies and function definitions ([3a2f232](https://github.com/gotgenes/pi-packages/commit/3a2f232d9c12588ea1feacf41cade0e56e66a44b)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
16
|
+
* **pi-permission-system:** project a command-name substitution's path operands ([9807d44](https://github.com/gotgenes/pi-packages/commit/9807d444b6d81526b01bc542cd4a829a1b2640c3)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** bound the path-slice claim on behavior, not diff footprint ([91ef5c9](https://github.com/gotgenes/pi-packages/commit/91ef5c9f2d0578fb64fe317bcddd3ec7d9418d4c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
22
|
+
* **pi-permission-system:** commit the instrument behind Step 4's measurement ([e634c2c](https://github.com/gotgenes/pi-packages/commit/e634c2cf068340cc0deae86648330e44c2741f06)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
23
|
+
|
|
24
|
+
## [27.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.1...pi-permission-system-v27.1.2) (2026-08-29)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** claim no arity for awk's long forms, whose parser the bare name does not fix ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([6c8e4a0](https://github.com/gotgenes/pi-packages/commit/6c8e4a0b813d17750296cbd065aec20377c82d2a))
|
|
30
|
+
* **pi-permission-system:** discharge a flag argument on whatever node type follows ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([228f6fb](https://github.com/gotgenes/pi-packages/commit/228f6fb235310b304557ec501567373e33d4cfab))
|
|
31
|
+
* **pi-permission-system:** read --context per tool, whose arity differs between grep and rg ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([bba8317](https://github.com/gotgenes/pi-packages/commit/bba83175d1f9f8079cd9613412c65868e4a2fa09))
|
|
32
|
+
* **pi-permission-system:** read awk's long forms only for gawk ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([52488f0](https://github.com/gotgenes/pi-packages/commit/52488f02cc902b8669f893cc1cfee090482f2e72))
|
|
33
|
+
* **pi-permission-system:** recognize the long, embedded, and glued spellings of a pattern-first flag ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([ce7b65e](https://github.com/gotgenes/pi-packages/commit/ce7b65eb607cc2e6f61a6eceda1d1a9bb22aea85))
|
|
34
|
+
* **pi-permission-system:** spend a pattern positional on a computed or numeric pattern ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([dce4d3f](https://github.com/gotgenes/pi-packages/commit/dce4d3f06b164f6f45df3c5550ec624f3acba896))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
* **pi-permission-system:** record the optional-argument flag residuals ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([94e51fc](https://github.com/gotgenes/pi-packages/commit/94e51fc5a05acf3b2c4bc49479b45b0b7797b9ac))
|
|
40
|
+
|
|
8
41
|
## [27.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.0...pi-permission-system-v27.1.1) (2026-08-28)
|
|
9
42
|
|
|
10
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "27.1.
|
|
3
|
+
"version": "27.1.3",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"test:watch": "vitest",
|
|
91
91
|
"verify:public-types": "bash scripts/verify-public-types.sh",
|
|
92
92
|
"measure:core-coverage": "node scripts/measure-core-coverage.mjs",
|
|
93
|
+
"measure:statement-descent": "node scripts/measure-statement-descent.mjs",
|
|
93
94
|
"measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
|
|
94
95
|
"lint:md": "rumdl check *.md docs/**/*.md",
|
|
95
96
|
"lint": "biome check . && eslint . && pnpm run lint:md"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EXECUTION_HOST_TYPES,
|
|
3
|
-
|
|
3
|
+
forEachExecutionIn,
|
|
4
4
|
} from "#src/access-intent/bash/nested-execution";
|
|
5
5
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
6
6
|
import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
|
|
@@ -79,7 +79,7 @@ interface UnitScope {
|
|
|
79
79
|
/** A top-level command in the current shell, writing no file. */
|
|
80
80
|
const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
|
|
81
81
|
|
|
82
|
-
// ──
|
|
82
|
+
// ── Node-type vocabulary ─────────────────────────────────────────────────────
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Container node types descended into with the enclosing scope unchanged.
|
|
@@ -89,6 +89,39 @@ const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
|
|
|
89
89
|
*/
|
|
90
90
|
const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Compound statements: emitted whole, then descended for their statements.
|
|
94
|
+
*
|
|
95
|
+
* The whole emit is what keeps the #306 never-weaker invariant — the commands
|
|
96
|
+
* found inside are additional units, never a replacement.
|
|
97
|
+
*
|
|
98
|
+
* `select` parses as `for_statement` and `until` as `while_statement`, so each
|
|
99
|
+
* pair is one entry.
|
|
100
|
+
*/
|
|
101
|
+
const COMPOUND_STATEMENT_TYPES = new Set([
|
|
102
|
+
"if_statement",
|
|
103
|
+
"while_statement",
|
|
104
|
+
"for_statement",
|
|
105
|
+
"c_style_for_statement",
|
|
106
|
+
"case_statement",
|
|
107
|
+
"function_definition",
|
|
108
|
+
"compound_statement",
|
|
109
|
+
"negated_command",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Syntactic groupings inside a compound statement: descended, never emitted.
|
|
114
|
+
*
|
|
115
|
+
* None of these is something anybody runs — a `do_group` is the loop body's
|
|
116
|
+
* punctuation — so emitting one would produce a `do rm $f; done` unit.
|
|
117
|
+
*/
|
|
118
|
+
const STATEMENT_GROUP_TYPES = new Set([
|
|
119
|
+
"do_group",
|
|
120
|
+
"case_item",
|
|
121
|
+
"elif_clause",
|
|
122
|
+
"else_clause",
|
|
123
|
+
]);
|
|
124
|
+
|
|
92
125
|
/**
|
|
93
126
|
* Named node types abandoned during command enumeration: they are neither
|
|
94
127
|
* commands nor able to host one, so nothing in their subtree ever runs.
|
|
@@ -104,6 +137,33 @@ const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
|
|
|
104
137
|
*/
|
|
105
138
|
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
|
106
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Every node type the enumerator recognizes as a statement.
|
|
142
|
+
*
|
|
143
|
+
* This is the enumerator's third question, beside "is this a command?" and
|
|
144
|
+
* "can this host one?": "is this a *statement*, so that descending an enclosing
|
|
145
|
+
* compound reaches it?" A compound statement's named children are a mix —
|
|
146
|
+
* `for_statement` carries its loop variable and word list, `case_statement` its
|
|
147
|
+
* subject, `function_definition` its name — and descending all of them emits
|
|
148
|
+
* operand words as bash command units, naming `a` as the offending *command* in
|
|
149
|
+
* a prompt. Membership is what {@link descendStatementChildren} filters on.
|
|
150
|
+
*/
|
|
151
|
+
const STATEMENT_TYPES = new Set([
|
|
152
|
+
"command",
|
|
153
|
+
"redirected_statement",
|
|
154
|
+
"subshell",
|
|
155
|
+
"declaration_command",
|
|
156
|
+
"variable_assignment",
|
|
157
|
+
"test_command",
|
|
158
|
+
"unset_command",
|
|
159
|
+
"ERROR",
|
|
160
|
+
...COMMAND_ENUM_DESCEND,
|
|
161
|
+
...COMPOUND_STATEMENT_TYPES,
|
|
162
|
+
...STATEMENT_GROUP_TYPES,
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
// ── Command enumeration ──────────────────────────────────────────────
|
|
166
|
+
|
|
107
167
|
/**
|
|
108
168
|
* Enumerate the command units of a bash program, in source order.
|
|
109
169
|
*
|
|
@@ -114,10 +174,14 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
|
|
114
174
|
* subshells (`( … )`) — emitting each inner command as its own unit *in
|
|
115
175
|
* addition to* the enclosing command, since those inner commands really execute
|
|
116
176
|
* (#306).
|
|
117
|
-
*
|
|
118
|
-
*
|
|
177
|
+
* A compound statement (control flow, a function definition, a `{ … }` brace
|
|
178
|
+
* group) is emitted whole and then descended for the statements it contains,
|
|
179
|
+
* while its operand words — a loop variable, a word list, a `case` subject, a
|
|
180
|
+
* function's own name — are not commands and are left unemitted. An `ERROR`
|
|
181
|
+
* node is the one exception: its recovered structure is invented rather than
|
|
182
|
+
* observed, so the unparsed blob is emitted whole and never descended (#742).
|
|
119
183
|
*
|
|
120
|
-
* The enclosing command/
|
|
184
|
+
* The enclosing command/statement is always still emitted whole, so adding the
|
|
121
185
|
* nested units can only ever produce a more-restrictive decision, never weaker.
|
|
122
186
|
*
|
|
123
187
|
* Each emitted command unit has any leading `variable_assignment` prefix
|
|
@@ -172,9 +236,33 @@ function collectCommandsInto(
|
|
|
172
236
|
return;
|
|
173
237
|
}
|
|
174
238
|
|
|
239
|
+
if (COMPOUND_STATEMENT_TYPES.has(node.type)) {
|
|
240
|
+
out.push(makeUnit(node.text, scope)); // never-weaker whole emit
|
|
241
|
+
descendStatementChildren(node, scope, out);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (STATEMENT_GROUP_TYPES.has(node.type)) {
|
|
246
|
+
descendStatementChildren(node, scope, out);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (node.type === "ERROR") {
|
|
251
|
+
// Tree-sitter's error recovery *invents* structure, so the node types
|
|
252
|
+
// inside an ERROR subtree are not evidence that anything runs: descending
|
|
253
|
+
// one turns backtick-quoted prose in an unterminated heredoc into command
|
|
254
|
+
// units. Emit the unparsed blob whole and stop (#742).
|
|
255
|
+
out.push(makeUnit(node.text, scope));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
175
259
|
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
|
|
176
260
|
// function_definition): emit whole, do not descend — deferred (#306).
|
|
261
|
+
// A declaration, assignment, test, or `unset` still hosts executions that
|
|
262
|
+
// really run (`local x=$(rm y)`, `[[ $(rm x) ]]`), so those are enumerated
|
|
263
|
+
// in addition to the statement (#742).
|
|
177
264
|
out.push(makeUnit(node.text, scope));
|
|
265
|
+
collectHostedCommands(node, out);
|
|
178
266
|
}
|
|
179
267
|
|
|
180
268
|
/** The wrapper facts a `command` node's words establish about its unit. */
|
|
@@ -291,6 +379,35 @@ function descendCommandChildren(
|
|
|
291
379
|
}
|
|
292
380
|
}
|
|
293
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Descend a compound statement's children, enumerating only the ones that are
|
|
384
|
+
* themselves statements.
|
|
385
|
+
*
|
|
386
|
+
* The filter is the whole difference from {@link descendCommandChildren}, whose
|
|
387
|
+
* container types (`program` / `list` / `pipeline` / `redirected_statement` /
|
|
388
|
+
* `subshell`) have nothing but statement children. Here the children are a mix,
|
|
389
|
+
* and a non-statement one is an operand word rather than something that runs.
|
|
390
|
+
*
|
|
391
|
+
* A non-statement child is not abandoned, though: `for f in $(rm x)` hosts a
|
|
392
|
+
* real execution in its word list, which is what the second branch reaches.
|
|
393
|
+
*
|
|
394
|
+
* The scope is relayed unchanged — a compound statement's body runs in the
|
|
395
|
+
* current shell, so a write established by an enclosing `redirected_statement`
|
|
396
|
+
* covers every unit beneath it (#803).
|
|
397
|
+
*/
|
|
398
|
+
function descendStatementChildren(
|
|
399
|
+
node: TSNode,
|
|
400
|
+
scope: UnitScope,
|
|
401
|
+
out: BashCommand[],
|
|
402
|
+
): void {
|
|
403
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
404
|
+
const child = node.child(i);
|
|
405
|
+
if (!child?.isNamed) continue;
|
|
406
|
+
if (STATEMENT_TYPES.has(child.type)) collectCommandsInto(child, scope, out);
|
|
407
|
+
else collectHostedCommands(child, out);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
294
411
|
/**
|
|
295
412
|
* Enumerate the commands of every nested execution context in a subtree, each
|
|
296
413
|
* tagged with the context it was found in.
|
|
@@ -298,9 +415,12 @@ function descendCommandChildren(
|
|
|
298
415
|
* The traversal itself lives in `nested-execution.ts` so the bash path surface
|
|
299
416
|
* shares one definition of what counts as a nested execution (#741); this
|
|
300
417
|
* function supplies the command-surface interpretation of each one found.
|
|
418
|
+
*
|
|
419
|
+
* `node` may be a context outright or merely host one, so the traversal is the
|
|
420
|
+
* root-inclusive `forEachExecutionIn`.
|
|
301
421
|
*/
|
|
302
422
|
function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
303
|
-
|
|
423
|
+
forEachExecutionIn(node, (contextNode, context) => {
|
|
304
424
|
// A nested execution starts fresh: an enclosing statement's redirect is
|
|
305
425
|
// that statement's, not the substitution's, exactly as #807 attributes a
|
|
306
426
|
// nested command's path tokens to its own command.
|
|
@@ -47,6 +47,24 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
|
|
|
47
47
|
"heredoc_body",
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Visit every execution context `node` *is or contains*, in source order.
|
|
52
|
+
*
|
|
53
|
+
* The root-inclusive question, and the one nearly every consumer asks: a node
|
|
54
|
+
* handed in can be a substitution outright (`> $(cmd)`) or merely host one
|
|
55
|
+
* (`> ${DIR}/$(cmd)`), and both really execute. {@link forEachNestedExecution}
|
|
56
|
+
* answers the strictly-within question instead, which is what a visitor needs
|
|
57
|
+
* once it has already decided to treat a context's interior itself.
|
|
58
|
+
*/
|
|
59
|
+
export function forEachExecutionIn(
|
|
60
|
+
node: TSNode,
|
|
61
|
+
visit: (contextNode: TSNode, context: BashCommandContext) => void,
|
|
62
|
+
): void {
|
|
63
|
+
const context = NESTED_EXECUTION_CONTEXTS.get(node.type);
|
|
64
|
+
if (context) visit(node, context);
|
|
65
|
+
else forEachNestedExecution(node, visit);
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
/**
|
|
51
69
|
* Visit every nested execution context in `node`'s subtree, in source order.
|
|
52
70
|
*
|
|
@@ -58,6 +76,10 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
|
|
|
58
76
|
* A substitution can nest under `command_name` (when the whole command is
|
|
59
77
|
* `$(…)`), under an argument, inside a redirect destination, or inside an
|
|
60
78
|
* interpolating heredoc body, so the entire subtree is searched.
|
|
79
|
+
*
|
|
80
|
+
* `node` itself is never visited, however it is typed — use
|
|
81
|
+
* {@link forEachExecutionIn} when it may *be* a context rather than merely
|
|
82
|
+
* contain one.
|
|
61
83
|
*/
|
|
62
84
|
export function forEachNestedExecution(
|
|
63
85
|
node: TSNode,
|
|
@@ -2,8 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
|
|
3
3
|
import {
|
|
4
4
|
EXECUTION_HOST_TYPES,
|
|
5
|
-
|
|
6
|
-
NESTED_EXECUTION_CONTEXTS,
|
|
5
|
+
forEachExecutionIn,
|
|
7
6
|
} from "#src/access-intent/bash/nested-execution";
|
|
8
7
|
import {
|
|
9
8
|
ARG_NODE_TYPES,
|
|
@@ -79,10 +78,11 @@ export function collectCommandTokens(node: TSNode): PathToken[] {
|
|
|
79
78
|
const config = commandName
|
|
80
79
|
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
81
80
|
: undefined;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
if (config) return collectPatternCommandTokens(node, config, effect);
|
|
82
|
+
return [
|
|
83
|
+
...collectGenericCommandTokens(node, effect),
|
|
84
|
+
...collectEmbeddedOptionValues(node, effect),
|
|
85
|
+
];
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
@@ -128,15 +128,12 @@ export function collectRedirectTokens(node: TSNode): PathToken[] {
|
|
|
128
128
|
* its prose stays out of the path surface entirely.
|
|
129
129
|
*
|
|
130
130
|
* `node` may be a context outright (`> $(cmd)`) or merely contain one
|
|
131
|
-
* (`> ${DIR}/$(cmd)`)
|
|
132
|
-
*
|
|
131
|
+
* (`> ${DIR}/$(cmd)`), so the traversal is the root-inclusive
|
|
132
|
+
* `forEachExecutionIn`.
|
|
133
133
|
*/
|
|
134
134
|
function collectHostedExecutionTokens(node: TSNode): PathToken[] {
|
|
135
|
-
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
|
|
136
|
-
return collectPathCandidateTokens(node);
|
|
137
|
-
}
|
|
138
135
|
const tokens: PathToken[] = [];
|
|
139
|
-
|
|
136
|
+
forEachExecutionIn(node, (contextNode) => {
|
|
140
137
|
tokens.push(...collectPathCandidateTokens(contextNode));
|
|
141
138
|
});
|
|
142
139
|
return tokens;
|
|
@@ -200,6 +197,23 @@ function commandArgumentWords(node: TSNode): string[] {
|
|
|
200
197
|
return words;
|
|
201
198
|
}
|
|
202
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The children of a `command` node that supply no operand text of their own:
|
|
202
|
+
* its head word, and any env-var prefix assignment.
|
|
203
|
+
*
|
|
204
|
+
* Both are skipped as operands — the head word is not an argument, and a prefix
|
|
205
|
+
* assignment's value is assigned rather than accessed — but either can *host* a
|
|
206
|
+
* substitution that really runs (`$(cat /etc/shadow)`,
|
|
207
|
+
* `FOO=$(cat /etc/shadow) echo hi`), whose own operands are candidates like any
|
|
208
|
+
* other position (ADR 0009's positional invariance). The two walkers below are
|
|
209
|
+
* different state machines and so each carry their own skip, which is why the
|
|
210
|
+
* question is named here once rather than spelled twice (#742).
|
|
211
|
+
*/
|
|
212
|
+
const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
|
|
213
|
+
"command_name",
|
|
214
|
+
"variable_assignment",
|
|
215
|
+
]);
|
|
216
|
+
|
|
203
217
|
/**
|
|
204
218
|
* A long or short option carrying its value inline: one or two leading dashes,
|
|
205
219
|
* a name containing no `=` or whitespace, then `=` and a non-empty value.
|
|
@@ -208,11 +222,11 @@ function commandArgumentWords(node: TSNode): string[] {
|
|
|
208
222
|
const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
|
|
209
223
|
|
|
210
224
|
/**
|
|
211
|
-
* The values embedded in
|
|
225
|
+
* The values embedded in a **generic** command's `--opt=value` argument tokens.
|
|
212
226
|
*
|
|
213
227
|
* Read straight from the argument nodes rather than from the collected token
|
|
214
|
-
* list, because a
|
|
215
|
-
*
|
|
228
|
+
* list, because a collector classifies a flag and never emits it — so
|
|
229
|
+
* `tar --directory=/etc` would otherwise lose the path.
|
|
216
230
|
*
|
|
217
231
|
* This is token *preprocessing*, not classification: the extracted value is
|
|
218
232
|
* handed to the ordinary shape classifiers and existence probe, so
|
|
@@ -220,6 +234,10 @@ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
|
|
|
220
234
|
* yields a bare `json` that names nothing and is dropped. Keeping the split
|
|
221
235
|
* here is what lets the projection see option-embedded paths without per-command
|
|
222
236
|
* option tables (ADR 0009, #645).
|
|
237
|
+
*
|
|
238
|
+
* A pattern-first command runs the same split from inside its own walker
|
|
239
|
+
* instead, because there the flag's *role* is known: splitting blindly emits a
|
|
240
|
+
* pattern flag's value as a path candidate (#823).
|
|
223
241
|
*/
|
|
224
242
|
function collectEmbeddedOptionValues(
|
|
225
243
|
node: TSNode,
|
|
@@ -239,11 +257,64 @@ function collectEmbeddedOptionValues(
|
|
|
239
257
|
return values;
|
|
240
258
|
}
|
|
241
259
|
|
|
260
|
+
/** The value embedded in a single `--opt=value` token, if it carries one. */
|
|
261
|
+
function embeddedOptionValueToken(
|
|
262
|
+
text: string,
|
|
263
|
+
effect: TokenEffect,
|
|
264
|
+
): PathToken[] {
|
|
265
|
+
const value = OPTION_VALUE_PATTERN.exec(text)?.[1];
|
|
266
|
+
return value === undefined ? [] : [{ token: value, effect }];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* What a recognized flag's argument is, for the pattern-first walker.
|
|
271
|
+
*
|
|
272
|
+
* `script` and `script-file` mark the inline pattern positional as already
|
|
273
|
+
* supplied; `value` and `suffix` do not. Only `script-file` contributes a path
|
|
274
|
+
* candidate — the others name a pattern, a count, or a backup suffix.
|
|
275
|
+
*/
|
|
276
|
+
type PatternFlagRole =
|
|
277
|
+
/** Supplies the pattern/script inline (`grep -e`, `sed --expression`). */
|
|
278
|
+
| "script"
|
|
279
|
+
/** Supplies the pattern/script from a file (`grep -f`, `sed --file`). */
|
|
280
|
+
| "script-file"
|
|
281
|
+
/** Consumes a value that is neither pattern nor path (`grep -A`, `rg -g`). */
|
|
282
|
+
| "value"
|
|
283
|
+
/**
|
|
284
|
+
* Consumes the following argument only when it is empty.
|
|
285
|
+
*
|
|
286
|
+
* BSD `sed` requires a separate suffix argument (`sed -i '' 's/a/b/' f`)
|
|
287
|
+
* while GNU `sed` requires it glued (`-i`, `-i.bak`). Consuming
|
|
288
|
+
* unconditionally is right for one and eats the *script* on the other,
|
|
289
|
+
* leaving the file operand to be skipped as the inline pattern — a write
|
|
290
|
+
* target that reaches no path surface. The argument's own emptiness decides
|
|
291
|
+
* it, so the walk needs no knowledge of which sed is installed (#823).
|
|
292
|
+
*
|
|
293
|
+
* BSD also accepts a separate *non-empty* suffix (`sed -i bak 's/a/b/' f`),
|
|
294
|
+
* which this rule declines: the suffix then spends the pattern positional
|
|
295
|
+
* and the script surfaces as a candidate. The file operand still survives,
|
|
296
|
+
* so the residual is on ADR 0009's recoverable side.
|
|
297
|
+
*/
|
|
298
|
+
| "suffix"
|
|
299
|
+
/**
|
|
300
|
+
* Recognized, but whose arity depends on which implementation the command's
|
|
301
|
+
* *name* resolves to — so it takes neither the following argument nor the
|
|
302
|
+
* pattern positional.
|
|
303
|
+
*
|
|
304
|
+
* `awk` is GNU awk on Fedora/RHEL, where `--file prog.awk` reads `prog.awk`,
|
|
305
|
+
* and one-true-awk or mawk elsewhere, where the long option is ignored
|
|
306
|
+
* outright and `prog.awk` is the program *text*. Asserting either arity
|
|
307
|
+
* drops a real operand on the other family, and the projection cannot see
|
|
308
|
+
* which binary the name will reach. Claiming neither over-surfaces on both
|
|
309
|
+
* — the recoverable direction — and the extra token names nothing, so the
|
|
310
|
+
* existence probe discards it. Prefer a precise role wherever the name does
|
|
311
|
+
* fix the parser: `gawk` gets the real ones (#823).
|
|
312
|
+
*/
|
|
313
|
+
| "unknown-arity";
|
|
314
|
+
|
|
242
315
|
interface PatternCommandConfig {
|
|
243
|
-
/**
|
|
244
|
-
readonly
|
|
245
|
-
/** Flags that consume the next argument as a file path */
|
|
246
|
-
readonly fileConsumingFlags: ReadonlySet<string>;
|
|
316
|
+
/** Recognized flag spellings, short and long, mapped to their roles. */
|
|
317
|
+
readonly flags: ReadonlyMap<string, PatternFlagRole>;
|
|
247
318
|
/**
|
|
248
319
|
* Number of leading positional arguments that are patterns/scripts, not paths.
|
|
249
320
|
* Default: 1 (covers sed, awk, grep, rg).
|
|
@@ -252,130 +323,186 @@ interface PatternCommandConfig {
|
|
|
252
323
|
readonly patternPositionals?: number;
|
|
253
324
|
}
|
|
254
325
|
|
|
326
|
+
const GREP_FLAGS = new Map<string, PatternFlagRole>([
|
|
327
|
+
["-e", "script"],
|
|
328
|
+
["--regexp", "script"],
|
|
329
|
+
["-f", "script-file"],
|
|
330
|
+
["--file", "script-file"],
|
|
331
|
+
["-A", "value"],
|
|
332
|
+
["--after-context", "value"],
|
|
333
|
+
["-B", "value"],
|
|
334
|
+
["--before-context", "value"],
|
|
335
|
+
["-C", "value"],
|
|
336
|
+
// `--context` is deliberately absent, though `-C` is present and `rg` lists
|
|
337
|
+
// the long form below. grep parses with getopt, which declares `context`
|
|
338
|
+
// with an *optional* argument (`-C[NUM]`'s history), and a long option
|
|
339
|
+
// declared that way never takes a separate `argv`: `grep --context 2 pat f`
|
|
340
|
+
// searches for `2` in the files `pat` and `f`. Listing it would consume the
|
|
341
|
+
// `2`, leaving `pat` — a real file operand — to be skipped as the inline
|
|
342
|
+
// pattern. Its absence costs only a bare `2` token from `--context=2`, which
|
|
343
|
+
// names nothing and the existence probe drops (#823).
|
|
344
|
+
["-m", "value"],
|
|
345
|
+
["--max-count", "value"],
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
const SED_CONFIG: PatternCommandConfig = {
|
|
349
|
+
flags: new Map<string, PatternFlagRole>([
|
|
350
|
+
["-e", "script"],
|
|
351
|
+
["--expression", "script"],
|
|
352
|
+
["-f", "script-file"],
|
|
353
|
+
["--file", "script-file"],
|
|
354
|
+
["-i", "suffix"],
|
|
355
|
+
]),
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The short flags are POSIX and consume on every awk; the GNU long forms are
|
|
360
|
+
* `unknown-arity` because the bare name does not fix the parser.
|
|
361
|
+
*
|
|
362
|
+
* `awk` is GNU awk on Fedora/RHEL, where `--file prog.awk` reads `prog.awk`,
|
|
363
|
+
* and one-true-awk or mawk on macOS and Debian/Ubuntu, where the long option
|
|
364
|
+
* is ignored outright (`awk: unknown option --field-separator ignored`) and
|
|
365
|
+
* the following words are the program text and its input files. Asserting
|
|
366
|
+
* either arity drops a real operand on the other family, so the table asserts
|
|
367
|
+
* neither. `nawk` shares this for the same reason (#823).
|
|
368
|
+
*/
|
|
369
|
+
const AWK_CONFIG: PatternCommandConfig = {
|
|
370
|
+
flags: new Map<string, PatternFlagRole>([
|
|
371
|
+
["-e", "script"],
|
|
372
|
+
["-f", "script-file"],
|
|
373
|
+
["-F", "value"],
|
|
374
|
+
["-v", "value"],
|
|
375
|
+
["--source", "unknown-arity"],
|
|
376
|
+
["--file", "unknown-arity"],
|
|
377
|
+
["--field-separator", "unknown-arity"],
|
|
378
|
+
["--assign", "unknown-arity"],
|
|
379
|
+
]),
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/** `gawk` names GNU awk outright, so its long forms carry their real roles. */
|
|
383
|
+
const GAWK_CONFIG: PatternCommandConfig = {
|
|
384
|
+
flags: new Map<string, PatternFlagRole>([
|
|
385
|
+
...AWK_CONFIG.flags,
|
|
386
|
+
["--source", "script"],
|
|
387
|
+
["--file", "script-file"],
|
|
388
|
+
["--field-separator", "value"],
|
|
389
|
+
["--assign", "value"],
|
|
390
|
+
]),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const GREP_CONFIG: PatternCommandConfig = { flags: GREP_FLAGS };
|
|
394
|
+
|
|
395
|
+
const RG_CONFIG: PatternCommandConfig = {
|
|
396
|
+
flags: new Map<string, PatternFlagRole>([
|
|
397
|
+
...GREP_FLAGS,
|
|
398
|
+
// rg parses with clap rather than getopt, where `--context` takes a
|
|
399
|
+
// required argument, so its separated spelling really does consume:
|
|
400
|
+
// `rg --context 2 pat f` searches for `pat` in `f`. Same spelling as
|
|
401
|
+
// grep's, opposite arity — which is why it is listed per tool rather than
|
|
402
|
+
// shared above (#823).
|
|
403
|
+
["--context", "value"],
|
|
404
|
+
["-g", "value"],
|
|
405
|
+
["--glob", "value"],
|
|
406
|
+
["-t", "value"],
|
|
407
|
+
["--type", "value"],
|
|
408
|
+
["-T", "value"],
|
|
409
|
+
["--type-not", "value"],
|
|
410
|
+
["-j", "value"],
|
|
411
|
+
["--threads", "value"],
|
|
412
|
+
["-M", "value"],
|
|
413
|
+
["--max-columns", "value"],
|
|
414
|
+
["-r", "value"],
|
|
415
|
+
["--replace", "value"],
|
|
416
|
+
["-E", "value"],
|
|
417
|
+
["--encoding", "value"],
|
|
418
|
+
]),
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const SD_CONFIG: PatternCommandConfig = {
|
|
422
|
+
flags: new Map<string, PatternFlagRole>([
|
|
423
|
+
["-f", "value"],
|
|
424
|
+
["--flags", "value"],
|
|
425
|
+
["-n", "value"],
|
|
426
|
+
["--max-replacements", "value"],
|
|
427
|
+
]),
|
|
428
|
+
patternPositionals: 2,
|
|
429
|
+
};
|
|
430
|
+
|
|
255
431
|
/**
|
|
256
432
|
* Commands whose first N positional arguments are inline patterns/scripts,
|
|
257
433
|
* not filesystem paths. The map stores per-command flag configuration so
|
|
258
434
|
* the walker can correctly identify which arguments are consumed by flags
|
|
259
435
|
* vs. which are positional.
|
|
436
|
+
*
|
|
437
|
+
* Names share a configuration object only when they share a *parser*, which is
|
|
438
|
+
* narrower than being aliases: `egrep`/`fgrep` are the same binary as `grep`
|
|
439
|
+
* here, and `nawk` is one-true-awk like `awk` — but `gawk` has its own config,
|
|
440
|
+
* because it is the only one of the three that certainly means GNU awk and so
|
|
441
|
+
* the only one whose long options certainly consume (#823).
|
|
260
442
|
*/
|
|
261
443
|
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
|
|
262
444
|
new Map([
|
|
263
|
-
[
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
],
|
|
270
|
-
[
|
|
271
|
-
|
|
272
|
-
{
|
|
273
|
-
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
274
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
275
|
-
},
|
|
276
|
-
],
|
|
277
|
-
[
|
|
278
|
-
"gawk",
|
|
279
|
-
{
|
|
280
|
-
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
281
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
282
|
-
},
|
|
283
|
-
],
|
|
284
|
-
[
|
|
285
|
-
"nawk",
|
|
286
|
-
{
|
|
287
|
-
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
288
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
289
|
-
},
|
|
290
|
-
],
|
|
291
|
-
[
|
|
292
|
-
"grep",
|
|
293
|
-
{
|
|
294
|
-
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
295
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
296
|
-
},
|
|
297
|
-
],
|
|
298
|
-
[
|
|
299
|
-
"egrep",
|
|
300
|
-
{
|
|
301
|
-
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
302
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
303
|
-
},
|
|
304
|
-
],
|
|
305
|
-
[
|
|
306
|
-
"fgrep",
|
|
307
|
-
{
|
|
308
|
-
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
309
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
310
|
-
},
|
|
311
|
-
],
|
|
312
|
-
[
|
|
313
|
-
"rg",
|
|
314
|
-
{
|
|
315
|
-
argConsumingFlags: new Set([
|
|
316
|
-
"-e",
|
|
317
|
-
"-A",
|
|
318
|
-
"-B",
|
|
319
|
-
"-C",
|
|
320
|
-
"-m",
|
|
321
|
-
"-g",
|
|
322
|
-
"-t",
|
|
323
|
-
"-T",
|
|
324
|
-
"-j",
|
|
325
|
-
"-M",
|
|
326
|
-
"-r",
|
|
327
|
-
"-E",
|
|
328
|
-
]),
|
|
329
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
330
|
-
},
|
|
331
|
-
],
|
|
332
|
-
[
|
|
333
|
-
"sd",
|
|
334
|
-
{
|
|
335
|
-
argConsumingFlags: new Set(["-n", "-f"]),
|
|
336
|
-
fileConsumingFlags: new Set([]),
|
|
337
|
-
patternPositionals: 2,
|
|
338
|
-
},
|
|
339
|
-
],
|
|
445
|
+
["sed", SED_CONFIG],
|
|
446
|
+
["awk", AWK_CONFIG],
|
|
447
|
+
["gawk", GAWK_CONFIG],
|
|
448
|
+
["nawk", AWK_CONFIG],
|
|
449
|
+
["grep", GREP_CONFIG],
|
|
450
|
+
["egrep", GREP_CONFIG],
|
|
451
|
+
["fgrep", GREP_CONFIG],
|
|
452
|
+
["rg", RG_CONFIG],
|
|
453
|
+
["sd", SD_CONFIG],
|
|
340
454
|
]);
|
|
341
455
|
|
|
342
456
|
/**
|
|
343
457
|
* Describes what the walker should do when it encounters a flag word inside
|
|
344
458
|
* a pattern-first command. Using a discriminated union lets the `switch` in
|
|
345
|
-
* `collectPatternCommandTokens` narrow
|
|
459
|
+
* `collectPatternCommandTokens` narrow the flag's role without a non-null
|
|
346
460
|
* assertion (which would trigger the Biome/ESLint assertion conflict).
|
|
347
461
|
*/
|
|
348
462
|
type PatternCommandFlagDirective =
|
|
349
463
|
| { kind: "end-of-flags" }
|
|
350
464
|
| { kind: "regular-flag" }
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
465
|
+
/** A recognized flag whose value is the argument that follows it. */
|
|
466
|
+
| { kind: "consume-next"; role: PatternFlagRole }
|
|
467
|
+
/** A recognized flag carrying its value in the same token. */
|
|
468
|
+
| { kind: "inline-value"; role: PatternFlagRole; value: string };
|
|
469
|
+
|
|
470
|
+
/** A long option carrying its value inline: `--name=value`. */
|
|
471
|
+
const LONG_OPTION_VALUE_PATTERN = /^(--[^=\s]+)=(.+)$/;
|
|
356
472
|
|
|
357
473
|
/**
|
|
358
474
|
* Classify a flag word from a pattern-first command into a directive that
|
|
359
|
-
* tells the walker how to handle the flag and its
|
|
475
|
+
* tells the walker how to handle the flag and its value.
|
|
476
|
+
*
|
|
477
|
+
* Matched in the order the tools accept: the exact spelling (short or long),
|
|
478
|
+
* then a long option's `=`-embedded value, then a glued short value. The glued
|
|
479
|
+
* form matches only the **first** short flag, which is getopt's own rule —
|
|
480
|
+
* `grep -ei pattern` really is `-e` with the value `i`. A cluster whose
|
|
481
|
+
* argument-taking flag is not first (`grep -ie pattern`) therefore stays a
|
|
482
|
+
* plain flag, which over-surfaces the pattern rather than dropping the
|
|
483
|
+
* command's operand (ADR 0009's recoverable direction).
|
|
360
484
|
*/
|
|
361
485
|
function classifyPatternCommandFlag(
|
|
362
486
|
text: string,
|
|
363
487
|
config: PatternCommandConfig,
|
|
364
488
|
): PatternCommandFlagDirective {
|
|
365
489
|
if (text === "--") return { kind: "end-of-flags" };
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
490
|
+
|
|
491
|
+
const exact = config.flags.get(text);
|
|
492
|
+
if (exact) return { kind: "consume-next", role: exact };
|
|
493
|
+
|
|
494
|
+
const longOption = LONG_OPTION_VALUE_PATTERN.exec(text);
|
|
495
|
+
if (longOption) {
|
|
496
|
+
const [, name, value] = longOption;
|
|
497
|
+
const role = config.flags.get(name);
|
|
498
|
+
return role === undefined
|
|
499
|
+
? { kind: "regular-flag" }
|
|
500
|
+
: { kind: "inline-value", role, value };
|
|
372
501
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
setsExplicitScript: true,
|
|
378
|
-
};
|
|
502
|
+
|
|
503
|
+
if (!text.startsWith("--") && text.length > 2) {
|
|
504
|
+
const role = config.flags.get(text.slice(0, 2));
|
|
505
|
+
if (role) return { kind: "inline-value", role, value: text.slice(2) };
|
|
379
506
|
}
|
|
380
507
|
return { kind: "regular-flag" };
|
|
381
508
|
}
|
|
@@ -389,11 +516,13 @@ function classifyPatternCommandFlag(
|
|
|
389
516
|
* inline patterns/scripts and are skipped. Remaining positional
|
|
390
517
|
* arguments are collected as path candidates.
|
|
391
518
|
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* flag, so
|
|
519
|
+
* A recognized flag's role (see {@link PatternFlagRole}) decides three things
|
|
520
|
+
* at once: whether the pattern positional is still expected, whether the
|
|
521
|
+
* flag's value is a path candidate, and — for `suffix` — whether the
|
|
522
|
+
* following argument belongs to the flag at all. The `=`-embedded and glued
|
|
523
|
+
* spellings carry the value in the flag's own token, so the walker splits it
|
|
524
|
+
* here rather than letting {@link collectEmbeddedOptionValues} emit a
|
|
525
|
+
* pattern's text as a path (#823).
|
|
397
526
|
*/
|
|
398
527
|
function collectPatternCommandTokens(
|
|
399
528
|
node: TSNode,
|
|
@@ -403,7 +532,7 @@ function collectPatternCommandTokens(
|
|
|
403
532
|
const patternPositionals = config.patternPositionals ?? 1;
|
|
404
533
|
let hasExplicitScript = false;
|
|
405
534
|
let positionalsSeen = 0;
|
|
406
|
-
let
|
|
535
|
+
let pendingConsumption: PatternFlagRole | null = null;
|
|
407
536
|
let pastEndOfFlags = false;
|
|
408
537
|
const tokens: PathToken[] = [];
|
|
409
538
|
|
|
@@ -411,27 +540,56 @@ function collectPatternCommandTokens(
|
|
|
411
540
|
const child = node.child(i);
|
|
412
541
|
if (!child) continue;
|
|
413
542
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
// Only process argument-like nodes; recurse into others
|
|
419
|
-
// (e.g. command_substitution) for nested commands.
|
|
420
|
-
if (!ARG_NODE_TYPES.has(child.type)) {
|
|
421
|
-
tokens.push(...collectPathCandidateTokens(child));
|
|
543
|
+
if (COMMAND_PREFIX_TYPES.has(child.type)) {
|
|
544
|
+
// Supplies no operand of its own, but may host one that really runs.
|
|
545
|
+
tokens.push(...collectHostedExecutionTokens(child));
|
|
422
546
|
continue;
|
|
423
547
|
}
|
|
424
548
|
|
|
549
|
+
const isArgNode = ARG_NODE_TYPES.has(child.type);
|
|
425
550
|
const text = resolveNodeText(child);
|
|
426
551
|
|
|
427
|
-
// Handle
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
552
|
+
// Handle the argument a previous flag consumed. The consumption discharges
|
|
553
|
+
// on whatever node type follows, not only on an ARG_NODE_TYPES one: a bare
|
|
554
|
+
// number (`-A 3`), an expansion (`-A $N`), and a substitution
|
|
555
|
+
// (`-A $(echo 3)`) are all this flag's argument, and carrying the pending
|
|
556
|
+
// skip past them lands it on the *pattern* — shifting the positional count
|
|
557
|
+
// by one and eating the command's real file operand (#823).
|
|
558
|
+
if (pendingConsumption !== null) {
|
|
559
|
+
const consumption = pendingConsumption;
|
|
560
|
+
pendingConsumption = null;
|
|
561
|
+
if (!isArgNode) {
|
|
562
|
+
// Contributes no operand text of its own, but may host a nested
|
|
563
|
+
// execution whose operands are candidates (#741).
|
|
564
|
+
tokens.push(...collectPathCandidateTokens(child));
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const discharge = dischargePendingConsumption(consumption, text, effect);
|
|
568
|
+
if (discharge.token) tokens.push(discharge.token);
|
|
569
|
+
if (discharge.consumed) continue;
|
|
431
570
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
571
|
+
|
|
572
|
+
// A node outside ARG_NODE_TYPES is still one word the shell passes as an
|
|
573
|
+
// argument (`grep 42 f`, `grep $PATTERN f`, `grep $(cmd) f`), so it spends
|
|
574
|
+
// a pattern positional even though no reliable operand text can be read
|
|
575
|
+
// from it. Counting only argument nodes left a numeric or computed pattern
|
|
576
|
+
// unseen, so the slot was spent on the command's real operand instead and
|
|
577
|
+
// the operand reached no path surface (#823).
|
|
578
|
+
//
|
|
579
|
+
// A redirect hosted on the command node is not an argument and is excluded;
|
|
580
|
+
// counting it would push the real pattern out as an operand token. The
|
|
581
|
+
// exclusion is the narrow side on purpose: miscounting an argument as a
|
|
582
|
+
// redirect drops an operand, while the reverse only over-surfaces.
|
|
583
|
+
if (!isArgNode) {
|
|
584
|
+
if (
|
|
585
|
+
!EXECUTION_HOST_TYPES.has(child.type) &&
|
|
586
|
+
!hasExplicitScript &&
|
|
587
|
+
positionalsSeen < patternPositionals
|
|
588
|
+
) {
|
|
589
|
+
positionalsSeen++;
|
|
590
|
+
}
|
|
591
|
+
// Recurse for nested commands (e.g. command_substitution).
|
|
592
|
+
tokens.push(...collectPathCandidateTokens(child));
|
|
435
593
|
continue;
|
|
436
594
|
}
|
|
437
595
|
|
|
@@ -447,11 +605,19 @@ function collectPatternCommandTokens(
|
|
|
447
605
|
case "end-of-flags":
|
|
448
606
|
pastEndOfFlags = true;
|
|
449
607
|
break;
|
|
450
|
-
case "consume-
|
|
451
|
-
|
|
452
|
-
if (directive.
|
|
608
|
+
case "consume-next":
|
|
609
|
+
pendingConsumption = directive.role;
|
|
610
|
+
if (suppliesScript(directive.role)) hasExplicitScript = true;
|
|
611
|
+
break;
|
|
612
|
+
case "inline-value":
|
|
613
|
+
if (directive.role === "script-file")
|
|
614
|
+
tokens.push({ token: directive.value, effect });
|
|
615
|
+
if (suppliesScript(directive.role)) hasExplicitScript = true;
|
|
453
616
|
break;
|
|
454
617
|
case "regular-flag":
|
|
618
|
+
// Unrecognized: fall back to the blind `--opt=value` split, which is
|
|
619
|
+
// safe precisely because the flag's role is unknown (#645).
|
|
620
|
+
tokens.push(...embeddedOptionValueToken(text, effect));
|
|
455
621
|
break;
|
|
456
622
|
}
|
|
457
623
|
continue;
|
|
@@ -459,17 +625,63 @@ function collectPatternCommandTokens(
|
|
|
459
625
|
|
|
460
626
|
// Positional argument.
|
|
461
627
|
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
462
|
-
positionalsSeen++;
|
|
463
|
-
|
|
628
|
+
positionalsSeen++; // Skip: this is an inline pattern/script.
|
|
629
|
+
} else {
|
|
630
|
+
tokens.push({ token: text, effect });
|
|
464
631
|
}
|
|
465
|
-
|
|
466
|
-
//
|
|
467
|
-
tokens.push(
|
|
632
|
+
// A quoted flag never reaches the flag branch above, so its embedded value
|
|
633
|
+
// is split here instead.
|
|
634
|
+
tokens.push(...embeddedOptionValueToken(text, effect));
|
|
468
635
|
}
|
|
469
636
|
|
|
470
637
|
return tokens;
|
|
471
638
|
}
|
|
472
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Whether a flag in this role means the inline pattern positional is spent.
|
|
642
|
+
*
|
|
643
|
+
* `unknown-arity` says so for the opposite reason to the others: not because
|
|
644
|
+
* the script was supplied, but because the walker cannot tell which word the
|
|
645
|
+
* script is, and skipping the wrong one drops a real operand.
|
|
646
|
+
*/
|
|
647
|
+
function suppliesScript(role: PatternFlagRole): boolean {
|
|
648
|
+
return (
|
|
649
|
+
role === "script" || role === "script-file" || role === "unknown-arity"
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* What a pending flag consumption made of the argument node that followed it.
|
|
655
|
+
*
|
|
656
|
+
* `consumed` is the flag's own verdict, not the walker's: a `suffix` flag
|
|
657
|
+
* declines a non-empty argument, which the walker then reads as an ordinary
|
|
658
|
+
* argument.
|
|
659
|
+
*/
|
|
660
|
+
interface ConsumptionDischarge {
|
|
661
|
+
readonly consumed: boolean;
|
|
662
|
+
/** The path candidate the consumed argument contributes, if any. */
|
|
663
|
+
readonly token?: PathToken;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Apply a pending consumption to the argument text that follows its flag. */
|
|
667
|
+
function dischargePendingConsumption(
|
|
668
|
+
role: PatternFlagRole,
|
|
669
|
+
text: string,
|
|
670
|
+
effect: TokenEffect,
|
|
671
|
+
): ConsumptionDischarge {
|
|
672
|
+
switch (role) {
|
|
673
|
+
case "script-file":
|
|
674
|
+
return { consumed: true, token: { token: text, effect } };
|
|
675
|
+
case "script":
|
|
676
|
+
case "value":
|
|
677
|
+
return { consumed: true };
|
|
678
|
+
case "suffix":
|
|
679
|
+
return { consumed: text === "" };
|
|
680
|
+
case "unknown-arity":
|
|
681
|
+
return { consumed: false };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
473
685
|
/**
|
|
474
686
|
* Collect all argument tokens from a generic (non-pattern-first) command node,
|
|
475
687
|
* skipping the command name and variable assignments.
|
|
@@ -485,12 +697,12 @@ function collectGenericCommandTokens(
|
|
|
485
697
|
const child = node.child(i);
|
|
486
698
|
if (!child) continue;
|
|
487
699
|
|
|
488
|
-
if (child.type
|
|
489
|
-
|
|
700
|
+
if (COMMAND_PREFIX_TYPES.has(child.type)) {
|
|
701
|
+
// Supplies no operand of its own, but may host one that really runs.
|
|
702
|
+
if (child.type === "command_name") seenCommandName = true;
|
|
703
|
+
tokens.push(...collectHostedExecutionTokens(child));
|
|
490
704
|
continue;
|
|
491
705
|
}
|
|
492
|
-
// Skip variable_assignment nodes (FOO=/bar)
|
|
493
|
-
if (child.type === "variable_assignment") continue;
|
|
494
706
|
|
|
495
707
|
// If there was no explicit command_name node, the first word-like
|
|
496
708
|
// child is the command name itself — skip it.
|