Commit 1fc9d0b

Anton Golub <antongolub@antongolub.com>
2025-02-17 20:59:24
feat: make `ProcessOutput` iterable (#1101)
* feat: make `ProcessOutput` iterable closes #1053 closes #1027 superseds #1088 relates #984 * refactor: reuse iterators logic
1 parent ea5f5c0
src/core.ts
@@ -47,8 +47,8 @@ import {
   log,
   isString,
   isStringLiteral,
-  bufToString,
   getLast,
+  getLines,
   noop,
   once,
   parseBool,
@@ -601,26 +601,19 @@ export class ProcessPromise extends Promise<ProcessOutput> {
 
   // Async iterator API
   async *[Symbol.asyncIterator](): AsyncIterator<string> {
-    let last: string | undefined
-    const getLines = (chunk: Buffer | string) => {
-      const lines = ((last || '') + bufToString(chunk)).split('\n')
-      last = lines.pop()
-      return lines
-    }
+    const memo: (string | undefined)[] = []
 
     for (const chunk of this._zurk!.store.stdout) {
-      const lines = getLines(chunk)
-      for (const line of lines) yield line
+      yield* getLines(chunk, memo)
     }
 
     for await (const chunk of this.stdout[Symbol.asyncIterator]
       ? this.stdout
       : VoidStream.from(this.stdout)) {
-      const lines = getLines(chunk)
-      for (const line of lines) yield line
+      yield* getLines(chunk, memo)
     }
 
-    if (last) yield last
+    if (memo[0]) yield memo[0]
 
     if ((await this.exitCode) !== 0) throw this._output
   }
@@ -758,13 +751,23 @@ export class ProcessOutput extends Error {
   }
 
   lines(): string[] {
-    return this.valueOf().split(/\r?\n/)
+    return [...this]
   }
 
   valueOf(): string {
     return this.stdall.trim()
   }
 
+  *[Symbol.iterator](): Iterator<string> {
+    const memo: (string | undefined)[] = []
+
+    for (const chunk of this._dto.store.stdall) {
+      yield* getLines(chunk, memo)
+    }
+
+    if (memo[0]) yield memo[0]
+  }
+
   static getExitMessage = formatExitMessage
 
   static getErrorMessage = formatErrorMessage;
src/util.ts
@@ -383,3 +383,12 @@ export const toCamelCase = (str: string) =>
 
 export const parseBool = (v: string): boolean | string =>
   ({ true: true, false: false })[v] ?? v
+
+export const getLines = (
+  chunk: Buffer | string,
+  next: (string | undefined)[]
+) => {
+  const lines = ((next.pop() || '') + bufToString(chunk)).split(/\r?\n/)
+  next.push(lines.pop())
+  return lines
+}
test/smoke/node.test.mjs
@@ -19,6 +19,7 @@ import 'zx/globals'
   {
     const p = await $`echo foo`
     assert.match(p.stdout, /foo/)
+    assert.deepEqual(p.lines(), ['foo'])
   }
 
   // captures err stack
test/core.test.js
@@ -1162,6 +1162,21 @@ describe('core', () => {
       globalThis.Blob = Blob
     })
 
+    test('[Symbol.Iterator]', () => {
+      const o = new ProcessOutput({
+        store: {
+          stdall: ['foo\nba', 'r\nbaz'],
+        },
+      })
+      const lines = []
+      const expected = ['foo', 'bar', 'baz']
+      for (const line of o) {
+        lines.push(line)
+      }
+      assert.deepEqual(lines, expected)
+      assert.deepEqual(o.lines(), expected)
+    })
+
     describe('static', () => {
       test('getExitMessage()', () => {
         assert.match(
.size-limit.json
@@ -2,7 +2,7 @@
   {
     "name": "zx/core",
     "path": ["build/core.cjs", "build/util.cjs", "build/vendor-core.cjs"],
-    "limit": "77 kB",
+    "limit": "77.5 kB",
     "brotli": false,
     "gzip": false
   },
@@ -30,7 +30,7 @@
   {
     "name": "all",
     "path": "build/*",
-    "limit": "849 kB",
+    "limit": "850 kB",
     "brotli": false,
     "gzip": false
   }