UP | HOME

🔙回到目录 | 🔻 本网页更新于 [2026-10-18 日 21:18]

emacs-保存ediff结果为带字符级高亮的src-block


有的时候,你很怀念magit/ediff 的字符级别高亮。相较于普通的行级diff ,这种更符合直觉,也更便利。但是自带的 src-block 中只支持 diff 选项,且因为 font-lock 等设置,导致无法显示 (setq diff-refine 'font-lock) 时的高亮。

还好,我们有 LLM 可以帮助我们生成代码。

编辑:提取逻辑使其更加通用之后上传到了这里 https://github.com/TomoeMami/org-diff-src

1. 使用流程

在一个 org-mode buffer 中,执行 M-x my/org-heading-ediff-src-block ,然后选择原 heading 与新 heading ,则会在光标所在位置生成一个字符级别高亮的 ediff src-block 。

结果例:

--- original: 1
+++ new: 2
@@ -1,4 +1,4 @@
-* 1
+* 2
   18:09 🌠┈┈┈┈┈┈┈┈┈┈┈┈ now
-  24:00 ┈┈┈┈┈ ┈┈┈┈┈┈┈┈┈┈┈┈
-  英文减少
+  14:00 ┈┈┈┈┈ ┈┈┈┈┈┈┈┈┈┈┈┈
+  中文减

2. LLM 分析

2.1. org 本身的限制

  • font-lock-mode 在 Org 的隐藏渲染缓冲区中被禁用 (缓冲区名以空格开头);
  • diff-refine 产生的是 overlay,而 Org 从隐藏缓冲区把渲染结果搬回主缓冲区时只复制 text properties,不复制 overlay 。

2.2. 解决思路

选 heading → 复制 subtree → 临时 buffer → diff -u → 清理 header
→ 插入 src block
→ org-src-font-lock-fontify-block 触发
→ 创建隐藏缓冲区 → ediff-src-mode
→ font-lock-flush
→ 自定义 matcher 执行 diff–refine-hunk
→ 产生精化 overlay
→ copy-fine-overlays 转成 text property
→ Org 把 text properties 搬回主 buffer
→ 主 buffer 中字符级高亮可见

2.3. 更本质的结论

只要拿到一段标准的 unified diff 文本 ,不管来源是 ediff、 git diff 、 diff -u 还是 Magit,都可以塞进 #+begin_src ediff ,然后靠前面那套 fontify 机制出字符级高亮。

  • git diff / git diff --word-diff 的输出 → 直接粘进 ediff 块;
  • Magit 的 diff buffer → (substring-no-properties (buffer-string)) 提取 → 插入块;
  • diff-no-select → 同上;
  • ediff 的 ediff-diff-buffer → 就是上面那个函数。

代码中那套 ediff-src-mode / copy-fine-overlays / font-lock-flush 的机制 只做一件事 :给定一段 unified diff 文本,在 Org 的渲染流程里算出字符级精化,并把它转成能被 Org 搬运的 text property。至于这段文本从哪来,它不关心。

3. 相关代码

插入ediff 字级高亮

;;; ediff src blocks for Org
(require 'diff)
(require 'diff-mode)
;;;; 1. Heading selection and subtree extraction ---------------------------

(defun my/org-ediff--consult-lookup (selected candidates &rest _)
  "Lookup SELECTED in CANDIDATES and return its heading marker."
  (let ((cand (car (member selected candidates))))
    (when cand
      (or (get-text-property 0 'org-marker cand)
          (get-text-property 0 'consult--candidate cand)))))

(defun my/org-ediff--heading-label ()
  "Return a one-line title for the heading at point."
  (let ((heading (org-get-heading t t t t)))
    (if (or (null heading) (string-match-p "\\`[ \t]*\\'" heading))
        "heading"
      (replace-regexp-in-string "[\t\n\r]+" " "
                                (substring-no-properties heading)))))

(defun my/org-ediff--select-heading (prompt)
  "Select a heading with consult, showing PROMPT in the minibuffer.
Return the heading title."
  (let ((candidates (consult-org--headings nil nil nil)))
    (unless candidates
      (user-error "No headings in buffer"))
    ;; `consult--read' returns the value produced by :lookup, i.e. the
    ;; heading marker -- not the candidate string.
    (let ((marker (consult--read
                   candidates
                   :prompt prompt
                   :sort nil
                   :require-match t
                   :history '(:input consult-org--history)
                   :narrow (consult-org--narrow)
                   :state (consult--jump-state)
                   :lookup #'my/org-ediff--consult-lookup)))
      (unless marker
        (user-error "No heading selected"))
      (goto-char marker)
      (my/org-ediff--heading-label))))

(defun my/org-ediff--copy-subtree ()
  "Copy the subtree at point and return it as a string.
The kill ring and the system clipboard are left unchanged."
  (let ((kill-ring kill-ring)
        (kill-ring-yank-pointer kill-ring-yank-pointer)
        (interprogram-cut-function nil))
    (org-copy-subtree)
    org-subtree-clip))

(defun my/org-ediff--read-subtree (prompt prefix)
  "Prompt for a heading with PROMPT and copy its subtree.
Return a cons (LABEL . TREE), where LABEL is PREFIX plus the
heading title."
  (save-excursion
    (let ((title (my/org-ediff--select-heading prompt)))
      (cons (concat prefix title)
            (my/org-ediff--copy-subtree)))))

;;;; 2. Diff generation -----------------------------------------------------

(defun my/org-ediff--normalize-subtree (tree)
  "Paste TREE as a level-1 subtree in a temporary Org buffer.
Return the buffer text."
  (with-temp-buffer
    (let ((org-mode-hook nil)
          (org-inhibit-startup t))
      (org-mode))
    (org-paste-subtree 1 tree)
    (buffer-string)))

(defun my/org-ediff--unified-diff (text-a text-b label-a label-b coding)
  "Return a unified diff between TEXT-A and TEXT-B.
LABEL-A and LABEL-B become the names in the `---'/`+++' header.
CODING is used for the temporary files."
  (let ((file-a (make-temp-file "org-ediff-a-"))
        (file-b (make-temp-file "org-ediff-b-")))
    (unwind-protect
        (progn
          (let ((coding-system-for-write coding))
            (with-temp-file file-a (insert text-a))
            (with-temp-file file-b (insert text-b)))
          (with-temp-buffer
            (let ((coding-system-for-read coding)
                  (status (call-process diff-command nil t nil
                                        "-u"
                                        "--label" label-a
                                        "--label" label-b
                                        file-a file-b)))
              (if (memq status '(0 1))
                  (buffer-string)
                (error "diff failed (status %S): %s"
                       status (buffer-string))))))
      (ignore-errors (delete-file file-a))
      (ignore-errors (delete-file file-b)))))

;;;; 3. ediff-src-mode ------------------------------------------------------

(defun my/org-ediff--copy-fine-overlays (beg end)
  "Turn `diff-mode fine' overlay faces in BEG..END into `face' text properties.
Org's source-block fontification copies text properties, but not
overlays, so this is what makes refinement visible inline."
  (dolist (ov (overlays-in beg end))
    (when (eq (overlay-get ov 'diff-mode) 'fine)
      (let ((face (overlay-get ov 'face))
            (obeg (overlay-start ov))
            (oend (overlay-end ov)))
        (when (and face (< obeg oend))
          (let* ((old (get-text-property obeg 'face))
                 (faces (cond ((null old) nil)
                              ((listp old) old)
                              (t (list old)))))
            ;; `face' lists are merged with the first face taking
            ;; precedence, so put the refinement face first.
            (unless (memq face faces)
              (put-text-property obeg oend 'face (cons face faces)))))))))

(defun my/org-ediff--font-lock-refined (max)
  "Like `diff--font-lock-refined', but write refinement as text properties.
`diff-refine' must be `font-lock'."
  (when (eq diff-refine 'font-lock)
    (when (get-char-property (point) 'diff--font-lock-refined)
      (goto-char (next-single-char-property-change
                  (point) 'diff--font-lock-refined nil max)))
    (condition-case nil
        (diff--iterate-hunks
         max
         (lambda (beg end)
           (unless (get-char-property beg 'diff--font-lock-refined)
             (diff--refine-hunk beg end)
             (let ((ol (make-overlay beg end)))
               (overlay-put ol 'diff--font-lock-refined t)
               (overlay-put ol 'evaporate t)
               (overlay-put ol 'modification-hooks
                            '(diff--overlay-auto-delete))))
           ;; Re-copy on every pass: font-lock removes `face'
           ;; properties before refontifying.
           (my/org-ediff--copy-fine-overlays beg end)))
      (error nil))))

(defvar my/org-ediff-font-lock-keywords
  (mapcar (lambda (kw)
            (if (or (eq kw 'diff--font-lock-refined)
                    (and (consp kw)
                         (eq (car kw) 'diff--font-lock-refined)))
                (list #'my/org-ediff--font-lock-refined)
              kw))
          diff-font-lock-keywords)
  "`diff-font-lock-keywords' with text-property based refinement.")

(define-derived-mode ediff-src-mode diff-mode "Ediff"
  "Major mode for `ediff' Org source blocks.
This is `diff-mode' with `diff-refine' set to `font-lock'.  The
refinement is turned into `face' text properties so that Org's
source-block fontification can copy it into the Org buffer."
  (setq-local diff-refine 'font-lock)
  (setq-local diff-font-lock-keywords my/org-ediff-font-lock-keywords)
  (font-lock-flush))

;; `org-src-get-lang-mode' appends "-mode", so map "ediff" to
;; `ediff-src', which yields `ediff-src-mode'.
(let ((pair (assoc "ediff" org-src-lang-modes)))
  (if pair
      (setcdr pair 'ediff-src)
    (push '("ediff" . ediff-src) org-src-lang-modes)))

;;;; 4. Command -------------------------------------------------------------

;;;###autoload
(defun my/org-heading-ediff-src-block ()
  "Insert an `ediff' src block comparing two Org subtrees.

Select an original heading and a new heading in the current buffer.
Their subtrees are copied with `org-copy-subtree', pasted with
`org-paste-subtree' into temporary Org buffers, and compared with
`diff'.  The unified diff is inserted at point as

  #+begin_src ediff
  ...
  #+end_src

The block is fontified with `ediff-src-mode', which enables
`diff-refine' = `font-lock' and uses the Magit-style
`diff-refine-removed' / `diff-refine-added' faces for
character-level changes.  No Ediff session is started."
  (interactive)
  (unless (derived-mode-p 'org-mode)
    (user-error "Not in an Org buffer"))
  (let* ((orig-buf (current-buffer))
         (origin (point-marker))
         (coding (or buffer-file-coding-system 'utf-8-unix))
         (entry-a (my/org-ediff--read-subtree
                   "Original heading: " "original: "))
         (entry-b (my/org-ediff--read-subtree
                   "New heading: " "new: "))
         (label-a (car entry-a))
         (label-b (car entry-b))
         (text-a (my/org-ediff--normalize-subtree (cdr entry-a)))
         (text-b (my/org-ediff--normalize-subtree (cdr entry-b)))
         (diff-text (my/org-ediff--unified-diff
                     text-a text-b label-a label-b coding)))
    (pop-to-buffer orig-buf)
    (goto-char origin)
    (unless (bolp) (insert "\n"))
    (insert "#+begin_src ediff\n")
    (let ((body-beg (point)))
      (if (or (null diff-text) (string= "" diff-text))
          (insert "# No differences\n")
        (insert diff-text)
        (unless (eq (aref diff-text (1- (length diff-text))) ?\n)
          (insert "\n")))
      (let ((body-end (point)))
        (insert "#+end_src\n")
        ;; Force inline fontification/refinement now.
        (when (fboundp 'org-src-font-lock-fontify-block)
          (condition-case nil
              (org-src-font-lock-fontify-block "ediff"
                                               body-beg body-end)
            (error nil)))))))

© Published by Emacs 32.0.50 (Org mode 10.0-pre) | RSS 评论