lisp/lines.lisp @ 1273bba3a35a

More
author Steve Losh <steve@stevelosh.com>
date Mon, 19 Aug 2024 08:55:42 -0400
parents 2f06facce49e
children (none)
(eval-when (:compile-toplevel :load-toplevel :execute)
  (ql:quickload '(:adopt :cl-ppcre :with-user-abort) :silent t))

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

(in-package :lines)

;; TODO: Optimize by eliminating collection when all matchers are in order.

;;;; Configuration ------------------------------------------------------------
(defparameter *version* "1.0.0")
(defparameter *index-base* 0)
(defparameter *include-numbers* nil)
(defparameter *limit* nil)


;;;; Functionality ------------------------------------------------------------
(defclass matcher ()
  ((designator :type string :initarg :designator :accessor designator)))

(defmethod print-object ((o matcher) stream)
  (print-unreadable-object (o stream :type t)
    (format stream "~S" (designator o))))

(defgeneric matches-line-p (matcher line-number))
(defgeneric map-matching-lines (matcher function min max))
(defgeneric maximum (matcher))


(defclass single-line-matcher (matcher)
  ((n :type (integer 0) :initarg :n :accessor n)))

(defmethod matches-line-p ((matcher single-line-matcher) i)
  (= (n matcher) i))

(defmethod map-matching-lines ((matcher single-line-matcher) function min max)
  (when (<= min (n matcher) max)
    (funcall function (n matcher))))

(defmethod maximum ((matcher single-line-matcher))
  (n matcher))


(defclass range-matcher (matcher)
  ((m :type (or null (integer 0)) :initarg :m :accessor m)
   (n :type (or null (integer 0)) :initarg :n :accessor n)))

(defmethod matches-line-p ((matcher range-matcher) i)
  (let ((m (m matcher))
        (n (n matcher)))
    (cond ((null m) (if (null n)
                      t
                      (<= i n)))
          ((null n) (<= m i))
          (t (<= m i n)))))

(defmethod map-matching-lines ((matcher range-matcher) function min max)
  (loop :with m = (or (m matcher) min)
        :with n = (or (n matcher) max)
        :for i :from (max m min) :to (min n max)
        :do (funcall function i)))

(defmethod maximum ((matcher range-matcher))
  (n matcher))


(defclass modulo-matcher (matcher)
  ((n :type (integer 0) :initarg :n :accessor n)
   (modulus :type (integer 1) :initarg :modulus :accessor modulus)))

(defmethod matches-line-p ((matcher modulo-matcher) i)
  (= (mod i (modulus matcher))
     (n matcher)))

(defmethod map-matching-lines ((matcher modulo-matcher) function min max)
  (loop
    :with n = (n matcher)
    :with modulus = (modulus matcher)
    :with start = (if (< n min)
                    (+ n modulus)
                    n)
    :for i :from start :to max :by modulus
    :do (funcall function i)))

(defmethod maximum ((matcher modulo-matcher))
  nil)


(defun parse-single-line-designator (designator)
  (ppcre:register-groups-bind
      ((#'parse-integer n))
      ("^(\\d+)$" designator)
    (make-instance 'single-line-matcher :designator designator :n n)))

(defun parse-range-designator (designator)
  (ppcre:register-groups-bind
      ((#'parse-integer m n))
      ("^(\\d+)?-(\\d+)?$" designator)
    (when (and m n)
      (assert (<= m n) ()
        "Bad line range designator ~S: start (~D) must not be greater than end (~D)."
        designator m n))
    (make-instance 'range-matcher :designator designator :m m :n n)))

(defun parse-modulo-designator (designator)
  (ppcre:register-groups-bind
      ((#'parse-integer n modulus))
      ("^(\\d+)?m(\\d+)$" designator)
    (when (null n)
      (setf n 0))
    (assert (< n modulus) ()
      "Bad modulo designator ~S: offset (~D) must be less than modulus (~D)."
      designator n modulus)
    (make-instance 'modulo-matcher :designator designator :n n :modulus modulus)))

(defun parse-line-designator (designator)
  (or (parse-single-line-designator designator)
      (parse-range-designator designator)
      (parse-modulo-designator designator)
      (error "Could not parse line designator ~S" designator)))

(defun parse-line-designators (designators)
  (mapcar #'parse-line-designator (ppcre:split "," designators)))


(defun dbg (&rest args)
  (apply #'format *error-output* args)
  (finish-output *error-output*))

(defun compute-limit (matchers)
  (let ((maxes (mapcar #'maximum matchers)))
    (unless (some #'null maxes)
      (reduce #'max maxes))))

(defun collect-lines (matchers)
  (loop
    :with limit = (compute-limit matchers)
    :with min = *index-base*
    :with max = nil
    :with lines = (make-hash-table)
    :for i :from min
    :for line = (read-line *standard-input* nil)
    :while line
    :while (or (null limit) (<= i limit))
    :do (progn (setf max i)
               (dolist (matcher matchers)
                 (when (matches-line-p matcher i)
                   (setf (gethash i lines) line))))
    :finally (return (values lines min max))))


(defun output-line (line i)
  (when i
    (format *standard-output* "~D " i))
  (write-line line))

(defun run-slow (matchers)
  (let ((include-numbers *include-numbers*))
    (multiple-value-bind (lines min max) (collect-lines matchers)
      (dolist (matcher matchers)
        (map-matching-lines
          matcher
          (lambda (i)
            (output-line (gethash i lines)
                         (when include-numbers i)))
          min max)))))

(defun run-fast (matcher)
  (loop
    :with limit = (when *limit* (+ *index-base* *limit*))
    :with include-numbers = *include-numbers*
    :for i :from *index-base*
    :for line = (read-line *standard-input* nil)
    :while (or (null limit) (< i limit))
    :while line
    :when (zerop (mod i 100000))
    :do (progn #+sbcl (sb-ext:gc))
    :when (matches-line-p matcher i)
    :do (output-line line (when include-numbers i))))

(defun run (line-designators)
  (let ((matchers (parse-line-designators line-designators)))
    (if (= 1 (length matchers))
      (run-fast (first matchers))
      (run-slow matchers)))
  (values))


;;;; User Interface -----------------------------------------------------------
(adopt:defparameters (*option-debug* *option-no-debug*)
  (adopt:make-boolean-options 'debug
    :long "debug"
    :short #\d
    :help "Enable the Lisp debugger."
    :help-no "Disable the Lisp debugger (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)))


(defparameter *option-one-based*
  (adopt:make-option 'one-based
    :result-key 'index-base
    :help "Start numbering lines at 1."
    :short #\1
    :long "one"
    :reduce (constantly 1)))

(defparameter *option-zero-based*
  (adopt:make-option 'zero-based
    :result-key 'index-base
    :help "Start numbering lines at 0 (default)."
    :short #\0
    :long "zero"
    :reduce (constantly 0)))

(defparameter *option-other-base*
  (adopt:make-option 'other-base
    :result-key 'index-base
    :initial-value 0
    :help "Start numbering lines at N."
    :parameter "N"
    :short #\s
    :long "start"
    :key #'parse-integer
    :reduce #'adopt:last))

(adopt:defparameters (*option-include-numbers* *option-no-include-numbers*)
  (adopt:make-boolean-options 'include-numbers
    :long "number"
    :short #\n
    :help "Add line numbers to the output."
    :help-no "Do not add line number to the output (default)."))


(defparameter *option-limit*
  (adopt:make-option 'limit
    :help "Read at most N lines."
    :parameter "N"
    :short #\l
    :long "limit"
    :key #'parse-integer
    :reduce #'adopt:last))

(defparameter *option-no-limit*
  (adopt:make-option 'no-limit
    :result-key 'limit
    :help "Read all lines (default)."
    :short #\L
    :long "no-limit"
    :reduce (constantly nil)))


(defparameter *option-input-encoding*
  (adopt:make-option 'input-encoding
    :help "Treat input as being encoded with ENC (default utf-8)."
    :parameter "ENC"
    :short #\i
    :long "input-encoding"
    :initial-value "utf-8"
    :reduce #'adopt:last))

(defparameter *option-output-encoding*
  (adopt:make-option 'output-encoding
    :help "Output text encoded with ENC (default utf-8)."
    :parameter "ENC"
    :short #\o
    :long "output-encoding"
    :initial-value "utf-8"
    :reduce #'adopt:last))

(defparameter *option-input-replacement*
  (adopt:make-option 'input-replacement
    :help "If an input character is not valid in the selected encoding, replace it with REP."
    :parameter "REP"
    :short #\r
    :long "input-replacement"
    :reduce #'adopt:last))

(defparameter *option-no-input-replacement*
  (adopt:make-option 'no-input-replacement
    :result-key 'input-replacement
    :help "If an input character is not valid in the selected encoding, return an error (default)."
    :short #\R
    :long "no-input-replacement"
    :reduce (constantly nil)))

(defparameter *option-output-replacement*
  (adopt:make-option 'output-replacement
    :help "If an output character would not be not valid in the selected encoding, replace it with REP."
    :parameter "REP"
    :short #\q
    :long "output-replacement"
    :reduce #'adopt:last))

(defparameter *option-no-output-replacement*
  (adopt:make-option 'no-output-replacement
    :result-key 'output-replacement
    :help "If an output character would not be valid in the selected encoding, return an error (default)."
    :short #\Q
    :long "no-output-replacement"
    :reduce (constantly nil)))


(adopt:define-string *help-text*
  "lines takes a string denoting which lines to print, and prints those lines ~
   of standard input.")

(defparameter *examples*
  '(("Print line 1234:"
     . "cat log.txt | lines 1234")
    ("Print line 2, then line 1:"
     . "cat log.txt | lines 2,1")
    ("Print lines 6, 12, 100-200, and 999+ (1-indexed):"
     . "cat log.txt | lines -1 6,12,100-200,999-")
    ("Print lines 0, 2, 4, 6, …:"
     . "cat log.txt | lines m2")
    ("Print the third line of every set of four lines:"
     . "cat foo.fastq | lines 3m4")))


(defparameter *ui*
  (adopt:make-interface
    :name "lines"
    :usage "[OPTIONS] LINE-DESIGNATORS"
    :summary "print designated lines of standard input"
    :help *help-text*
    :examples *examples*
    :contents (list
                *option-limit*
                *option-no-limit*
                *option-help*
                *option-version*
                *option-debug*
                *option-no-debug*
                (adopt:make-group 'character-encodings
                                  :title "Character Encodings"
                                  :options (list
                                             *option-input-encoding*
                                             *option-input-replacement*
                                             *option-no-input-replacement*
                                             *option-output-encoding*
                                             *option-output-replacement*
                                             *option-no-output-replacement*))
                (adopt:make-group 'line-numbering
                                  :title "Line Numbering"
                                  :options (list
                                             *option-one-based*
                                             *option-zero-based*
                                             *option-other-base*
                                             *option-include-numbers*
                                             *option-no-include-numbers*)))))


(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)
       #+sbcl (sb-int:broken-pipe () (adopt:exit))
       (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 determine-external-format (encoding replacement)
  (let ((encoding (intern (string-upcase encoding) :keyword)))
    (if (null replacement)
      encoding
      (list encoding :replacement replacement))))

(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 "LINE-DESIGNATORS are required."))
          (t (destructuring-bind (line-designators . more) arguments
               (when more
                 (error "Unrecognized command line arguments: ~S" more))
               (let ((*index-base* (gethash 'index-base options))
                     (*include-numbers* (gethash 'include-numbers options))
                     (*limit* (gethash 'limit options))
                     (input-format (determine-external-format
                                     (gethash 'input-encoding options)
                                     (gethash 'input-replacement options)))
                     (output-format (determine-external-format
                                      (gethash 'output-encoding options)
                                      (gethash 'output-replacement options))))
                 (with-open-file (*standard-input* "/dev/stdin"
                                                   :external-format input-format)
                   (with-open-file (*standard-output* "/dev/stdout"
                                                      :external-format output-format
                                                      :direction :output
                                                      :if-exists :append)
                     (run line-designators)))))))))))


#; Scratch --------------------------------------------------------------------

(let ((*standard-input* (make-string-input-stream
"0 a foo
1 b bar
2 c baz
3 d hello
4 a world
5 b yes
6 c this
7 d is
8 a cat
9 b meow")))
  (run "4m4"))