@nimbus-sh/core 0.5.0 → 0.7.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.
Files changed (145) hide show
  1. package/README.md +47 -26
  2. package/dist/_shared/byte-stream.d.ts +5 -4
  3. package/dist/_shared/byte-stream.d.ts.map +1 -1
  4. package/dist/_shared/cache-stats.d.ts +23 -1
  5. package/dist/_shared/cache-stats.d.ts.map +1 -1
  6. package/dist/_shared/cache-stats.js +13 -1
  7. package/dist/_shared/exports-resolver.d.ts +1 -1
  8. package/dist/_shared/exports-resolver.js +1 -1
  9. package/dist/_shared/real-node-imports.d.ts +7 -0
  10. package/dist/_shared/real-node-imports.d.ts.map +1 -1
  11. package/dist/_shared/real-node-imports.js +8 -0
  12. package/dist/_shared/retry.js +1 -1
  13. package/dist/_shared/tarball.d.ts +23 -5
  14. package/dist/_shared/tarball.d.ts.map +1 -1
  15. package/dist/_shared/tarball.js +38 -5
  16. package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
  17. package/dist/_shared/vfs-write-ledger.js +22 -16
  18. package/dist/constants.d.ts +5 -11
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +16 -56
  21. package/dist/runtime/assets-loader.js +1 -1
  22. package/dist/runtime/comment-strip.d.ts +0 -35
  23. package/dist/runtime/comment-strip.d.ts.map +1 -1
  24. package/dist/runtime/comment-strip.js +48 -20
  25. package/dist/runtime/esbuild-service.d.ts +38 -42
  26. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  27. package/dist/runtime/esbuild-service.js +494 -197
  28. package/dist/runtime/javascript-ast.d.ts.map +1 -1
  29. package/dist/runtime/javascript-ast.js +45 -7
  30. package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
  31. package/dist/runtime/opentui-wasm-backend.js +18 -16
  32. package/dist/runtime/port-registry.d.ts +4 -2
  33. package/dist/runtime/port-registry.d.ts.map +1 -1
  34. package/dist/runtime/port-registry.js +60 -8
  35. package/dist/runtime/virtual-socket-kernel.generated.d.ts +1 -1
  36. package/dist/runtime/virtual-socket-kernel.generated.js +1 -1
  37. package/dist/shell/stdin-adapter.d.ts +11 -0
  38. package/dist/shell/stdin-adapter.d.ts.map +1 -0
  39. package/dist/shell/stdin-adapter.js +59 -0
  40. package/dist/shell/unix-commands.d.ts +2 -1
  41. package/dist/shell/unix-commands.d.ts.map +1 -1
  42. package/dist/shell/unix-commands.js +1078 -50
  43. package/dist/substrate/lifo/commands/io/dd.js +1 -1
  44. package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
  45. package/dist/substrate/lifo/commands/net/curl.js +270 -54
  46. package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
  47. package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
  48. package/dist/substrate/lifo/commands/system/npm.js +26 -43
  49. package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
  50. package/dist/substrate/lifo/commands/text/head.js +2 -1
  51. package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
  52. package/dist/substrate/lifo/commands/text/sed.js +499 -117
  53. package/dist/substrate/lifo/commands/types.d.ts +8 -4
  54. package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
  55. package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
  56. package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
  57. package/dist/substrate/lifo/node-compat/zlib.js +122 -23
  58. package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
  59. package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
  60. package/dist/substrate/lifo/shell/interpreter.js +79 -29
  61. package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
  62. package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
  63. package/dist/substrate/lifo/shell/pipe.js +62 -18
  64. package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
  65. package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
  66. package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
  67. package/dist/vfs/seed-project.d.ts.map +1 -1
  68. package/dist/vfs/seed-project.js +1 -1
  69. package/dist/vfs/sqlite-vfs.d.ts +21 -30
  70. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  71. package/dist/vfs/sqlite-vfs.js +113 -29
  72. package/dist/workspace/nimbus-workspace.d.ts +15 -0
  73. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  74. package/dist/workspace/nimbus-workspace.js +20 -3
  75. package/dist/workspace/supervisor-op.d.ts +23 -0
  76. package/dist/workspace/supervisor-op.d.ts.map +1 -0
  77. package/dist/workspace/supervisor-op.js +92 -0
  78. package/package.json +3 -2
  79. package/src/_shared/byte-stream.ts +5 -4
  80. package/src/_shared/cache-stats.ts +27 -1
  81. package/src/_shared/exports-resolver.ts +1 -1
  82. package/src/_shared/real-node-imports.ts +8 -0
  83. package/src/_shared/retry.ts +1 -1
  84. package/src/_shared/tarball.ts +56 -5
  85. package/src/_shared/vfs-write-ledger.ts +22 -16
  86. package/src/constants.ts +30 -60
  87. package/src/runtime/assets-loader.ts +1 -1
  88. package/src/runtime/comment-strip.ts +42 -23
  89. package/src/runtime/esbuild-service.ts +566 -217
  90. package/src/runtime/javascript-ast.ts +40 -9
  91. package/src/runtime/opentui-wasm-backend.ts +19 -17
  92. package/src/runtime/port-registry.ts +66 -8
  93. package/src/runtime/virtual-socket-kernel.generated.ts +1 -1
  94. package/src/shell/stdin-adapter.ts +58 -0
  95. package/src/shell/unix-commands.ts +1054 -50
  96. package/src/substrate/lifo/commands/io/dd.ts +2 -2
  97. package/src/substrate/lifo/commands/net/curl.ts +310 -58
  98. package/src/substrate/lifo/commands/system/npm.ts +58 -38
  99. package/src/substrate/lifo/commands/text/head.ts +2 -1
  100. package/src/substrate/lifo/commands/text/sed.ts +519 -109
  101. package/src/substrate/lifo/commands/types.ts +8 -5
  102. package/src/substrate/lifo/node-compat/zlib.ts +155 -26
  103. package/src/substrate/lifo/shell/interpreter.ts +74 -27
  104. package/src/substrate/lifo/shell/pipe.ts +65 -24
  105. package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
  106. package/src/vfs/seed-project.ts +3 -3
  107. package/src/vfs/sqlite-vfs.ts +128 -64
  108. package/src/workspace/nimbus-workspace.ts +26 -2
  109. package/src/workspace/supervisor-op.ts +117 -0
  110. package/dist/_shared/install-phase.d.ts +0 -47
  111. package/dist/_shared/install-phase.d.ts.map +0 -1
  112. package/dist/_shared/install-phase.js +0 -1
  113. package/dist/_shared/rpc-dispose.d.ts +0 -4
  114. package/dist/_shared/rpc-dispose.d.ts.map +0 -1
  115. package/dist/_shared/rpc-dispose.js +0 -30
  116. package/dist/_shared/w7-frame.d.ts +0 -79
  117. package/dist/_shared/w7-frame.d.ts.map +0 -1
  118. package/dist/_shared/w7-frame.js +0 -840
  119. package/dist/_shared/weighted-credit-pool.d.ts +0 -61
  120. package/dist/_shared/weighted-credit-pool.d.ts.map +0 -1
  121. package/dist/_shared/weighted-credit-pool.js +0 -196
  122. package/dist/observability/diag-counters.d.ts +0 -236
  123. package/dist/observability/diag-counters.d.ts.map +0 -1
  124. package/dist/observability/diag-counters.js +0 -195
  125. package/dist/observability/heap-estimate.d.ts +0 -225
  126. package/dist/observability/heap-estimate.d.ts.map +0 -1
  127. package/dist/observability/heap-estimate.js +0 -185
  128. package/dist/observability/heavy-alloc-coord.d.ts +0 -93
  129. package/dist/observability/heavy-alloc-coord.d.ts.map +0 -1
  130. package/dist/observability/heavy-alloc-coord.js +0 -181
  131. package/dist/observability/oom-classify.d.ts +0 -138
  132. package/dist/observability/oom-classify.d.ts.map +0 -1
  133. package/dist/observability/oom-classify.js +0 -268
  134. package/dist/observability/oom-discriminator.d.ts +0 -176
  135. package/dist/observability/oom-discriminator.d.ts.map +0 -1
  136. package/dist/observability/oom-discriminator.js +0 -272
  137. package/src/_shared/install-phase.ts +0 -56
  138. package/src/_shared/rpc-dispose.ts +0 -31
  139. package/src/_shared/w7-frame.ts +0 -1013
  140. package/src/_shared/weighted-credit-pool.ts +0 -244
  141. package/src/observability/diag-counters.ts +0 -343
  142. package/src/observability/heap-estimate.ts +0 -331
  143. package/src/observability/heavy-alloc-coord.ts +0 -241
  144. package/src/observability/oom-classify.ts +0 -260
  145. package/src/observability/oom-discriminator.ts +0 -405
@@ -22,14 +22,83 @@ export type SedExecutionContext = {
22
22
  stdin?: SedInput;
23
23
  };
24
24
 
25
- interface SedExpr {
25
+ type SedAddress =
26
+ | { kind: 'line'; value: number }
27
+ | { kind: 'last' }
28
+ // An empty `//` address repeats the last regular expression that execution
29
+ // actually evaluated — parse order is irrelevant.
30
+ | { kind: 'empty' }
31
+ | { kind: 'regex'; regex: RegExp };
32
+
33
+ type SedRange = { kind: 'range'; start: SedAddress; end: SedAddress };
34
+
35
+ interface SedCommand {
36
+ address?: SedAddress | SedRange;
37
+ negate: boolean;
26
38
  type: 's' | 'd' | 'p';
27
39
  pattern?: RegExp;
40
+ emptyPattern?: boolean;
28
41
  replacement?: string;
29
42
  global?: boolean;
43
+ insensitive?: boolean;
30
44
  print?: boolean;
31
45
  }
32
46
 
47
+ class SedParseError extends Error {
48
+ constructor(readonly expr: string) {
49
+ super(`invalid expression: ${expr}`);
50
+ }
51
+ }
52
+
53
+ type SedScriptCursor = { expr: string; i: number };
54
+
55
+ type SedRegexRecord = { source?: string; flags?: string };
56
+
57
+ type SedLogicalLine = { text: string; terminated: boolean };
58
+
59
+ type SedEvalContext = {
60
+ line: string;
61
+ lineNumber: number;
62
+ isLast: boolean;
63
+ lastRegex: SedRegexRecord;
64
+ };
65
+
66
+ // GNU keeps already-written output when an empty pattern has nothing to
67
+ // repeat yet; whatever a pass emitted before the failure has already left
68
+ // through its sink.
69
+ class SedRuntimeError extends Error {
70
+ constructor() {
71
+ super('no previous regular expression');
72
+ }
73
+ }
74
+
75
+ type SedLineSource = () => SedLogicalLine | null;
76
+
77
+ // Yields logical lines on demand instead of materializing an array, so a
78
+ // multi-file pass holds only the current file's text plus one lookahead
79
+ // line. A file boundary starts a new logical line either way (GNU manual
80
+ // §6.1); `terminated` records whether the source ended the line itself.
81
+ function* iterateLogicalLines(text: string): Generator<SedLogicalLine> {
82
+ if (text === '') return;
83
+ const finalTerminated = text.endsWith('\n');
84
+ let start = 0;
85
+ for (let nl = text.indexOf('\n'); nl !== -1; nl = text.indexOf('\n', start)) {
86
+ yield { text: text.slice(start, nl), terminated: true };
87
+ start = nl + 1;
88
+ }
89
+ const tail = text.slice(start);
90
+ if (tail !== '' || !finalTerminated) {
91
+ yield { text: tail, terminated: finalTerminated };
92
+ }
93
+ }
94
+
95
+ function pullFrom(lines: Iterator<SedLogicalLine>): SedLineSource {
96
+ return () => {
97
+ const step = lines.next();
98
+ return step.done ? null : step.value;
99
+ };
100
+ }
101
+
33
102
  type SedCommandOptions = {
34
103
  inPlace: boolean;
35
104
  quiet: boolean;
@@ -37,57 +106,226 @@ type SedCommandOptions = {
37
106
  files: string[];
38
107
  };
39
108
 
40
- function parseSedExpr(expr: string): SedExpr | null {
41
- if (expr === 'd') {
42
- return { type: 'd' };
109
+ function parseSedScript(expr: string): SedCommand[] {
110
+ const cur: SedScriptCursor = { expr, i: 0 };
111
+ const commands: SedCommand[] = [];
112
+
113
+ for (;;) {
114
+ skipBlanks(cur);
115
+ const ch = peek(cur);
116
+ if (ch === undefined) break;
117
+ if (ch === ';' || ch === '\n') {
118
+ cur.i++;
119
+ continue;
120
+ }
121
+ if (ch === '#') {
122
+ skipComment(cur);
123
+ continue;
124
+ }
125
+
126
+ commands.push(parseSedCommand(cur));
127
+ }
128
+ return commands;
129
+ }
130
+
131
+ function parseSedCommand(cur: SedScriptCursor): SedCommand {
132
+ let address: SedAddress | SedRange | undefined;
133
+ const start = tryParseSedAddress(cur);
134
+ if (start) {
135
+ skipBlanks(cur);
136
+ if (peek(cur) === ',') {
137
+ cur.i++;
138
+ skipBlanks(cur);
139
+ const end = tryParseSedAddress(cur);
140
+ if (!end) throw new SedParseError(cur.expr);
141
+ address = { kind: 'range', start, end };
142
+ } else {
143
+ address = start;
144
+ }
43
145
  }
44
- if (expr === 'p') {
45
- return { type: 'p' };
146
+
147
+ skipBlanks(cur);
148
+ let negate = false;
149
+ if (peek(cur) === '!') {
150
+ negate = true;
151
+ cur.i++;
152
+ skipBlanks(cur);
46
153
  }
47
- // s/pattern/replacement/flags
48
- if (expr.startsWith('s')) {
49
- const delim = expr[1];
50
- if (!delim) return null;
51
- const parts: string[] = [];
52
- let current = '';
154
+ if (!address && negate) throw new SedParseError(cur.expr);
155
+ if (peek(cur) === '!') throw new SedParseError(cur.expr);
156
+
157
+ const op = peek(cur);
158
+ if (op === 'd') {
159
+ cur.i++;
160
+ ensureCommandEnd(cur);
161
+ return { address, negate, type: 'd' };
162
+ }
163
+ if (op === 'p') {
164
+ cur.i++;
165
+ ensureCommandEnd(cur);
166
+ return { address, negate, type: 'p' };
167
+ }
168
+ if (op === 's') {
169
+ return { address, negate, ...parseSubstitution(cur) };
170
+ }
171
+ throw new SedParseError(cur.expr);
172
+ }
173
+
174
+ function tryParseSedAddress(cur: SedScriptCursor): SedAddress | undefined {
175
+ const ch = peek(cur);
176
+ if (ch === undefined) return undefined;
177
+ if (isDigit(ch)) {
178
+ const start = cur.i;
179
+ while (isDigit(peek(cur))) cur.i++;
180
+ const value = Number(cur.expr.slice(start, cur.i));
181
+ if (value === 0) throw new SedParseError(cur.expr);
182
+ return { kind: 'line', value };
183
+ }
184
+ if (ch === '$') {
185
+ cur.i++;
186
+ return { kind: 'last' };
187
+ }
188
+ if (ch === '/') {
189
+ return parseDelimitedRegex(cur);
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ function parseDelimitedRegex(cur: SedScriptCursor): SedAddress {
195
+ cur.i++;
196
+ let raw = '';
197
+ let closed = false;
198
+ while (cur.i < cur.expr.length) {
199
+ const ch = peek(cur);
200
+ if (ch === '\\') {
201
+ // An escaped newline continues the pattern; everything else is literal.
202
+ const next = cur.expr[cur.i + 1];
203
+ if (next === undefined) break;
204
+ raw += ch + next;
205
+ cur.i += 2;
206
+ continue;
207
+ }
208
+ if (ch === '\n') throw new SedParseError(cur.expr);
209
+ if (ch === '/') {
210
+ cur.i++;
211
+ closed = true;
212
+ break;
213
+ }
214
+ raw += ch;
215
+ cur.i++;
216
+ }
217
+ if (!closed) throw new SedParseError(cur.expr);
218
+
219
+ const source = toJavascriptPattern(raw);
220
+ if (source === '') return { kind: 'empty' };
221
+ try {
222
+ return { kind: 'regex', regex: new RegExp(source) };
223
+ } catch {
224
+ throw new SedParseError(cur.expr);
225
+ }
226
+ }
227
+
228
+ function parseSubstitution(cur: SedScriptCursor): Pick<SedCommand, 'type' | 'pattern' | 'emptyPattern' | 'replacement' | 'global' | 'insensitive' | 'print'> {
229
+ cur.i++;
230
+ const delim = peek(cur);
231
+ if (delim === undefined) throw new SedParseError(cur.expr);
232
+ cur.i++;
233
+
234
+ const readPart = (): string => {
235
+ let part = '';
53
236
  let escaped = false;
54
- for (let i = 2; i < expr.length; i++) {
237
+ while (cur.i < cur.expr.length) {
238
+ const ch = peek(cur);
55
239
  if (escaped) {
56
- current += expr[i];
240
+ part += ch;
57
241
  escaped = false;
58
- } else if (expr[i] === '\\') {
242
+ cur.i++;
243
+ continue;
244
+ }
245
+ if (ch === '\\') {
59
246
  escaped = true;
60
- current += '\\';
61
- } else if (expr[i] === delim) {
62
- parts.push(current);
63
- current = '';
64
- } else {
65
- current += expr[i];
247
+ part += '\\';
248
+ cur.i++;
249
+ continue;
66
250
  }
251
+ if (ch === delim) {
252
+ cur.i++;
253
+ return part;
254
+ }
255
+ part += ch;
256
+ cur.i++;
67
257
  }
68
- parts.push(current); // remaining flags part
69
- if (parts.length < 2) return null;
258
+ throw new SedParseError(cur.expr);
259
+ };
70
260
 
71
- const patternStr = parts[0];
72
- const replacement = parts[1] ?? '';
73
- const flagStr = parts[2] || '';
74
- const globalFlag = flagStr.includes('g');
75
- const caseInsensitive = flagStr.includes('i');
76
- const print = flagStr.includes('p');
261
+ const patternStr = readPart();
262
+ const replacement = readPart();
77
263
 
78
- let regex: RegExp;
79
- try {
80
- let flags = '';
81
- if (globalFlag) flags += 'g';
82
- if (caseInsensitive) flags += 'i';
83
- regex = new RegExp(toJavascriptPattern(patternStr ?? ''), flags);
84
- } catch {
85
- return null;
86
- }
264
+ // GNU's in_nonblank(): blanks may sit between flags; a flag run ends only
265
+ // at `;`, a newline, a comment, or the end of the script.
266
+ let flagStr = '';
267
+ for (;;) {
268
+ skipBlanks(cur);
269
+ const ch = peek(cur);
270
+ if (ch === undefined || ch === ';' || ch === '\n' || ch === '#') break;
271
+ flagStr += ch;
272
+ cur.i++;
273
+ }
274
+
275
+ for (const flag of flagStr) {
276
+ if (flag !== 'g' && flag !== 'i' && flag !== 'p') throw new SedParseError(cur.expr);
277
+ }
87
278
 
88
- return { type: 's', pattern: regex, replacement, global: globalFlag, print };
279
+ const global = flagStr.includes('g');
280
+ const insensitive = flagStr.includes('i');
281
+ if (patternStr === '') {
282
+ // Match modifiers belong to the repeated expression; only action flags
283
+ // may accompany an empty pattern.
284
+ if (insensitive) throw new SedParseError(cur.expr);
285
+ return { type: 's', emptyPattern: true, replacement, global, insensitive, print: flagStr.includes('p') };
286
+ }
287
+ try {
288
+ let flags = '';
289
+ if (global) flags += 'g';
290
+ if (insensitive) flags += 'i';
291
+ const pattern = new RegExp(toJavascriptPattern(patternStr), flags);
292
+ return { type: 's', pattern, replacement, global, insensitive, print: flagStr.includes('p') };
293
+ } catch {
294
+ throw new SedParseError(cur.expr);
89
295
  }
90
- return null;
296
+ }
297
+
298
+ // Blanks may surround an address or an operation, but a command ends only at
299
+ // `;`, a newline, a comment, or the end of the script — never at a following
300
+ // command letter, which GNU sed reports as extra characters.
301
+ function ensureCommandEnd(cur: SedScriptCursor): void {
302
+ skipBlanks(cur);
303
+ const ch = peek(cur);
304
+ if (ch === undefined || ch === ';' || ch === '\n' || ch === '#') return;
305
+ throw new SedParseError(cur.expr);
306
+ }
307
+
308
+ function skipComment(cur: SedScriptCursor): void {
309
+ const nl = cur.expr.indexOf('\n', cur.i);
310
+ cur.i = nl === -1 ? cur.expr.length : nl + 1;
311
+ }
312
+
313
+ function skipBlanks(cur: SedScriptCursor): void {
314
+ while (isBlank(peek(cur))) cur.i++;
315
+ }
316
+
317
+ function peek(cur: SedScriptCursor): string | undefined {
318
+ return cur.expr[cur.i];
319
+ }
320
+
321
+ function isBlank(value: string | undefined): boolean {
322
+ return value === ' ' || value === '\t';
323
+ }
324
+
325
+ function isDigit(value: string | undefined): boolean {
326
+ if (value === undefined) return false;
327
+ const code = value.charCodeAt(0);
328
+ return code >= 48 && code <= 57;
91
329
  }
92
330
 
93
331
  function toJavascriptPattern(pattern: string): string {
@@ -95,7 +333,7 @@ function toJavascriptPattern(pattern: string): string {
95
333
  for (let i = 0; i < pattern.length; i++) {
96
334
  const char = pattern[i];
97
335
  const next = pattern[i + 1];
98
- if (char === '\\' && (next === '(' || next === ')')) {
336
+ if (char === '\\' && (next === '(' || next === ')' || next === '/')) {
99
337
  result += next;
100
338
  i++;
101
339
  continue;
@@ -105,9 +343,101 @@ function toJavascriptPattern(pattern: string): string {
105
343
  return result;
106
344
  }
107
345
 
108
- function isDigit(value: string): boolean {
109
- const code = value.charCodeAt(0);
110
- return code >= 48 && code <= 57;
346
+ // One pass over a logical-line stream: per-command range state, cycle
347
+ // numbering, and GNU footnote-8 newline handling live here. A non-in-place
348
+ // run shares one pass across all files and hands every emitted chunk
349
+ // straight to its sink; an in-place run buffers a single file's chunks so
350
+ // its rewrite stays all-or-nothing. The previous-regex record is passed in
351
+ // so it spans passes.
352
+ class SedPass {
353
+ private readonly entries: { command: SedCommand; range: { open: boolean } }[];
354
+ private readonly evalCtx: SedEvalContext;
355
+ private pendingNewline = false;
356
+
357
+ constructor(
358
+ commands: readonly SedCommand[],
359
+ private readonly quiet: boolean,
360
+ private readonly out: (chunk: string) => void,
361
+ lastRegex: SedRegexRecord,
362
+ ) {
363
+ this.entries = commands.map((command) => ({ command, range: { open: false } }));
364
+ this.evalCtx = { line: '', lineNumber: 0, isLast: false, lastRegex };
365
+ }
366
+
367
+ // Runs one cycle per line with a single lookahead, so `$` still selects
368
+ // the true last line while later input is not read yet.
369
+ runAll(next: SedLineSource): void {
370
+ let current = next();
371
+ let lookahead = next();
372
+ let lineNumber = 0;
373
+ while (current !== null) {
374
+ this.runCycle(current.text, current.terminated, ++lineNumber, lookahead === null);
375
+ current = lookahead;
376
+ lookahead = next();
377
+ }
378
+ }
379
+
380
+ private runCycle(text: string, terminated: boolean, lineNumber: number, isLast: boolean): void {
381
+ const ctx = this.evalCtx;
382
+ ctx.lineNumber = lineNumber;
383
+ ctx.isLast = isLast;
384
+ let line = text;
385
+ let deleted = false;
386
+
387
+ for (const entry of this.entries) {
388
+ // Later addresses see the pattern space as earlier commands left it.
389
+ ctx.line = line;
390
+ if (!selects(entry.command, entry.range, ctx)) continue;
391
+ const command = entry.command;
392
+ if (command.type === 'p') {
393
+ this.emit(line, terminated);
394
+ } else if (command.type === 'd') {
395
+ deleted = true;
396
+ break;
397
+ } else if (command.type === 's' && command.replacement !== undefined) {
398
+ let regex: RegExp | undefined = command.pattern;
399
+ if (command.emptyPattern) {
400
+ if (!ctx.lastRegex.source) throw new SedRuntimeError();
401
+ // Match flags come from the repeated expression; only action
402
+ // flags such as g come from this substitution.
403
+ let flags = ctx.lastRegex.flags ?? '';
404
+ if (command.global && !flags.includes('g')) flags += 'g';
405
+ regex = new RegExp(ctx.lastRegex.source, flags);
406
+ }
407
+ if (!regex) continue;
408
+ let changed = false;
409
+ regex.lastIndex = 0;
410
+ line = line.replace(regex, (...match) => {
411
+ changed = true;
412
+ return expandReplacement(command.replacement ?? '', match.slice(1, -2), String(match[0]));
413
+ });
414
+ if (!command.emptyPattern) {
415
+ ctx.lastRegex.source = regex.source;
416
+ // Only match modifiers repeat with an empty pattern; g is an
417
+ // action flag of the substitution that used the expression.
418
+ ctx.lastRegex.flags = command.insensitive ? 'i' : '';
419
+ }
420
+ if (changed && command.print) this.emit(line, terminated);
421
+ }
422
+ }
423
+
424
+ if (!deleted && !this.quiet) this.emit(line, terminated);
425
+ }
426
+
427
+ private emit(text: string, terminated: boolean): void {
428
+ if (!terminated) {
429
+ // Footnote 8: the missing newline waits until more output follows.
430
+ if (this.pendingNewline) this.out('\n');
431
+ this.out(text);
432
+ this.pendingNewline = true;
433
+ return;
434
+ }
435
+ if (this.pendingNewline) {
436
+ this.out('\n');
437
+ this.pendingNewline = false;
438
+ }
439
+ this.out(`${text}\n`);
440
+ }
111
441
  }
112
442
 
113
443
  export async function runSed(ctx: SedExecutionContext): Promise<number> {
@@ -118,86 +448,166 @@ export async function runSed(ctx: SedExecutionContext): Promise<number> {
118
448
  return 1;
119
449
  }
120
450
 
121
- const parsedExprs: SedExpr[] = [];
451
+ // Execution state: the last regular expression any address or substitution
452
+ // actually evaluated, shared by `//` and empty s patterns across -e chunks
453
+ // and files.
454
+ const lastRegex: SedRegexRecord = {};
455
+ const commands: SedCommand[] = [];
122
456
  for (const expr of options.expressions) {
123
- const parsed = parseSedExpr(expr);
124
- if (!parsed) {
125
- ctx.stderr.write(`sed: invalid expression: ${expr}\n`);
126
- return 1;
457
+ try {
458
+ commands.push(...parseSedScript(expr));
459
+ } catch (e) {
460
+ if (e instanceof SedParseError) {
461
+ ctx.stderr.write(`sed: invalid expression: ${expr}\n`);
462
+ return 1;
463
+ }
464
+ throw e;
127
465
  }
128
- parsedExprs.push(parsed);
129
- }
130
-
131
- function processText(text: string): string {
132
- const lines = text.replace(/\n$/, '').split('\n');
133
- const output: string[] = [];
134
-
135
- for (let line of lines) {
136
- let deleted = false;
137
- for (const expr of parsedExprs) {
138
- if (expr.type === 's' && expr.pattern && expr.replacement !== undefined) {
139
- let changed = false;
140
- expr.pattern.lastIndex = 0;
141
- line = line.replace(expr.pattern, (...match) => {
142
- changed = true;
143
- return expandReplacement(expr.replacement ?? '', match.slice(1, -2), String(match[0]));
144
- });
145
- if (changed && expr.print) {
146
- output.push(line);
466
+ }
467
+
468
+ const streamOut = (chunk: string): void => ctx.stdout.write(chunk);
469
+
470
+ try {
471
+ if (options.files.length === 0) {
472
+ if (ctx.stdin) {
473
+ const lines = iterateLogicalLines(await ctx.stdin.readAll());
474
+ new SedPass(commands, options.quiet, streamOut, lastRegex).runAll(pullFrom(lines));
475
+ } else {
476
+ ctx.stderr.write('sed: missing file operand\n');
477
+ return 1;
478
+ }
479
+ return 0;
480
+ }
481
+
482
+ if (options.inPlace) {
483
+ let exitCode = 0;
484
+ for (const file of options.files) {
485
+ const path = resolve(ctx.cwd, file);
486
+ try {
487
+ ctx.vfs.stat(path);
488
+ if (isBinaryMime(getMimeType(path))) {
489
+ ctx.stderr.write(`sed: ${file}: binary file, skipping\n`);
490
+ continue;
491
+ }
492
+ const content = ctx.vfs.readFileString(path);
493
+ // One buffered pass per file: nothing touches the file until its
494
+ // whole result exists, keeping the rewrite all-or-nothing.
495
+ const chunks: string[] = [];
496
+ new SedPass(commands, options.quiet, (chunk) => chunks.push(chunk), lastRegex)
497
+ .runAll(pullFrom(iterateLogicalLines(content)));
498
+ ctx.vfs.writeFile(path, chunks.join(''));
499
+ } catch (e) {
500
+ if (e instanceof VFSError) {
501
+ ctx.stderr.write(`sed: ${file}: ${e.message}\n`);
502
+ exitCode = 1;
503
+ } else {
504
+ throw e;
147
505
  }
148
- } else if (expr.type === 'd') {
149
- deleted = true;
150
- break;
151
- } else if (expr.type === 'p') {
152
- output.push(line);
153
506
  }
154
507
  }
155
- if (!deleted && !options.quiet) {
156
- output.push(line);
157
- }
508
+ return exitCode;
158
509
  }
159
510
 
160
- return output.join('\n') + '\n';
161
- }
511
+ // All readable files feed one shared pass, so global line numbers, range
512
+ // state, `$` and the previous-regex record span files. Each file opens
513
+ // only after the previous one is fully consumed — at most one file's text
514
+ // is ever held — and output leaves as each line is decided.
515
+ let exitCode = 0;
516
+ let index = 0;
517
+ let lines: Iterator<SedLogicalLine> | null = null;
518
+ const nextLine = (): SedLogicalLine | null => {
519
+ for (;;) {
520
+ if (lines !== null) {
521
+ const step = lines.next();
522
+ if (!step.done) return step.value;
523
+ lines = null; // release this file before any later file is read
524
+ }
525
+ if (index >= options.files.length) return null;
526
+ const file = options.files[index++];
527
+ const path = resolve(ctx.cwd, file);
528
+ try {
529
+ ctx.vfs.stat(path);
530
+ if (isBinaryMime(getMimeType(path))) {
531
+ ctx.stderr.write(`sed: ${file}: binary file, skipping\n`);
532
+ continue;
533
+ }
534
+ lines = iterateLogicalLines(ctx.vfs.readFileString(path));
535
+ } catch (e) {
536
+ if (e instanceof VFSError) {
537
+ ctx.stderr.write(`sed: ${file}: ${e.message}\n`);
538
+ exitCode = 1;
539
+ } else {
540
+ throw e;
541
+ }
542
+ }
543
+ }
544
+ };
545
+ new SedPass(commands, options.quiet, streamOut, lastRegex).runAll(nextLine);
162
546
 
163
- if (options.files.length === 0) {
164
- if (ctx.stdin) {
165
- const text = await ctx.stdin.readAll();
166
- ctx.stdout.write(processText(text));
167
- } else {
168
- ctx.stderr.write('sed: missing file operand\n');
547
+ return exitCode;
548
+ } catch (e) {
549
+ if (e instanceof SedRuntimeError) {
550
+ // Output emitted before the failing cycle already reached stdout; an
551
+ // in-place run wrote no file for the aborted pass.
552
+ ctx.stderr.write(`sed: ${e.message}\n`);
169
553
  return 1;
170
554
  }
171
- return 0;
555
+ throw e;
172
556
  }
557
+ }
173
558
 
174
- let exitCode = 0;
175
- for (const file of options.files) {
176
- const path = resolve(ctx.cwd, file);
177
- try {
178
- ctx.vfs.stat(path);
179
- if (isBinaryMime(getMimeType(path))) {
180
- ctx.stderr.write(`sed: ${file}: binary file, skipping\n`);
181
- continue;
182
- }
183
- const content = ctx.vfs.readFileString(path);
184
- const result = processText(content);
185
- if (options.inPlace) {
186
- ctx.vfs.writeFile(path, result);
187
- } else {
188
- ctx.stdout.write(result);
189
- }
190
- } catch (e) {
191
- if (e instanceof VFSError) {
192
- ctx.stderr.write(`sed: ${file}: ${e.message}\n`);
193
- exitCode = 1;
194
- } else {
195
- throw e;
196
- }
559
+ function selects(command: SedCommand, range: { open: boolean }, ctx: SedEvalContext): boolean {
560
+ const address = command.address;
561
+ let selected: boolean;
562
+ if (!address) {
563
+ selected = true;
564
+ } else if (address.kind === 'range') {
565
+ selected = rangeSelects(address, range, ctx);
566
+ } else {
567
+ selected = addressSelects(address, ctx);
568
+ }
569
+ return command.negate ? !selected : selected;
570
+ }
571
+
572
+ function addressSelects(address: SedAddress, ctx: SedEvalContext): boolean {
573
+ switch (address.kind) {
574
+ case 'line':
575
+ return ctx.lineNumber === address.value;
576
+ case 'last':
577
+ return ctx.isLast;
578
+ case 'empty': {
579
+ // `//` reads the record without writing it; nothing may establish one.
580
+ if (!ctx.lastRegex.source) throw new SedRuntimeError();
581
+ return new RegExp(ctx.lastRegex.source, ctx.lastRegex.flags ?? '').test(ctx.line);
582
+ }
583
+ case 'regex': {
584
+ const matched = address.regex.test(ctx.line);
585
+ ctx.lastRegex.source = address.regex.source;
586
+ ctx.lastRegex.flags = address.regex.flags;
587
+ return matched;
197
588
  }
198
589
  }
590
+ }
199
591
 
200
- return exitCode;
592
+ function rangeSelects(
593
+ range: SedRange,
594
+ state: { open: boolean },
595
+ ctx: SedEvalContext,
596
+ ): boolean {
597
+ if (!state.open) {
598
+ if (!addressSelects(range.start, ctx)) return false;
599
+ // A numeric end at or before the start line leaves the range one line wide.
600
+ state.open = !(range.end.kind === 'line' && range.end.value <= ctx.lineNumber);
601
+ return true;
602
+ }
603
+ // A regular-expression end is first tested on the line after the range opens.
604
+ const closes = range.end.kind === 'line'
605
+ ? ctx.lineNumber >= range.end.value
606
+ : range.end.kind === 'last'
607
+ ? ctx.isLast
608
+ : addressSelects(range.end, ctx);
609
+ if (closes) state.open = false;
610
+ return true;
201
611
  }
202
612
 
203
613
  function parseSedArgs(args: string[]): SedCommandOptions {