@aliou/pi-processes 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -3
- package/src/commands/settings-command.ts +22 -0
- package/src/components/process-picker-component.ts +14 -2
- package/src/components/processes-component.ts +41 -20
- package/src/config.ts +10 -0
- package/src/hooks/background-blocker.ts +66 -0
- package/src/hooks/index.ts +11 -1
- package/src/hooks/widget.ts +32 -6
- package/src/index.ts +2 -1
- package/src/utils/command-executor.test.ts +2 -1
- package/src/utils/shell-utils.ts +133 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-processes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -30,18 +30,20 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@aliou/pi-utils-settings": "^0.4.0",
|
|
32
32
|
"@aliou/pi-utils-ui": "^0.1.0",
|
|
33
|
+
"@aliou/sh": "^0.1.0",
|
|
33
34
|
"@sinclair/typebox": "^0.34.41"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"@mariozechner/pi-coding-agent": ">=0.51.0"
|
|
37
|
+
"@mariozechner/pi-coding-agent": ">=0.51.0",
|
|
38
|
+
"@mariozechner/pi-tui": ">=0.51.0"
|
|
37
39
|
},
|
|
38
40
|
"devDependencies": {
|
|
41
|
+
"@aliou/biome-plugins": "^0.3.2",
|
|
39
42
|
"@aliou/pi-evals": "^0.3.0",
|
|
40
43
|
"@biomejs/biome": "^2.3.13",
|
|
41
44
|
"@changesets/cli": "^2.27.11",
|
|
42
45
|
"@mariozechner/pi-ai": "0.52.7",
|
|
43
46
|
"@mariozechner/pi-coding-agent": "0.52.7",
|
|
44
|
-
"@mariozechner/pi-tui": "0.52.7",
|
|
45
47
|
"@types/node": "^25.0.10",
|
|
46
48
|
"husky": "^9.1.7",
|
|
47
49
|
"typescript": "^5.9.3",
|
|
@@ -50,12 +52,16 @@
|
|
|
50
52
|
"peerDependenciesMeta": {
|
|
51
53
|
"@mariozechner/pi-coding-agent": {
|
|
52
54
|
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@mariozechner/pi-tui": {
|
|
57
|
+
"optional": true
|
|
53
58
|
}
|
|
54
59
|
},
|
|
55
60
|
"scripts": {
|
|
56
61
|
"typecheck": "tsc --noEmit",
|
|
57
62
|
"lint": "biome check",
|
|
58
63
|
"format": "biome check --write",
|
|
64
|
+
"check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
|
|
59
65
|
"changeset": "changeset",
|
|
60
66
|
"version": "changeset version",
|
|
61
67
|
"release": "pnpm changeset publish"
|
|
@@ -93,6 +93,23 @@ export function registerProcessesSettings(
|
|
|
93
93
|
},
|
|
94
94
|
],
|
|
95
95
|
},
|
|
96
|
+
{
|
|
97
|
+
label: "Interception",
|
|
98
|
+
items: [
|
|
99
|
+
{
|
|
100
|
+
id: "interception.blockBackgroundCommands",
|
|
101
|
+
label: "Block background commands",
|
|
102
|
+
description:
|
|
103
|
+
"Block bash background commands (&, nohup, disown, setsid) and guide the model to use the process tool",
|
|
104
|
+
currentValue:
|
|
105
|
+
(tabConfig?.interception?.blockBackgroundCommands ??
|
|
106
|
+
resolved.interception.blockBackgroundCommands)
|
|
107
|
+
? "on"
|
|
108
|
+
: "off",
|
|
109
|
+
values: ["on", "off"],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
96
113
|
{
|
|
97
114
|
label: "Widget",
|
|
98
115
|
items: [
|
|
@@ -114,6 +131,11 @@ export function registerProcessesSettings(
|
|
|
114
131
|
onSettingChange: (id, newValue, config) => {
|
|
115
132
|
const updated = structuredClone(config);
|
|
116
133
|
// Boolean fields.
|
|
134
|
+
if (id === "interception.blockBackgroundCommands") {
|
|
135
|
+
if (!updated.interception) updated.interception = {};
|
|
136
|
+
updated.interception.blockBackgroundCommands = newValue === "on";
|
|
137
|
+
return updated;
|
|
138
|
+
}
|
|
117
139
|
if (id === "widget.showStatusWidget") {
|
|
118
140
|
if (!updated.widget) updated.widget = {};
|
|
119
141
|
updated.widget.showStatusWidget = newValue === "on";
|
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
renderPanelTitleLine,
|
|
5
5
|
} from "@aliou/pi-utils-ui";
|
|
6
6
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Component,
|
|
9
|
+
matchesKey,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
} from "@mariozechner/pi-tui";
|
|
8
13
|
import type { ProcessInfo } from "../constants";
|
|
9
14
|
import type { ProcessManager } from "../manager";
|
|
10
15
|
import { statusIcon, statusLabel } from "./status-format";
|
|
@@ -111,7 +116,14 @@ export class ProcessPickerComponent implements Component {
|
|
|
111
116
|
const dim = (s: string) => theme.fg("dim", s);
|
|
112
117
|
const accent = (s: string) => theme.fg("accent", s);
|
|
113
118
|
|
|
114
|
-
const
|
|
119
|
+
const innerWidth = width - 2;
|
|
120
|
+
const basePadLine = createPanelPadder(width);
|
|
121
|
+
const padLine = (content: string): string =>
|
|
122
|
+
basePadLine(
|
|
123
|
+
visibleWidth(content) > innerWidth
|
|
124
|
+
? truncateToWidth(content, innerWidth)
|
|
125
|
+
: content,
|
|
126
|
+
);
|
|
115
127
|
|
|
116
128
|
const lines: string[] = [];
|
|
117
129
|
const processes = this.getProcesses();
|
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
renderPanelTitleLine,
|
|
5
5
|
} from "@aliou/pi-utils-ui";
|
|
6
6
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Component,
|
|
9
|
+
matchesKey,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
} from "@mariozechner/pi-tui";
|
|
8
13
|
import { configLoader } from "../config";
|
|
9
14
|
import type { ProcessInfo } from "../constants";
|
|
10
15
|
import type { ProcessManager } from "../manager";
|
|
@@ -214,7 +219,13 @@ export class ProcessesComponent implements Component {
|
|
|
214
219
|
const processes = this.manager.list();
|
|
215
220
|
const innerWidth = width - 2;
|
|
216
221
|
|
|
217
|
-
const
|
|
222
|
+
const basePadLine = createPanelPadder(width);
|
|
223
|
+
const padLine = (content: string): string =>
|
|
224
|
+
basePadLine(
|
|
225
|
+
visibleWidth(content) > innerWidth
|
|
226
|
+
? truncateToWidth(content, innerWidth)
|
|
227
|
+
: content,
|
|
228
|
+
);
|
|
218
229
|
|
|
219
230
|
lines.push(renderPanelTitleLine("Background Processes", width, theme));
|
|
220
231
|
|
|
@@ -225,11 +236,18 @@ export class ProcessesComponent implements Component {
|
|
|
225
236
|
lines.push(padLine(""));
|
|
226
237
|
} else {
|
|
227
238
|
const prefixWidth = 2;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
const
|
|
239
|
+
|
|
240
|
+
// Responsive column widths based on available space
|
|
241
|
+
// Minimum widths: id=6, name=8, cmd=4, status=10, time=4, size=4 = ~40 chars minimum
|
|
242
|
+
const minTotalWidth = 40;
|
|
243
|
+
const scaleFactor =
|
|
244
|
+
innerWidth < minTotalWidth ? innerWidth / minTotalWidth : 1;
|
|
245
|
+
|
|
246
|
+
const idWidth = Math.max(6, Math.floor(9 * scaleFactor));
|
|
247
|
+
const nameWidth = Math.max(8, Math.floor(15 * scaleFactor));
|
|
248
|
+
const statusWidth = Math.max(10, Math.floor(18 * scaleFactor));
|
|
249
|
+
const timeWidth = Math.max(4, Math.floor(8 * scaleFactor));
|
|
250
|
+
const sizeWidth = Math.max(4, Math.floor(8 * scaleFactor));
|
|
233
251
|
|
|
234
252
|
const hasProcessScroll = processes.length > maxVisibleProcesses;
|
|
235
253
|
const headerSuffixText = hasProcessScroll
|
|
@@ -237,18 +255,16 @@ export class ProcessesComponent implements Component {
|
|
|
237
255
|
: "";
|
|
238
256
|
const headerSuffixLen = hasProcessScroll ? headerSuffixText.length : 0;
|
|
239
257
|
|
|
240
|
-
//
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
headerSuffixLen,
|
|
251
|
-
);
|
|
258
|
+
// Calculate command column width based on remaining space
|
|
259
|
+
const fixedWidth =
|
|
260
|
+
prefixWidth +
|
|
261
|
+
idWidth +
|
|
262
|
+
nameWidth +
|
|
263
|
+
statusWidth +
|
|
264
|
+
timeWidth +
|
|
265
|
+
sizeWidth +
|
|
266
|
+
headerSuffixLen;
|
|
267
|
+
const cmdWidth = Math.max(4, innerWidth - fixedWidth);
|
|
252
268
|
|
|
253
269
|
lines.push(padLine(""));
|
|
254
270
|
const header =
|
|
@@ -409,7 +425,12 @@ export class ProcessesComponent implements Component {
|
|
|
409
425
|
const footerLeftLen = visibleWidth(footerLeft);
|
|
410
426
|
const footerRightLen = visibleWidth(footerRight);
|
|
411
427
|
const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
|
|
412
|
-
|
|
428
|
+
let footer = footerLeft + " ".repeat(footerGap) + footerRight;
|
|
429
|
+
|
|
430
|
+
// Truncate footer if it exceeds inner width (e.g., on very narrow terminals)
|
|
431
|
+
if (footerLeftLen + footerGap + footerRightLen > innerWidth) {
|
|
432
|
+
footer = truncateToWidth(footer, innerWidth);
|
|
433
|
+
}
|
|
413
434
|
|
|
414
435
|
lines.push(padLine(footer));
|
|
415
436
|
lines.push(renderPanelRule(width, theme));
|
package/src/config.ts
CHANGED
|
@@ -28,6 +28,10 @@ export interface ProcessesConfig {
|
|
|
28
28
|
/** Show the status widget below the editor. */
|
|
29
29
|
showStatusWidget?: boolean;
|
|
30
30
|
};
|
|
31
|
+
interception?: {
|
|
32
|
+
/** Block background bash commands (&, nohup, disown, setsid) and guide the model to use the process tool. */
|
|
33
|
+
blockBackgroundCommands?: boolean;
|
|
34
|
+
};
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export interface ResolvedProcessesConfig {
|
|
@@ -45,6 +49,9 @@ export interface ResolvedProcessesConfig {
|
|
|
45
49
|
widget: {
|
|
46
50
|
showStatusWidget: boolean;
|
|
47
51
|
};
|
|
52
|
+
interception: {
|
|
53
|
+
blockBackgroundCommands: boolean;
|
|
54
|
+
};
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
const DEFAULT_CONFIG: ResolvedProcessesConfig = {
|
|
@@ -60,6 +67,9 @@ const DEFAULT_CONFIG: ResolvedProcessesConfig = {
|
|
|
60
67
|
widget: {
|
|
61
68
|
showStatusWidget: true,
|
|
62
69
|
},
|
|
70
|
+
interception: {
|
|
71
|
+
blockBackgroundCommands: false,
|
|
72
|
+
},
|
|
63
73
|
};
|
|
64
74
|
|
|
65
75
|
export const configLoader = new ConfigLoader<
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks background bash commands (e.g. `cmd &`, `nohup cmd`) and guides
|
|
3
|
+
* the model to use the process tool instead.
|
|
4
|
+
*
|
|
5
|
+
* Opt-in via config: `interception.blockBackgroundCommands`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parse } from "@aliou/sh";
|
|
9
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
11
|
+
|
|
12
|
+
const BACKGROUND_CMD_NAMES = new Set(["nohup", "disown", "setsid"]);
|
|
13
|
+
const BACKGROUND_PATTERN = /&\s*$/;
|
|
14
|
+
|
|
15
|
+
export function setupBackgroundBlocker(pi: ExtensionAPI): void {
|
|
16
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
17
|
+
if (event.toolName !== "bash") return;
|
|
18
|
+
|
|
19
|
+
const command = String(event.input.command ?? "");
|
|
20
|
+
|
|
21
|
+
let hasBackground = false;
|
|
22
|
+
try {
|
|
23
|
+
const { ast } = parse(command);
|
|
24
|
+
|
|
25
|
+
// Check statement-level background flag (cmd &)
|
|
26
|
+
for (const stmt of ast.body) {
|
|
27
|
+
if (stmt.background) {
|
|
28
|
+
hasBackground = true;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check for nohup/disown/setsid as command names
|
|
34
|
+
if (!hasBackground) {
|
|
35
|
+
walkCommands(ast, (cmd) => {
|
|
36
|
+
const name = cmd.words?.[0] ? wordToString(cmd.words[0]) : undefined;
|
|
37
|
+
if (name && BACKGROUND_CMD_NAMES.has(name)) {
|
|
38
|
+
hasBackground = true;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fallback to regex on parse failure
|
|
46
|
+
hasBackground = BACKGROUND_PATTERN.test(command);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (hasBackground) {
|
|
50
|
+
ctx.ui?.notify(
|
|
51
|
+
"Blocked background command. Use the process tool instead.",
|
|
52
|
+
"warning",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
block: true,
|
|
57
|
+
reason:
|
|
58
|
+
"Background commands (&, nohup, disown, setsid) are not supported in bash. " +
|
|
59
|
+
'Use the "process" tool with action "start" to run commands in the background. ' +
|
|
60
|
+
'Example: process({ action: "start", name: "my-server", command: "npm run dev" })',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return;
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { ResolvedProcessesConfig } from "../config";
|
|
2
3
|
import type { ProcessManager } from "../manager";
|
|
4
|
+
import { setupBackgroundBlocker } from "./background-blocker";
|
|
3
5
|
import { setupCleanupHook } from "./cleanup";
|
|
4
6
|
import { setupMessageRenderer } from "./message-renderer";
|
|
5
7
|
import { setupProcessEndHook } from "./process-end";
|
|
6
8
|
import { setupProcessWidget } from "./widget";
|
|
7
9
|
|
|
8
|
-
export function setupProcessesHooks(
|
|
10
|
+
export function setupProcessesHooks(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
manager: ProcessManager,
|
|
13
|
+
config: ResolvedProcessesConfig,
|
|
14
|
+
) {
|
|
9
15
|
setupCleanupHook(pi, manager);
|
|
10
16
|
setupProcessEndHook(pi, manager);
|
|
11
17
|
|
|
18
|
+
if (config.interception.blockBackgroundCommands) {
|
|
19
|
+
setupBackgroundBlocker(pi);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
// Set up widget AFTER process-end so it chains onto the existing callback
|
|
13
23
|
const widget = setupProcessWidget(pi, manager);
|
|
14
24
|
|
package/src/hooks/widget.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@mariozechner/pi-coding-agent";
|
|
5
|
-
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
5
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
6
6
|
import { configLoader } from "../config";
|
|
7
7
|
import type { ProcessInfo } from "../constants";
|
|
8
8
|
import type { ProcessManager } from "../manager";
|
|
@@ -75,16 +75,28 @@ function renderWidget(
|
|
|
75
75
|
for (const proc of allProcs) {
|
|
76
76
|
const formatted = formatProcessStatus(proc, theme);
|
|
77
77
|
const formattedLen = visibleWidth(formatted);
|
|
78
|
+
const remaining = allProcs.length - includedCount - 1;
|
|
78
79
|
|
|
79
80
|
// Check if adding this part would exceed the width
|
|
80
81
|
const needed =
|
|
81
82
|
includedCount > 0 ? separatorLen + formattedLen : formattedLen;
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
// If there are more processes after this one, reserve space for the
|
|
85
|
+
// overflow suffix (separator + "+N more") so the final line fits.
|
|
86
|
+
let reservedForSuffix = 0;
|
|
87
|
+
if (remaining > 0) {
|
|
88
|
+
const suffixText = `+${remaining} more`;
|
|
89
|
+
reservedForSuffix = separatorLen + visibleWidth(suffixText);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
currentLen + needed + reservedForSuffix > effectiveMax &&
|
|
94
|
+
includedCount > 0
|
|
95
|
+
) {
|
|
84
96
|
// Show how many are hidden
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
parts.push(theme.fg("dim", `+${
|
|
97
|
+
const hiddenCount = allProcs.length - includedCount;
|
|
98
|
+
if (hiddenCount > 0) {
|
|
99
|
+
parts.push(theme.fg("dim", `+${hiddenCount} more`));
|
|
88
100
|
}
|
|
89
101
|
break;
|
|
90
102
|
}
|
|
@@ -94,11 +106,25 @@ function renderWidget(
|
|
|
94
106
|
includedCount++;
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
// Edge case: the very first element was too wide and the loop's overflow
|
|
110
|
+
// check was skipped (it only fires when includedCount > 0). The suffix
|
|
111
|
+
// reservation already shrinks the budget, but a single process that fills
|
|
112
|
+
// the whole line still slips through. Show it truncated rather than nothing.
|
|
113
|
+
if (includedCount === 0 && allProcs.length > 0) {
|
|
114
|
+
const formatted = formatProcessStatus(allProcs[0], theme);
|
|
115
|
+
parts.push(formatted);
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
if (parts.length === 0) {
|
|
98
119
|
return [];
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
|
|
122
|
+
const line = prefix + parts.join(separator);
|
|
123
|
+
return [
|
|
124
|
+
visibleWidth(line) > effectiveMax
|
|
125
|
+
? truncateToWidth(line, effectiveMax)
|
|
126
|
+
: line,
|
|
127
|
+
];
|
|
102
128
|
}
|
|
103
129
|
|
|
104
130
|
export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
20
20
|
getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const config = configLoader.getConfig();
|
|
24
|
+
const { update: updateWidget } = setupProcessesHooks(pi, manager, config);
|
|
24
25
|
setupProcessesCommands(pi, manager);
|
|
25
26
|
setupProcessesTools(pi, manager);
|
|
26
27
|
registerProcessesSettings(pi, () => {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import type * as nodeFs from "node:fs";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import { describe, expect, it, vi } from "vitest";
|
|
3
4
|
import { resolveShellExecutable } from "./command-executor";
|
|
4
5
|
|
|
5
6
|
vi.mock("node:fs", async (importOriginal) => {
|
|
6
|
-
const actual = await importOriginal<typeof
|
|
7
|
+
const actual = await importOriginal<typeof nodeFs>();
|
|
7
8
|
return { ...actual, existsSync: vi.fn() };
|
|
8
9
|
});
|
|
9
10
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Shell AST helpers. Duplicated from pi-toolchain since cross-extension imports are not allowed.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
Command,
|
|
5
|
+
Program,
|
|
6
|
+
SimpleCommand,
|
|
7
|
+
Statement,
|
|
8
|
+
Word,
|
|
9
|
+
WordPart,
|
|
10
|
+
} from "@aliou/sh";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a Word node to its literal string value.
|
|
14
|
+
* Concatenates Literal, SglQuoted, and simple DblQuoted parts.
|
|
15
|
+
* For parts containing parameter expansions, command substitutions, etc.,
|
|
16
|
+
* includes the raw text representation (e.g. `$VAR`).
|
|
17
|
+
*/
|
|
18
|
+
export function wordToString(word: Word): string {
|
|
19
|
+
return word.parts.map(partToString).join("");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function partToString(part: WordPart): string {
|
|
23
|
+
switch (part.type) {
|
|
24
|
+
case "Literal":
|
|
25
|
+
return part.value;
|
|
26
|
+
case "SglQuoted":
|
|
27
|
+
return part.value;
|
|
28
|
+
case "DblQuoted":
|
|
29
|
+
return part.parts.map(partToString).join("");
|
|
30
|
+
case "ParamExp":
|
|
31
|
+
return part.short
|
|
32
|
+
? `$${part.param.value}`
|
|
33
|
+
: `\${${part.param.value}${part.op ?? ""}${part.value ? wordToString(part.value) : ""}}`;
|
|
34
|
+
case "CmdSubst":
|
|
35
|
+
return "$(...)";
|
|
36
|
+
case "ArithExp":
|
|
37
|
+
return `$((${part.expr}))`;
|
|
38
|
+
case "ProcSubst":
|
|
39
|
+
return `${part.op}(...)`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk the AST and call `callback` for every SimpleCommand found at any
|
|
45
|
+
* nesting depth. Returns early if callback returns `true`.
|
|
46
|
+
*/
|
|
47
|
+
export function walkCommands(
|
|
48
|
+
node: Program,
|
|
49
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
50
|
+
): void {
|
|
51
|
+
for (const stmt of node.body) {
|
|
52
|
+
if (walkStatement(stmt, callback)) return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function walkStatement(
|
|
57
|
+
stmt: Statement,
|
|
58
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
return walkCommand(stmt.command, callback);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function walkStatements(
|
|
64
|
+
stmts: Statement[],
|
|
65
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
66
|
+
): boolean {
|
|
67
|
+
for (const stmt of stmts) {
|
|
68
|
+
if (walkStatement(stmt, callback)) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function walkCommand(
|
|
74
|
+
cmd: Command,
|
|
75
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
76
|
+
): boolean {
|
|
77
|
+
switch (cmd.type) {
|
|
78
|
+
case "SimpleCommand":
|
|
79
|
+
return callback(cmd) === true;
|
|
80
|
+
|
|
81
|
+
case "Pipeline":
|
|
82
|
+
return walkStatements(cmd.commands, callback);
|
|
83
|
+
|
|
84
|
+
case "Logical":
|
|
85
|
+
return (
|
|
86
|
+
walkStatement(cmd.left, callback) || walkStatement(cmd.right, callback)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
case "Subshell":
|
|
90
|
+
case "Block":
|
|
91
|
+
return walkStatements(cmd.body, callback);
|
|
92
|
+
|
|
93
|
+
case "IfClause":
|
|
94
|
+
return (
|
|
95
|
+
walkStatements(cmd.cond, callback) ||
|
|
96
|
+
walkStatements(cmd.then, callback) ||
|
|
97
|
+
(cmd.else ? walkStatements(cmd.else, callback) : false)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
case "ForClause":
|
|
101
|
+
case "SelectClause":
|
|
102
|
+
case "WhileClause":
|
|
103
|
+
return (
|
|
104
|
+
("cond" in cmd && cmd.cond
|
|
105
|
+
? walkStatements(cmd.cond, callback)
|
|
106
|
+
: false) || walkStatements(cmd.body, callback)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
case "CaseClause":
|
|
110
|
+
for (const item of cmd.items) {
|
|
111
|
+
if (walkStatements(item.body, callback)) return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
|
|
115
|
+
case "FunctionDecl":
|
|
116
|
+
return walkStatements(cmd.body, callback);
|
|
117
|
+
|
|
118
|
+
case "TimeClause":
|
|
119
|
+
return walkStatement(cmd.command, callback);
|
|
120
|
+
|
|
121
|
+
case "CoprocClause":
|
|
122
|
+
return walkStatement(cmd.body, callback);
|
|
123
|
+
|
|
124
|
+
case "CStyleLoop":
|
|
125
|
+
return walkStatements(cmd.body, callback);
|
|
126
|
+
|
|
127
|
+
case "TestClause":
|
|
128
|
+
case "ArithCmd":
|
|
129
|
+
case "DeclClause":
|
|
130
|
+
case "LetClause":
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|