[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[elpa] externals/crdt 22910f2 01/80: initial commit
From: |
ELPA Syncer |
Subject: |
[elpa] externals/crdt 22910f2 01/80: initial commit |
Date: |
Sat, 28 Aug 2021 10:57:29 -0400 (EDT) |
branch: externals/crdt
commit 22910f25374c5be1501ab22d184652d484b735b9
Author: Qiantan Hong <qhong@mit.edu>
Commit: Qiantan Hong <qhong@mit.edu>
initial commit
---
crdt.el | 862 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 862 insertions(+)
diff --git a/crdt.el b/crdt.el
new file mode 100644
index 0000000..fcec24f
--- /dev/null
+++ b/crdt.el
@@ -0,0 +1,862 @@
+;;; crdt.el --- collaborative editing using Conflict-free Replicated Data Types
+;;
+;; Copyright (C) 2020 Qiantan Hong
+;;
+;; Author: Qiantan Hong <qhong@mit.edu>
+;; Maintainer: Qiantan Hong <qhong@mit.edu>
+;; Keywords: collaboration crdt
+;;
+;; crdt.el is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+;;
+;; crdt.el is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with crdt.el. If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;; * Algorithm
+;; This packages implements the Logoot split algorithm
+;; André, Luc, et al. "Supporting adaptable granularity of changes for
massive-scale collaborative editing." 9th IEEE International Conference on
Collaborative Computing: Networking, Applications and Worksharing. IEEE, 2013.
+;; * Protocol
+;; Text-based version
+;; (it should be easy to migrate to a binary version. Using text for better
debugging for now)
+;; Every message takes the form (type . body)
+;; type can be: insert hello cursor challenge sync
+;; - insert
+;; body takes the form (crdt-id position-hint content)
+;; - position-hint is the buffer position where the operation happens at
the site
+;; which generates the operation. Then we can play the trick that start
search
+;; near this position at other sites to speedup crdt-id search
+;; - content is the string to be inserted
+;; - delete
+;; body takes the form (position-hint (crdt-id . length)*)
+;; - cursor
+;; body takes the form
+;; (site-id point-position-hint point-crdt-id mark-position-hint
mark-crdt-id)
+;; *-crdt-id can be either a CRDT ID, or
+;; - nil, which means clear the point/mark
+;; - contact
+;; body takes the form
+;; (site-id name address)
+;; when name is nil, clear the contact for this site-id
+;; - hello
+;; This message is sent from client to server, when a client connect to
the server.
+;; body takes the form (client-name &optional response)
+;; - challenge
+;; body takes the form (salt)
+;; - sync
+;; This message is sent from server to client to get it sync to the state
on the server.
+;; It's always sent after server receives a hello message.
+;; Might be used for error recovery or other optimization in the future.
+;; One optimization I have in mind is let server try to merge all CRDT
item into a single
+;; one and try to synchronize this state to clients at best effort.
+;; body takes the form (site-id major-mode content . crdt-id-list)
+;; - site-id is the site ID the server assigned to the client
+;; - major-mode is the major mode used at the server site
+;; - content is the string in the buffer
+;; - crdt-id-list is generated from CRDT--DUMP-IDS
+
+
+;;; Code:
+
+(defgroup crdt nil
+ "Collaborative editing using Conflict-free Replicated Data Types."
+ :prefix "crdt-"
+ :group 'applications)
+(defcustom crdt-ask-for-name t
+ "Ask for display name everytime a CRDT session is to be started."
+ :type 'boolean)
+(defcustom crdt-default-name ""
+ "Default display name."
+ :type 'string)
+(defcustom crdt-ask-for-password t
+ "Ask for server password everytime a CRDT server is to be started."
+ :type 'boolean)
+
+(require 'cl-lib)
+
+(require 'color)
+(defvar crdt-cursor-region-colors
+ (let ((n 10))
+ (cl-loop for i below n
+ for hue by (/ 1.0 n)
+ collect (cons
+ (apply #'color-rgb-to-hex
+ (color-hsl-to-rgb hue 0.5 0.5))
+ (apply #'color-rgb-to-hex
+ (color-hsl-to-rgb hue 0.2 0.5))))))
+
+(defun crdt--get-cursor-color (site-id)
+ "Get cursor color for SITE-ID."
+ (car (nth (mod site-id (length crdt-cursor-region-colors))
crdt-cursor-region-colors)))
+(defun crdt--get-region-color (site-id)
+ "Get region color for SITE-ID."
+ (cdr (nth (mod site-id (length crdt-cursor-region-colors))
crdt-cursor-region-colors)))
+(defun crdt--move-cursor (ov pos)
+ "Move pseudo cursor overlay OV to POS."
+ ;; Hax!
+ (let* ((eof (eq pos (point-max)))
+ (eol (unless eof (eq (char-after pos) ?\n)))
+ (end (if eof pos (1+ pos)))
+ (display-string
+ (when eof
+ (unless (or (eq (point) (point-max))
+ (cl-some (lambda (ov)
+ (and (eq (overlay-get ov 'category)
'crdt-pseudo-cursor)
+ (overlay-get ov 'before-string)))
+ (overlays-in (point-max) (point-max))))
+ (propertize " " 'face (overlay-get ov 'face))))))
+ (move-overlay ov pos end)
+ (overlay-put ov 'before-string display-string)))
+(defun crdt--move-region (ov pos mark)
+ "Move pseudo marked region overlay OV to mark between POS and MARK."
+ (move-overlay ov (min pos mark) (max pos mark)))
+
+
+;; CRDT IDs are represented by unibyte strings (for efficient comparison)
+;; Every two bytes represent a big endian encoded integer
+;; For base IDs, last two bytes are always representing site ID
+;; Stored strings are BASE-ID:OFFSETs. So the last two bytes represent offset,
+;; and second last two bytes represent site ID
+(defconst crdt--max-value (lsh 1 16))
+;; (defconst crdt--max-value 4)
+;; for debug
+(defconst crdt--low-byte-mask 255)
+(defsubst crdt--get-two-bytes (string index)
+ "Get the big-endian encoded integer from STRING starting from INDEX.
+INDEX is counted by bytes."
+ (logior (lsh (elt string index) 8)
+ (elt string (1+ index))))
+(defsubst crdt--get-two-bytes-with-offset (string offset index default)
+ "Helper function for CRDT--GENERATE-ID.
+Get the big-endian encoded integer from STRING starting from INDEX,
+but with last two-bytes of STRING (the offset portion) replaced by OFFSET,
+and padded infintely by DEFAULT to the right."
+ (cond ((= index (- (string-bytes string) 2))
+ offset)
+ ((< (1+ index) (string-bytes string))
+ (logior (lsh (elt string index) 8)
+ (elt string (1+ index))))
+ (t default)))
+
+(defsubst crdt--id-offset (id)
+ "Get the literal offset integer from ID.
+Note that it might deviate from real offset for a character
+in the middle of a block."
+ (crdt--get-two-bytes id (- (string-bytes id) 2)))
+(defsubst crdt--set-id-offset (id offset)
+ "Set the OFFSET portion of ID destructively."
+ (let ((length (string-bytes id)))
+ (aset id (- length 2) (lsh offset -8))
+ (aset id (- length 1) (logand offset crdt--low-byte-mask))))
+(defsubst crdt--id-replace-offset (id offset)
+ "Create and return a new id string by replacing the OFFSET portion from ID."
+ (let ((new-id (substring id)))
+ (crdt--set-id-offset new-id offset)
+ new-id))
+(defsubst crdt--id-site (id)
+ "Get the site id from ID."
+ (crdt--get-two-bytes id (- (string-bytes id) 4)))
+(defsubst crdt--generate-id (low-id low-offset high-id high-offset site-id)
+ "Generate a new ID between LOW-ID and HIGH-ID.
+The generating site is marked as SITE-ID.
+Offset parts of LOW-ID and HIGH-ID are overriden by LOW-OFFSET
+and HIGH-OFFSET. (to save two copying from using CRDT--ID-REPLACE-OFFSET)"
+ (let* ((l (crdt--get-two-bytes-with-offset low-id low-offset 0 0))
+ (h (crdt--get-two-bytes-with-offset high-id high-offset 0
crdt--max-value))
+ (bytes (cl-loop for pos from 2 by 2
+ while (< (- h l) 2)
+ append (list (lsh l -8)
+ (logand l crdt--low-byte-mask))
+ do (setq l (crdt--get-two-bytes-with-offset low-id
low-offset pos 0))
+ do (setq h (crdt--get-two-bytes-with-offset high-id
high-offset pos crdt--max-value))))
+ (m (+ l 1 (random (- h l 1)))))
+ (apply #'unibyte-string
+ (append bytes (list (lsh m -8)
+ (logand m crdt--low-byte-mask)
+ (lsh site-id -8)
+ (logand site-id crdt--low-byte-mask)
+ 0
+ 0)))))
+
+;; CRDT-ID text property actually stores a cons of (ID-STRING . END-OF-BLOCK-P)
+(defsubst crdt--get-crdt-id-pair (pos &optional obj)
+ "Get the (CRDT-ID . END-OF-BLOCK-P) pair at POS in OBJ."
+ (get-text-property pos 'crdt-id obj))
+(defsubst crdt--get-starting-id (pos &optional obj)
+ "Get the CRDT-ID at POS in OBJ."
+ (car (crdt--get-crdt-id-pair pos obj)))
+(defsubst crdt--end-of-block-p (pos &optional obj)
+ "Get the END-OF-BLOCK-P at POS in OBJ."
+ (cdr (crdt--get-crdt-id-pair pos obj)))
+(defsubst crdt--get-starting-id-maybe (pos &optional obj limit)
+ "Get the CRDT-ID at POS in OBJ if POS is no smaller than LIMIT.
+Return NIL otherwise."
+ (unless (< pos (or limit (point-min)))
+ (car (get-text-property pos 'crdt-id obj))))
+(defsubst crdt--get-id-offset (starting-id pos &optional obj limit)
+ "Get the real offset integer for a character at POS.
+Assume the stored literal ID is STARTING-ID."
+ (let* ((start-pos (previous-single-property-change (1+ pos) 'crdt-id obj (or
limit (point-min)))))
+ (+ (- pos start-pos) (crdt--id-offset starting-id))))
+(defsubst crdt--get-id (pos &optional obj limit)
+ "Get the real CRDT ID at POS."
+ (let ((limit (or limit (point-min))))
+ (if (< pos limit) ""
+ (let* ((starting-id (crdt--get-starting-id pos obj))
+ (left-offset (crdt--get-id-offset starting-id pos obj limit)))
+ (crdt--id-replace-offset starting-id left-offset)))))
+
+(defsubst crdt--set-id (pos id &optional end-of-block-p obj limit)
+ "Set the crdt ID and END-OF-BLOCK-P at POS in OBJ.
+Any characters after POS but before LIMIT that used to
+have the same (CRDT-ID . END-OF-BLOCK-P) pair are also updated
+with ID and END-OF-BLOCK-P."
+ (put-text-property pos (next-single-property-change pos 'crdt-id obj (or
limit (point-max))) 'crdt-id (cons id end-of-block-p) obj))
+
+(cl-defmacro crdt--with-insertion-information
+ ((beg end &optional beg-obj end-obj beg-limit end-limit) &body body)
+ "Helper macro to setup some useful variables."
+ `(let* ((not-begin (> ,beg ,(or beg-limit '(point-min)))) ; if it's nil,
we're at the beginning of buffer
+ (left-pos (1- ,beg))
+ (starting-id-pair (when not-begin (crdt--get-crdt-id-pair left-pos
,beg-obj)))
+ (starting-id (if not-begin (car starting-id-pair) ""))
+ (left-offset (if not-begin (crdt--get-id-offset starting-id left-pos
,beg-obj ,beg-limit) 0))
+ (not-end (< ,end ,(or end-limit '(point-max))))
+ (ending-id (if not-end (crdt--get-starting-id ,end ,end-obj) ""))
+ (right-offset (if not-end (crdt--id-offset ending-id) 0))
+ (beg ,beg)
+ (end ,end)
+ (beg-obj ,beg-obj)
+ (end-obj ,end-obj)
+ (beg-limit ,beg-limit)
+ (end-limit ,end-limit))
+ ,@body))
+(defmacro crdt--split-maybe ()
+ '(when (and not-end (eq starting-id (crdt--get-starting-id end end-obj)))
+ ;; need to split id block
+ (crdt--set-id end (crdt--id-replace-offset starting-id (1+ left-offset))
+ (crdt--end-of-block-p left-pos beg-obj) end-obj end-limit)
+ (rplacd (get-text-property left-pos 'crdt-id beg-obj) nil) ;; clear
end-of-block flag
+ t))
+
+(defsubst crdt--same-base-p (a b)
+ (let* ((a-length (string-bytes a))
+ (b-length (string-bytes b)))
+ (and (eq a-length b-length)
+ (let ((base-length (- a-length 2)))
+ (eq t (compare-strings a 0 base-length b 0 base-length))))))
+
+(defmacro crdt--defvar-permanent-local (name &optional val docstring)
+ `(progn
+ (defvar-local ,name ,val ,docstring)
+ (put ',name 'permanent-local t)))
+(crdt--defvar-permanent-local crdt--local-id nil "Local site-id.")
+(crdt--defvar-permanent-local crdt--inhibit-update nil "When set, don't call
CRDT--LOCAL-* on change.
+This is useful for functions that apply remote change to local buffer,
+to avoid recusive calling of CRDT synchronization functions.")
+(crdt--defvar-permanent-local crdt--changed-string nil)
+(crdt--defvar-permanent-local crdt--last-point nil)
+(crdt--defvar-permanent-local crdt--last-mark nil)
+(crdt--defvar-permanent-local crdt--overlay-table nil
+ "A hash table that maps SITE-ID to CONSes of the
form (CURSOR-OVERLAY . REGION-OVERLAY).")
+(crdt--defvar-permanent-local crdt--contact-table nil
+ "A hash table that maps SITE-ID to LISTs of the
form (DISPLAY-NAME ADDRESS).")
+(crdt--defvar-permanent-local crdt--local-name nil)
+(crdt--defvar-permanent-local crdt--user-list-buffer nil)
+
+(defvar crdt-user-list-mode-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "RET") #'crdt-user-list-goto)
+ map))
+(define-derived-mode crdt-user-list-mode tabulated-list-mode
+ "CRDT User List"
+ (setq tabulated-list-format [("Display name" 15 t)
+ ("Address" 15 t)
+ ("Port" 7 t)]))
+(defun crdt-user-list-goto ()
+ (interactive)
+ (let ((site-id (tabulated-list-get-id)))
+ (switch-to-buffer-other-window crdt--user-list-parent)
+ (when site-id
+ (goto-char (overlay-start (car (gethash site-id
crdt--overlay-table)))))))
+(crdt--defvar-permanent-local crdt--user-list-parent nil "Set to the CRDT
shared buffer, local in a CRDT-USER-LIST buffer.")
+(defun crdt-list-users (&optional crdt-buffer display-buffer)
+ "Display a list of active users working on a CRDT-shared buffer CRDT-BUFFER.
+If DISPLAY-BUFFER is provided, display the output there. Otherwise use a
dedicated
+buffer for displaying active users on CRDT-BUFFER."
+ (interactive)
+ (with-current-buffer (or crdt-buffer (current-buffer))
+ (unless crdt-mode
+ (error "Not a CRDT shared buffer."))
+ (unless display-buffer
+ (unless (and crdt--user-list-buffer (buffer-live-p
crdt--user-list-buffer))
+ (let ((crdt-buffer (current-buffer)))
+ (setq crdt--user-list-buffer
+ (generate-new-buffer (concat (buffer-name (current-buffer))
+ " users")))
+ (with-current-buffer crdt--user-list-buffer
+ (setq crdt--user-list-parent crdt-buffer))))
+ (setq display-buffer crdt--user-list-buffer))
+ (crdt-refresh-users display-buffer)
+ (switch-to-buffer-other-window display-buffer)))
+(defun crdt-refresh-users (display-buffer)
+ (let ((table crdt--contact-table)
+ (local-name crdt--local-name))
+ (with-current-buffer display-buffer
+ (crdt-user-list-mode)
+ (setq tabulated-list-entries nil)
+ (push (list crdt--local-id (vector local-name "*myself*" "--"))
tabulated-list-entries)
+ (maphash (lambda (k v)
+ (push (list k (cl-destructuring-bind (name contact) v
+ (let ((colored-name (concat name " ")))
+ (put-text-property 0 (1- (length
colored-name))
+ 'face `(:background
,(crdt--get-region-color k))
+ colored-name)
+ (put-text-property (1- (length
colored-name)) (length colored-name)
+ 'face `(:background
,(crdt--get-cursor-color k))
+ colored-name)
+ (vector colored-name (car contact) (format
"%s" (cadr contact))))))
+ tabulated-list-entries))
+ table)
+ (tabulated-list-init-header)
+ (tabulated-list-print))))
+(defsubst crdt--refresh-users-maybe ()
+ (when (and crdt--user-list-buffer (buffer-live-p crdt--user-list-buffer))
+ (crdt-refresh-users crdt--user-list-buffer)))
+
+(defun crdt--local-insert (beg end)
+ "To be called after a local insert happened in current buffer from BEG to
END.
+Returns a list of (insert type) messages to be sent."
+ (let (resulting-commands)
+ (crdt--with-insertion-information
+ (beg end)
+ (unless (crdt--split-maybe)
+ (when (and not-begin
+ (eq (crdt--id-site starting-id) crdt--local-id)
+ (crdt--end-of-block-p left-pos))
+ ;; merge crdt id block
+ (let* ((max-offset crdt--max-value)
+ (merge-end (min end (+ (- max-offset left-offset 1) beg))))
+ (unless (= merge-end beg)
+ (put-text-property beg merge-end 'crdt-id starting-id-pair)
+ (let ((virtual-id (substring starting-id)))
+ (crdt--set-id-offset virtual-id (1+ left-offset))
+ (push `(insert ,(base64-encode-string virtual-id) ,beg
+ ,(buffer-substring-no-properties beg merge-end))
+ resulting-commands))
+ (setq beg merge-end)))))
+ (while (< beg end)
+ (let ((block-end (min end (+ crdt--max-value beg))))
+ (let ((new-id (crdt--generate-id starting-id left-offset ending-id
right-offset crdt--local-id)))
+ (put-text-property beg block-end 'crdt-id (cons new-id t))
+ (push `(insert ,(base64-encode-string new-id) ,beg
+ ,(buffer-substring-no-properties beg block-end))
+ resulting-commands)
+ (setq beg block-end)
+ (setq left-offset (1- crdt--max-value)) ; this is always true when
we need to continue
+ (setq starting-id new-id)))))
+ ;; (crdt--verify-buffer)
+ (nreverse resulting-commands)))
+
+(defun crdt--find-id (id pos)
+ "Find the first position *after* ID. Start the search from POS."
+ (let* ((left-pos (previous-single-property-change (if (< pos (point-max))
(1+ pos) pos)
+ 'crdt-id nil (point-min)))
+ (left-id (crdt--get-starting-id left-pos))
+ (right-pos (next-single-property-change pos 'crdt-id nil (point-max)))
+ (right-id (crdt--get-starting-id right-pos)))
+ (cl-block nil
+ (while t
+ (cond ((<= right-pos (point-min))
+ (cl-return (point-min)))
+ ((>= left-pos (point-max))
+ (cl-return (point-max)))
+ ((and right-id (not (string< id right-id)))
+ (setq left-pos right-pos)
+ (setq left-id right-id)
+ (setq right-pos (next-single-property-change right-pos 'crdt-id
nil (point-max)))
+ (setq right-id (crdt--get-starting-id right-pos)))
+ ((string< id left-id)
+ (setq right-pos left-pos)
+ (setq right-id left-id)
+ (setq left-pos (previous-single-property-change left-pos
'crdt-id nil (point-min)))
+ (setq left-id (crdt--get-starting-id left-pos)))
+ (t
+ ;; will unibyte to multibyte conversion cause any problem?
+ (cl-return
+ (if (eq t (compare-strings left-id 0 (- (string-bytes left-id)
2)
+ id 0 (- (string-bytes left-id) 2)))
+ (min right-pos (+ left-pos 1
+ (- (crdt--get-two-bytes id (-
(string-bytes left-id) 2))
+ (crdt--id-offset left-id))))
+ right-pos))))))))
+(defun crdt--remote-insert (message)
+ (let ((crdt--inhibit-update t))
+ (cl-destructuring-bind (id-base64 position-hint content) message
+ (let* ((id (base64-decode-string id-base64))
+ (beg (crdt--find-id id position-hint)) end)
+ (goto-char beg)
+ (insert content)
+ (setq end (point))
+ (with-silent-modifications
+ (crdt--with-insertion-information
+ (beg end)
+ (let ((base-length (- (string-bytes starting-id) 2)))
+ (if (and (eq (string-bytes id) (string-bytes starting-id))
+ (eq t (compare-strings starting-id 0 base-length
+ id 0 base-length))
+ (eq (1+ left-offset) (crdt--id-offset id)))
+ (put-text-property beg end 'crdt-id starting-id-pair)
+ (put-text-property beg end 'crdt-id (cons id t))))
+ (crdt--split-maybe))))))
+ ;; (crdt--verify-buffer)
+ )
+
+(defun crdt--local-delete (beg end)
+ (let ((outer-end end))
+ (crdt--with-insertion-information
+ (beg 0 nil crdt--changed-string nil (length crdt--changed-string))
+ (if (crdt--split-maybe)
+ (let* ((not-end (< outer-end (point-max)))
+ (ending-id (when not-end (crdt--get-starting-id outer-end))))
+ (when (and not-end (eq starting-id (crdt--get-starting-id
outer-end)))
+ (crdt--set-id outer-end (crdt--id-replace-offset starting-id (+ 1
left-offset (length crdt--changed-string))))
+ t))
+ (crdt--with-insertion-information
+ ((length crdt--changed-string) outer-end crdt--changed-string nil 0
nil)
+ (crdt--split-maybe)))))
+ ;; (crdt--verify-buffer)
+ `(delete ,beg ,@ (crdt--dump-ids 0 (length crdt--changed-string)
crdt--changed-string t)))
+(defun crdt--remote-delete (message)
+ (cl-destructuring-bind (position-hint . id-pairs) message
+ (dolist (id-pair id-pairs)
+ (cl-destructuring-bind (length . id-base64) id-pair
+ (let ((id (base64-decode-string id-base64)))
+ (while (> length 0)
+ (goto-char (1- (crdt--find-id id position-hint)))
+ (let* ((end-of-block (next-single-property-change (point) 'crdt-id
nil (point-max)))
+ (block-length (- end-of-block (point))))
+ (cl-case (cl-signum (- length block-length))
+ ((1) (delete-char block-length)
+ (cl-decf length block-length)
+ (crdt--set-id-offset id (+ (crdt--id-offset id)
block-length)))
+ ((0) (delete-char length)
+ (setq length 0))
+ ((-1)
+ (let* ((starting-id (crdt--get-starting-id (point)))
+ (left-offset (crdt--get-id-offset starting-id
(point))))
+ (delete-char length)
+ (crdt--set-id (point) (crdt--id-replace-offset starting-id
(+ left-offset length))))
+ (setq length 0))))))
+ ;; (crdt--verify-buffer)
+ ))))
+
+(defun crdt--before-change (beg end)
+ (unless crdt--inhibit-update
+ (setq crdt--changed-string (buffer-substring beg end))))
+
+(defun crdt--after-change (beg end length)
+ (mapc (lambda (ov)
+ (when (eq (overlay-get ov 'category) 'crdt-pseudo-cursor)
+ (crdt--move-cursor ov beg)))
+ (overlays-in beg (min (point-max) (1+ beg))))
+ (when crdt--local-id ; CRDT--LOCAL-ID is NIL when a client haven't received
the first sync message
+ (unless crdt--inhibit-update
+ (let ((crdt--inhibit-update t))
+ ;; we're only interested in text change
+ ;; ignore property only changes
+ (save-excursion
+ (goto-char beg)
+ (unless (and (= length (- end beg)) (looking-at (regexp-quote
crdt--changed-string)))
+ (widen)
+ (with-silent-modifications
+ (unless (= length 0)
+ (crdt--broadcast-maybe
+ (format "%S" (crdt--local-delete beg end))))
+ (unless (= beg end)
+ (dolist (message (crdt--local-insert beg end))
+ (crdt--broadcast-maybe
+ (format "%S" message)))))))))))
+
+(defun crdt--remote-cursor (message)
+ (cl-destructuring-bind
+ (site-id point-position-hint point-crdt-id-base64 mark-position-hint
mark-crdt-id-base64) message
+ (let ((ov-pair (gethash site-id crdt--overlay-table)))
+ (if point-crdt-id-base64
+ (let* ((point (crdt--find-id (base64-decode-string
point-crdt-id-base64) point-position-hint))
+ (mark (if mark-crdt-id-base64
+ (crdt--find-id (base64-decode-string
mark-crdt-id-base64) mark-position-hint)
+ point)))
+ (unless ov-pair
+ (let ((new-cursor (make-overlay 1 1))
+ (new-region (make-overlay 1 1)))
+ (overlay-put new-cursor 'face `(:background
,(crdt--get-cursor-color site-id)))
+ (overlay-put new-cursor 'category 'crdt-pseudo-cursor)
+ (overlay-put new-region 'face `(:background
,(crdt--get-region-color site-id) :extend t))
+ (setq ov-pair (puthash site-id (cons new-cursor new-region)
+ crdt--overlay-table))))
+ (crdt--move-cursor (car ov-pair) point)
+ (crdt--move-region (cdr ov-pair) point mark))
+ (when ov-pair
+ (remhash site-id crdt--overlay-table)
+ (delete-overlay (car ov-pair))
+ (delete-overlay (cdr ov-pair)))))))
+
+(cl-defun crdt--local-cursor (&optional (lazy t))
+ (let ((point (point))
+ (mark (when (use-region-p) (mark))))
+ (unless (and lazy
+ (eq point crdt--last-point)
+ (eq mark crdt--last-mark))
+ (when (or (eq point (point-max)) (eq crdt--last-point (point-max)))
+ (mapc (lambda (ov)
+ (when (eq (overlay-get ov 'category) 'crdt-pseudo-cursor)
+ (crdt--move-cursor ov (point-max))))
+ (overlays-in (point-max) (point-max))))
+ (setq crdt--last-point point)
+ (setq crdt--last-mark mark)
+ (let ((point-id-base64 (base64-encode-string (crdt--get-id (1- point))))
+ (mark-id-base64 (when mark (base64-encode-string (crdt--get-id (1-
mark))))))
+ `(cursor ,crdt--local-id
+ ,point ,point-id-base64 ,mark ,mark-id-base64)))))
+(defun crdt--post-command ()
+ (let ((cursor-message (crdt--local-cursor)))
+ (when cursor-message
+ (crdt--broadcast-maybe (format "%S" cursor-message)))))
+
+
+(defun crdt--dump-ids (beg end object &optional omit-end-of-block-p)
+ "Serialize all CRDT ids in OBJECT from BEG to END into a list.
+The list contains CONSes of the form (LENGTH CRDT-ID-BASE64 . END-OF-BLOCK-P),
+or (LENGTH . CRDT-ID-BASE64) if OMIT-END-OF-BLOCK-P is non-NIL.
+in the order that they appears in the document"
+ (let (ids (pos end))
+ (while (> pos beg)
+ (let ((prev-pos (previous-single-property-change pos 'crdt-id object
beg)))
+ (push (cons (- pos prev-pos)
+ (cl-destructuring-bind (id . eob) (crdt--get-crdt-id-pair
prev-pos object)
+ (let ((id-base64 (base64-encode-string id)))
+ (if omit-end-of-block-p id-base64 (cons id-base64
eob)))))
+ ids)
+ (setq pos prev-pos)))
+ ids))
+(defun crdt--load-ids (ids)
+ "Load the CRDT ids in IDS (generated by CRDT--DUMP-IDS)
+into current buffer."
+ (let ((pos (point-min)))
+ (dolist (id-pair ids)
+ (let ((next-pos (+ pos (car id-pair))))
+ (put-text-property pos next-pos 'crdt-id
+ (cons (base64-decode-string (cadr id-pair)) (cddr
id-pair)))
+ (setq pos next-pos)))))
+(defun crdt--verify-buffer ()
+ "Debug helper function.
+Verify that CRDT IDs in a document follows ascending order."
+ (let* ((pos (point-min))
+ (id (crdt--get-starting-id pos)))
+ (cl-block
+ (while t
+ (let* ((next-pos (next-single-property-change pos 'crdt-id))
+ (next-id (if (< next-pos (point-max))
+ (crdt--get-starting-id next-pos)
+ (cl-return)))
+ (prev-id (substring id)))
+ (crdt--set-id-offset id (+ (- next-pos pos) (crdt--id-offset id)))
+ (unless (string< prev-id next-id)
+ (error "Not monotonic!"))
+ (setq pos next-pos)
+ (setq id next-id))))))
+
+(crdt--defvar-permanent-local crdt--network-process nil)
+(crdt--defvar-permanent-local crdt--network-clients nil)
+(crdt--defvar-permanent-local crdt--next-client-id nil)
+(cl-defun crdt--broadcast-maybe (message-string &optional (without t))
+ "Broadcast or send MESSAGE-STRING.
+If CRDT--NETWORK-PROCESS is a server process, broadcast MESSAGE-STRING
+to clients except the one of which CLIENT-ID property is EQ to WITHOUT.
+If CRDT--NETWORK-PROCESS is a server process, send MESSAGE-STRING
+to server unless WITHOUT is NIL."
+ ;; (message message-string)
+ (if (process-contact crdt--network-process :server)
+ (dolist (client crdt--network-clients)
+ (when (and (eq (process-status client) 'open)
+ (not (eq (process-get client 'client-id) without)))
+ (process-send-string client message-string)
+ ;; (run-at-time 1 nil #'process-send-string client message-string)
+ ;; ^ quick dirty way to simulate network latency, for debugging
+ ))
+ (when without
+ (process-send-string crdt--network-process message-string)
+ ;; (run-at-time 1 nil #'process-send-string crdt--network-process
message-string)
+ )))
+(defun crdt--generate-challenge ()
+ (apply #'unibyte-string (cl-loop for i below 32 collect (random 256))))
+(defun crdt--greet-client (process)
+ (cl-pushnew process crdt--network-clients)
+ (let ((client-id (process-get process 'client-id)))
+ (unless client-id
+ (unless (< crdt--next-client-id crdt--max-value)
+ (error "Used up client IDs. Need to implement allocation algorithm."))
+ (process-put process 'client-id crdt--next-client-id)
+ (setq client-id crdt--next-client-id)
+ (cl-incf crdt--next-client-id))
+ (process-send-string process (format "%S" `(sync
+ ,client-id
+ ,major-mode
+
,(buffer-substring-no-properties (point-min) (point-max))
+ ,@ (crdt--dump-ids (point-min)
(point-max) nil))))
+ (maphash (lambda (site-id ov-pair)
+ (cl-destructuring-bind (cursor-ov . region-ov) ov-pair
+ (let* ((point (overlay-start cursor-ov))
+ (region-beg (overlay-start region-ov))
+ (region-end (overlay-end region-ov))
+ (mark (if (eq point region-beg)
+ (unless (eq point region-end) region-end)
+ region-beg))
+ (point-id-base64 (base64-encode-string (crdt--get-id
(1- point))))
+ (mark-id-base64 (when mark (base64-encode-string
(crdt--get-id (1- mark))))))
+ (process-send-string process
+ (format "%S"
+ `(cursor ,site-id
+ ,point
,point-id-base64 ,mark ,mark-id-base64))))))
+ crdt--overlay-table)
+ (process-send-string process (format "%S" (crdt--local-cursor nil)))
+ (maphash (lambda (k v)
+ (process-send-string process (format "%S" `(contact ,k ,@v))))
+ crdt--contact-table)
+ (process-send-string process
+ (format "%S" `(contact ,crdt--local-id
+ ,crdt--local-name nil)))
+ (let ((contact-message `(contact ,client-id ,(process-get process
'client-name)
+ ,(process-contact process))))
+ (crdt--broadcast-maybe (format "%S" contact-message) client-id)
+ (crdt-process-message contact-message nil))))
+
+(cl-defgeneric crdt-process-message (message process))
+(cl-defmethod crdt-process-message ((message (head insert)) process)
+ (crdt--remote-insert (cdr message))
+ (crdt--broadcast-maybe (format "%S" message) (process-get process
'client-id)))
+(cl-defmethod crdt-process-message ((message (head delete)) process)
+ (crdt--remote-delete (cdr message))
+ (crdt--broadcast-maybe (format "%S" message) (process-get process
'client-id)))
+(cl-defmethod crdt-process-message ((message (head cursor)) process)
+ (crdt--remote-cursor (cdr message))
+ (crdt--broadcast-maybe (format "%S" message) (process-get process
'client-id)))
+(cl-defmethod crdt-process-message ((message (head sync)) process)
+ (unless (crdt--server-p) ; server shouldn't receive this
+ (erase-buffer)
+ (cl-destructuring-bind (id mode content . ids) (cdr message)
+ (if (fboundp mode)
+ (unless (eq major-mode mode)
+ (funcall mode) ; trust your server...
+ (crdt-mode))
+ (message "Server uses %s, but not available locally." mode))
+ (insert content)
+ (setq crdt--local-id id)
+ (crdt--load-ids ids)
+ (puthash 0 (list nil (process-contact process)) crdt--contact-table))))
+(cl-defmethod crdt-process-message ((message (head challenge)) process)
+ (unless (crdt--server-p) ; server shouldn't receive this
+ (message nil)
+ (let ((password (read-passwd
+ (format "Password for %s:%s: "
+ (process-contact crdt--network-process :host)
+ (process-contact crdt--network-process
:service)))))
+ (crdt--broadcast-maybe (format "%S"
+ `(hello nil ,(gnutls-hash-mac 'SHA1
password (cadr message))))))))
+(cl-defmethod crdt-process-message ((message (head contact)) process)
+ (cl-destructuring-bind (site-id display-name address) (cdr message)
+ (if display-name
+ (puthash site-id (list display-name
+ (or address (cadr (gethash site-id
crdt--contact-table))))
+ crdt--contact-table)
+ (remhash site-id crdt--contact-table))
+ (crdt--refresh-users-maybe)))
+
+(defsubst crdt--server-p ()
+ (process-contact crdt--network-process :server))
+
+(defun crdt--network-filter (process string)
+ (unless (process-buffer process)
+ (set-process-buffer process (generate-new-buffer "*crdt-server*"))
+ (set-marker (process-mark process) 1))
+ (when (buffer-live-p (process-buffer process))
+ (with-current-buffer (process-buffer process)
+ (save-excursion
+ (goto-char (process-mark process))
+ (insert string)
+ (set-marker (process-mark process) (point))
+ (goto-char (point-min))
+ (let (message)
+ (while (setq message (ignore-errors (read (current-buffer))))
+ ;; (print message)
+ (with-current-buffer (process-get process 'crdt-buffer)
+ (save-excursion
+ (widen)
+ (if (or (not (crdt--server-p)) (process-get process
'authenticated))
+ (let ((crdt--inhibit-update t))
+ (crdt-process-message message process))
+ (cl-block nil
+ (when (eq (car message) 'hello)
+ (cl-destructuring-bind (name &optional response) (cdr
message)
+ (when (or (not (process-get process 'password)) ;
server password is empty
+ (and response (string-equal response
(process-get process 'challenge))))
+ (process-put process 'authenticated t)
+ (process-put process 'client-name name)
+ (crdt--greet-client process)
+ (cl-return))))
+ (let ((challenge (crdt--generate-challenge)))
+ (process-put process 'challenge
+ (gnutls-hash-mac 'SHA1 (substring
(process-get process 'password)) challenge))
+ (process-send-string process (format "%S" `(challenge
,challenge))))))))
+ (delete-region (point-min) (point))
+ (goto-char (point-min))))))))
+(defun crdt--server-process-sentinel (client message)
+ (with-current-buffer (process-get client 'crdt-buffer)
+ (unless (eq (process-status client) 'open)
+ ;; client disconnected
+ (setq crdt--network-clients (delete client crdt--network-clients))
+ ;; generate a clear cursor message and a clear contact message
+ (let* ((client-id (process-get client 'client-id))
+ (clear-cursor-message `(cursor ,client-id 1 nil 1 nil))
+ (clear-contact-message `(contact ,client-id nil nil)))
+ (crdt-process-message clear-cursor-message client)
+ (crdt-process-message clear-contact-message client)
+ (crdt-refresh-users-maybe)))))
+(defun crdt--client-process-sentinel (process message)
+ (with-current-buffer (process-get process 'crdt-buffer)
+ (unless (eq (process-status process) 'open)
+ (crdt-stop-client))))
+(defun crdt-serve-buffer (port &optional password name)
+ "Share the current buffer on PORT."
+ (interactive "nPort: ")
+ (crdt-mode)
+ (setq crdt--local-id 0)
+ (setq crdt--network-clients nil)
+ (setq crdt--local-clock 0)
+ (setq crdt--next-client-id 1)
+ (save-excursion
+ (widen)
+ (let ((crdt--inhibit-update t))
+ (with-silent-modifications
+ (crdt--local-insert (point-min) (point-max)))))
+ (add-hook 'kill-buffer-hook #'crdt-stop-serve-buffer nil t)
+ (unless password
+ (setq password
+ (when crdt-ask-for-password
+ (read-from-minibuffer "Set password (empty for no authentication):
"))))
+ (unless name
+ (when crdt-ask-for-name
+ (setq name (read-from-minibuffer "Display name: "))))
+ (setq crdt--local-name name)
+ (setq crdt--network-process
+ (make-network-process
+ :name "CRDT Server"
+ :server t
+ :family 'ipv4
+ :host "0.0.0.0"
+ :service port
+ :filter #'crdt--network-filter
+ :sentinel #'crdt--server-process-sentinel
+ :plist `(crdt-buffer ,(current-buffer) password
+ ,(when (and password (> (length password) 0))
password)))))
+(defsubst crdt--clear-overlay-table ()
+ (when crdt--overlay-table
+ (maphash (lambda (key pair)
+ (delete-overlay (car pair))
+ (delete-overlay (cdr pair)))
+ crdt--overlay-table)
+ (setq crdt--overlay-table nil)))
+(defun crdt-stop-serve-buffer ()
+ "Stop sharing the current buffer."
+ (interactive)
+ (if (or (not crdt--network-process)
+ (not (process-contact crdt--network-process :server)))
+ (message "No CRDT server running on current buffer.")
+ (when (process-buffer crdt--network-process)
+ (kill-buffer (process-buffer crdt--network-process)))
+ (delete-process crdt--network-process)
+ (dolist (client crdt--network-clients)
+ (when (process-live-p client)
+ (delete-process client))
+ (when (process-buffer client)
+ (kill-buffer (process-buffer client))))
+ (setq crdt--network-process nil)
+ (setq crdt--network-clients nil)
+ (crdt--clear-overlay-table)
+ (setq crdt--local-id nil)
+ (setq crdt--contact-table nil))
+ (crdt-mode 0))
+(defun crdt-stop-client ()
+ "Stop the CRDT client running on current buffer if any.
+Leave the buffer open."
+ (interactive)
+ (if (or (not crdt--network-process) (process-contact crdt--network-process
:server))
+ (message "No CRDT client running on current buffer.")
+ (when (process-buffer crdt--network-process)
+ (kill-buffer (process-buffer crdt--network-process)))
+ (delete-process crdt--network-process)
+ (setq crdt--network-process nil)
+ (crdt--clear-overlay-table)
+ (setq crdt--local-id nil)
+ (setq crdt--contact-table nil)
+ (message "Disconnected from server."))
+ (crdt-mode 0))
+(defun crdt-connect (address port &optional name)
+ "Connect to a CRDT server running at ADDRESS:PORT.
+Open a new buffer to display the shared content."
+ (interactive "MAddress: \nnPort: ")
+ (switch-to-buffer (generate-new-buffer "CRDT Client"))
+ (unless name
+ (when crdt-ask-for-name
+ (setq name (read-from-minibuffer "Display name: "))))
+ (setq crdt--local-name name)
+ (setq crdt--network-process
+ (make-network-process
+ :name "CRDT Client"
+ :buffer (generate-new-buffer "*crdt-client*")
+ :host address
+ :family 'ipv4
+ :service port
+ :filter #'crdt--network-filter
+ :sentinel #'crdt--client-process-sentinel
+ :plist `(crdt-buffer ,(current-buffer))))
+ (crdt-mode)
+ (add-hook 'kill-buffer-hook #'crdt-stop-client nil t)
+ (process-send-string crdt--network-process
+ (format "%S" `(hello ,name)))
+ (insert (format "Connected to server %s:%s, synchronizing..." address port)))
+(defun crdt-test-client ()
+ (interactive)
+ (crdt-connect "127.0.0.1" 1333))
+(defun crdt-test-server ()
+ (interactive)
+ (crdt-serve-buffer 1333))
+(defun crdt--install-hooks ()
+ (add-hook 'after-change-functions #'crdt--after-change nil t)
+ (add-hook 'before-change-functions #'crdt--before-change nil t)
+ (add-hook 'post-command-hook #'crdt--post-command nil t))
+(defun crdt--uninstall-hooks ()
+ (remove-hook 'after-change-functions #'crdt--after-change t)
+ (remove-hook 'before-change-functions #'crdt--before-change t)
+ (remove-hook 'post-command-hook #'crdt--post-command t))
+(define-minor-mode crdt-mode
+ "CRDT mode" nil " CRDT" nil
+ (if crdt-mode
+ (progn
+ (setq crdt--overlay-table (make-hash-table))
+ (setq crdt--contact-table (make-hash-table))
+ (crdt--install-hooks))
+ (crdt--uninstall-hooks)
+ (when crdt--user-list-buffer
+ (kill-buffer crdt--user-list-buffer)
+ (setq crdt--user-list-buffer nil))))
+
+(provide 'crdt)
- [elpa] branch externals/crdt created (now 8cbd0fd), ELPA Syncer, 2021/08/28
- [elpa] externals/crdt f224e8a 06/80: add doc. add error handler in network filter, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 22910f2 01/80: initial commit,
ELPA Syncer <=
- [elpa] externals/crdt 7ebbd5e 05/80: cleanup debug code, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 21f62ad 14/80: various fixes, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 958394a 08/80: doc, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 6b320c7 11/80: bug fix, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt bfce95c 17/80: formatting, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 692d2cc 24/80: initial work for lazily pulling buffer, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt b8cd461 22/80: Minor improvements, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 8307092 29/80: better read function, tab width wider, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 4c7e12d 37/80: documentation, ELPA Syncer, 2021/08/28
- [elpa] externals/crdt 53e5676 33/80: fix bug with org table, ELPA Syncer, 2021/08/28