roots of elisp

绘制Vanilla Emacs里面所有函数的引用关系图。

这是我大概一个月前想到的emacs lisp趣味挑战,原本想在论坛上办一个挑战比赛。但是因为紫薯布丁被吃完了,没有合适的奖品,所以只好作罢。

我的想法很简单,穷搜所有的函数符号。如果一个函数是字节码,想办法找到它所在的文件,加载那个文件就可以得到它原本的定义了。对于宏展开和词法绑定依赖我没有考虑那么多,因为其实有很多字节码是追溯不到源头的。虽然反汇编字节码也不是不能做,但是做全套有点超出我的能力了。因此我的图实际上几乎并不包含隐蔽的依赖(那是调试器级别的工作),更多的是一些表面的符号依赖关系。

我选定的工具是graphviz dot,虽然它不支持动态演示和并行处理,但是可以产生很大的图片。这个体量的数据使用d3.js处理起来应该也足够了,但是用elisp生产json比较麻烦,所以我弃了。

我的代码生成了一个大约10M的dot文件,使用sfdp layout,生成了一个172M的svg文件。普通的图片查看器根本加载不了这么大的svg文件,一打开就崩了,librsvg也处理不了超过100000行的svg,inkscape可以打开(我这台32G内存的机器内存爆满了)但很卡。然后我发现,inkscape居然不支持小于1%的缩放比,此需求至今仍未实现。所以最后,我放弃了矢量图,输出了一张大小为76M的png(上图是压缩过的)

以下贴上代码实现,感兴趣的坛友可以自己挑战一下(无奖),或者抄我的作业做点更漂亮的图。

;;; roots-of-lisp.el --- Generate a relationship graph of Elisp functions -*- lexical-binding: t -*-

;; generate a dependency deps of all lisp calls
;; tested in Vanilla Emacs 30.1-4
;;
;; emacs -q --batch -l roots-of-lisp.el

(defun build-function-call-graph ()
  "Build a deps of function calls from all Elisp functions."
  (let ((deps (make-hash-table :test 'equal))
        (type (make-hash-table :test 'equal)))
    (mapatoms (lambda (symbol)
                (when (fboundp symbol)
                  (classify-function symbol deps type)
                  ;; debug print
                  ;;(message "%S :: %S <- %S" symbol (gethash symbol type) (gethash symbol deps))
                  )))
    (visualize-function-call-graph-dot deps type)))

(defun classify-function (symbol deps type)
  "Find all function depends of SYMBOL, store a list of them in DEPS, keep a tag in TYPE."
  (unless (gethash symbol type)
    (let ((func (symbol-function symbol)))
      (cond
       ((symbolp func)
        (puthash symbol 'symbol type)
        (puthash symbol (list func) deps))
       ((primitive-function-p func)
        (puthash symbol 'primitive-function type))
       ((special-form-p func)
        (puthash symbol 'special-form type))
       ((and (listp func)
             (equal (car func) 'autoload))
        (if (gethash symbol deps)
            ;; autoload does not help
            (puthash symbol 'ill-autoload-form type)
          ;; if it is not loaded yet, try to autoload it first
          (condition-case nil
              (autoload-do-load func)
            (:success
             (message "searching %S" symbol)
             (puthash symbol (find-func-deps nil func) deps)
             (classify-function symbol deps type))
            (t (puthash symbol 'load-error type))))
        )
       ((closurep func)
        (cond
         ((interpreted-function-p func)
          (puthash symbol 'interpreted type))
         ((commandp symbol)
          (puthash symbol 'command type))
         (t
          (puthash symbol 'closure type))
         )
        ;; parse it
        (let ((args (aref func 0))
              (body (aref func 1)))
          (if (listp body)
              (progn
                (message "searching %S" symbol)
                (puthash symbol (find-func-deps args body) deps)
                )
            (puthash symbol 'byte-compiled-macro-generated type)))
        )
       ((macrop func)
        (puthash symbol 'macro type)
        (let ((args (aref (cdr func) 0))
              (body (aref (cdr func) 1)))
          (if (listp body)
              (progn
                (message "searching %S" symbol)
                (puthash symbol (find-func-deps args body) deps)
                )
            (puthash symbol 'byte-compiled-macro-generated type))))
       ((or (byte-code-function-p func)
            (native-comp-function-p func)
            (subrp func))
        ;; try to load it again from source code
        (let ((def (find-symbol-definition-exclusive symbol)))
          (cond
           ((equal def 'C-source)
            (puthash symbol 'C-source type))
           ((closurep def)
            (classify-function symbol deps type))
           (t
            (puthash symbol 'subr type))
           ))
        )
       (t ;; unknow type
        (puthash symbol 'unknown type))
       ))))

(defvar also-C-source nil
  "Whether search in Emacs C source code.")

(defun find-symbol-definition (symbol)
  "Find Lisp definition of given SYMBOL of TYPE, for test."
  (let* ((func (symbol-function symbol))
         (def (find-definition-noselect
               symbol nil (find-lisp-object-file-name
                           symbol func also-C-source))))
    (with-current-buffer (car def)
      (goto-char (cdr def))
      (eval-expression (sexp-at-point))))
  (symbol-function symbol))

(defvar library-blacklist
  '(;; macroexpansion exceed
    "/usr/share/emacs/30.1/lisp/emacs-lisp/cconv.el.gz"
    ;; macroexpansion exceed
    "/usr/share/emacs/30.1/lisp/emacs-lisp/macroexp.el.gz"
    ;; depends on specific network env
    "/usr/share/emacs/30.1/lisp/net/eudc-bob.el.gz"
    ;; should not be loaded twice
    "/usr/share/emacs/30.1/lisp/international/characters.el.gz"
    ;; emacs will crash when loading this
    "/usr/share/emacs/30.1/lisp/erc/erc.el.gz"
    ;; BUG: failed to search `latin1-display-ucs-per-lynx'
    "/usr/share/emacs/30.1/lisp/international/latin1-disp.el.gz"
    ;; BUG: failed to search `comp--add-cond-cstrs'
    "/usr/share/emacs/30.1/lisp/emacs-lisp/comp.el.gz"
    )
  "Library paths that will not be loaded.

Some Emacs libraries contains malformed `cl-defmacro' which may cause
stackoverflow or other dangerous forms.  We had better skip them before
get prepared for them.")

(defun find-symbol-definition-exclusive (symbol)
  "Find Lisp definition of given SYMBOL exclusively."
  (let* ((func (symbol-function symbol))
         (def (find-lisp-object-file-name symbol func also-C-source)))
    (cond
     ((and (stringp def)
           (string-match "\\.el\\(\\.gz\\)?$" def))
      (unless (file-regular-p def)
        ;; you may get gzipped elisp libraries.
        (setq def (concat def ".gz")))
      (when (and (file-regular-p def)
                 (not (member def library-blacklist)))
        (condition-case nil
            (load-file def)
          (:success
           ;; symbol may get redefined on loading!
           (when symbol (symbol-function symbol)))
          (t 'load-error)))
      )
     (t def))))

(defun find-func-deps (args body)
  ;; Here we simply try to find symbols that are functions.
  ;; Neglect inner dynamic bindings in lambda expressions.
  ;; For more complexed macro forms, it's hard to go further.
  ;; Doing macroexpansion will not solve it completely
  ;; because outer lexical bindings may be not parseable.
  ;; to completely parse a function, you will need something more.
  (cond
   ((null body) nil)
   ((listp body)
    (if (listp (cdr body))
        ;; regular list
        ;; first try to match a lambda form
        (if (and (equal (car body) 'lambda)
                 (listp (cadr body))
                 (caddr body))
            (find-func-deps (append args (cadr body))
                            (caddr body))
          ;; we do DFS on each entry
          ;; this may capture some explicit apply forms
          ;; but may cause problems when there is an infinite list generator
          ;; after all, let's forget about macros first.
          (cl-remove-duplicates
           (cl-loop for item in body append
                    (find-func-deps args item))))
      ;; dotted pair
      (cl-remove-duplicates
       (append
        (find-func-deps args (car body))
        (find-func-deps args (cdr body)))))
    )
   (t ;; an atom
    (when (and (symbolp body)
               (fboundp body)
               (not (member body args)))
      (list body)))))

(defun visualize-function-call-graph-dot (deps type)
  "Visualize the function call deps GRAPH using Graphviz."
  (with-temp-buffer
    (insert "digraph G {\n")
    (insert "  node [colorscheme=accent8];\n\n")
    (let ((nodemap (make-hash-table))
          (cnt 0))
      (maphash (lambda (func type)
                 (insert
                  (format "  f%d[label=\"%s\",color=%s];\n" cnt func
                          (pcase type
                            ('C-source "1")
                            ('primitive-function "2")
                            ('special-form "3")
                            ('interpreted-function "4")
                            ('closure "5")
                            ('macro "6")
                            ('byte-compiled-macro-generated "7")
                            ('command "8")
                            (_ "\"red\""))))
                 (puthash func cnt nodemap)
                 (setq cnt (1+ cnt)))
               type)
      (maphash (lambda (func calls)
                 (dolist (call calls)
                   (when (gethash call nodemap)
                     (insert (format "  f%d -> f%d;\n"
                                 (gethash func nodemap)
                                 (gethash call nodemap))))))
               deps))
    (insert "}\n")
    (write-region (point-min) (point-max) "function-call-graph.dot")
    ;; this could take hours
    ;; (shell-command "dot -Ksfdp -Tpng function-call-graph.dot -o function-call-graph.png")
    ))

(build-function-call-graph)

执行上面这份代码大概需要5-10分钟。

sfdp -x -Tpng function-call-graph.dot -o function-call-graph.png

大概需要5分钟。

6 个赞

这是绘制给AI看的吧, 应该只有AI才能理清楚,记忆住elisp中所有函数的引用关系

图只是数据的一种表示形式,你可以只选中一部分函数画出来,看看你感兴趣的函数之间有怎样的关系。

而且,这是一个游戏,没有那么强的目的性。

那分享一下我做的类似的事情: 在 LispWorks 和 SBCL 里面用 disassemble 可以显示编译后的结果, 其中通过 ; 类似的操作可以提取出部分在编译前使用的符号:

然后用 capi:graph-pane 即可绘制调用树:

(当然, 如果不添加规则约束的话画出来是一堆乱麻, 不过好处是可以懒得写规则也可以手动交互用鼠标把树节点给折叠掉)

可以看我的博客 ObjC 1

2 个赞

Cool ! 所以黑洞的中心是什么?

我猜应该是 Car, cdr 那些,不过我没装 graphviz 画不出来。

等会用 LZ 的代码排个序看看,用符号的被指向的箭头数量。

这样统计是错误的哦,需要计算的每个符号的逆向依赖树的体积,因为依赖是一层套一层的。还要记得剪掉递归依赖的圈圈。

1 个赞

我不太了解sbcl的字节码,但我猜想,这么做无法追踪built-in函数,因为它们可能被优化掉。

不过用这种追踪方式可以去检查外部库的接口依赖是很实用的,总之非常hacker!

但是从意义上来说,也许统计某个函数的出场频率更能反映我们喜欢用什么函数。如果一个函数的出场频率奇高,说明它具有某种根深蒂固的属性。

追踪包的依赖关系比较有意义。dot有子图功能,也许可以做个包依赖的子图。

要肉眼看清中心也很简单,给dot加上选项-Goverlap=scale,即可稀释箭头,但是会花费更多时间,能不能显示另当别论。

重新发明了 slime/xref.lisp at master · slime/slime · GitHub

https://dl.acm.org/doi/10.1145/1862396.1862397

https://www.lispworks.com/documentation/lw60/LW/html/lw-810.htm

1 个赞

built-in 函数确实会被优化掉, 主要的作用是用来逆向 LispWorks 的 objc-bridge 然后在 SBCL 上用 CFFI 做模拟.

(在不提供源码的情况下利用调用链来猜实现逻辑还是比较容易的

我的这段代码只是根据elisp手册13.1对elisp函数进行了简单的分类,也没有加入匹配form的能力。如果是xref其实超出我的能力范围,因为我并不熟悉commonlisp。

我一开始想的就是怎么画这张图,它的形状一定很有意思。至于xref,虽然我一直在用,反而不会去想它是怎么实现的。

我很讨厌事事遵循前人的约束,因为前人设计这些约束的时候往往只考虑了机器的事情,没有很好的考虑到人的事情。但是,在疲惫的时候,躺在前人的遗产里,爽。

在此揭晓,Vanilla Emacs中出勤率前十的函数:

24268 if
23398 quote
20780 let
18765 progn
17277 setq
15673 and
13980 or
10024 car
9907 let*
9812 not

恭喜if获得出勤率冠军!

3 个赞

slime/xref.lisp at 11adf42f2e9b62aa21934094dba2d4b2671bfef0 · slime/slime · GitHub

它是支持生成 call graph 的

https://www.cliki.net/psgraph

实际上我最早就是在 CMU AI Repo 里看到过 xref,对其能绘制 call graph 印象深刻,本身其目地就是画图,slime 只是复用代码来生成引用查找,所以一看这主题就知道早有人做过类似的事。

https://www.cs.cmu.edu/Groups/AI/lang/lisp/code/tools/xref/0.html

1 个赞

关于前人做过我倒是并不觉得意外,只是没想到graphviz dot居然也古老到这种程度,早知道就用d3.js做交互图了(其实我之前套模板做了一个支持拖拽的图,但是浏览器被超大图给卡死了)。

提到call graph这个名字,我想起来一个基于gtags的项目call-graph,这个项目在我6年前刚接触emacs和gtags的时候也给我留下了深刻印象,用tags建立c/c++调用的正向索引,然后使用hierarchy.el表示调用树。当时它bug还很多,我也不会修,就没有用下去。如今回去看了一眼,作者还在维护。

if quote 这种本质上是 operator 的东西我觉得不应该统计

你可以把special-form那一栏划掉,穷搜的过程中需要对函数分类。我们处理的函数是词法上的,并非语义上的。

实际上搜索范围还包括了宏,但是我们在搜索时始终不对任何表达式求值。用宏生成的函数名目前都是感知不到的,因为这些名字需要对宏求值才出现,在我的代码里应该会归类到byte-compiled-macro-generated或者ill-autoload-form下面。相关的处理我在这个问题提到过。