pi-guard 1.0.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/README.md +193 -0
- package/package.json +50 -0
- package/src/config.ts +310 -0
- package/src/extract.ts +424 -0
- package/src/format.ts +206 -0
- package/src/index.ts +426 -0
- package/src/matchers.ts +72 -0
- package/src/matching.ts +133 -0
- package/src/prompt.ts +47 -0
- package/src/resolve.ts +9 -0
- package/src/types.ts +52 -0
package/src/extract.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { parse as parseBash } from "unbash";
|
|
2
|
+
import type {
|
|
3
|
+
ArithmeticExpression,
|
|
4
|
+
AssignmentPrefix,
|
|
5
|
+
Command,
|
|
6
|
+
CommandExpansionPart,
|
|
7
|
+
Node,
|
|
8
|
+
ParameterExpansionPart,
|
|
9
|
+
ProcessSubstitutionPart,
|
|
10
|
+
Redirect,
|
|
11
|
+
Script,
|
|
12
|
+
TestExpression,
|
|
13
|
+
Word,
|
|
14
|
+
WordPart,
|
|
15
|
+
} from "unbash";
|
|
16
|
+
import type { CommandRef } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export type { CommandRef };
|
|
19
|
+
|
|
20
|
+
export function extractAllCommandsFromAST(node: Script | Node, source: string): CommandRef[] {
|
|
21
|
+
const commands: CommandRef[] = [];
|
|
22
|
+
collectNode(node, source, commands);
|
|
23
|
+
return commands;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectNode(node: Script | Node | undefined, source: string, commands: CommandRef[]) {
|
|
27
|
+
if (!node) return;
|
|
28
|
+
|
|
29
|
+
switch (node.type) {
|
|
30
|
+
case "Script":
|
|
31
|
+
case "AndOr":
|
|
32
|
+
case "Pipeline":
|
|
33
|
+
case "CompoundList":
|
|
34
|
+
for (const child of node.commands) {
|
|
35
|
+
collectNode(child, source, commands);
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
|
|
39
|
+
case "Statement":
|
|
40
|
+
collectNode(node.command, source, commands);
|
|
41
|
+
for (const redirect of node.redirects) {
|
|
42
|
+
collectRedirect(redirect, source, commands);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
|
|
46
|
+
case "Command":
|
|
47
|
+
collectCommand(node, source, commands);
|
|
48
|
+
return;
|
|
49
|
+
|
|
50
|
+
case "Subshell":
|
|
51
|
+
case "BraceGroup":
|
|
52
|
+
collectNode(node.body, source, commands);
|
|
53
|
+
return;
|
|
54
|
+
|
|
55
|
+
case "If":
|
|
56
|
+
collectNode(node.clause, source, commands);
|
|
57
|
+
collectNode(node.then, source, commands);
|
|
58
|
+
if (node.else) collectNode(node.else, source, commands);
|
|
59
|
+
return;
|
|
60
|
+
|
|
61
|
+
case "While":
|
|
62
|
+
collectNode(node.clause, source, commands);
|
|
63
|
+
collectNode(node.body, source, commands);
|
|
64
|
+
return;
|
|
65
|
+
|
|
66
|
+
case "For":
|
|
67
|
+
collectWord(node.name, source, commands);
|
|
68
|
+
for (const word of node.wordlist) {
|
|
69
|
+
collectWord(word, source, commands);
|
|
70
|
+
}
|
|
71
|
+
collectNode(node.body, source, commands);
|
|
72
|
+
return;
|
|
73
|
+
|
|
74
|
+
case "Select":
|
|
75
|
+
collectWord(node.name, source, commands);
|
|
76
|
+
for (const word of node.wordlist) {
|
|
77
|
+
collectWord(word, source, commands);
|
|
78
|
+
}
|
|
79
|
+
collectNode(node.body, source, commands);
|
|
80
|
+
return;
|
|
81
|
+
|
|
82
|
+
case "Case":
|
|
83
|
+
collectWord(node.word, source, commands);
|
|
84
|
+
for (const item of node.items) {
|
|
85
|
+
collectCaseItem(item, source, commands);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
|
|
89
|
+
case "Function":
|
|
90
|
+
collectWord(node.name, source, commands);
|
|
91
|
+
collectNode(node.body, source, commands);
|
|
92
|
+
for (const redirect of node.redirects) {
|
|
93
|
+
collectRedirect(redirect, source, commands);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
|
|
97
|
+
case "Coproc":
|
|
98
|
+
if (node.name) collectWord(node.name, source, commands);
|
|
99
|
+
collectNode(node.body, source, commands);
|
|
100
|
+
for (const redirect of node.redirects) {
|
|
101
|
+
collectRedirect(redirect, source, commands);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
|
|
105
|
+
case "TestCommand":
|
|
106
|
+
collectTestExpression(node.expression, source, commands);
|
|
107
|
+
return;
|
|
108
|
+
|
|
109
|
+
case "ArithmeticFor":
|
|
110
|
+
collectArithmeticExpression(node.initialize, source, commands);
|
|
111
|
+
collectArithmeticExpression(node.test, source, commands);
|
|
112
|
+
collectArithmeticExpression(node.update, source, commands);
|
|
113
|
+
collectNode(node.body, source, commands);
|
|
114
|
+
return;
|
|
115
|
+
|
|
116
|
+
case "ArithmeticCommand":
|
|
117
|
+
collectArithmeticExpression(node.expression, source, commands);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectCommand(node: Command, source: string, commands: CommandRef[]) {
|
|
123
|
+
if (node.name) {
|
|
124
|
+
commands.push({ node, source });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const prefix of node.prefix) {
|
|
128
|
+
collectAssignment(prefix, source, commands);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const word of node.suffix) {
|
|
132
|
+
collectWord(word, source, commands);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const redirect of node.redirects) {
|
|
136
|
+
collectRedirect(redirect, source, commands);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function collectAssignment(assignment: AssignmentPrefix, source: string, commands: CommandRef[]) {
|
|
141
|
+
if (assignment.value) {
|
|
142
|
+
collectWord(assignment.value, source, commands);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (assignment.array) {
|
|
146
|
+
for (const word of assignment.array) {
|
|
147
|
+
collectWord(word, source, commands);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collectRedirect(redirect: Redirect, source: string, commands: CommandRef[]) {
|
|
153
|
+
if (redirect.target) {
|
|
154
|
+
collectWord(redirect.target, source, commands);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (redirect.body?.parts) {
|
|
158
|
+
collectWord(redirect.body, source, commands);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function collectWord(word: Word | undefined, source: string, commands: CommandRef[]) {
|
|
163
|
+
if (!word?.parts) return;
|
|
164
|
+
for (const part of word.parts) {
|
|
165
|
+
collectWordPart(part, source, commands);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function collectWordPart(
|
|
170
|
+
part: WordPart | CommandExpansionPart | ProcessSubstitutionPart,
|
|
171
|
+
source: string,
|
|
172
|
+
commands: CommandRef[],
|
|
173
|
+
) {
|
|
174
|
+
switch (part.type) {
|
|
175
|
+
case "DoubleQuoted":
|
|
176
|
+
case "LocaleString":
|
|
177
|
+
for (const child of part.parts) {
|
|
178
|
+
collectWordPart(child, source, commands);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
|
|
182
|
+
case "CommandExpansion":
|
|
183
|
+
case "ProcessSubstitution":
|
|
184
|
+
if (part.script) {
|
|
185
|
+
collectNode(part.script, expansionSource(part, source), commands);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
|
|
189
|
+
case "ParameterExpansion":
|
|
190
|
+
collectParameterExpansion(part, source, commands);
|
|
191
|
+
return;
|
|
192
|
+
|
|
193
|
+
case "ArithmeticExpansion":
|
|
194
|
+
collectArithmeticExpression(part.expression, source, commands);
|
|
195
|
+
return;
|
|
196
|
+
|
|
197
|
+
default:
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function collectParameterExpansion(part: ParameterExpansionPart, source: string, commands: CommandRef[]) {
|
|
203
|
+
if (part.operand) {
|
|
204
|
+
collectWord(part.operand, source, commands);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (part.slice) {
|
|
208
|
+
collectWord(part.slice.offset, source, commands);
|
|
209
|
+
if (part.slice.length) {
|
|
210
|
+
collectWord(part.slice.length, source, commands);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (part.replace) {
|
|
215
|
+
collectWord(part.replace.pattern, source, commands);
|
|
216
|
+
collectWord(part.replace.replacement, source, commands);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function expansionSource(part: CommandExpansionPart | ProcessSubstitutionPart, fallbackSource: string): string {
|
|
221
|
+
if (part.inner != null) return part.inner;
|
|
222
|
+
|
|
223
|
+
const text = part.text;
|
|
224
|
+
if (text.startsWith("$(") && text.endsWith(")")) {
|
|
225
|
+
return text.slice(2, -1);
|
|
226
|
+
}
|
|
227
|
+
if ((text.startsWith("<(") || text.startsWith(">(")) && text.endsWith(")")) {
|
|
228
|
+
return text.slice(2, -1);
|
|
229
|
+
}
|
|
230
|
+
if (text.startsWith("`") && text.endsWith("`")) {
|
|
231
|
+
return text.slice(1, -1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return fallbackSource;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function collectCaseItem(item: { pattern: Word[]; body: Node }, source: string, commands: CommandRef[]) {
|
|
238
|
+
for (const pattern of item.pattern) {
|
|
239
|
+
collectWord(pattern, source, commands);
|
|
240
|
+
}
|
|
241
|
+
collectNode(item.body, source, commands);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function collectTestExpression(expr: TestExpression, source: string, commands: CommandRef[]) {
|
|
245
|
+
switch (expr.type) {
|
|
246
|
+
case "TestUnary":
|
|
247
|
+
collectWord(expr.operand, source, commands);
|
|
248
|
+
return;
|
|
249
|
+
case "TestBinary":
|
|
250
|
+
collectWord(expr.left, source, commands);
|
|
251
|
+
collectWord(expr.right, source, commands);
|
|
252
|
+
return;
|
|
253
|
+
case "TestLogical":
|
|
254
|
+
collectTestExpression(expr.left, source, commands);
|
|
255
|
+
collectTestExpression(expr.right, source, commands);
|
|
256
|
+
return;
|
|
257
|
+
case "TestNot":
|
|
258
|
+
collectTestExpression(expr.operand, source, commands);
|
|
259
|
+
return;
|
|
260
|
+
case "TestGroup":
|
|
261
|
+
collectTestExpression(expr.expression, source, commands);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function collectArithmeticExpression(expr: ArithmeticExpression | undefined, source: string, commands: CommandRef[]) {
|
|
267
|
+
if (!expr) return;
|
|
268
|
+
|
|
269
|
+
switch (expr.type) {
|
|
270
|
+
case "ArithmeticBinary":
|
|
271
|
+
collectArithmeticExpression(expr.left, source, commands);
|
|
272
|
+
collectArithmeticExpression(expr.right, source, commands);
|
|
273
|
+
return;
|
|
274
|
+
case "ArithmeticUnary":
|
|
275
|
+
collectArithmeticExpression(expr.operand, source, commands);
|
|
276
|
+
return;
|
|
277
|
+
case "ArithmeticTernary":
|
|
278
|
+
collectArithmeticExpression(expr.test, source, commands);
|
|
279
|
+
collectArithmeticExpression(expr.consequent, source, commands);
|
|
280
|
+
collectArithmeticExpression(expr.alternate, source, commands);
|
|
281
|
+
return;
|
|
282
|
+
case "ArithmeticGroup":
|
|
283
|
+
collectArithmeticExpression(expr.expression, source, commands);
|
|
284
|
+
return;
|
|
285
|
+
case "ArithmeticCommandExpansion":
|
|
286
|
+
if (expr.script) {
|
|
287
|
+
// Extract inner source from text like "$(cmd)" -> "cmd"
|
|
288
|
+
const innerSource = expr.text.startsWith("$(") && expr.text.endsWith(")")
|
|
289
|
+
? expr.text.slice(2, -1)
|
|
290
|
+
: expr.text;
|
|
291
|
+
collectNode(expr.script, innerSource, commands);
|
|
292
|
+
} else if (expr.inner) {
|
|
293
|
+
// Parse the inner text and collect commands (for double-quoted context)
|
|
294
|
+
const innerAst = parseBash(expr.inner);
|
|
295
|
+
collectArithmeticCommands(innerAst, expr.inner, commands);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
case "ArithmeticWord":
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function collectArithmeticCommands(node: Script | Node | undefined, source: string, commands: CommandRef[]) {
|
|
304
|
+
if (!node) return;
|
|
305
|
+
|
|
306
|
+
switch (node.type) {
|
|
307
|
+
case "Script":
|
|
308
|
+
case "AndOr":
|
|
309
|
+
case "Pipeline":
|
|
310
|
+
case "CompoundList":
|
|
311
|
+
for (const child of node.commands) {
|
|
312
|
+
collectArithmeticCommands(child, source, commands);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
|
|
316
|
+
case "Statement":
|
|
317
|
+
collectArithmeticCommands(node.command, source, commands);
|
|
318
|
+
for (const redirect of node.redirects) {
|
|
319
|
+
collectArithmeticRedirect(redirect, source, commands);
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
|
|
323
|
+
case "Command":
|
|
324
|
+
if (node.name) {
|
|
325
|
+
commands.push({ node, source });
|
|
326
|
+
}
|
|
327
|
+
for (const prefix of node.prefix) {
|
|
328
|
+
collectAssignment(prefix, source, commands);
|
|
329
|
+
}
|
|
330
|
+
for (const word of node.suffix) {
|
|
331
|
+
collectWord(word, source, commands);
|
|
332
|
+
}
|
|
333
|
+
for (const redirect of node.redirects) {
|
|
334
|
+
collectArithmeticRedirect(redirect, source, commands);
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
|
|
338
|
+
case "Subshell":
|
|
339
|
+
case "BraceGroup":
|
|
340
|
+
collectArithmeticCommands(node.body, source, commands);
|
|
341
|
+
return;
|
|
342
|
+
|
|
343
|
+
case "If":
|
|
344
|
+
collectArithmeticCommands(node.clause, source, commands);
|
|
345
|
+
collectArithmeticCommands(node.then, source, commands);
|
|
346
|
+
if (node.else) collectArithmeticCommands(node.else, source, commands);
|
|
347
|
+
return;
|
|
348
|
+
|
|
349
|
+
case "While":
|
|
350
|
+
collectArithmeticCommands(node.clause, source, commands);
|
|
351
|
+
collectArithmeticCommands(node.body, source, commands);
|
|
352
|
+
return;
|
|
353
|
+
|
|
354
|
+
case "For":
|
|
355
|
+
collectWord(node.name, source, commands);
|
|
356
|
+
for (const word of node.wordlist) {
|
|
357
|
+
collectWord(word, source, commands);
|
|
358
|
+
}
|
|
359
|
+
collectArithmeticCommands(node.body, source, commands);
|
|
360
|
+
return;
|
|
361
|
+
|
|
362
|
+
case "Select":
|
|
363
|
+
collectWord(node.name, source, commands);
|
|
364
|
+
for (const word of node.wordlist) {
|
|
365
|
+
collectWord(word, source, commands);
|
|
366
|
+
}
|
|
367
|
+
collectArithmeticCommands(node.body, source, commands);
|
|
368
|
+
return;
|
|
369
|
+
|
|
370
|
+
case "Case":
|
|
371
|
+
collectWord(node.word, source, commands);
|
|
372
|
+
for (const item of node.items) {
|
|
373
|
+
collectArithmeticCaseItem(item, source, commands);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
|
|
377
|
+
case "Function":
|
|
378
|
+
collectWord(node.name, source, commands);
|
|
379
|
+
collectArithmeticCommands(node.body, source, commands);
|
|
380
|
+
for (const redirect of node.redirects) {
|
|
381
|
+
collectArithmeticRedirect(redirect, source, commands);
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
|
|
385
|
+
case "Coproc":
|
|
386
|
+
if (node.name) collectWord(node.name, source, commands);
|
|
387
|
+
collectArithmeticCommands(node.body, source, commands);
|
|
388
|
+
for (const redirect of node.redirects) {
|
|
389
|
+
collectArithmeticRedirect(redirect, source, commands);
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
|
|
393
|
+
case "TestCommand":
|
|
394
|
+
collectTestExpression(node.expression, source, commands);
|
|
395
|
+
return;
|
|
396
|
+
|
|
397
|
+
case "ArithmeticFor":
|
|
398
|
+
collectArithmeticExpression(node.initialize, source, commands);
|
|
399
|
+
collectArithmeticExpression(node.test, source, commands);
|
|
400
|
+
collectArithmeticExpression(node.update, source, commands);
|
|
401
|
+
collectArithmeticCommands(node.body, source, commands);
|
|
402
|
+
return;
|
|
403
|
+
|
|
404
|
+
case "ArithmeticCommand":
|
|
405
|
+
collectArithmeticExpression(node.expression, source, commands);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function collectArithmeticRedirect(redirect: Redirect, source: string, commands: CommandRef[]) {
|
|
411
|
+
if (redirect.target) {
|
|
412
|
+
collectWord(redirect.target, source, commands);
|
|
413
|
+
}
|
|
414
|
+
if (redirect.body?.parts) {
|
|
415
|
+
collectWord(redirect.body, source, commands);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function collectArithmeticCaseItem(item: { pattern: Word[]; body: Node }, source: string, commands: CommandRef[]) {
|
|
420
|
+
for (const pattern of item.pattern) {
|
|
421
|
+
collectWord(pattern, source, commands);
|
|
422
|
+
}
|
|
423
|
+
collectArithmeticCommands(item.body, source, commands);
|
|
424
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { Redirect, Word } from "unbash";
|
|
2
|
+
import type { CommandRef } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export const FORMAT_COMMAND_DEFAULT_MAX_LENGTH = 120;
|
|
5
|
+
export const FORMAT_COMMAND_DEFAULT_ARG_MAX_LENGTH = 40;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format an extracted command for display.
|
|
9
|
+
*
|
|
10
|
+
* Re-serializes from AST tokens, preserving original quoting via source slices.
|
|
11
|
+
* The command name is always shown verbatim. If the full command fits, it is
|
|
12
|
+
* shown unchanged. Otherwise, the formatter starts from the full display and
|
|
13
|
+
* shrinks later tokens only as much as needed to fit within maxLength:
|
|
14
|
+
* - Path-like tokens get path-aware middle elision that preserves the tail.
|
|
15
|
+
* - Other tokens are prefix-truncated with "…".
|
|
16
|
+
* - argMaxLength acts as the minimum per-token elision target, not a hard cap
|
|
17
|
+
* when there is still room in the overall maxLength budget.
|
|
18
|
+
* If the total result still exceeds maxLength, it is hard-truncated with "…".
|
|
19
|
+
*/
|
|
20
|
+
export function formatCommand(
|
|
21
|
+
cmd: CommandRef,
|
|
22
|
+
options?: { maxLength?: number; argMaxLength?: number },
|
|
23
|
+
): string {
|
|
24
|
+
const maxLength = options?.maxLength ?? FORMAT_COMMAND_DEFAULT_MAX_LENGTH;
|
|
25
|
+
const argMaxLength = options?.argMaxLength ?? FORMAT_COMMAND_DEFAULT_ARG_MAX_LENGTH;
|
|
26
|
+
|
|
27
|
+
const name = displayWord(cmd.node.name, cmd.source).replace(/\n/g, "↵");
|
|
28
|
+
const tokenSpecs = [
|
|
29
|
+
...cmd.node.suffix.map(arg => {
|
|
30
|
+
const full = displayWord(arg, cmd.source).replace(/\n/g, "↵");
|
|
31
|
+
return makeTokenSpec(full, argMaxLength);
|
|
32
|
+
}),
|
|
33
|
+
...cmd.node.redirects
|
|
34
|
+
.filter(redirect => !isRenderableHeredoc(redirect))
|
|
35
|
+
.map(redirect => {
|
|
36
|
+
const full = renderRedirect(redirect, cmd.source).replace(/\n/g, "↵");
|
|
37
|
+
return makeTokenSpec(full, argMaxLength);
|
|
38
|
+
}),
|
|
39
|
+
...cmd.node.redirects
|
|
40
|
+
.filter(isRenderableHeredoc)
|
|
41
|
+
.map(redirect => {
|
|
42
|
+
const full = renderFullHeredoc(redirect, cmd.source);
|
|
43
|
+
const min = renderElidedHeredoc(redirect, cmd.source, argMaxLength);
|
|
44
|
+
return makeTokenSpec(full, argMaxLength, min);
|
|
45
|
+
}),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const fullDisplay = [name, ...tokenSpecs.map(spec => spec.full)].join(" ");
|
|
49
|
+
if (fullDisplay.length <= maxLength) return fullDisplay;
|
|
50
|
+
|
|
51
|
+
const tokens = tokenSpecs.map(spec => spec.full);
|
|
52
|
+
let overflow = fullDisplay.length - maxLength;
|
|
53
|
+
|
|
54
|
+
for (let i = tokenSpecs.length - 1; i >= 0 && overflow > 0; i--) {
|
|
55
|
+
const spec = tokenSpecs[i]!;
|
|
56
|
+
const current = tokens[i]!;
|
|
57
|
+
const maxShrink = current.length - spec.min.length;
|
|
58
|
+
if (maxShrink <= 0) continue;
|
|
59
|
+
|
|
60
|
+
const nextTargetLength = current.length - Math.min(maxShrink, overflow);
|
|
61
|
+
const shrunk = spec.shrink(nextTargetLength);
|
|
62
|
+
tokens[i] = shrunk;
|
|
63
|
+
overflow -= current.length - shrunk.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let display = [name, ...tokens].join(" ");
|
|
67
|
+
if (display.length > maxLength) {
|
|
68
|
+
display = display.slice(0, maxLength - 1) + "…";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return display;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isRenderableHeredoc(redirect: Redirect): boolean {
|
|
75
|
+
return (redirect.operator === "<<" || redirect.operator === "<<-") && redirect.content != null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderRedirect(redirect: Redirect, source: string): string {
|
|
79
|
+
const prefix = redirectPrefix(redirect);
|
|
80
|
+
const target = redirect.target ? displayWord(redirect.target, source) : "";
|
|
81
|
+
return `${prefix}${target}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderFullHeredoc(redirect: Redirect, source: string): string {
|
|
85
|
+
const prefix = `${redirectPrefix(redirect)}${heredocTargetDisplay(redirect, source)}↵`;
|
|
86
|
+
return prefix + (redirect.content ?? "").replace(/\n/g, "↵") + heredocMarker(redirect, source);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderElidedHeredoc(redirect: Redirect, source: string, argMaxLength: number): string {
|
|
90
|
+
const prefix = `${redirectPrefix(redirect)}${heredocTargetDisplay(redirect, source)}↵`;
|
|
91
|
+
const content = (redirect.content ?? "").replace(/\n/g, "↵");
|
|
92
|
+
const full = content + heredocMarker(redirect, source);
|
|
93
|
+
|
|
94
|
+
if (full.length <= argMaxLength) {
|
|
95
|
+
return prefix + full;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return prefix + content.slice(0, argMaxLength) + "…";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function redirectPrefix(redirect: Redirect): string {
|
|
102
|
+
const fd = redirect.fileDescriptor != null ? String(redirect.fileDescriptor) : "";
|
|
103
|
+
const variableName = redirect.variableName ? `{${redirect.variableName}}` : "";
|
|
104
|
+
return `${fd}${variableName}${redirect.operator}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function heredocTargetDisplay(redirect: Redirect, source: string): string {
|
|
108
|
+
const marker = rawHeredocMarker(redirect, source);
|
|
109
|
+
return redirect.heredocQuoted ? `'${marker}'` : marker;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function heredocMarker(redirect: Redirect, source: string): string {
|
|
113
|
+
return rawHeredocMarker(redirect, source).replaceAll("\\", "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rawHeredocMarker(redirect: Redirect, source: string): string {
|
|
117
|
+
if (!redirect.target) return "";
|
|
118
|
+
const raw = displayWord(redirect.target, source);
|
|
119
|
+
return raw.length > 0 ? raw : (redirect.target.value ?? redirect.target.text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function displayWord(word: Word | undefined, source: string): string {
|
|
123
|
+
if (!word) return "";
|
|
124
|
+
const sliced = source.slice(word.pos, word.end);
|
|
125
|
+
return sliced.length > 0 ? sliced : word.text;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function makeTokenSpec(full: string, argMaxLength: number, min = elideToken(full, argMaxLength)) {
|
|
129
|
+
return {
|
|
130
|
+
full,
|
|
131
|
+
min,
|
|
132
|
+
shrink: (targetLength: number) => shrinkToken(full, targetLength, min),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Elide a single argument token if warranted. */
|
|
137
|
+
function elideToken(token: string, argMaxLength: number): string {
|
|
138
|
+
if (isPathToken(token)) {
|
|
139
|
+
const elided = elidePath(token);
|
|
140
|
+
return elided.length < token.length ? elided : token;
|
|
141
|
+
}
|
|
142
|
+
if (token.length > argMaxLength) {
|
|
143
|
+
return token.slice(0, argMaxLength) + "…";
|
|
144
|
+
}
|
|
145
|
+
return token;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function shrinkToken(token: string, targetLength: number, min: string): string {
|
|
149
|
+
if (token.length <= targetLength) return token;
|
|
150
|
+
if (targetLength <= min.length) return min;
|
|
151
|
+
if (targetLength <= 1) return "…";
|
|
152
|
+
if (isPathToken(token)) return shrinkPathToken(token, targetLength);
|
|
153
|
+
return token.slice(0, targetLength - 1) + "…";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Path-like detection using character composition.
|
|
158
|
+
* A token is considered path-like if:
|
|
159
|
+
* - It contains a slash (required)
|
|
160
|
+
* - It is not a URL (no ://)
|
|
161
|
+
* - After stripping surrounding quotes, the non-space characters are
|
|
162
|
+
* ≥85% path-safe ([a-zA-Z0-9/._~$@%+=,:-]) — handles bare relative
|
|
163
|
+
* paths like packages/tui/src/terminal.ts and quoted paths with $
|
|
164
|
+
* like "$PROJECT_ROOT/src/routes/$page.tsx"
|
|
165
|
+
* - Spaces don't exceed 10% of the inner length (guards against sentences
|
|
166
|
+
* that happen to contain a slash)
|
|
167
|
+
*/
|
|
168
|
+
function isPathToken(token: string): boolean {
|
|
169
|
+
if (!token.includes("/")) return false;
|
|
170
|
+
if (token.includes("://")) return false;
|
|
171
|
+
const inner = token.replace(/^["']|["']$/g, "");
|
|
172
|
+
const spaces = (inner.match(/ /g) ?? []).length;
|
|
173
|
+
if (spaces / inner.length > 0.1) return false;
|
|
174
|
+
const nonSpace = inner.replace(/ /g, "");
|
|
175
|
+
if (nonSpace.length === 0) return false;
|
|
176
|
+
const safe = (nonSpace.match(/[a-zA-Z0-9/._~$@%+=,:-]/g) ?? []).length;
|
|
177
|
+
return safe / nonSpace.length >= 0.85;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Path-aware elision: keep the first two segments and the last.
|
|
182
|
+
* /Users/jdiamond/code/pi-unbash → /Users/…/pi-unbash
|
|
183
|
+
*/
|
|
184
|
+
function elidePath(p: string): string {
|
|
185
|
+
const parts = p.split("/");
|
|
186
|
+
if (parts.length <= 3) return p;
|
|
187
|
+
return parts.slice(0, 2).join("/") + "/…/" + parts[parts.length - 1];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function shrinkPathToken(token: string, targetLength: number): string {
|
|
191
|
+
if (token.length <= targetLength) return token;
|
|
192
|
+
if (targetLength <= 1) return "…";
|
|
193
|
+
|
|
194
|
+
const lastSlash = token.lastIndexOf("/");
|
|
195
|
+
if (lastSlash <= 0) {
|
|
196
|
+
return token.slice(0, targetLength - 1) + "…";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const suffix = token.slice(lastSlash);
|
|
200
|
+
const prefixBudget = targetLength - suffix.length - 1;
|
|
201
|
+
if (prefixBudget <= 0) {
|
|
202
|
+
return token.slice(0, targetLength - 1) + "…";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return token.slice(0, prefixBudget) + "…" + suffix;
|
|
206
|
+
}
|