面向产品经理的Emacs教程:14. 在Org mode里写代码块

· 3602字 · 8分钟

1 课程回顾 🔗

我们在上节课学习了如何在Emacs里通过 magit 插件来对我们的文件做版本管理,今后我们就可以通过 magit + github (甚至你可以自己搭建一个 git 服务器)来管理你想管理的任何文件,其中强烈建议大家将配置文件管理起来。

这节课,我们来聊一聊Org mode里的代码块。

2 Org mode代码块概述 🔗

我们在 第2课:Org mode初识 - Org mode能干嘛 - 文学编程 这个章节已经简单介绍过Org mode里的代码块。它非常的强大!

今天,我们对代码块进行一些个性化的配置,让它更加好用。

Org mode的代码块着实强大,不同的后端(backend)能提供不同的功能,有的可以让你在代码块里画图,有的可以让你在代码块里写Python,有的可以让你在代码块里写Shell,有的甚至可以让你在代码块里实现API请求的测试。

我们今天的课程,不涉及第三方的代码块,所有第三方的代码块后端,将在后续课程里按照场景的方式进行介绍。

3 Org mode代码块基本配置 🔗

我们先对代码块做一个基本配置:

(use-package org-src
  :ensure nil
  :hook (org-babel-after-execute . org-redisplay-inline-images)
  :bind (("s-l" . show-line-number-in-src-block)
         :map org-src-mode-map
         ("C-c C-c" . org-edit-src-exit))
  :init
  ;; 设置代码块的默认头参数
  (setq org-babel-default-header-args
        '(
          (:eval    . "never-export")     ; 导出时不执行代码块
          (:session . "none")
          (:results . "replace")          ; 执行结果替换
          (:exports . "both")             ; 导出代码和结果
          (:cache   . "no")
          (:noweb   . "no")
          (:hlines  . "no")
          (:wrap    . "results")          ; 结果通过#+begin_results包裹
          (:tangle  . "no")               ; 不写入文件
          ))
  :config
  ;; ==================================
  ;; 如果出现代码运行结果为乱码,可以参考:
  ;; https://github.com/nnicandro/emacs-jupyter/issues/366
  ;; ==================================
  (defun display-ansi-colors ()
    (ansi-color-apply-on-region (point-min) (point-max)))
  (add-hook 'org-babel-after-execute-hook #'display-ansi-colors)

  ;; ==============================================
  ;; 通过overlay在代码块里显示行号,s-l显示,任意键关闭
  ;; ==============================================
  (defvar number-line-overlays '()
    "List of overlays for line numbers.")

  (defun show-line-number-in-src-block ()
    (interactive)
    (save-excursion
      (let* ((src-block (org-element-context))
             (nlines (- (length
                         (s-split
                          "\n"
                          (org-element-property :value src-block)))
                        1)))
        (goto-char (org-element-property :begin src-block))
        (re-search-forward (regexp-quote (org-element-property :value src-block)))
        (goto-char (match-beginning 0))

        (cl-loop for i from 1 to nlines
                 do
                 (beginning-of-line)
                 (let (ov)
                   (setq ov (make-overlay (point) (point)))
                   (overlay-put ov 'before-string (format "%3s | " (number-to-string i)))
                   (add-to-list 'number-line-overlays ov))
                 (next-line))))

    ;; now read a char to clear them
    (read-key "Press a key to clear numbers.")
    (mapc 'delete-overlay number-line-overlays)
    (setq number-line-overlays '()))

  ;; =================================================
  ;; 执行结果后,如果结果所在的文件夹不存在将自动创建
  ;; =================================================
  (defun check-directory-exists-before-src-execution (orig-fun
                                                      &optional arg
                                                      info
                                                      params)
    (when (and (assq ':file (cadr (cdr (org-babel-get-src-block-info))))
               (member (car (org-babel-get-src-block-info)) '("mermaid" "ditaa" "dot" "lilypond" "plantuml" "gnuplot" "d2")))
      (let ((foldername (file-name-directory (alist-get :file (nth 2 (org-babel-get-src-block-info))))))
        (if (not (file-exists-p foldername))
            (mkdir foldername)))))
  (advice-add 'org-babel-execute-src-block :before #'check-directory-exists-before-src-execution)

  ;; =================================================
  ;; 自动给结果的图片加上相关属性
  ;; =================================================
  (setq original-image-width-before-del "400") ; 设置图片的默认宽度为400
  (setq original-caption-before-del "")        ; 设置默认的图示文本为空

  (defun insert-attr-decls ()
    "insert string before babel execution results"
    (insert (concat "\n#+CAPTION:"
                    original-caption-before-del
                    "\n#+ATTR_ORG: :width "
                    original-image-width-before-del
                    "\n#+ATTR_LATEX: :width "
                    (if (>= (/ (string-to-number original-image-width-before-del) 800.0) 1)
                        "1.0"
                      (number-to-string (/ (string-to-number original-image-width-before-del) 800.0)))
                    "\\linewidth :float nil"
                    "\n#+ATTR_HTML: :width "
                    original-image-width-before-del
                    )))

  (defun insert-attr-decls-at (s)
    "insert string right after specific string"
    (let ((case-fold-search t))
      (if (search-forward s nil t)
          (progn
            ;; (search-backward s nil t)
            (insert-attr-decls)))))

  (defun insert-attr-decls-at-results (orig-fun
                                       &optional arg
                                       info
                                       param)
    "insert extra image attributes after babel execution"
    (interactive)
    (progn
      (when (member (car (org-babel-get-src-block-info)) '("mermaid" "ditaa" "dot" "lilypond" "plantuml" "gnuplot" "d2"))
        (setq original-image-width-before-del (number-to-string (if-let* ((babel-width (alist-get :width (nth 2 (org-babel-get-src-block-info))))) babel-width (string-to-number original-image-width-before-del))))
        (save-excursion
          ;; `#+begin_results' for :wrap results, `#+RESULTS:' for non :wrap results
          (insert-attr-decls-at "#+begin_results")))
      (org-redisplay-inline-images)))
  (advice-add 'org-babel-execute-src-block :after #'insert-attr-decls-at-results)

  ;; 再次执行时需要将旧的图片相关参数行删除,并从中头参数中获得宽度参数,参考
  ;; https://emacs.stackexchange.com/questions/57710/how-to-set-image-size-in-result-of-src-block-in-org-mode
  (defun get-attributes-from-src-block-result (&rest args)
    "get information via last babel execution"
    (let ((location (org-babel-where-is-src-block-result))
          ;; 主要获取的是图示文字和宽度信息,下面这个正则就是为了捕获这两个信息
          (attr-regexp "[:blank:]*#\\+\\(ATTR_ORG: :width \\([0-9]\\{3\\}\\)\\|CAPTION:\\(.*\\)\\)"))
      (setq original-caption-before-del "") ; 重置为空
      (when location
        (save-excursion
          (goto-char location)
          (when (looking-at (concat org-babel-result-regexp ".*$"))
            (next-line 2)               ; 因为有个begin_result的抽屉,所以往下2行
            ;; 通过正则表达式来捕获需要的信息
            (while (looking-at attr-regexp)
              (when (match-string 2)
                (setq original-image-width-before-del (match-string 2)))
              (when (match-string 3)
                (setq original-caption-before-del (match-string 3)))
              (next-line)               ; 因为设置了:wrap,所以这里不需要删除这一行
              )
            )))))
  (advice-add 'org-babel-execute-src-block :before #'get-attributes-from-src-block-result)

  :custom
  ;; 代码块语法高亮
  (org-src-fontify-natively t)
  ;; 使用编程语言的TAB绑定设置
  (org-src-tab-acts-natively t)
  ;; 保留代码块前面的空格
  (org-src-preserve-indentation t)
  ;; 代码块编辑窗口的打开方式:当前窗口+代码块编辑窗口
  (org-src-window-setup 'reorganize-frame)
  ;; 执行前是否需要确认
  (org-confirm-babel-evaluate nil)
  ;; 代码块默认前置多少空格
  (org-edit-src-content-indentation 0)
  ;; 代码块的语言模式设置,设置之后才能正确语法高亮
  (org-src-lang-modes '(("C"            . c)
                        ("C++"          . c++)
                        ("bash"         . sh)
                        ("cpp"          . c++)
                        ("elisp"        . emacs-lisp)
                        ("python"       . python)
                        ("shell"        . sh)
                        ("mysql"        . sql)
                        ))
  ;; 在这个阶段,只需要加载默认支持的语言
  (org-babel-load-languages '((python          . t)
                              (awk             . t)
                              (C               . t)
                              (calc            . t)
                              (emacs-lisp      . t)
                              (eshell          . t)
                              (shell           . t)
                              (sql             . t)
                              (css             . t)
                              ))
  )

上面的配置里有几个要点:

  1. 我个人经常使用代码块画图,如 plantuml , d2 等,这些代码块都会通过 :file 头参数指定一个输出的图片文件路径。因此,配置里有关于这部分的优化,主要有
    • 如果输出的图片不在当前目录,如放在 xxxx.assets 文件夹里时,自动创建该目录;
    • 自动给图片加上 #+attr_org:, #+attr_html:, #+attr_latex: 等属性,并根据头参数里的 :width 参数自动设置,这样在导出时会按照设定的宽度导出;
  2. 想对所有代码块默认生效的头参数,可以统一设置在 org-babel-default-header-args 这个变量里;
  3. 代码块的语法高亮需要在 org-src-lang-modes 里设置,如 jupyter-python 的语法高亮需要设置成 python
  4. Org mode默认启动的代码块语言仅仅有 emacs-lisp ,我们需要将我们想用到的代码块语言加到这个列表中去;

配置完后,我们可以在org文件里写一些代码块,光标可以在代码块内,按下 C-c C-c 执行这个代码块,Org mode会自动执行代码块并输出答案。这里因为设置了 :wrap 参数默认为 results ,所以它会把结果自动包裹在 #+begin_results#+end_results 的区域里:

经过这个基本配置,我们修改了代码块默认空格2个字符的行为,所有的代码都会顶格,这样看上去舒服多了。

下面这张图是我们用 d2 代码块画图的一个例子,可以先看下效果,先不用急,后面我会专门写一篇如何通过Org mode的纯文本来画图的教程:

4 emacs-lisp代码块设置 🔗

在Emacs里, emacs-lisp 当然是最常用的代码块了,我们所有的配置文件都是通过 emacs-lisp 的代码块来管理的。下面我们对 emacs-lisp 语言进行一些设置:

(use-package elisp-mode
  :ensure nil
  :after org
  :bind (:map emacs-lisp-mode-map
              ("C-c C-b" . eval-buffer)
              ("C-c C-c" . eval-to-comment)
              :map lisp-interaction-mode-map
              ("C-c C-c" . eval-to-comment)
              :map org-mode-map
              ("C-c C-;" . eval-to-comment)
              )
  :init
  ;; for emacs-lisp org babel
  (add-to-list 'org-babel-default-header-args:emacs-lisp
             '(:results . "value pp"))
  :config
  (defconst eval-as-comment-prefix " ⇒ ")
  (defun eval-to-comment (&optional arg)
    (interactive "P")
    ;; (if (not (looking-back ";\\s*"))
    ;;     (call-interactively 'comment-dwim))
    (call-interactively 'comment-dwim)
    (progn
      (search-backward ";")
      (forward-char 1))
    (delete-region (point) (line-end-position))
    (save-excursion
      (let ((current-prefix-arg '(4)))
        (call-interactively 'eval-last-sexp)))
    (insert eval-as-comment-prefix)
    (end-of-line 1))
  )

设置完 emacs-lisp 后,我们可以在代码块里按下 C-c C-; 来执行一个s表达式,通过上面设置的 eval-to-comment 函数,会自动在这个s表达式的后添加一个注释,并输出执行的结果,非常方便:

5 Python代码块设置 🔗

产品经理经常也会需要写一些简单的Python脚本,因此,我们对Python的代码块也做一个配置,其中我们安装了一个插件 py-autopep8.el ,它可以让我们在编写Python保存文件的时候,自动以 PEP8 标准做格式美化(需要提前 brew install autopep8 )。

这里有一个小的函数 add-list-to-list ,这个函数可以让我们不用一行一行的通过 add-to-list 将元素添加到列表,非常方便,我们设置 org-babel-default-header-args:python 这个变量的时候就会用到这个函数,我们可以把这个函数放到 init-base.el 这个标题行下,我们后续有一些非常便利的小函数都可以放到那里。

;; 将列表加入到列表的函数
(defun add-list-to-list (dst src)
  "Similar to `add-to-list', but accepts a list as 2nd argument"
  (set dst
       (append (eval dst) src)))
(use-package python
  :ensure nil
  :mode ("\\.py\\'" . python-mode)
  :hook ((inferior-python-mode . my/buffer-auto-close))
  :init
  (add-list-to-list 'org-babel-default-header-args:python '((:results . "output pp")
                                                            (:noweb   . "yes")
                                                            (:session . "py")
                                                            (:async   . "yes")
                                                            (:exports . "both")
                                                            ))
  :config
  (setq python-indent-offset 4)
  ;; Disable readline based native completion
  (setq python-shell-completion-native-enable nil)
  (setq python-indent-guess-indent-offset-verbose nil)
  (setq python-shell-interpreter "python3"
        python-shell-prompt-detect-failure-warning nil)
  ;; disable native completion
  (add-to-list 'python-shell-completion-native-disabled-interpreters "python3")
  ;; Env vars
  (with-eval-after-load 'exec-path-from-shell
    (exec-path-from-shell-copy-env "PYTHONPATH"))
  )

;; need to pip install autopep8 first
(use-package py-autopep8
  :ensure t
  :hook (python-mode . py-autopep8-mode)
  )

可以看到,我们的Python代码块,设置默认开启了 :session 这个功能,这个功能可以让我们在不同的Python代码块里共享变量等定义:

我们也默认开启了 :noweb 功能,通过这个功能,我们可以在某个代码块里,引用另外一个代码块的代码,非常灵活:

为什么要用Emacs来写Python代码呢?因为他可以像 Jupyter notebook 一样,让你一个代码块一个代码块的写,跟着你的思路一步一步的实现你想要的功能。这对于产品经理而言,尤为重要,因为产品经理不要求代码写的多么漂亮,多么鲁棒,产品经理需要的是快速有效的实现。

6 shell代码块设置 🔗

shell脚本是我们经常会用到的另外一种代码块,下面我们设置一下:

(use-package sh-script
  :ensure nil
  :mode (("\\.sh\\'"     . sh-mode)
         ("zshrc"        . sh-mode)
         ("zshenv"       . sh-mode)
         ("/PKGBUILD\\'" . sh-mode))
  :hook (sh-mode . sh-mode-setup)
  :bind (:map sh-mode-map
         ("C-c C-e" . sh-execute-region))
  :init
  ;; for org babel
  (add-list-to-list 'org-babel-default-header-args:shell
                  '((:results . "output")
                    (:tangle  . "no")))
  :config
  (defun sh-mode-setup ()
    (add-hook 'after-save-hook #'executable-make-buffer-file-executable-if-script-p nil t))
  :custom
  (sh-basic-offset 2)
  (sh-indentation 2))

然后我们可以就可以愉快的在Org mode里写shell脚本或执行shell命令啦:

7 限定我们代码块的结果行数 🔗

我们有的时候通过代码块执行一些 shell 命令,常常会发现太长了,几百行的结果,此时我们需要对代码块的结果进行一个限制(当然,你也可以不用限制),下面这段代码能实现这个需求,代码来自于 twlz0ne 大佬在这篇贴子的回复

;; limit the babel result length
(defvar org-babel-result-lines-limit 40)
(defvar org-babel-result-length-limit 6000)

(defun org-babel-insert-result@limit (orig-fn result &rest args)
  (if (not (member (car (org-babel-get-src-block-info)) '("jupyter-python"))) ; not for jupyter-python etc.
    (if (and result (or org-babel-result-lines-limit org-babel-result-length-limit))
        (let (new-result plines plenght limit)
          (with-temp-buffer
            (insert result)
            (setq plines (if org-babel-result-lines-limit
                             (goto-line org-babel-result-lines-limit)
                           (point-max)))
            (setq plenght (if org-babel-result-length-limit
                              (min org-babel-result-length-limit (point-max))
                            (point-max)))
            (setq limit (min plines plenght))
            (setq new-result (concat (buffer-substring (point-min) limit)
                                     (if (< limit (point-max)) "..."))))
          (apply orig-fn new-result args))
      (apply orig-fn result args))
    (apply orig-fn result args)))

(advice-add 'org-babel-insert-result :around #'org-babel-insert-result@limit)

设置完这个后,我们看到当我们 ls -al ~/ 时,我们的home目录下有1488个文件和文件夹,但代码块的执行结果只会输出40行,非常方便:

8 关于括号显示的增强 🔗

在 emacs-lisp 里,所有的语句都是通过括号括起来的,括号嵌套一多,我们很多时候就就很难区分了,我们可以看下Emacs在默认设置下括号嵌套多的时候代码块里的效果:

我们来设置优化一下括号的显示。

8.1 高亮匹配的括号 🔗

(use-package paren
  :ensure nil
  :hook (after-init . show-paren-mode)
  :custom
  (show-paren-when-point-inside-paren t)
  (show-paren-when-point-in-periphery t))

8.2 不同层级的括号用不同的颜色显示 🔗

rainbow-delimiters 插件将多彩显示括号等分隔符。

(use-package rainbow-delimiters
  :ensure t
  :hook (prog-mode . rainbow-delimiters-mode))

设置完这两个插件后,我们可以看到,所有不同层级的括号,都用了不同的颜色区分,光标放在括号里和括号外,都会自动高亮匹配的括号对,增强了括号显示的视觉效果:

9 结语 🔗

经过今天的学习,我们了解了在Org mode里写代码块的一些知识,我们可以愉快的在Org mode里写emacs-lisp、shell、python等代码了,而且我们对代码块相关的设置也做了一些强化,如括号的高亮和多彩显示等。这对于我们通过Org mode来构建自己的知识体系是非常有帮助的。

这节课的配置文件的快照见:emacs-config-l14.org

你也可以在 这里 查看最新的配置文件。