【存档】用 pp fill lisp 代码的问题

欲用 pp 格式化 elisp 至指定列宽,然输出与预期不符,细节如下:

格式化 elisp, 列宽 42 (for phone):

(let ((fill-column 42))
  (pp
   '(defalias 'X
      ((lambda (log)
         (defalias log
           (lambda (fmt &rest args)
             (when debug-on-error
               (princ
                (apply #'format
                       (concat
                        (format-time-string
                         "[%Y-%m-%d %H:%M:%S.%3N]"
                         (current-time))
                        fmt "\n")
                       args)
                (get-buffer-create " *log:X*"))))))
       (make-symbol "log")))))

输出:

(defalias 'X
  ((lambda (log)
     (defalias log
       (lambda (fmt &rest args)
         (when debug-on-error
           (princ
            (apply #'format
                   (concat
                    (format-time-string
                     "[%Y-%m-%d %H:%M:%S.%3N]"
                                          ;; ^ column 46
                     (current-time))
                    fmt "\n")
                   args)
            (get-buffer-create
             " *log:X*"))))))
   (make-symbol "log")))

预期:

(defalias 'X
  ((lambda (log)
     (defalias log
       (lambda (fmt &rest args)
         (when debug-on-error
           (princ
            (apply
             #'format
             (concat
              (format-time-string
               "[%Y-%m-%d %H:%M:%S.%3N]"
                                    ;; ^ column 40
               (current-time))
              fmt "\n")
             args)
            (get-buffer-create
             " *log:X*"))))))
   (make-symbol "log")))

问题:是用法不对,还是?或者:有无其他 “代码与注释” 一起 fill 的内置工具?


注: pp → pp-default-function → pp-fill

pp-fill is an interactive native-comp-function in ‘pp.el’.

(pp-fill BEG &optional END)

Break lines in Lisp code between BEG and END so it fits within ‘fill-column’.
Presumes the current buffer has syntax and indentation properly
configured for that.
Designed under the assumption that the region occupies a single line,
tho it should also work if that’s not the case.
Can also be called with a single argument, in which case
it inserts and pretty-prints that arg at point.

参考 pp-fiil 撸了个 fill-elisp, 以将当前 elisp buffer 的 代码与注释 一同 fill. 出于保留格式的原因,暂时不管 docstring.

注:不保证正确性,不保证性能。

#+name: 2025-06-29-17-18
#+header: :tangle ~/org/fill-elisp.el
#+begin_src emacs-lisp :results silent :lexical t :noweb yes
;;; fill-elisp.el -*- lexical-binding: t; -*-

;;; Commentary:

;;; Code:

<<2025-06-29-17-24>>

<<2025-06-29-17-29>>

(defun elisp-compress (&optional remove-empty-line)
  "尽可能地删除换行与空白字符。"
  (interactive)
  (goto-char (point-min))
  (while (< (point) (point-max))
    ;; 移除当前行首的空白字符
    (unless (nth 3 (syntax-ppss))
      (goto-char (line-beginning-position))
      (fixup-whitespace)
      (goto-char (line-end-position)))
    (cond
     ;; 注释行或字符串中?
     ((or (nth 4 (syntax-ppss)) (nth 3 (syntax-ppss)))
      (forward-line 1))
     ;; 多行空行中或多行空行首?
     ((and (not remove-empty-line)
           (or (eq (char-before) ?\n)
               (looking-at-p "\n[ \t\r]*\n")))
      (skip-chars-forward "\n"))
     (t
      (delete-indentation t)
      ;; 如果删除字符至注释行,让注释重新占据一行。
      (when (looking-at-p "[ \t\r]*\\s<") (insert "\n")))))
  (delete-trailing-whitespace))

(defun fill-elisp (&optional buffer-or-name)
  (declare (indent 1))
  (interactive)
  (with-current-buffer (or buffer-or-name (current-buffer))
    (!let* ((point (point))

            (break-line
             (lambda nil (ignore (insert "\n") (indent-according-to-mode))))

            ;; First sexp in the current line?
            (first-sexp?
             (lambda nil (save-excursion (skip-syntax-backward " ") (bolp))))

            ;; 视情况将当前 point 指向的 (作为某个 sexp 的子节点的)
            ;; sexp 折置新行。当它:非当前行内首个 sexp; point 指向行
            ;; 尾; 已无同级 sexp; point 指向注释。
            (break-subsexp
             (lambda nil
               (unless
                   (or
                    ;; Has no sexp in the rest of the current line?
                    ;; 这里用 `$' 不用 `\n' 因为 `(point-max)' 之后
                    ;; 无 `\n' 时,用 `\n' 无法正确识别“行尾”。
                    (looking-at-p "[ \t\r]*$")

                    ;; Done with all child sexps?
                    (looking-at-p "[ \r\t]*)")

                    (first-sexp?)

                    ;; 非注释
                    (looking-at-p "[ \r\t]*\\s<"))
                 (break-line))))

            ;; FIXME: 这个检测 list 与否的 test 可能不太完备。
            (looking-at-list?
             (lambda nil (looking-at-p "[',`]*(")))

            ;; 语法上必须 break 的 case, 否则当前 sexp 会被染红。另见
            ;; `font-lock-warning-face', `lisp--match-hidden-arg'.
            (syntax-invalid?
             (lambda ()
               (ignore-errors
                 (save-excursion
                   (let* ((lbp (line-beginning-position))
                          (p (point))
                          (ppss (parse-partial-sexp lbp p -1)))
                     (skip-syntax-forward " )")
                     (unless (or (>= (car ppss) 0)
                                 (eolp)
                                 (looking-at-p ";")
                                 (nth 8 (syntax-ppss)))
                       (and (looking-at-p ".*") (>= p (point)))))))))

            (indent-if-line-change
             (let ((last-line 1)) ; FIXME: what if no start from 1?
               (lambda ()
                 (unless (eq last-line (line-number-at-pos))
                   (save-excursion
                     (let ((inhibit-message t)
                           (message-log-max nil))
                       (indent-region
                        (save-excursion
                          (goto-line last-line)
                          (point))
                        (point))))
                   (setq last-line (line-number-at-pos))
                   (indent-according-to-mode)
                   (skip-chars-forward " \t\r")))))

            ;; 判断当前 point 指向的 sexp 是否需折行,具体有两个方面:
            ;; 一、当前 sexp 是否需折置新行;二、当前 sexp 的子 sexp
            ;; 是否需折置新行。
            (break-sexp?
             (lambda ()
               (or
                ;; 有些 sexp 被注释行分割为多行,如果它的 body 正好被注释
                ;; 分隔为两部分,且注释前的 body 和它同处一行且长度小于
                ;; `fill-column', 该部分 body 也许会被判定为无需折行,导
                ;; 致注释后的 body 与 注释前的 body 对齐,进而导致它的
                ;; body 部分缩进太多,页面左边留白严重。所以,这里我们一
                ;; 旦识别到 sexp 占据多行,我们必折其body.
                (>
                 (let* ((sexp (thing-at-point 'sexp t))
                        (lines (string-split sexp "\n")))
                   (length lines))
                 1)

                ;; 如果单行的 sexp 长度超过 `fill-column'.
                (>
                 (save-excursion
                   ;; 去 sexp 尾部。
                   (forward-sexp)
                   ;; 回退置最外层 sexp.
                   (skip-chars-forward " \t\r)")
                   ;; current-column 从 0 起,这里 `1+' 处理。
                   (1+ (current-column)))
                 fill-column))))

            (log
             (lambda (fmt &rest args)
               ;; "[%Y-%m-%d %H:%M:%S.%6N]"
               (when debug-on-error
                 (princ
                  (apply #'format
                         (concat
                          (format-time-string
                           "[%s.%6N]"
                           (current-time))
                          fmt "\n")
                         args)
                  (get-buffer-create " *log:fill-elisp*"))
                 nil)))

            (log-current-sexp
             (lambda ()
               (let* ((sexp (thing-at-point 'sexp t))
                      (sexp-len (length sexp)))
                 (log
                  (concat
                   "line %S ccol %S len %S "
                   "fcol %S sexp %S "
                   ;; "%S"
                   )
                  (line-number-at-pos)
                  (current-column)
                  (length sexp)
                  fill-column
                  (cond
                   ((length> sexp 20) (substring sexp 0 20))
                   (t sexp))
                  ;; (buffer-substring-no-properties (point) (point-max))
                  )))))
      (ignore-errors (kill-buffer " *log:fill-elisp*"))

      (elisp-compress)

      (map-sexp
       (lambda (lv)
         ;; 如果我们进入了新的行,调整新行到先前行之间的缩进,以免因这些行之
         ;; 间可能存在的注释行影响代码行缩进。
         (indent-if-line-change)

         (log-current-sexp)

         ;; 针对当前 point 所指的当前 sexp, 有如下决策:
         ;;
         ;; 一、当前 sexp 是否需要另起一行;
         ;;
         ;; 二、当前 sexp 的 body 子节点是否需要另起一行。
         ;;
         ;; 所以我们有两部分的处理逻辑:

         ;; 是否将当前 sexp 折置新行:
         (cond
          ;; 行内首个 sexp 不再折行。
          ((log "  >h1"))
          ((first-sexp?))

          ;; 语法上必须 break 的 case, 否则当前 sexp 会被染红。
          ((log "  >h2"))
          ((syntax-invalid?) (break-line))

          ;; 如果 sexp 内部需要折行,而它又非行内首个 sexp,
          ;; 我们试着将其折置新行,看看能省多少缩进。
          ((log "  >h3"))
          ((and (break-sexp?))
           (let ((oldcol (current-column)))
             (break-line)
             ;; 折后行,若缩进量无明显改变,我们取消折行。
             ;; 比如对 let 的 varlist 折行时,
             ;; varlist 的缩进量仅减一。
             (when (<= (- oldcol (current-column)) 2)
               (delete-indentation)
               (skip-syntax-forward " "))))

          ;; 不折行。
          ((log "  >he")))

         ;; 是否将当前 sexp 的子 sexp 折置新行:
         (save-excursion
           (cond
            ;; 非 list, 无 body 需处理;或者当前 sexp 无需折行。
            ((or (not (looking-at-list?)) (not (break-sexp?))))

            ((ignore (down-list))) ; 深入 sexp.

            ;; 如果当前 sexp 是嵌套 list, 把它的除首个子节点外
            ;; 的所有子节点全置于新行中。
            ((log "  >b1"))
            ((looking-at-list?)
             (while (ignore-errors (forward-sexp) t)
               (break-subsexp))
             t)

            ;; 处理 symbol.
            ((log "  >b2"))
            ((when-let*
                 ((sym (symbol-at-point))
                  ;; symbol 的 lisp-indent-function 属性为
                  ;; `nil', 按无参形式折行。
                  (lif (or (get sym 'lisp-indent-function) 0))
                  (lif (if (eq lif 'defun) 2 lif))
                  (_ (natnump lif)))
               (when
                   (ignore-errors
                     ;; 跳过通常作为 function 的首个 symbol.
                     (forward-sexp 1)
                     ;; 跳过 lif 个参数。
                     (forward-sexp lif)
                     t)
                 (break-subsexp))
               t))

            ;; 其他非 symbol 非 list 的东西,暂时不管。
            ((log "  >be"))))))

      (map-elisp-comment #'fill-comment-paragraph)

      ;; (delete-trailing-whitespace)
      (goto-char (min point (point-max))))))

;;; End

(provide 'fill-elisp)

;;; fill-elisp.el ends here
#+end_src

map sexp 及 comment:

#+name: 2025-06-29-17-29
#+begin_src emacs-lisp :results silent :lexical t
(defun map-sexp (func &optional beg lv)
  "从 beg 起,遍历当前 buffer 中的 sexp, 深度优先。"
  (save-excursion
    (goto-char (or beg (point-min)))
    (let ((parse-sexp-ignore-comments t)
          (lv (or lv 1)) r start end)
      ;; scan-sexps 前进时默认跑到 sexp 的末尾,
      (setq end (point))

      (while (ignore-errors (setq end (scan-sexps end 1)))
        (goto-char end)

        ;; 我们在这里临时折回 sexp 的头部,调用用户函数。因为 quote 等
        ;; 前缀字符,我们用 `backward-sexp' 而不用 `scan-sexps'. 另外,
        ;; 我们这里用特殊的 marker 标记 start, 以免受 func 中的 insert
        ;; 操作影响。
        (setq start (progn (backward-sexp) (copy-marker (point) t)))

        (setq r (append r (list (funcall func lv))))

        ;; func 有可能已经改变了 point.
        (goto-char start)
        (setq end (scan-sexps (point) 1))

        ;; 处理 sexp 的子节点。
        (ignore-errors
          (down-list)

          ;; `down-list' 可能会跨越多个 sexp 跳入最近的 list, 比如给定
          ;; sexp: (^atom list), 光标位置^, 此时 `down-list' 会完全跨
          ;; 过作为 atom 的 sexp 进入 list, 而我们希望 `down-list' 在
          ;; 这种情况下报错——因非 list 无可深入,所以我们在此检测它是
          ;; 否跳出了当前 sexp.
          (when (> (point) end) (error nil))

          (setq r (append r (map-sexp func (point) (1+ lv)))))

        ;; 处理下个同级节点。
        (goto-char start)
        (setq end (scan-sexps (point) 1)))
      r)))

(defun map-elisp-comment (func)
  (save-excursion
    (save-match-data
      (goto-char (point-min))
      (let (r)
        (while (re-search-forward "\\s<" nil t)
          (backward-char (length comment-start))
          ;; 我们不想处理类似大纲的注释。
          (unless (or (looking-at ";;;"))
            (push (funcall func) r))
          (forward-comment 1))
        (nreverse r)))))
#+end_src

let 改:

#+name: 2025-06-29-17-24
#+begin_src emacs-lisp :results silent :lexical t
(defmacro !let (bindings &rest body)
  (declare (indent 1) (debug let))
  (cond
   ((null bindings) `(progn ,@body))
   (t
    (let (vars vals lambdas)
      (mapc
       (lambda (binding &optional var val carval)
         (setq var (or (car-safe binding) binding))
         (setq val (car (cdr-safe binding)))
         (setq carval (car-safe val))
         (cond
          ((memq carval '(lambda function))
           (push var lambdas))
          ((and (memq carval '(let let* letrec !let !let*))
                (eq (car (car (last val))) 'lambda))
           (push var lambdas)))
         (push var vars)
         (push val vals))
       bindings)
      (setq body `(funcall
                   (lambda (,@(nreverse vars))
                     ,@body)
                   ,@(nreverse vals)))
      (cond
       ((null lambdas) body)
       (t
        `(cl-macrolet
             ,(mapcar
               (lambda (s)
                 `(,s (&rest args)
                      `(funcall ,',s ,@args)))
               (nreverse lambdas))
           ,body)))))))

(defmacro !let* (bindings &rest body)
  (declare (indent 1) (debug let))
  (if (null bindings) `(progn ,@body)
    (setq bindings (reverse bindings))
    (while bindings
      (setq body (list `(!let (,(pop bindings)) ,@body))))
    (car body)))
#+end_src
1 个赞

测试:

M-x eww RET https://emacs-china.org/t/pp-fill-lisp/29712/2 RET
M-x org-mode
M-x read-only-mode
M-x org-babel-execute-buffer