/ jujutsu-diff.el
jujutsu-diff.el
  1  ;;; jujutsu-diff.el --- Description -*- lexical-binding: t; -*-
  2  ;;
  3  ;; Copyright (C) 2024 Benjamin Andresen
  4  ;;
  5  ;; Author: Benjamin Andresen <b@lambda.icu>
  6  ;; Maintainer: Benjamin Andresen <b@lambda.icu>
  7  ;; Created: August 12, 2024
  8  ;; Modified: August 12, 2024
  9  ;; Version: 0.0.1
 10  ;; Homepage: https://github.com/bennyandresen/jujutsu-diff
 11  ;; Package-Requires: ((emacs "24.3"))
 12  ;;
 13  ;; This file is not part of GNU Emacs.
 14  ;;
 15  ;;; Commentary:
 16  ;;
 17  ;;  Description
 18  ;;
 19  ;;; Code:
 20  (require 'dash)
 21  (require 'dash-x)
 22  (require 'ht)
 23  (require 's)
 24  
 25  (require 'jujutsu-core)
 26  
 27  (defun jujutsu-diff--run (filename &optional revision)
 28    (let ((revision (or revision "@")))
 29      (jujutsu-core--run-command
 30       (s-join " " (list "diff" "-r" revision "--git" filename)))))
 31  
 32  (-comment
 33   (jujutsu-diff--run "dash-x.el")
 34   1)
 35  
 36  (defun jujutsu-diff--split-git-diff-by-file (diff-output)
 37    "Split DIFF-OUTPUT into separate diffs for each file."
 38    (let ((file-diffs '())
 39          (current-diff "")
 40          (diff-header-regex "^diff --git a/\\(.+\\) b/\\1$"))
 41      (dolist (line (split-string diff-output "\n"))
 42        (if (string-match diff-header-regex line)
 43            (progn
 44              (unless (s-blank? current-diff)
 45                (push current-diff file-diffs))
 46              (setq current-diff line))
 47          (setq current-diff (concat current-diff "\n" line))))
 48      (unless (string-empty-p current-diff)
 49        (push current-diff file-diffs))
 50      (nreverse file-diffs)))
 51  
 52  (-tests
 53   (->> "resources/test-diff-git.output"
 54        -slurp
 55        jujutsu-diff--split-git-diff-by-file
 56        (nth 0))
 57   1)
 58  
 59  (defun jujutsu-diff--parse-diffs (diff-lst)
 60    "Parse a list of git diff outputs DIFF-LST into a hash table.
 61  Each entry in the hash table will contain the filename, metadata,
 62  and diff content."
 63    (let ((result (ht-create)))
 64      (dolist (diff diff-lst)
 65        (let* ((lines (s-split "\n" diff))
 66               (filename-line (car lines))
 67               (filename (when (string-match "^diff --git a/\\(.+\\) b/\\1$" filename-line)
 68                           (match-string 1 filename-line)))
 69               (metadata-end (--find-index (s-starts-with? "@@" it) lines))
 70               (metadata (when metadata-end
 71                           (-slice lines 1 metadata-end)))
 72               (diff-content (when metadata-end
 73                               (-slice lines metadata-end))))
 74          (when filename
 75            (ht-set! result filename
 76                     (ht (:metadata metadata)
 77                         (:diff-content (s-join "\n" diff-content)))))))
 78      result))
 79  
 80  (-tests
 81   (-> "resources/test-diff-git.output"
 82       -slurp
 83       jujutsu-diff--split-git-diff-by-file
 84       jujutsu-diff--parse-diffs
 85       (ht-get* "jujutsu.el" :metadata))
 86   :=
 87   '("index 5b32d77748...1aa38bdebf 100644"
 88     "--- a/jujutsu.el"
 89     "+++ b/jujutsu.el"))
 90  
 91  
 92  (defun jujutsu-diff--split-git-diff-into-hunks (diff-output)
 93    "Split DIFF-OUTPUT into hunks based on '@@' markers."
 94    (with-temp-buffer
 95      (insert diff-output)
 96      (goto-char (point-min))
 97      (let ((hunks '())
 98            (hunk-start (point-min))
 99            (hunk-end (point-min)))
100        (while (re-search-forward "^@@" nil t)
101          (setq hunk-end (match-beginning 0))
102          (when (> hunk-end hunk-start)
103            (push (buffer-substring-no-properties hunk-start hunk-end) hunks))
104          (setq hunk-start hunk-end))
105        (push (buffer-substring-no-properties hunk-start (point-max)) hunks)
106        (nreverse hunks))))
107  
108  (-comment
109   (--> "resources/test-diff-git.output"
110        -slurp
111        jujutsu-diff--split-git-diff
112        jujutsu-diff--parse-diffs
113        (ht-get* it "jujutsu.el" :diff-content)
114        jujutsu-diff--split-git-diff-into-hunks)
115   1)
116  
117  (defun jujutsu-diff--parse-diff-hunk (hunk-content)
118    "Parse the HUNK-CONTENT into a list of hash-tables, excluding the header."
119    (let ((result '()))
120      (dolist (line (s-lines hunk-content))
121        (let ((entry (ht-create)))
122          (cond
123           ((string-prefix-p "-" line)
124            (ht-set! entry :type :removed)
125            (ht-set! entry :content (substring line 1)))
126           ((string-prefix-p "+" line)
127            (ht-set! entry :type :added)
128            (ht-set! entry :content (substring line 1)))
129           (t
130            (ht-set! entry :type :context)
131            (ht-set! entry :content (if (> (length line) 0)
132                                        (substring line 1)
133                                      ""))))
134          (push entry result)))
135      (nreverse result)))
136  
137  (-tests
138   (->
139    "        (s-split \"\\n\" it t)))
140  
141   (defun jj--map-to-escaped-string (map)
142  -  \"Convert MAP (hash-table) to an escaped string.\"
143  +  \"Convert MAP (hash-table) to an escaped string for use as a jj template.\"
144     (->> map
145          (ht-map (lambda (key value)
146                    (format \"\\\"%s \\\" ++ %s ++ \\\"\\\\n\\\"\"
147  "
148    jujutsu-diff--parse-diff-hunk
149    ;; because of ht-equality specialness just a single map is checked.
150    car
151    (ht-equal? (ht (:content "       (s-split \"\\n\" it t)))")
152                   (:type :context))))
153   := t)
154  
155  (defun jujutsu-diff--parse-hunks (hunks)
156    "Parse a list of HUNKS into a hash-table.
157  Each entry in the hash-table will contain the hunk header and the
158  parsed diff content."
159    (let ((result (ht-create)))
160      (dolist (hunk hunks)
161        (let* ((lines (s-split "\n" hunk))
162               (header (car lines))
163               (content (s-join "\n" (cdr lines))))
164          (when (s-starts-with? "@@ " header)
165            (ht-set! result
166                     header
167                     (jujutsu-diff--parse-diff-hunk content)))))
168      result))
169  
170  (-tests
171   (--> "resources/test-diff-git.output"
172        -slurp
173        jujutsu-diff--split-git-diff-by-file
174        jujutsu-diff--parse-diffs
175        (ht-get* it "jujutsu.el" :diff-content)
176        jujutsu-diff--split-git-diff-into-hunks
177        jujutsu-diff--parse-hunks
178        (ht-get it "@@ -75,7 +82,7 @@")
179        (nth 3 it)
180        (ht-equal?
181         it
182         (ht (:type :removed)
183             (:content "  \"Convert MAP (hash-table) to an escaped string.\""))))
184   := t)
185  
186  (defun jujutsu-diff--max-content-width (chunks type)
187    "Calculate the maximum content width for CHUNKS of given TYPE."
188    (->> chunks
189         (--filter (or (eq (ht-get it :type) :context)
190                       (eq (ht-get it :type) type)))
191         (--map (length (ht-get it :content)))
192         (-max)))
193  
194  (defun jujutsu-diff--pad-string (s width)
195    "Pad string S to WIDTH."
196    (format (format "%%-%ds" width) (or s "")))
197  
198  (-comment
199   (jujutsu-diff--pad-string "  hello world" 30)
200  
201   1)
202  
203  (defun jujutsu-diff--format-line (left right left-width right-width separator)
204    "Format LEFT and RIGHT content with given widths (LEFT-WIDTH,RIGHT-WIDTH) and SEPARATOR."
205    (-let* [((&hash :content left-content :type left-type) left)
206            ((&hash :content right-content :type right-type) right)
207            (left-formatted (jujutsu-diff--pad-string left-content left-width))
208            (right-formatted (jujutsu-diff--pad-string right-content right-width))
209            (is-context (and (eq left-type :context) (eq right-type :context)))]
210      (concat
211       (if is-context
212           (propertize left-formatted 'face 'magit-diff-context)
213         (propertize left-formatted 'face 'magit-diff-removed))
214       separator
215       (if is-context
216           (propertize right-formatted 'face 'magit-diff-context)
217         (propertize right-formatted 'face 'magit-diff-added)))))
218  
219  (defun jujutsu-diff--format-header (content total-width)
220    "Format header CONTENT to TOTAL-WIDTH."
221    (propertize (jujutsu-diff--pad-string content total-width) 'face 'magit-diff-hunk-heading))
222  
223  (defun jujutsu-diff--process-chunk (acc chunk left-width right-width separator)
224    "Process a single CHUNK, updating the accumulator ACC."
225    (let ((formatted (car acc))
226          (removed (cdr acc))
227          (type (ht-get chunk :type))
228          (content (ht-get chunk :content)))
229      (pcase type
230        (:header
231         (cons (cons (jujutsu-diff--format-header content (+ left-width (length separator) right-width))
232                     formatted)
233               removed))
234        (:context
235         (cons (cons (jujutsu-diff--format-line chunk chunk left-width right-width separator)
236                     formatted)
237               removed))
238        (:removed
239         (cons formatted (cons chunk removed)))
240        (:added
241         (if (null removed)
242             (cons (cons (jujutsu-diff--format-line (ht-create) chunk left-width right-width separator)
243                         formatted)
244                   removed)
245           (cons (cons (jujutsu-diff--format-line (car removed) chunk left-width right-width separator)
246                       formatted)
247                 (cdr removed)))))))
248  
249  (defun jujutsu-diff--create-side-by-side-diff (diff-git-chunk)
250    "Create a side-by-side diff representation from DIFF-GIT-CHUNK."
251    (let* ((left-width (jujutsu-diff--max-content-width diff-git-chunk :removed))
252           (right-width (jujutsu-diff--max-content-width diff-git-chunk :added))
253           (separator " ")
254           (initial-acc (cons nil nil))  ; (formatted-lines . removed-lines)
255           (result-and-removed
256            (-reduce-from
257             (lambda (acc chunk)
258               (jujutsu-diff--process-chunk acc chunk left-width right-width separator))
259             initial-acc
260             diff-git-chunk))
261           (formatted-lines (car result-and-removed))
262           (remaining-removed (cdr result-and-removed)))
263      (-concat
264       (nreverse formatted-lines)
265       (--map (jujutsu-diff--format-line it (ht-create) left-width right-width separator)
266              remaining-removed))))
267  
268  (-comment
269   ;; for demo purposes only
270   (let* ((hunks (--> "resources/test-diff-git.output"
271                      -slurp
272                      jujutsu-diff--split-git-diff-by-file
273                      jujutsu-diff--parse-diffs
274                      (ht-get* it "jujutsu.el" :diff-content)
275                      jujutsu-diff--split-git-diff-into-hunks
276                      jujutsu-diff--parse-hunks))
277          (hunk-keys (ht-keys hunks))
278          (len (length hunk-keys))
279          (rand (random len)))
280     (with-current-buffer (get-buffer-create "*jj debug*")
281       (fundamental-mode)
282       (erase-buffer)
283       (--> hunks
284            (ht-get it (nth rand hunk-keys))
285            jujutsu-diff--create-side-by-side-diff
286            (-map (lambda (s) (insert s) (insert "\n")) it))
287       (display-buffer "*jj debug*")))
288   1)
289  
290  (provide 'jujutsu-diff)
291  ;;; jujutsu-diff.el ends here