Commit 85c260c

Anton Golub <antongolub@antongolub.com>
2025-03-10 10:16:56
feat(log): enhance cmd highlighter and make it configurable (#1122)
closes #1119 * fix: enhance cmd highlighter * test: add more `formatCmd` cases * feat: make `log.formatCmd` configurable * refactor: separate log module * test: provide tests for `log()` * docs: describe logger options
1 parent 10a0eef
docs/configuration.md
@@ -84,7 +84,7 @@ all `$` processes use `process.cwd()` by default (same as `spawn` behavior).
 
 ## `$.log`
 
-Specifies a [logging function](src/core.ts).
+Specifies a [logging function](src/log.ts).
 
 ```ts
 import {LogEntry, log} from 'zx/core'
@@ -101,6 +101,19 @@ $.log = (entry: LogEntry) => {
 }
 ```
 
+Log mostly acts like a debugger, so by default it uses `process.error` for output.
+Set `log.output` to change the stream.
+
+```ts
+$.log.output = process.stdout
+```
+
+Set `log.formatCmd` to customize the command highlighter:
+
+```ts
+$.log.formatCmd = (cmd: string) => chalk.bgRedBright.black(cmd)
+```
+
 ## `$.timeout`
 
 Specifies a timeout for the command execution.
src/core.ts
@@ -44,7 +44,6 @@ import {
 } from './vendor-core.ts'
 import {
   type Duration,
-  log,
   isString,
   isStringLiteral,
   getLast,
@@ -62,7 +61,9 @@ import {
   bufArrJoin,
 } from './util.ts'
 
-export { log, type LogEntry } from './util.ts'
+import { log } from './log.ts'
+
+export { log, type LogEntry } from './log.ts'
 
 const CWD = Symbol('processCwd')
 const SYNC = Symbol('syncExec')
src/log.ts
@@ -0,0 +1,187 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { chalk, type RequestInfo, type RequestInit } from './vendor-core.ts'
+import { inspect } from 'node:util'
+
+export type LogEntry = {
+  verbose?: boolean
+} & (
+  | {
+      kind: 'cmd'
+      cmd: string
+      id: string
+    }
+  | {
+      kind: 'stdout' | 'stderr'
+      data: Buffer
+      id: string
+    }
+  | {
+      kind: 'end'
+      exitCode: number | null
+      signal: NodeJS.Signals | null
+      duration: number
+      error: null | Error
+      id: string
+    }
+  | {
+      kind: 'cd'
+      dir: string
+    }
+  | {
+      kind: 'fetch'
+      url: RequestInfo
+      init?: RequestInit
+    }
+  | {
+      kind: 'retry'
+      attempt: number
+      total: number
+      delay: number
+      exception: unknown
+      error?: string
+    }
+  | {
+      kind: 'custom'
+      data: any
+    }
+)
+
+type LogFormatter = (cmd?: string) => string
+type Log = {
+  (entry: LogEntry): void
+  formatCmd?: LogFormatter
+  output?: NodeJS.WriteStream
+}
+export const log: Log = function (entry) {
+  if (!entry.verbose) return
+  const stream = log.output || process.stderr
+  switch (entry.kind) {
+    case 'cmd':
+      stream.write((log.formatCmd || formatCmd)(entry.cmd))
+      break
+    case 'stdout':
+    case 'stderr':
+    case 'custom':
+      stream.write(entry.data)
+      break
+    case 'cd':
+      stream.write('$ ' + chalk.greenBright('cd') + ` ${entry.dir}\n`)
+      break
+    case 'fetch':
+      const init = entry.init ? ' ' + inspect(entry.init) : ''
+      stream.write('$ ' + chalk.greenBright('fetch') + ` ${entry.url}${init}\n`)
+      break
+    case 'retry':
+      stream.write(
+        chalk.bgRed.white(' FAIL ') +
+          ` Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}` +
+          (entry.delay > 0 ? `; next in ${entry.delay}ms` : '') +
+          '\n'
+      )
+  }
+}
+
+const SYNTAX = '()[]{}<>;:+|&='
+const CMD_BREAK = new Set(['|', '&', ';', '>', '<'])
+const SPACE_RE = /\s/
+const RESERVED_WORDS = new Set([
+  'if',
+  'then',
+  'else',
+  'elif',
+  'fi',
+  'case',
+  'esac',
+  'for',
+  'select',
+  'while',
+  'until',
+  'do',
+  'done',
+  'in',
+  'EOF',
+])
+
+export const formatCmd: LogFormatter = function (cmd) {
+  if (cmd == undefined) return chalk.grey('undefined')
+  let q = ''
+  let out = '$ '
+  let buf = ''
+  let mode: 'syntax' | 'quote' | 'dollar' | '' = ''
+  let pos = 0
+  const cap = () => {
+    const word = buf.trim()
+    if (word) {
+      pos++
+      if (mode === 'syntax') {
+        if (CMD_BREAK.has(word)) {
+          pos = 0
+        }
+        out += chalk.red(buf)
+      } else if (mode === 'quote' || mode === 'dollar') {
+        out += chalk.yellowBright(buf)
+      } else if (RESERVED_WORDS.has(word)) {
+        out += chalk.cyanBright(buf)
+      } else if (pos === 1) {
+        out += chalk.greenBright(buf)
+        pos = Infinity
+      } else {
+        out += buf
+      }
+    } else {
+      out += buf
+    }
+    mode = ''
+    buf = ''
+  }
+
+  for (const c of [...cmd]) {
+    if (!q) {
+      if (c === '$') {
+        cap()
+        mode = 'dollar'
+        buf += c
+        cap()
+      } else if (c === "'" || c === '"') {
+        cap()
+        mode = 'quote'
+        q = c
+        buf += c
+      } else if (SPACE_RE.test(c)) {
+        cap()
+        buf += c
+      } else if (SYNTAX.includes(c)) {
+        const isEnv = c === '=' && pos === 0
+        isEnv && (pos = 1)
+        cap()
+        mode = 'syntax'
+        buf += c
+        cap()
+        isEnv && (pos = -1)
+      } else {
+        buf += c
+      }
+    } else {
+      buf += c
+      if (c === q) {
+        cap()
+        q = ''
+      }
+    }
+  }
+  cap()
+  return out.replaceAll('\n', chalk.reset('\n> ')) + '\n'
+}
src/util.ts
@@ -15,13 +15,7 @@
 import os from 'node:os'
 import path from 'node:path'
 import fs, { type Mode } from 'node:fs'
-import {
-  chalk,
-  type RequestInfo,
-  type RequestInit,
-  type TSpawnStoreChunks,
-} from './vendor-core.ts'
-import { inspect } from 'node:util'
+import { type TSpawnStoreChunks } from './vendor-core.ts'
 
 export { isStringLiteral } from './vendor-core.ts'
 
@@ -162,197 +156,6 @@ export function parseDuration(d: Duration) {
   throw new Error(`Unknown duration: "${d}".`)
 }
 
-export type LogEntry = {
-  verbose?: boolean
-} & (
-  | {
-      kind: 'cmd'
-      cmd: string
-      id: string
-    }
-  | {
-      kind: 'stdout' | 'stderr'
-      data: Buffer
-      id: string
-    }
-  | {
-      kind: 'end'
-      exitCode: number | null
-      signal: NodeJS.Signals | null
-      duration: number
-      error: null | Error
-      id: string
-    }
-  | {
-      kind: 'cd'
-      dir: string
-    }
-  | {
-      kind: 'fetch'
-      url: RequestInfo
-      init?: RequestInit
-    }
-  | {
-      kind: 'retry'
-      attempt: number
-      total: number
-      delay: number
-      exception: unknown
-      error?: string
-    }
-  | {
-      kind: 'custom'
-      data: any
-    }
-)
-
-export function log(entry: LogEntry) {
-  if (!entry.verbose) return
-  const stream = process.stderr
-  switch (entry.kind) {
-    case 'cmd':
-      stream.write(formatCmd(entry.cmd))
-      break
-    case 'stdout':
-    case 'stderr':
-    case 'custom':
-      stream.write(entry.data)
-      break
-    case 'cd':
-      stream.write('$ ' + chalk.greenBright('cd') + ` ${entry.dir}\n`)
-      break
-    case 'fetch':
-      const init = entry.init ? ' ' + inspect(entry.init) : ''
-      stream.write('$ ' + chalk.greenBright('fetch') + ` ${entry.url}${init}\n`)
-      break
-    case 'retry':
-      stream.write(
-        chalk.bgRed.white(' FAIL ') +
-          ` Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}` +
-          (entry.delay > 0 ? `; next in ${entry.delay}ms` : '') +
-          '\n'
-      )
-  }
-}
-
-export function formatCmd(cmd?: string): string {
-  if (cmd == undefined) return chalk.grey('undefined')
-  const chars = [...cmd]
-  let out = '$ '
-  let buf = ''
-  let ch: string
-  type State = (() => State) | undefined
-  let state: State = root
-  let wordCount = 0
-  while (state) {
-    ch = chars.shift() || 'EOF'
-    if (ch == '\n') {
-      out += style(state, buf) + '\n> '
-      buf = ''
-      continue
-    }
-    const next: State = ch === 'EOF' ? undefined : state()
-    if (next !== state) {
-      out += style(state, buf)
-      buf = ''
-    }
-    state = next === root ? next() : next
-    buf += ch
-  }
-
-  function style(state: State, s: string): string {
-    if (s === '') return ''
-    if (RESERVED_WORDS.has(s)) {
-      return chalk.cyanBright(s)
-    }
-    if (state == word && wordCount == 0) {
-      wordCount++
-      return chalk.greenBright(s)
-    }
-    if (state == syntax) {
-      wordCount = 0
-      return chalk.cyanBright(s)
-    }
-    if (state == dollar) return chalk.yellowBright(s)
-    if (state?.name.startsWith('str')) return chalk.yellowBright(s)
-    return s
-  }
-
-  function isSyntax(ch: string) {
-    return '()[]{}<>;:+|&='.includes(ch)
-  }
-
-  function root() {
-    if (/\s/.test(ch)) return space
-    if (isSyntax(ch)) return syntax
-    if (ch === '$') return dollar
-    if (ch === '"') return strDouble
-    if (ch === "'") return strSingle
-    return word
-  }
-
-  function space() {
-    return /\s/.test(ch) ? space : root
-  }
-
-  function word() {
-    return /[\w/.]/i.test(ch) ? word : root
-  }
-
-  function syntax() {
-    return isSyntax(ch) ? syntax : root
-  }
-
-  function dollar() {
-    return ch === "'" ? str : root
-  }
-
-  function str() {
-    if (ch === "'") return strEnd
-    if (ch === '\\') return strBackslash
-    return str
-  }
-
-  function strBackslash() {
-    return strEscape
-  }
-
-  function strEscape() {
-    return str
-  }
-
-  function strDouble() {
-    return ch === '"' ? strEnd : strDouble
-  }
-
-  function strSingle() {
-    return ch === "'" ? strEnd : strSingle
-  }
-
-  function strEnd() {
-    return root
-  }
-
-  return out + '\n'
-}
-
-const RESERVED_WORDS = new Set([
-  'if',
-  'then',
-  'else',
-  'elif',
-  'fi',
-  'case',
-  'esac',
-  'for',
-  'select',
-  'while',
-  'until',
-  'do',
-  'done',
-  'in',
-])
-
 export const once = <T extends (...args: any[]) => any>(fn: T) => {
   let called = false
   let result: ReturnType<T>
test/all.test.js
@@ -20,6 +20,7 @@ import './export.test.js'
 import './global.test.js'
 import './goods.test.js'
 import './index.test.js'
+import './log.test.ts'
 import './md.test.ts'
 import './package.test.js'
 import './util.test.js'
test/log.test.ts
@@ -0,0 +1,154 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import assert from 'node:assert'
+import { test, describe, beforeEach, before, after } from 'node:test'
+import { formatCmd, log } from '../src/log.ts'
+
+describe('log', () => {
+  describe('log()', () => {
+    const data = []
+    const stream = {
+      write(s: string) {
+        data.push(s)
+      },
+    } as NodeJS.WriteStream
+
+    before(() => (log.output = stream))
+
+    after(() => delete log.output)
+
+    beforeEach(() => (data.length = 0))
+
+    test('cmd', () => {
+      log({
+        kind: 'cmd',
+        cmd: 'echo hi',
+        id: '1',
+        verbose: true,
+      })
+      assert.equal(data.join(''), '$ \x1B[92mecho\x1B[39m hi\n')
+    })
+
+    test('stdout', () => {
+      log({
+        kind: 'stdout',
+        data: Buffer.from('foo'),
+        id: '1',
+        verbose: true,
+      })
+      assert.equal(data.join(''), 'foo')
+    })
+
+    test('cd', () => {
+      log({
+        kind: 'cd',
+        dir: '/tmp',
+        verbose: true,
+      })
+      assert.equal(data.join(''), '$ \x1B[92mcd\x1B[39m /tmp\n')
+    })
+
+    test('fetch', () => {
+      log({
+        kind: 'fetch',
+        url: 'https://example.com',
+        init: { method: 'GET' },
+        verbose: true,
+      })
+      assert.equal(
+        data.join(''),
+        "$ \x1B[92mfetch\x1B[39m https://example.com { method: 'GET' }\n"
+      )
+    })
+
+    test('retry', () => {
+      log({
+        kind: 'retry',
+        attempt: 1,
+        total: 3,
+        delay: 1000,
+        exception: new Error('foo'),
+        error: 'bar',
+        verbose: true,
+      })
+      assert.equal(
+        data.join(''),
+        '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 1/3; next in 1000ms\n'
+      )
+    })
+  })
+
+  test('formatCwd()', () => {
+    const cases = [
+      [
+        `echo $'hi'`,
+        "$ \x1B[92mecho\x1B[39m \x1B[93m$\x1B[39m\x1B[93m'hi'\x1B[39m\n",
+      ],
+      [`echo$foo`, '$ \x1B[92mecho\x1B[39m\x1B[93m$\x1B[39mfoo\n'],
+      [
+        `test --foo=bar p1 p2`,
+        '$ \x1B[92mtest\x1B[39m --foo\x1B[31m=\x1B[39mbar p1 p2\n',
+      ],
+      [
+        `cmd1 --foo || cmd2`,
+        '$ \x1B[92mcmd1\x1B[39m --foo \x1B[31m|\x1B[39m\x1B[31m|\x1B[39m\x1B[92m cmd2\x1B[39m\n',
+      ],
+      [
+        `A=B C='D' cmd`,
+        "$ A\x1B[31m=\x1B[39mB C\x1B[31m=\x1B[39m\x1B[93m'D'\x1B[39m\x1B[92m cmd\x1B[39m\n",
+      ],
+      [
+        `foo-extra --baz = b-a-z --bar = 'b-a-r' -q -u x`,
+        "$ \x1B[92mfoo-extra\x1B[39m --baz \x1B[31m=\x1B[39m b-a-z --bar \x1B[31m=\x1B[39m \x1B[93m'b-a-r'\x1B[39m -q -u x\n",
+      ],
+      [
+        `while true; do "$" done`,
+        '$ \x1B[96mwhile\x1B[39m true\x1B[31m;\x1B[39m\x1B[96m do\x1B[39m \x1B[93m"$"\x1B[39m\x1B[96m done\x1B[39m\n',
+      ],
+      [
+        `echo '\n str\n'`,
+        "$ \x1B[92mecho\x1B[39m \x1B[93m'\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m str\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m'\x1B[39m\n",
+      ],
+      [`$'\\''`, "$ \x1B[93m$\x1B[39m\x1B[93m'\\'\x1B[39m\x1B[93m'\x1B[39m\n"],
+      [
+        'sass-compiler --style=compressed src/static/bootstrap.scss > dist/static/bootstrap-v5.3.3.min.css',
+        '$ \x1B[92msass-compiler\x1B[39m --style\x1B[31m=\x1B[39mcompressed src/static/bootstrap.scss \x1B[31m>\x1B[39m\x1B[92m dist/static/bootstrap-v5.3.3.min.css\x1B[39m\n',
+      ],
+      [
+        'echo 1+2 | bc',
+        '$ \x1B[92mecho\x1B[39m 1\x1B[31m+\x1B[39m2 \x1B[31m|\x1B[39m\x1B[92m bc\x1B[39m\n',
+      ],
+      [
+        'echo test &>> filepath',
+        '$ \x1B[92mecho\x1B[39m test \x1B[31m&\x1B[39m\x1B[31m>\x1B[39m\x1B[31m>\x1B[39m\x1B[92m filepath\x1B[39m\n',
+      ],
+      [
+        'bc < filepath',
+        '$ \x1B[92mbc\x1B[39m \x1B[31m<\x1B[39m\x1B[92m filepath\x1B[39m\n',
+      ],
+      [
+        `cat << 'EOF' | tee -a filepath
+line 1
+line 2
+EOF`,
+        "$ \x1B[92mcat\x1B[39m \x1B[31m<\x1B[39m\x1B[31m<\x1B[39m \x1B[93m'EOF'\x1B[39m \x1B[31m|\x1B[39m\x1B[92m tee\x1B[39m -a filepath\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0mline 1\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0mline 2\x1B[96m\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[96mEOF\x1B[39m\n",
+      ],
+    ]
+
+    cases.forEach(([input, expected]) => {
+      assert.equal(formatCmd(input), expected, input)
+    })
+  })
+})
test/package.test.js
@@ -62,6 +62,7 @@ describe('package', () => {
         'build/index.cjs',
         'build/index.d.ts',
         'build/index.js',
+        'build/log.d.ts',
         'build/md.d.ts',
         'build/util.cjs',
         'build/util.d.ts',
test/util.test.js
@@ -14,10 +14,8 @@
 
 import assert from 'node:assert'
 import fs from 'node:fs'
-import { test, describe, after } from 'node:test'
-import { fs as fsCore } from '../build/index.js'
+import { test, describe } from 'node:test'
 import {
-  formatCmd,
   isString,
   isStringLiteral,
   noop,
@@ -84,25 +82,6 @@ describe('util', () => {
     assert.throws(() => parseDuration(-1))
   })
 
-  test('formatCwd works', () => {
-    assert.equal(
-      formatCmd(`echo $'hi'`),
-      "$ \u001b[92mecho\u001b[39m \u001b[93m$\u001b[39m\u001b[93m'hi\u001b[39m\u001b[93m'\u001b[39m\n"
-    )
-    assert.equal(
-      formatCmd(`while true; do "$" done`),
-      '$ \u001b[96mwhile\u001b[39m \u001b[92mtrue\u001b[39m\u001b[96m;\u001b[39m \u001b[96mdo\u001b[39m \u001b[93m"$\u001b[39m\u001b[93m"\u001b[39m \u001b[96mdone\u001b[39m\n'
-    )
-    assert.equal(
-      formatCmd(`echo '\n str\n'`),
-      "$ \u001b[92mecho\u001b[39m \u001b[93m'\u001b[39m\n> \u001b[93m str\u001b[39m\n> \u001b[93m'\u001b[39m\n"
-    )
-    assert.equal(
-      formatCmd(`$'\\''`),
-      "$ \u001b[93m$\u001b[39m\u001b[93m'\u001b[39m\u001b[93m\\\u001b[39m\u001b[93m'\u001b[39m\u001b[93m'\u001b[39m\n"
-    )
-  })
-
   // test('normalizeMultilinePieces()', () => {
   //   assert.equal(
   //     normalizeMultilinePieces([' a ', 'b    c    d', ' e']).join(','),
.size-limit.json
@@ -16,7 +16,7 @@
   {
     "name": "dts libdefs",
     "path": "build/*.d.ts",
-    "limit": "39 kB",
+    "limit": "39.15 kB",
     "brotli": false,
     "gzip": false
   },
@@ -30,7 +30,7 @@
   {
     "name": "all",
     "path": "build/*",
-    "limit": "851.1 kB",
+    "limit": "850.8 kB",
     "brotli": false,
     "gzip": false
   }
package.json
@@ -63,7 +63,7 @@
     "fmt": "prettier --write .",
     "fmt:check": "prettier --check .",
     "build": "npm run build:js && npm run build:dts && npm run build:tests",
-    "build:js": "node scripts/build-js.mjs --format=cjs --hybrid --entry=src/*.ts:!src/error.ts:!src/repl.ts:!src/md.ts && npm run build:vendor",
+    "build:js": "node scripts/build-js.mjs --format=cjs --hybrid --entry=src/*.ts:!src/error.ts:!src/repl.ts:!src/md.ts:!src/log.ts && npm run build:vendor",
     "build:vendor": "node scripts/build-js.mjs --format=cjs --entry=src/vendor-*.ts --bundle=all",
     "build:tests": "node scripts/build-tests.mjs",
     "build:dts": "tsc --project tsconfig.json && rm build/error.d.ts build/repl.d.ts && node scripts/build-dts.mjs",