Commit 236d4ae

Anton Medvedev <anton@medv.io>
2022-06-09 15:32:25
Add simple bash highlighting
1 parent def810c
src/index.ts
@@ -40,7 +40,7 @@ export {
   YAML,
 } from './goods.js'
 
-export { log, formatCmd, LogEntry } from './log.js'
+export { log, LogEntry } from './log.js'
 
 export { Duration } from './util.js'
 
src/log.ts
@@ -12,10 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import chalk from 'chalk'
 import { RequestInfo, RequestInit } from 'node-fetch'
 import { inspect } from 'node:util'
 import { $ } from './core.js'
-import { colorize } from './util.js'
+import { formatCmd } from './util.js'
 
 export type LogEntry =
   | {
@@ -55,25 +56,14 @@ export function log(entry: LogEntry) {
       break
     case 'cd':
       if (!$.verbose) return
-      process.stderr.write('$ ' + colorize(`cd ${entry.dir}`) + '\n')
+      process.stderr.write('$ ' + chalk.greenBright('cd') + ` ${entry.dir}\n`)
       break
     case 'fetch':
       if (!$.verbose) return
       const init = entry.init ? ' ' + inspect(entry.init) : ''
-      process.stderr.write('$ ' + colorize(`fetch ${entry.url}`) + init + '\n')
+      process.stderr.write(
+        '$ ' + chalk.greenBright('fetch') + ` ${entry.url}${init}\n`
+      )
       break
   }
 }
-
-export function formatCmd(cmd: string) {
-  if (/\n/.test(cmd)) {
-    return (
-      cmd
-        .split('\n')
-        .map((line, i) => `${i == 0 ? '$' : '>'} ${colorize(line)}`)
-        .join('\n') + '\n'
-    )
-  } else {
-    return `$ ${colorize(cmd)}\n`
-  }
-}
src/util.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import chalk from 'chalk'
+import chalk, { ChalkInstance } from 'chalk'
 import { promisify } from 'node:util'
 import psTreeModule from 'ps-tree'
 import { ProcessOutput, ProcessPromise } from './core.js'
@@ -62,12 +62,6 @@ export function stringify(arg: ProcessOutput | any) {
   return `${arg}`
 }
 
-export function colorize(cmd: string) {
-  return cmd.replace(/^[\w_.-]+(\s|$)/, (substr) => {
-    return chalk.greenBright(substr)
-  })
-}
-
 export function exitCodeInfo(exitCode: number | null): string | undefined {
   return {
     2: 'Misuse of shell builtins',
@@ -134,3 +128,113 @@ export function parseDuration(d: Duration) {
     throw new Error(`Unknown duration: "${d}".`)
   }
 }
+
+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 (reservedWords.includes(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 (/[$]/.test(ch)) return dollar
+    if (/["]/.test(ch)) return strDouble
+    if (/[']/.test(ch)) return strSingle
+    return word
+  }
+  function space() {
+    if (/\s/.test(ch)) return space
+    return root
+  }
+  function word() {
+    if (/[0-9a-z/_.]/i.test(ch)) return word
+    return root
+  }
+  function syntax() {
+    if (isSyntax(ch)) return syntax
+    return root
+  }
+  function dollar() {
+    if (/[']/.test(ch)) return str
+    return root
+  }
+  function str() {
+    if (/[']/.test(ch)) return strEnd
+    if (/[\\]/.test(ch)) return strBackslash
+    return str
+  }
+  function strBackslash() {
+    return strEscape
+  }
+  function strEscape() {
+    return str
+  }
+  function strDouble() {
+    if (/["]/.test(ch)) return strEnd
+    return strDouble
+  }
+  function strSingle() {
+    if (/[']/.test(ch)) return strEnd
+    return strSingle
+  }
+  function strEnd() {
+    return root
+  }
+  return out + '\n'
+}
+
+const reservedWords = [
+  'if',
+  'then',
+  'else',
+  'elif',
+  'fi',
+  'case',
+  'esac',
+  'for',
+  'select',
+  'while',
+  'until',
+  'do',
+  'done',
+  'in',
+]
test/util.test.js
@@ -16,11 +16,12 @@ import { suite } from 'uvu'
 import * as assert from 'uvu/assert'
 import {
   exitCodeInfo,
-  randomId,
-  noop,
+  formatCmd,
   isString,
-  quote,
+  noop,
   parseDuration,
+  quote,
+  randomId,
 } from '../build/util.js'
 
 const test = suite('util')
@@ -57,4 +58,23 @@ test('duration parsing works', () => {
   assert.throws(() => parseDuration('100'))
 })
 
+test('formatCwd works', () => {
+  assert.is(
+    formatCmd(`echo $'hi'`),
+    "$ \u001b[92mecho\u001b[39m \u001b[93m$\u001b[39m\u001b[93m'hi\u001b[39m\u001b[93m'\u001b[39m\n"
+  )
+  assert.is(
+    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.is(
+    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.is(
+    formatCmd(`$'\\''`),
+    "$ \u001b[93m$\u001b[39m\u001b[93m'\u001b[39m\u001b[93m\\\u001b[39m\u001b[93m'\u001b[39m\u001b[93m'\u001b[39m\n"
+  )
+})
+
 test.run()