main
  1// Copyright 2025 Google LLC
  2//
  3// Licensed under the Apache License, Version 2.0 (the "License");
  4// you may not use this file except in compliance with the License.
  5// You may obtain a copy of the License at
  6//
  7//     https://www.apache.org/licenses/LICENSE-2.0
  8//
  9// Unless required by applicable law or agreed to in writing, software
 10// distributed under the License is distributed on an "AS IS" BASIS,
 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12// See the License for the specific language governing permissions and
 13// limitations under the License.
 14
 15import { chalk, type RequestInfo, type RequestInit } from './vendor-core.ts'
 16import { inspect } from 'node:util'
 17import { type Buffer } from 'node:buffer'
 18import process from 'node:process'
 19
 20export type LogEntry = {
 21  verbose?: boolean
 22} & (
 23  | {
 24      kind: 'cmd'
 25      cmd: string
 26      cwd: string
 27      id: string
 28    }
 29  | {
 30      kind: 'stdout'
 31      data: Buffer
 32      id: string
 33    }
 34  | {
 35      kind: 'stderr'
 36      data: Buffer
 37      id: string
 38    }
 39  | {
 40      kind: 'end'
 41      exitCode: number | null
 42      signal: NodeJS.Signals | null
 43      duration: number
 44      error: null | Error
 45      id: string
 46    }
 47  | {
 48      kind: 'cd'
 49      dir: string
 50    }
 51  | {
 52      kind: 'fetch'
 53      url: RequestInfo
 54      init?: RequestInit
 55    }
 56  | {
 57      kind: 'retry'
 58      attempt: number
 59      total: number
 60      delay: number
 61      exception: unknown
 62      error?: string
 63    }
 64  | {
 65      kind: 'custom'
 66      data: any
 67    }
 68  | {
 69      kind: 'kill'
 70      pid: number | `${number}`
 71      signal: NodeJS.Signals | null
 72    }
 73)
 74
 75type LogFormatters = {
 76  [key in LogEntry['kind']]: (
 77    entry: Extract<LogEntry, { kind: key }>
 78  ) => string | Buffer
 79}
 80
 81const formatters: LogFormatters = {
 82  cmd({ cmd }) {
 83    return formatCmd(cmd)
 84  },
 85  stdout({ data }) {
 86    return data
 87  },
 88  stderr({ data }) {
 89    return data
 90  },
 91  custom({ data }) {
 92    return data
 93  },
 94  fetch(entry) {
 95    const init = entry.init ? ' ' + inspect(entry.init) : ''
 96    return `$ ${chalk.greenBright('fetch')} ${entry.url}${init}\n`
 97  },
 98  cd(entry) {
 99    return `$ ${chalk.greenBright('cd')} ${entry.dir}\n`
100  },
101  retry(entry) {
102    const attempt = `Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}`
103    const delay = entry.delay > 0 ? `; next in ${entry.delay}ms` : ''
104
105    return `${chalk.bgRed.white(' FAIL ')} ${attempt}${delay}\n`
106  },
107  end() {
108    return ''
109  },
110  kill() {
111    return ''
112  },
113}
114
115type Log = {
116  (entry: LogEntry): void
117  formatters?: Partial<LogFormatters>
118  output?: NodeJS.WriteStream
119}
120
121export const log: Log = function (entry) {
122  if (!entry.verbose) return
123  const stream = log.output || process.stderr
124  const format = (log.formatters?.[entry.kind] || formatters[entry.kind]) as (
125    entry: LogEntry
126  ) => string | Buffer
127  if (!format) return // ignore unknown log entries
128
129  stream.write(format(entry))
130}
131
132const SPACE_RE = /\s/
133const SYNTAX = '()[]{}<>;:+|&='
134const CMD_BREAK = '|&;><'
135const RESERVED_WORDS = new Set([
136  'if',
137  'then',
138  'else',
139  'elif',
140  'fi',
141  'case',
142  'esac',
143  'for',
144  'select',
145  'while',
146  'until',
147  'do',
148  'done',
149  'in',
150  'EOF',
151])
152
153export function formatCmd(cmd: string): string {
154  if (cmd == undefined) return chalk.grey('undefined')
155  let q = ''
156  let out = '$ '
157  let buf = ''
158  let mode: 'syntax' | 'quote' | 'dollar' | '' = ''
159  let pos = 0
160  const cap = () => {
161    const word = buf.trim()
162    if (word) {
163      pos++
164      if (mode === 'syntax') {
165        if (CMD_BREAK.includes(word)) {
166          pos = 0
167        }
168        out += chalk.red(buf)
169      } else if (mode === 'quote' || mode === 'dollar') {
170        out += chalk.yellowBright(buf)
171      } else if (RESERVED_WORDS.has(word)) {
172        out += chalk.cyanBright(buf)
173      } else if (pos === 1) {
174        out += chalk.greenBright(buf)
175        pos = Infinity
176      } else {
177        out += buf
178      }
179    } else {
180      out += buf
181    }
182    mode = ''
183    buf = ''
184  }
185
186  for (const c of [...cmd]) {
187    if (!q) {
188      if (c === '$') {
189        cap()
190        mode = 'dollar'
191        buf += c
192        cap()
193      } else if (c === "'" || c === '"') {
194        cap()
195        mode = 'quote'
196        q = c
197        buf += c
198      } else if (SPACE_RE.test(c)) {
199        cap()
200        buf += c
201      } else if (SYNTAX.includes(c)) {
202        const isEnv = c === '=' && pos === 0
203        isEnv && (pos = 1)
204        cap()
205        mode = 'syntax'
206        buf += c
207        cap()
208        isEnv && (pos = -1)
209      } else {
210        buf += c
211      }
212    } else {
213      buf += c
214      if (c === q) {
215        cap()
216        q = ''
217      }
218    }
219  }
220  cap()
221  return out.replaceAll('\n', chalk.reset('\n> ')) + '\n'
222}