branch: elpa/cider
commit 8569592c36a68b13fa76fb1294bb0559a218653b
Author: Iñaki Arenaza <inaki.aren...@biotz.io>
Commit: Bozhidar Batsov <bozhi...@batsov.dev>

    [Fix: #3786] Sort dictionaries by key in nrepl-bencode
    
    CIDER doesn't adhere to Bencode spec which requires dictionary keys to
    be sorted alphabetically. This hasn't been a problem so far because
    the bencode reader on nREPL side doesn't validate the order of
    keys. Still, it will be rigorous to produce correct values according
    to the selected format.
---
 CHANGELOG.md                    |  1 +
 nrepl-client.el                 | 19 +++++++++++-
 nrepl-dict.el                   |  4 ++-
 test/nrepl-bencode-tests.el     | 44 ++++++++++++++++++++++++--
 test/nrepl-server-mock.el       | 69 +++++++++++++++++++++++++++++++----------
 test/utils/nrepl-tests-utils.el | 44 ++++++++++++++++++++++++++
 6 files changed, 159 insertions(+), 22 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8be4f326b9..0f972f2d7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@
 ### Bugs fixed
 
 - [#3784](https://github.com/clojure-emacs/cider/issues/3784): Inspector: make 
point less erratic when navigating between inspector screens.
+- [#3786](https://github.com/clojure-emacs/cider/issues/3786): Sort 
dictionaries by key in nrepl-bencode
 
 ### Bugs fixed
 
diff --git a/nrepl-client.el b/nrepl-client.el
index 97d53503e8..b5b50ba48a 100644
--- a/nrepl-client.el
+++ b/nrepl-client.el
@@ -423,13 +423,30 @@ decoded message or nil if the strings were completely 
decoded."
       (erase-buffer)
       (cons string-q response-q))))
 
+(defun nrepl--bencode-dict (dict)
+  "Encode DICT with bencode.
+According to the Bittorrent protocol specification[1], when bencoding
+dictionaries, keys must be strings and appear in sorted order (sorted as
+raw strings, not alphanumerics).
+
+[1] https://www.bittorrent.org/beps/bep_0003.html#bencoding";
+  (let* ((sorted-keys (sort (nrepl-dict-keys dict)
+                            (lambda (a b)
+                                     (string< a b))))
+         (sorted-dict (nrepl-dict)))
+    (dolist (k sorted-keys sorted-dict)
+      (nrepl-dict-put sorted-dict
+                      k
+                      (nrepl-dict-get dict k)))
+    (mapconcat #'nrepl-bencode (cdr sorted-dict) "")))
+
 (defun nrepl-bencode (object)
   "Encode OBJECT with bencode.
 Integers, lists and nrepl-dicts are treated according to bencode
 specification.  Everything else is encoded as string."
   (cond
    ((integerp object) (format "i%de" object))
-   ((nrepl-dict-p object) (format "d%se" (mapconcat #'nrepl-bencode (cdr 
object) "")))
+   ((nrepl-dict-p object) (format "d%se" (nrepl--bencode-dict object)))
    ((listp object) (format "l%se" (mapconcat #'nrepl-bencode object "")))
    (t (format "%s:%s" (string-bytes object) object))))
 
diff --git a/nrepl-dict.el b/nrepl-dict.el
index 09d5fabb1c..e946dacfbe 100644
--- a/nrepl-dict.el
+++ b/nrepl-dict.el
@@ -37,7 +37,9 @@
 
 (defun nrepl-dict (&rest key-vals)
   "Create nREPL dict from KEY-VALS."
-  (cons 'dict key-vals))
+  (if (cl-evenp (length key-vals))
+      (cons 'dict key-vals)
+    (error "An even number of KEY-VALS is needed to build a dict object")))
 
 (defun nrepl-dict-from-hash (hash)
   "Create nREPL dict from HASH."
diff --git a/test/nrepl-bencode-tests.el b/test/nrepl-bencode-tests.el
index a4e3e59cbb..410c5f186c 100644
--- a/test/nrepl-bencode-tests.el
+++ b/test/nrepl-bencode-tests.el
@@ -327,10 +327,48 @@ If object is incomplete, return a decoded path."
                   "int" 1
                   "int-list" (1 2 3 4 5)
                   "string" "f30dbd69-7095-40c1-8e98-7873ae71a07f"
-                  "dict" (dict "k1" 1 "k2" 2 "k3" "333333")
+                  "unordered-dict" (dict "k3" "333333" "k2" 2 "k1" 1)
                   "status" ("eval-error")))
-      (expect (car (nrepl-bdecode-string (nrepl-bencode obj)))
-              :to-equal obj))))
+      ;; Bencoded dicts may change the order of the keys of original
+      ;; dict, as bencoding a dict MUST encode the keys in sorted
+      ;; order.  We need to compare objects taking this into account.
+      (expect (bencodable-obj-equal?
+               obj
+               (car (nrepl-bdecode-string (nrepl-bencode obj))))
+              :to-be t))))
+
+(describe "nrepl--bencode"
+  (it "encodes strings"
+    (expect (nrepl-bencode "spam") :to-equal "4:spam")
+    (expect (nrepl-bencode "") :to-equal "0:")
+    ;; Assuming we use UTF-8 encoded strings, which
+    ;; Clojure/Clojurescript do.
+    (expect (nrepl-bencode "Божидар") :to-equal "14:Божидар"))
+
+  (it "encodes integers"
+    (expect (nrepl-bencode 3) :to-equal "i3e")
+    (expect (nrepl-bencode -3) :to-equal "i-3e"))
+
+  (it "encodes lists"
+    (expect (nrepl-bencode '("spam" "eggs"))
+            :to-equal "l4:spam4:eggse")
+    (expect (nrepl-bencode '("spam" ("eggs" "salt")))
+            :to-equal "l4:spaml4:eggs4:saltee")
+    (expect (nrepl-bencode '(1 2 3 (4 5 (6)) 7 8))
+            :to-equal "li1ei2ei3eli4ei5eli6eeei7ei8ee"))
+
+  (it "encodes dicts"
+    (expect (nrepl-bencode '(dict "spam" "eggs" "cow" "moo"))
+            :to-equal "d3:cow3:moo4:spam4:eggse")
+    (expect (nrepl-bencode '(dict "spam" "eggs"
+                                  "cow" (dict "foo" "foobar" "bar" "baz")))
+            :to-equal "d3:cowd3:bar3:baz3:foo6:foobare4:spam4:eggse"))
+
+  (it "handles nils"
+    (expect (nrepl-bencode '("" nil (dict "" nil)))
+            :to-equal "l0:led0:leee")
+    (expect (nrepl-bencode '("" nil (dict "cow" nil "" 6)))
+            :to-equal "l0:led0:i6e3:cowleee")))
 
 ;; benchmarks
 
diff --git a/test/nrepl-server-mock.el b/test/nrepl-server-mock.el
index d02995bedc..4b3eb98d00 100644
--- a/test/nrepl-server-mock.el
+++ b/test/nrepl-server-mock.el
@@ -33,38 +33,73 @@
 (require 'queue)
 (require 'cl)
 
+(defun nrepl-server-mock--get-keys (dict keys)
+  "Get the values for KEYS from nrepl-dict DICT.
+Get them as a list, so they can be easily consumed by
+`cl-destructuring-bind`."
+  (mapcar (lambda (k) (nrepl-dict-get dict k)) keys))
+
 (defun nrepl-server-mock-filter (proc output)
   "Handle the nREPL message found in OUTPUT sent by the client PROC.
 Minimal implementation, just enough for fulfilling clients' testing
-requirements."
+requirements.
+
+Additional complexity is added by the fact that bencoded dictionaries
+must have their keys in sorted order.  But we don't want to have to
+remember to write them down as such in the test values here (because
+there is ample room for mistakes that are harder to debug)."
   ;; (mock/log! ":mock.filter/output %s :msg %s" proc output)
 
   (condition-case error-details
       (let* ((msg (queue-dequeue (cdr (nrepl-bdecode output))))
              (_ (mock/log! ":mock.filter/msg :in %S" msg))
+             ;; Message id and session are needed for all request
+             ;; messages and responses. Get them once here.
+             (msg-id (nrepl-dict-get msg "id"))
+             (msg-session (nrepl-dict-get msg "session"))
              (response (pcase msg
-                         (`(dict "op" "clone"
-                                 "client-name" "CIDER"
-                                 "client-version" ,cider-version
-                                 "id" ,id)
-                          `(dict "id" ,id
+                         ((pred (lambda (msg)
+                                  (let ((keys '("client-version")))
+                                    (cl-destructuring-bind (client-version) 
(nrepl-server-mock--get-keys msg keys)
+                                      (bencodable-obj-equal? msg
+                                                             `(dict "op" 
"clone"
+                                                                    
"client-name" "CIDER"
+                                                                    
"client-version" ,client-version
+                                                                    "id" 
,msg-id))))))
+                          `(dict "id" ,msg-id
                                  "session" "a-session"
                                  "status" ("done")
                                  "new-session" "a-new-session"))
 
-                         (`(dict "op" "describe" "session" ,session "id" ,id)
-                          `(dict "id" ,id "session" ,session "status"
-                                 ("done")))
+                         ((pred (bencodable-obj-equal? `(dict "op" "describe"
+                                                              "id" ,msg-id
+                                                              "session" 
,msg-session)))
+                          `(dict "id" ,msg-id
+                                 "session" ,msg-session
+                                 "status" ("done")))
+
                          ;; Eval op can include other fields in addition to the
                          ;; code, we only need the signature and the session 
and
-                         ;; id fields at the end.
-                         (`(dict "op" "eval" "code" ,_code . ,rest)
-                          (cl-destructuring-bind (_ session _ id) (seq-drop 
rest (- (seq-length rest) 4))
-                            `(dict "id" ,id "session" ,session "status"
-                                   ("done"))))
-                         (`(dict "op" "close" "session" ,session "id" ,id)
-                          `(dict "id" ,id "session" ,session "status"
-                                 ("done"))))))
+                         ;; id fields.
+                         ((pred (lambda (msg)
+                                  (let ((keys '("op")))
+                                    (cl-destructuring-bind (op) 
(nrepl-server-mock--get-keys msg keys)
+                                      (bencodable-obj-equal? `(dict "op" ,op
+                                                                    "id" 
,msg-id
+                                                                    "session" 
,msg-session)
+                                                             `(dict "op" "eval"
+                                                                    "id" 
,msg-id
+                                                                    "session" 
,msg-session))))))
+                          `(dict "id" ,msg-id
+                                 "session" ,msg-session
+                                 "status" ("done")))
+
+                         ((pred (bencodable-obj-equal? `(dict "op" "close"
+                                                              "id" ,msg-id
+                                                              "session" 
,msg-session)))
+                          `(dict "id" ,msg-id
+                                 "session" ,msg-session
+                                 "status" ("done"))))))
 
         (mock/log! ":mock.filter/msg :out %S" response)
         (if (not response)
diff --git a/test/utils/nrepl-tests-utils.el b/test/utils/nrepl-tests-utils.el
index 11ec71f3e8..64b7e4af3a 100644
--- a/test/utils/nrepl-tests-utils.el
+++ b/test/utils/nrepl-tests-utils.el
@@ -113,6 +113,50 @@ calling process."
                             (message 
":nrepl-mock-server-process-started...")))))
     server-process))
 
+(defun bencodable-obj-equal? (obj1 obj2)
+  "Compare bencodable objects OBJ1 and OBJ2 for equality.
+They are considered equal if they have the same content.  Dicts are
+considered equal if they have the same key-value pairs, even if the keys
+appear in different order."
+  (cond
+   ((nrepl-dict-p obj1)
+    (if (not (nrepl-dict-p obj2))
+        nil
+      (let ((obj1-keys (sort (nrepl-dict-keys obj1)
+                             (lambda (a b)
+                               (string< a b))))
+            (obj2-keys (sort (nrepl-dict-keys obj2)
+                             (lambda (a b)
+                               (string< a b)))))
+        (if (not (equal obj1-keys obj2-keys))
+            nil
+          (seq-every-p #'identity
+                       (mapcar (lambda (key)
+                                 (bencodable-obj-equal?
+                                  (nrepl-dict-get obj1 key)
+                                  (nrepl-dict-get obj2 key)))
+                               obj1-keys))))))
+   ((listp obj1)
+    (if (not (and (listp obj2)
+                  (= (length obj1)
+                     (length obj2))))
+        nil
+      (seq-every-p #'identity
+                   (cl-mapcar (lambda (obj1 obj2)
+                                (bencodable-obj-equal? obj1 obj2))
+                              obj1
+                              obj2))))
+   ((integerp obj1)
+    (if (not (integerp obj2))
+        nil
+      (= obj1 obj2)))
+   ((stringp obj1)
+    (if (not (stringp obj2))
+        nil
+      (string= obj1 obj2)))
+   ;; Any other kind of value is not a bencodable value.
+   nil))
+
 (provide 'nrepl-tests-utils)
 
 ;;; nrepl-tests-utils.el ends here

Reply via email to