Files
cl-xdg-trash/clash/format.lisp

408 lines
16 KiB
Common Lisp

(defpackage clash/format
(:documentation "Formatters for trashinfos and trash directories.")
(:import-from #:cl-xdg-trash/mountpoints
#:file-or-dir-namestring)
(:import-from #:cl-xdg-trash/directorysizes
#:trashed-file-size)
(:import-from #:cl-xdg-trash/trashinfo
#:trashinfo-trash-directory
#:trashinfo-name
#:trashinfo-deletion-date
#:trashinfo-original-path
#:trashinfo-info-file
#:trashinfo-trashed-file)
(:use #:cl)
(:export #:trashinfo-size
#:format-size
#:parse-format-string
#:option-format-string
#:option-format-string-directives
#:format-object
#:format-list
#:print-format-info
#:*trashinfo-formatters*
#:*directory-formatters*
#:print-clash-format-info
#:*missing-file-formatters*
#:*format-switch-base-two-base-ten*))
(in-package :clash/format)
(defvar *trashinfo-size-cache* (make-hash-table :test #'eq)
"Cache for trashinfo sizes.")
(defun trashinfo-size (trashinfo)
"Return the size of TRASHINFO and cache it."
(let ((res (gethash trashinfo *trashinfo-size-cache* :none)))
(if (eq res :none)
(setf (gethash trashinfo *trashinfo-size-cache*)
(trashed-file-size (trashinfo-trash-directory trashinfo)
(trashinfo-name trashinfo)))
res)))
(defun format-size (count &optional base-two (places 2))
"Pretty print COUNT, which is a number of bytes. This will append metric
suffixes as necessary. If BASE-TWO is non-nil, use MiB, GiB, etc. suffixes
instead."
(if (not count)
"N/A" ;; if finding the size failed
(let* ((base (if base-two 1024 1000))
(power (min 10 (floor (if (zerop count) 0 (log count base))))))
(if (zerop power)
(format nil "~DB" count)
(format nil "~,VF~[~:[k~;K~]~:*~;M~;G~;T~;P~;E~;Z~;Y~;R~;Q~]~@[i~]B"
places (/ count (expt base power))
(1- power) base-two)))))
(defclass format-code ()
((name :reader format-code-name
:type character
:initarg :name
:documentation "The character used to invoke this formatter.")
(action :reader format-code-action
:type (function (stream t) t)
:initarg :action
:documentation "The function to call to use this formatter.")
(padder :reader format-code-padder
:type (or null
(function (stream (member nil :left :right) string t) t))
:initarg :padder
:initform nil
:documentation
"An optional function to pad the result of the action.")
(doc :reader format-code-doc
:type (or null string)
:initarg :doc
:initform nil
:documentation "The documentation for this format code."))
(:documentation "A single format escape sequence."))
(defmethod print-object ((obj format-code) stream)
(print-unreadable-object (obj stream :type t :identity t)
(with-slots (name padder) obj
(format stream "%~A~@[ (supports padding)~]" name padder))))
(defun make-format-code (&rest args)
(apply #'make-instance 'format-code args))
(defun parse-format-string (format-string directives)
"Parse FORMAT-STRING into a list of string and functions."
(let ((start 0)
out end)
(labels ((ensure-next-char (i thing)
(unless (< i (1- (length format-string)))
(error "Unterminated ~A at char ~A: ~S" thing i format-string)))
(unknown (i thing)
(error "Unknown ~A at char ~A: ~S" thing i format-string))
(push-thing (thing)
(if (null out)
(setq out (list thing)
end out)
(setf (cdr end) (list thing)
end (cdr end))))
(push-string (str)
(unless (zerop (length str))
(if (stringp (car end))
(setf (car end) (format nil "~A~A" (car end) str))
(push-thing str)))))
(do ((i 0 (1+ i)))
((>= i (length format-string)))
(case (aref format-string i)
(#\%
(ensure-next-char i "substitution")
(push-string (subseq format-string start i))
(let* ((start-i i)
(next-c (aref format-string (1+ i)))
(pad-type (case next-c (#\< :left) (#\> :right) (t nil))))
(when pad-type
(ensure-next-char (1+ i) "substitution")
(setq next-c (aref format-string (+ i 2)))
(incf i)
(incf start))
(setq start (+ i 2)
i (1+ i))
(case next-c
(#\% (push-string "%"))
(#\# (push-thing (if pad-type
(cons :index pad-type)
:index)))
(t (let ((code (find next-c directives :key #'format-code-name)))
(unless (typep code 'format-code)
(unknown start-i "substitution"))
(unless (or (not pad-type) (format-code-padder code))
(error "Format code %~A does not support padding" next-c))
(push-thing (if pad-type (cons code pad-type) code)))))))
(#\\
(ensure-next-char i "escape sequence")
(push-string (subseq format-string start i))
(push-string
(case (aref format-string (1+ i))
(#\\ "\\")
(#\n (string #\Newline))
(#\t (string #\Tab))
(#\0 (string #\Nul))
(t (unknown i "escape sequence"))))
(setq start (+ i 2)
i (1+ i)))))
(push-string (subseq format-string start))
out)))
(defclass option-format-string (clingon:option)
((directives :accessor option-format-string-directives
:type list
:initarg :directives
:documentation "The format directives to use when parsing.")
(format-string :accessor option-format-string-format-string
:type string
:initform ""
:documentation "The used-passed format string."))
(:default-initargs :parameter "FORMAT")
(:documentation "Option that takes a format string."))
(defmethod clingon:initialize-option ((option option-format-string) &key)
(setf (option-format-string-format-string option)
(clingon:option-initial-value option))
(call-next-method))
(defmethod clingon:derive-option-value ((option option-format-string) arg &key)
(setf (option-format-string-format-string option) arg))
(defmethod clingon:finalize-option ((option option-format-string) &key)
(parse-format-string (option-format-string-format-string option)
(option-format-string-directives option)))
(defmethod clingon:make-option ((kind (eql :format-string)) &rest args)
(apply #'make-instance 'option-format-string args))
(defun number-length (n &optional (base 10))
"Return the number of digits in N when represented in BASE. If N is ngeative,
add one to the result."
(if (zerop n)
1
(+ (floor (log (abs n) base)) (if (minusp n) 2 1))))
(defun format-object (stream control-obj obj
&key (index 1) (max-index 1)
(max-index-length (number-length max-index))
extra-args)
"Format the object OBJ to STREAM according to CONTROL-OBJECT (which is from
parse-format-string)."
(dolist (part control-obj)
(cond
((eq :index part)
(format stream "~A" index))
((and (consp part) (eq :index (car part)))
(case (car part)
(:left (format stream "~VD" max-index-length index))
(:right (format stream "~V@<~D~>" max-index-length index))
(t (format stream "~A" index))))
((stringp part)
(format stream "~A" part))
((and (consp part))
(with-slots (padder action) (car part)
(funcall padder stream (cdr part)
(with-output-to-string (tmp-output)
(apply action tmp-output obj extra-args))
obj)))
(t (funcall (format-code-action part) stream obj)))))
(defun format-list (stream control-obj objs
&key (indices t) extra-action extra-args)
"Format a list of objects OBJS with format-object."
(if (eq t indices)
(loop with max-index = (length objs)
with max-index-length = (number-length max-index)
for obj in objs
for i upfrom 1
do (format-object stream control-obj obj
:index i :max-index max-index
:max-index-length max-index-length
:extra-args extra-args)
when extra-action
do (funcall extra-action obj))
(loop with max-index = (reduce #'max indices :initial-value 0)
with max-index-length = (number-length max-index)
with objs-arr = (coerce objs 'vector)
for index in indices
do (format-object stream control-obj (aref objs-arr index)
:index (1+ index) :max-index max-index
:max-index-length max-index-length
:extra-args extra-args)
when extra-action
do (funcall extra-action (aref objs-arr index)))))
(defun format-code-name-less-p (c1 c2)
"Return non-nil if C2 sorts after C1 (both are format code names)."
(if (equalp c1 c2)
(and (not (eql c1 c2)) (upper-case-p c2))
(char-lessp c1 c2)))
(defun print-format-info (stream &rest name-directive-pairs)
(format stream "~
Format strings use C-style and printf-style escape sequences. Each character
other than one of those with a special meaning noted below is copied to the
output verbatim. The recognized C-style escapes sequences are:
\"\\0\" - null byte
\"\\n\" - newline
\"\\t\" - tab character
\"\\\\\" - literal backslash~%~%")
(loop for (name directive . rest) = name-directive-pairs then rest
for first = t then nil
while name
when first
do (format stream "~
(Sequences with a \"*\" before them support padding by putting a < or > after
the initial % character)~%")
do (format stream "~
The recognized printf-style sequences for ~A are:
\"%%\" - a literal %
*\"%#\" - the index of the current item (used when prompting)~%" name)
do (dolist (code (stable-sort
(copy-list directive) #'format-code-name-less-p
:key #'format-code-name))
(with-slots (name doc padder) code
(format stream " ~:[ ~;*~]\"%~A\"~@[ - ~A~]~%"
padder name doc)))
when rest
do (terpri stream)))
(defun make-fixed-with-padder (width)
"Return a padder (for a format-code)"
(lambda (stream pad-type text obj)
(declare (ignore obj))
(case pad-type
(:left (format stream "~V@<~A~>" width text))
(:right (format stream "~V<~A~>" width text))
(t (format stream "~A" text)))))
(defvar *format-switch-base-two-base-ten* nil
"Switch the base 2 and base 10 for codes.")
(defparameter *trashinfo-formatters*
(list
(make-format-code
:name #\o
:action (lambda (stream info)
(format stream "~A" (uiop:native-namestring
(trashinfo-original-path info :resolve t))))
:doc "the (o)riginal path (always absolute)")
(make-format-code
:name #\O
:action (lambda (stream info)
(format stream "~A" (uiop:native-namestring
(trashinfo-original-path info :normalize t))))
:doc "the (o)riginal path (possibly relative)")
(make-format-code
:name #\n
:action (lambda (stream info)
(format stream "~A" (file-or-dir-namestring
(trashinfo-original-path info))))
:doc "the original (n)ame")
(make-format-code
:name #\d
:action (lambda (stream info)
(format stream "~A" (uiop:native-namestring
(trashinfo-trash-directory info))))
:doc "the trash (d)irectory")
(make-format-code
:name #\i
:action (lambda (stream info)
(format stream "~A" (uiop:native-namestring
(trashinfo-info-file info))))
:doc "the trash(i)nfo file path")
(make-format-code
:name #\c
:action (lambda (stream info)
(format stream "~A" (uiop:native-namestring
(trashinfo-trashed-file info))))
:doc "the (c)urrent (trashed) path")
(make-format-code
:name #\u
:action (lambda (stream info)
(format stream "~A" (local-time:timestamp-to-unix
(trashinfo-deletion-date info))))
:doc "the time the file was trashed (in (u)TC seconds)")
(make-format-code
:name #\t
:action (lambda (stream info)
(local-time:format-timestring
stream (trashinfo-deletion-date info)
:format local-time:+asctime-format+))
:doc "the (t)ime the file was trashed (pretty-printed local time)")
(make-format-code
:name #\s
:action (lambda (stream info)
(format stream "~A" (trashinfo-size info)))
:doc "the file's (s)size in bytes")
(make-format-code
:name #\h
:action (lambda (stream info)
(format stream "~A"
(format-size (trashinfo-size info)
*format-switch-base-two-base-ten*)))
:padder (make-fixed-with-padder 9)
:doc "the file's size with a (h)uman readable suffix (powers of 10)")
(make-format-code
:name #\H
:action (lambda (stream info)
(format stream "~A"
(format-size (trashinfo-size info)
(not *format-switch-base-two-base-ten*))))
:padder (make-fixed-with-padder 10)
:doc "the file's size with a (H)uman readable suffix (power of 2)")))
(defun trashinfo-list-size (infos)
"Return the sum of the sizes of each trashinfo in INFOS."
(loop for info in infos
summing (or (trashinfo-size info) 0)))
(defparameter *directory-formatters*
(list
(make-format-code
:name #\p
:action (lambda (stream path-and-infos)
(format stream "~A" (uiop:native-namestring
(uiop:ensure-directory-pathname
(car path-and-infos)))))
:doc "the directory's (p)ath (with a trailing slash)")
(make-format-code
:name #\s
:action (lambda (stream path-and-infos)
(format stream "~D" (trashinfo-list-size (cdr path-and-infos))))
:doc "the directory's (s)ize (in bytes)")
(make-format-code
:name #\h
:action (lambda (stream path-and-infos)
(format stream "~A" (format-size
(trashinfo-list-size (cdr path-and-infos))
*format-switch-base-two-base-ten*)))
:padder (make-fixed-with-padder 9)
:doc "the directory's size with a (h)uman readable suffix (powers of 10)")
(make-format-code
:name #\H
:action (lambda (stream path-and-infos)
(format stream "~A"
(format-size (trashinfo-list-size (cdr path-and-infos))
(not *format-switch-base-two-base-ten*))))
:padder (make-fixed-with-padder 10)
:doc "the directory's size with a (H)uman readable suffix (powers of 2)")
(make-format-code
:name #\c
:action (lambda (stream path-and-infos)
(format stream "~D" (length (cdr path-and-infos))))
:doc "the (c)ount of files trashed in the directory")
(make-format-code
:name #\m
:action (lambda (stream path-and-infos)
(let ((infos (cdr path-and-infos)))
(format stream "~@[s~]" (or (null infos) (cdr infos)))))
:doc "nothing if only one file was found in this directory, otherwise \"s\"")))
(defun print-clash-format-info (&optional (print-dir t) (stream t))
"Print format string information for clash to STREAM."
(apply #'print-format-info
stream
"trashed files" *trashinfo-formatters*
(when print-dir
(list "trash directories" *directory-formatters*))))