Commit 12c8c60

Anton Golub <antongolub@antongolub.com>
2025-06-04 10:17:07
feat: accept custom delimiter for `lines()` (#1220)
* feat: accept custom separator for `lines()` closes #1218 * chore: code imrps, add internal `DLMTR` const
1 parent 7138b05
build/core.cjs
@@ -43,6 +43,8 @@ var import_node_fs = __toESM(require("fs"), 1);
 var import_node_util2 = require("util");
 var import_node_os = require("os");
 var import_node_events = require("events");
+var import_node_buffer = require("buffer");
+var import_node_process2 = __toESM(require("process"), 1);
 
 // src/error.ts
 var EXIT_CODES = {
@@ -388,14 +390,13 @@ function formatCmd(cmd) {
 // src/core.ts
 var import_node_path = __toESM(require("path"), 1);
 var os = __toESM(require("os"), 1);
-var import_node_buffer = require("buffer");
-var import_node_process2 = __toESM(require("process"), 1);
 var import_vendor_core3 = require("./vendor-core.cjs");
 var import_util2 = require("./util.cjs");
 var CWD = Symbol("processCwd");
 var SYNC = Symbol("syncExec");
 var EOL = import_node_buffer.Buffer.from(import_node_os.EOL);
 var BR_CC = "\n".charCodeAt(0);
+var DLMTR = /\r?\n/;
 var SIGTERM = "SIGTERM";
 var ENV_PREFIX = "ZX_";
 var ENV_ALLOWED = /* @__PURE__ */ new Set([
@@ -439,7 +440,8 @@ var defaults = resolveDefaults({
   killSignal: SIGTERM,
   timeoutSignal: SIGTERM
 });
-var bound = [];
+var boundCtxs = [];
+var delimiters = [];
 var $ = new Proxy(
   function(pieces, ...args) {
     const snapshot = getStore();
@@ -462,7 +464,7 @@ var $ = new Proxy(
       args
     );
     const sync = snapshot[SYNC];
-    bound.push([cmd, from, snapshot]);
+    boundCtxs.push([cmd, from, snapshot]);
     const process3 = new ProcessPromise(import_util.noop);
     if (!process3.isHalted() || sync) process3.run();
     return sync ? process3.output : process3;
@@ -503,8 +505,8 @@ var _ProcessPromise = class _ProcessPromise extends Promise {
     this._resolve = import_util.noop;
     // Stream-like API
     this.writable = true;
-    if (bound.length) {
-      const [cmd, from, snapshot] = bound.pop();
+    if (boundCtxs.length) {
+      const [cmd, from, snapshot] = boundCtxs.pop();
       this._command = cmd;
       this._from = from;
       this._resolve = resolve;
@@ -745,8 +747,8 @@ var _ProcessPromise = class _ProcessPromise extends Promise {
   text(encoding) {
     return this.then((p) => p.text(encoding));
   }
-  lines() {
-    return this.then((p) => p.lines());
+  lines(delimiter) {
+    return this.then((p) => p.lines(delimiter));
   }
   buffer() {
     return this.then((p) => p.buffer());
@@ -787,13 +789,14 @@ var _ProcessPromise = class _ProcessPromise extends Promise {
   [Symbol.asyncIterator]() {
     return __asyncGenerator(this, null, function* () {
       const memo = [];
+      const dlmtr = this._snapshot.delimiter || $.delimiter || DLMTR;
       for (const chunk of this._zurk.store.stdout) {
-        yield* __yieldStar((0, import_util.getLines)(chunk, memo));
+        yield* __yieldStar((0, import_util.getLines)(chunk, memo, dlmtr));
       }
       try {
         for (var iter = __forAwait(this.stdout[Symbol.asyncIterator] ? this.stdout : import_vendor_core2.VoidStream.from(this.stdout)), more, temp, error; more = !(temp = yield new __await(iter.next())).done; more = false) {
           const chunk = temp.value;
-          yield* __yieldStar((0, import_util.getLines)(chunk, memo));
+          yield* __yieldStar((0, import_util.getLines)(chunk, memo, dlmtr));
         }
       } catch (temp) {
         error = [temp];
@@ -912,7 +915,8 @@ var _ProcessOutput = class _ProcessOutput extends Error {
   text(encoding = "utf8") {
     return encoding === "utf8" ? this.toString() : this.buffer().toString(encoding);
   }
-  lines() {
+  lines(delimiter) {
+    delimiters.push(delimiter);
     return [...this];
   }
   valueOf() {
@@ -920,8 +924,9 @@ var _ProcessOutput = class _ProcessOutput extends Error {
   }
   *[Symbol.iterator]() {
     const memo = [];
+    const dlmtr = delimiters.pop() || this._dto.delimiter || $.delimiter || DLMTR;
     for (const chunk of this._dto.store.stdall) {
-      yield* __yieldStar((0, import_util.getLines)(chunk, memo));
+      yield* __yieldStar((0, import_util.getLines)(chunk, memo, dlmtr));
     }
     if (memo[0]) yield memo[0];
   }
build/core.d.ts
@@ -5,12 +5,12 @@ import { type ChildProcess, type IOType, type StdioOptions, spawn, spawnSync } f
 import { type Encoding } from 'node:crypto';
 import { type Readable, type Writable } from 'node:stream';
 import { inspect } from 'node:util';
+import { Buffer } from 'node:buffer';
 import { type TSpawnStore } from './vendor-core.js';
 import { type Duration, quote } from './util.js';
 import { log } from './log.js';
 export { default as path } from 'node:path';
 export * as os from 'node:os';
-import { Buffer } from 'node:buffer';
 export { log, type LogEntry } from './log.js';
 export { chalk, which, ps } from './vendor-core.js';
 export { quote, quotePowerShell } from './util.js';
@@ -45,6 +45,7 @@ export interface Options {
     kill: typeof kill;
     killSignal?: NodeJS.Signals;
     halt?: boolean;
+    delimiter?: string | RegExp;
 }
 export declare const defaults: Options;
 export interface Shell<S = false, R = S extends true ? ProcessOutput : ProcessPromise> {
@@ -121,7 +122,7 @@ export declare class ProcessPromise extends Promise<ProcessOutput> {
     timeout(d: Duration, signal?: NodeJS.Signals | undefined): ProcessPromise;
     json<T = any>(): Promise<T>;
     text(encoding?: Encoding): Promise<string>;
-    lines(): Promise<string[]>;
+    lines(delimiter?: string | RegExp): Promise<string[]>;
     buffer(): Promise<Buffer>;
     blob(type?: string): Promise<Blob>;
     isQuiet(): boolean;
@@ -149,6 +150,7 @@ type ProcessDto = {
     error: any;
     from: string;
     store: TSpawnStore;
+    delimiter?: string | RegExp;
 };
 export declare class ProcessOutput extends Error {
     private readonly _dto;
@@ -170,7 +172,7 @@ export declare class ProcessOutput extends Error {
     buffer(): Buffer;
     blob(type?: string): Blob;
     text(encoding?: Encoding): string;
-    lines(): string[];
+    lines(delimiter?: string | RegExp): string[];
     valueOf(): string;
     [Symbol.iterator](): Iterator<string>;
     static getExitMessage: (code: number | null, signal: NodeJS.Signals | null, stderr: string, from: string, details?: string) => string;
build/util.cjs
@@ -112,8 +112,8 @@ var proxyOverride = (origin, ...fallbacks) => new Proxy(origin, {
 });
 var toCamelCase = (str) => str.toLowerCase().replace(/([a-z])[_-]+([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase());
 var parseBool = (v) => v === "true" || v !== "false" && v;
-var getLines = (chunk, next) => {
-  const lines = ((next.pop() || "") + bufToString(chunk)).split(/\r?\n/);
+var getLines = (chunk, next, delimiter) => {
+  const lines = ((next.pop() || "") + bufToString(chunk)).split(delimiter);
   next.push(lines.pop());
   return lines;
 };
build/util.d.ts
@@ -26,4 +26,4 @@ export declare const once: <T extends (...args: any[]) => any>(fn: T) => (...arg
 export declare const proxyOverride: <T extends object>(origin: T, ...fallbacks: any) => T;
 export declare const toCamelCase: (str: string) => string;
 export declare const parseBool: (v: string) => boolean | string;
-export declare const getLines: (chunk: Buffer | string, next: (string | undefined)[]) => string[];
+export declare const getLines: (chunk: Buffer | string, next: (string | undefined)[], delimiter: string | RegExp) => string[];
src/core.ts
@@ -26,6 +26,8 @@ import fs from 'node:fs'
 import { inspect } from 'node:util'
 import { EOL as _EOL } from 'node:os'
 import { EventEmitter } from 'node:events'
+import { Buffer } from 'node:buffer'
+import process from 'node:process'
 import {
   findErrors,
   formatErrorMessage,
@@ -61,13 +63,10 @@ import {
   randomId,
   bufArrJoin,
 } from './util.ts'
-
 import { log } from './log.ts'
 
 export { default as path } from 'node:path'
 export * as os from 'node:os'
-import { Buffer } from 'node:buffer'
-import process from 'node:process'
 export { log, type LogEntry } from './log.ts'
 export { chalk, which, ps } from './vendor-core.ts'
 export { quote, quotePowerShell } from './util.ts'
@@ -76,6 +75,7 @@ const CWD = Symbol('processCwd')
 const SYNC = Symbol('syncExec')
 const EOL = Buffer.from(_EOL)
 const BR_CC = '\n'.charCodeAt(0)
+const DLMTR = /\r?\n/
 const SIGTERM = 'SIGTERM'
 const ENV_PREFIX = 'ZX_'
 const ENV_ALLOWED: Set<string> = new Set([
@@ -129,6 +129,7 @@ export interface Options {
   kill:           typeof kill
   killSignal?:    NodeJS.Signals
   halt?:          boolean
+  delimiter?:     string | RegExp
 }
 
 // prettier-ignore
@@ -166,7 +167,8 @@ export interface Shell<
     (opts: Partial<Omit<Options, 'sync'>>): Shell<true>
   }
 }
-const bound: [string, string, Options][] = []
+const boundCtxs: [string, string, Options][] = []
+const delimiters: Array<string | RegExp | undefined> = []
 
 export const $: Shell & Options = new Proxy<Shell & Options>(
   function (pieces: TemplateStringsArray | Partial<Options>, ...args: any) {
@@ -192,7 +194,7 @@ export const $: Shell & Options = new Proxy<Shell & Options>(
       args
     ) as string
     const sync = snapshot[SYNC]
-    bound.push([cmd, from, snapshot])
+    boundCtxs.push([cmd, from, snapshot])
     const process = new ProcessPromise(noop)
 
     if (!process.isHalted() || sync) process.run()
@@ -259,8 +261,8 @@ export class ProcessPromise extends Promise<ProcessOutput> {
       executor(...args)
     })
 
-    if (bound.length) {
-      const [cmd, from, snapshot] = bound.pop()!
+    if (boundCtxs.length) {
+      const [cmd, from, snapshot] = boundCtxs.pop()!
       this._command = cmd
       this._from = from
       this._resolve = resolve!
@@ -571,8 +573,8 @@ export class ProcessPromise extends Promise<ProcessOutput> {
     return this.then((p) => p.text(encoding))
   }
 
-  lines(): Promise<string[]> {
-    return this.then((p) => p.lines())
+  lines(delimiter?: string | RegExp): Promise<string[]> {
+    return this.then((p) => p.lines(delimiter))
   }
 
   buffer(): Promise<Buffer> {
@@ -634,15 +636,16 @@ export class ProcessPromise extends Promise<ProcessOutput> {
   // Async iterator API
   async *[Symbol.asyncIterator](): AsyncIterator<string> {
     const memo: (string | undefined)[] = []
+    const dlmtr = this._snapshot.delimiter || $.delimiter || DLMTR
 
     for (const chunk of this._zurk!.store.stdout) {
-      yield* getLines(chunk, memo)
+      yield* getLines(chunk, memo, dlmtr)
     }
 
     for await (const chunk of this.stdout[Symbol.asyncIterator]
       ? this.stdout
       : VoidStream.from(this.stdout)) {
-      yield* getLines(chunk, memo)
+      yield* getLines(chunk, memo, dlmtr)
     }
 
     if (memo[0]) yield memo[0]
@@ -697,6 +700,7 @@ type ProcessDto = {
   error: any
   from: string
   store: TSpawnStore
+  delimiter?: string | RegExp
 }
 
 export class ProcessOutput extends Error {
@@ -798,7 +802,8 @@ export class ProcessOutput extends Error {
       : this.buffer().toString(encoding)
   }
 
-  lines(): string[] {
+  lines(delimiter?: string | RegExp): string[] {
+    delimiters.push(delimiter)
     return [...this]
   }
 
@@ -808,9 +813,11 @@ export class ProcessOutput extends Error {
 
   *[Symbol.iterator](): Iterator<string> {
     const memo: (string | undefined)[] = []
+    const dlmtr =
+      delimiters.pop() || this._dto.delimiter || $.delimiter || DLMTR
 
     for (const chunk of this._dto.store.stdall) {
-      yield* getLines(chunk, memo)
+      yield* getLines(chunk, memo, dlmtr)
     }
 
     if (memo[0]) yield memo[0]
src/util.ts
@@ -172,9 +172,10 @@ export const parseBool = (v: string): boolean | string =>
 
 export const getLines = (
   chunk: Buffer | string,
-  next: (string | undefined)[]
+  next: (string | undefined)[],
+  delimiter: string | RegExp
 ) => {
-  const lines = ((next.pop() || '') + bufToString(chunk)).split(/\r?\n/)
+  const lines = ((next.pop() || '') + bufToString(chunk)).split(delimiter)
   next.push(lines.pop())
   return lines
 }
test/core.test.js
@@ -1025,7 +1025,6 @@ describe('core', () => {
 
       it('should process all output before handling a non-zero exit code', async () => {
         const process = $`sleep 0.1; echo foo; sleep 0.1; echo bar; sleep 0.1; exit 1;`
-
         const chunks = []
 
         let errorCaught = null
@@ -1050,11 +1049,22 @@ describe('core', () => {
       })
 
       it('handles .nothrow() correctly', async () => {
-        const data = []
+        const lines = []
         for await (const line of $({ nothrow: true })`grep any test`) {
-          data.push(line)
+          lines.push(line)
+        }
+        assert.equal(lines.length, 0, 'Should not yield any lines')
+      })
+
+      it('handles a custom delimiter', async () => {
+        const lines = []
+        for await (const line of $({
+          delimiter: '\0',
+          cwd: tempdir(),
+        })`touch foo bar baz; find ./ -type f -print0 -maxdepth 1`) {
+          lines.push(line)
         }
-        assert.equal(data.length, 0, 'Should not yield any lines')
+        assert.deepEqual(lines.sort(), ['./bar', './baz', './foo'])
       })
     })
 
@@ -1153,6 +1163,15 @@ describe('core', () => {
 
       const p2 = $.sync`echo 'foo\nbar\r\nbaz'`
       assert.deepEqual(p2.lines(), ['foo', 'bar', 'baz'])
+
+      const p3 = $({
+        cwd: await tempdir(),
+      })`touch foo bar baz; find ./ -type f -print0 -maxdepth 1`
+      assert.deepEqual((await p3.lines('\0')).sort(), [
+        './bar',
+        './baz',
+        './foo',
+      ])
     })
 
     test('buffer()', async () => {
@@ -1230,8 +1249,12 @@ describe('core', () => {
     })
 
     test('lines()', async () => {
-      const o = new ProcessOutput(null, null, '', '', 'foo\nbar\r\nbaz\n')
-      assert.deepEqual(o.lines(), ['foo', 'bar', 'baz'])
+      const o1 = new ProcessOutput(null, null, '', '', 'foo\nbar\r\nbaz\n')
+      assert.deepEqual(o1.lines(), ['foo', 'bar', 'baz'])
+
+      const o2 = new ProcessOutput(null, null, '', '', 'foo\0bar\0baz\0')
+      assert.deepEqual(o2.lines(), ['foo\0bar\0baz\0'])
+      assert.deepEqual(o2.lines('\0'), ['foo', 'bar', 'baz'])
     })
 
     test('buffer()', async () => {
.size-limit.json
@@ -17,35 +17,35 @@
       "README.md",
       "LICENSE"
     ],
-    "limit": "121.8 kB",
+    "limit": "122.25 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "js parts",
     "path": "build/*.{js,cjs}",
-    "limit": "816.20 kB",
+    "limit": "816.50 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "libdefs",
     "path": "build/*.d.ts",
-    "limit": "40.2 kB",
+    "limit": "40.26 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "vendor",
     "path": "build/vendor-*",
-    "limit": "769.1 kB",
+    "limit": "769.10 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "all",
     "path": ["build/*", "man/*", "README.md", "LICENSE"],
-    "limit": "872.65 kB",
+    "limit": "873.10 kB",
     "brotli": false,
     "gzip": false
   }