lisp/batchcolor.lisp @ dd879591c545

Hack together a little weather CLI
author Steve Losh <steve@stevelosh.com>
date Sun, 30 Aug 2020 21:13:42 -0400
parents 48a26e13c94f
children 4139b0e71e08
(eval-when (:compile-toplevel :load-toplevel :execute)
  (ql:quickload '(:adopt :cl-ppcre :with-user-abort) :silent t))

(defpackage :batchcolor
  (:use :cl)
  (:export :toplevel :*ui*))

(in-package :batchcolor)

;;;; Configuration ------------------------------------------------------------
(defparameter *version* "1.0.0")
(defparameter *start* 0)

(defun rgb-code (r g b)
  ;; The 256 color mode color values are essentially r/g/b in base 6, but
  ;; shifted 16 higher to account for the intiial 8+8 colors.
  (+ (* r 36)
     (* g 6)
     (* b 1)
     16))

(defparameter *colors* (let ((result (make-array 256 :fill-pointer 0)))
                         (dotimes (r 6)
                           (dotimes (g 6)
                             (dotimes (b 6)
                               (unless (< (+ r g b) 3)
                                 ;; Don't use dark, hard-to-read colors.
                                 (vector-push-extend (rgb-code r g b) result)))))
                         result))


;;;; Functionality ------------------------------------------------------------
(define-condition bad-regex (error) ()
  (:report "Invalid regex."))

(define-condition bad-regex-group-count (bad-regex) ()
  (:report "Invalid regex: must contain exactly 1 register group, e.g. 'x (fo+) y'."))


(defun djb2 (string)
  ;; http://www.cse.yorku.ca/~oz/hash.html
  (reduce (lambda (hash c)
            (mod (+ (* 33 hash) c) (expt 2 64)))
          string
          :initial-value 5381
          :key #'char-code))

(defun find-color (string)
  (aref *colors* (mod (+ (djb2 string) *start*)
                      (length *colors*))))

(defun ansi-color-start (color)
  (format nil "~C[38;5;~Dm" #\Escape color))

(defun ansi-color-end ()
  (format nil "~C[0m" #\Escape))

(defun colorize-line (scanner line &aux (start 0))
  (ppcre:do-scans (ms me rs re scanner line)
    (setf rs (remove nil rs)
          re (remove nil re))
    (when (/= 1 (length rs))
      (error 'bad-regex-group-count))
    (let* ((word-start (aref rs 0))
           (word-end (aref re 0))
           (word (subseq line word-start word-end))
           (color (find-color word)))
      (write-string line *standard-output* :start start :end word-start)
      (format t "~A~A~A" (ansi-color-start color) word (ansi-color-end))
      (setf start word-end)))
  (write-line line *standard-output* :start start)
  (values))


;;;; Run ----------------------------------------------------------------------
(defun run% (scanner stream)
  (loop :for line = (read-line stream nil)
        :while line
        :do (tagbody retry
              (restart-case (colorize-line scanner line)
                (supply-new-regex (v)
                    :test (lambda (c) (typep c 'bad-regex))
                    :report "Supply a new regular expression as a string."
                    :interactive (lambda () (list (read-line *query-io*)))
                  (setf scanner (ppcre:create-scanner v))
                  (go retry))))))

(defun run (pattern paths)
  (if (null paths)
    (setf paths '("-")))
  (let ((scanner (ppcre:create-scanner pattern)))
    (dolist (path paths)
      (if (string= "-" path)
        (run% scanner *standard-input*)
        (with-open-file (stream path :direction :input)
          (run% scanner stream))))))


;;;; User Interface -----------------------------------------------------------
(defmacro defparameters (parameters values-form)
  `(progn
     ,@(loop :for parameter :in parameters
             :collect `(defparameter ,parameter nil))
     (setf (values ,@parameters) ,values-form)
     ',parameters))

(defun make-boolean-options
    (name &key
     (name-no (intern (concatenate 'string (string 'no-) (string name))))
     long
     (long-no (when long (format nil "no-~A" long)))
     short
     (short-no (when short (char-upcase short)))
     (result-key name)
     help
     help-no
     manual
     manual-no
     initial-value)
  (values (adopt:make-option name
            :result-key result-key
            :long long
            :short short
            :help help
            :manual manual
            :initial-value initial-value
            :reduce (constantly t))
          (adopt:make-option name-no
            :result-key result-key
            :long long-no
            :short short-no
            :help help-no
            :manual manual-no
            :reduce (constantly nil))))


(defparameters (*option-randomize* *option-no-randomize*)
  (make-boolean-options 'randomize
    :help "Randomize the choice of color each run."
    :help-no "Do not randomize the choice of color each run (the default)."
    :long "randomize"
    :short #\r))

(defparameters (*option-debug* *option-no-debug*)
  (make-boolean-options 'debug
    :long "debug"
    :short #\d
    :help "Enable the Lisp debugger."
    :help-no "Disable the Lisp debugger (the default)."))

(defparameter *option-help*
  (adopt:make-option 'help
    :help "Display help and exit."
    :long "help"
    :short #\h
    :reduce (constantly t)))

(defparameter *option-version*
  (adopt:make-option 'version
    :help "Display version information and exit."
    :long "version"
    :reduce (constantly t)))


(adopt:define-string *help-text*
  "batchcolor takes a regular expression and matches it against standard ~
   input one line at a time.  Each unique match is highlighted in a random ~
   color.")

(defparameter *examples*
  '(("Colorize IRC nicknames in a chat log:"
     . "cat channel.log | batchcolor '<(\\\\w+)>'")
    ("Colorize UUIDs in a request log:"
     . "tail -f /var/log/foo | batchcolor '([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})'")))


(defparameter *ui*
  (adopt:make-interface
    :name "batchcolor"
    :usage "[OPTIONS] REGEX [FILE...]"
    :summary "colorize regex matches in batches"
    :help *help-text*
    :examples *examples*
    :contents (list *option-randomize*
                    *option-no-randomize*
                    *option-debug*
                    *option-no-debug*
                    *option-help*
                    *option-version*)))


(defmacro without-debugger (&body body)
  `(multiple-value-prog1
     (progn
       #+sbcl (sb-ext:disable-debugger)
       (progn ,@body))
     (progn
       #+sbcl (sb-ext:enable-debugger))))

(defmacro exit-on-error (&body body)
  `(without-debugger
     (handler-case (progn ,@body)
       (error (c) (adopt:print-error-and-exit c)))))

(defmacro exit-on-error-unless (expr &body body)
  `(if ,expr
     (progn ,@body)
     (exit-on-error ,@body)))

(defmacro exit-on-ctrl-c (&body body)
  `(handler-case
       (with-user-abort:with-user-abort (progn ,@body))
     (with-user-abort:user-abort () (adopt:exit 130))))


(defun toplevel ()
  (exit-on-ctrl-c
    (multiple-value-bind (arguments options) (adopt:parse-options-or-exit *ui*)
      (exit-on-error-unless (gethash 'debug options)
        (cond
          ((gethash 'help options) (adopt:print-help-and-exit *ui*))
          ((gethash 'version options) (write-line *version*) (adopt:exit))
          ((null arguments) (error "A regular expression is required."))
          (t (destructuring-bind (pattern . files) arguments
               (let ((*start* (if (gethash 'randomize options)
                                (random 256 (make-random-state t))
                                0)))
                 (run pattern files)))))))))