/ 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