Model/View-like editing in Emacs

2019-05-15 13:24发布

问题:

I have some JSON files and I'm writing a mode that allows editing a single property of the JSON object independently from the rest. For example:

foo.json:

{
  "creation_timestamp": "1411210038.000000",
  "description": "lorem ipsum.\ndolor sit amet.",
  "version": 4
}

Opening foo.json results in this buffer:

lorem ipsum.

dolor sit amet.

Changing the first line to "foo bar" and saving the file results in a foo.json with only the description field updated:

{
  "creation_timestamp": "1411210038.000000",
  "description": "foo bar.\ndolor sit amet.",
  "version": 4
}

What's the best strategy for this? My current attempt is so:

  1. open the JSON file with find-file
  2. create an invisible overlay from point-min to point-max
  3. parse json
  4. insert the value of the description property at point-min, creating a "view"
  5. add a local-write-file hook and an after-save hook

The local-write-file hook kills the "view", updates the json in the overlay, and saves the file. The after-save hook recreates the "view" so the user can keep editing.

This is long-winded and brittle. Is there a better way of working with data where the screen representation should be different than the disk representation?

回答1:

You can define your own encoding and decoding in format-alist for that purpose. Your example could be implemented in the following way:

(defvar-local my-head nil
  "Header of json file cut off by json-descr format.")

(defvar-local my-tail nil
  "Tail of json file cut off by json-descr format.")

(defun my-from-fn (BEGIN END)
  "`format-alist'"
  (save-restriction
    (narrow-to-region BEGIN END)
    (goto-char (point-min))
    (let* ((b (re-search-forward "^[[:blank:]]*\"description\":[[:blank:]]*\"" nil t))
       (e (ignore-errors (1- (scan-sexps (1- b) 1)))))
      (unless (and b e)
    (error "Error in original mode")) ;;< TODO some more sensible error message
      ;; Save head and tail and delete corresponding buffer regions:
      (setq-local my-head (buffer-substring-no-properties (point-min) b))
      (setq-local my-tail (buffer-substring-no-properties e (point-max)))
      (delete-region e (point-max))
      (delete-region (point-min) b)
      ;; Formatting:
      (goto-char (point-min))
      (while (search-forward "\\n" nil t)
    (replace-match "\n"))
      )
    (point-max) ;;< required by `format-alist'
    ))

(defun my-to-fn (BEGIN END BUFFER)
  "`format-alist'"
  (save-restriction
    (narrow-to-region BEGIN END)
    ;; Formatting:
    (goto-char (point-min))
    (while (search-forward "\n" nil t)
      (replace-match "\\\\n"))
    ;; Insert head and tail:
    (let ((head (with-current-buffer BUFFER my-head))
      (tail (with-current-buffer BUFFER my-tail)))
      (goto-char (point-min))
      (insert head)
      (goto-char (point-max))
      (insert tail))
    (point-max)))

(add-to-list 'format-alist
         '(json-descr
           "File format for editing a single property of a json object."
           nil
           my-from-fn
           my-to-fn
           t ; MODIFY: my-to-fn modifies the buffer
           nil
           nil))

(define-derived-mode my-mode fundamental-mode "JDescr"
  "Major mode for editing json description properties."
  (format-decode-buffer 'json-descr))

Actually, one can also interpret this as a more general problem. Load a file into a hidden buffer. Use another visible buffer to edit its transformed content. At saving the visible buffer actually transform the content back to the original format and save the hidden buffer.

I do not have the time right now to implement the general case as described above. The following code roughly covers your special case. (Note, that it is a fast hack for demonstration purposes only.)

(defvar-local original-mode-other nil
  "Other buffer related to the current one.")


(define-derived-mode original-mode special-mode ""
  "Opens file in invisible auxiliary buffer."
  (let* ((b (re-search-forward "^[[:blank:]]*\"description\":[[:blank:]]*\"" nil t))
     (e (ignore-errors (1- (scan-sexps (1- b) 1))))
     (original-name (buffer-name))
     (original-buffer (current-buffer))
     str)
    (unless (and b e)
      (error "Error in original mode")) ;; TODO some more sensible error message
    (narrow-to-region b e)
    (setq str (buffer-substring-no-properties b e))
    (rename-buffer (concat " *" original-name))
    (with-current-buffer (switch-to-buffer (get-buffer-create original-name))
      ;; Set-up the clone buffer for editing the transformed content:
      (set-visited-file-name (buffer-file-name original-buffer) t)
      (setq original-mode-other original-buffer)
      (insert str)
      (set-buffer-modified-p nil)
      ;; Transform content to the format of the clone buffer:
      (goto-char (point-min))
      (while (search-forward "\\n" nil t) ;; TODO: Skip escaped \n.
    (replace-match "\n"))
      (add-to-list 'write-contents-functions  (lambda ()
                        ;; Transfer content to original buffer
                        (let ((str (buffer-substring-no-properties (point-min) (point-max))))
                          (with-current-buffer original-mode-other
                            (let ((inhibit-read-only t))
                              (delete-region (point-min) (point-max))
                              (insert str)
                              (goto-char (point-min))
                              ;; Transform content to the format of the original buffer:
                              (while (search-forward "\n" nil t)
                            (replace-match "\\\\n"))
                              (save-buffer)
                              )))
                        (set-buffer-modified-p nil)
                        t))
      (add-hook 'kill-buffer-hook (lambda ()
                    (kill-buffer original-mode-other)) t t)
      )))


回答2:

Is your use case really as simple as the scenario you describe (not the solution outline, but the problem/use case)?

If so, your solution sounds like overkill. If the use case is as simple as editing the value of a particular key, I would probably do this:

  1. Display the content of that field (value corresponding to the key) in a temporary buffer, for editing.

  2. Bind a key (e.g., C-c C-c) to save the edited value back to the file.

I do that in Bookmark+ for editing a bookmark's tags, for instance (and also for editing all of a bookmark's fields, using a different command). The command to edit the tags is bmkp-edit-tags. The command (bound to C-c C-c in the edit buffer) is bmkp-edit-tags-send. The code is here, in context. Here it is, out of context:


(defmacro bmkp-with-output-to-plain-temp-buffer (buf &rest body)
  "Like `with-output-to-temp-buffer', but with no `*Help*' navigation stuff."
  `(unwind-protect
    (progn
      (remove-hook 'temp-buffer-setup-hook 'help-mode-setup)
      (remove-hook 'temp-buffer-show-hook  'help-mode-finish)
      (with-output-to-temp-buffer ,buf ,@body))
    (add-hook 'temp-buffer-setup-hook 'help-mode-setup)
    (add-hook 'temp-buffer-show-hook  'help-mode-finish)))

(define-derived-mode bmkp-edit-tags-mode emacs-lisp-mode
    "Edit Bookmark Tags"
  "Mode for editing bookmark tags.
When you have finished composing, type \\[bmkp-edit-tags-send]."
  :group 'bookmark-plus)

;; This binding must be defined *after* the mode, so `bmkp-edit-tags-mode-map' is defined.
;; (Alternatively, we could use a `defvar' to define `bmkp-edit-tags-mode-map' before
;; calling `define-derived-mode'.)
(define-key bmkp-edit-tags-mode-map "\C-c\C-c" 'bmkp-edit-tags-send)

(defun bmkp-edit-tags (bookmark)        ; Bound to `C-x p t e'
  "Edit BOOKMARK's tags, and maybe save the result.
The edited value must be a list each of whose elements is either a
 string or a cons whose key is a string.
BOOKMARK is a bookmark name or a bookmark record."
  (interactive (list (bookmark-completing-read "Edit tags for bookmark" (bmkp-default-bookmark-name))))
  (setq bookmark  (bmkp-get-bookmark-in-alist bookmark))
  (let* ((btags    (bmkp-get-tags bookmark))
         (bmkname  (bmkp-bookmark-name-from-record bookmark))
         (edbuf    (format "*Edit Tags for Bookmark `%s'*" bmkname)))
    (setq bmkp-return-buffer  (current-buffer))
    (bmkp-with-output-to-plain-temp-buffer edbuf
      (princ
       (substitute-command-keys
        (concat ";; Edit tags for bookmark\n;;\n;; \"" bmkname "\"\n;;\n"
                ";; The edited value must be a list each of whose elements is\n"
                ";; either a string or a cons whose key is a string.\n;;\n"
                ";; DO NOT MODIFY THESE COMMENTS.\n;;\n"
                ";; Type \\<bmkp-edit-tags-mode-map>`\\[bmkp-edit-tags-send]' when done.\n\n")))
      (let ((print-circle  bmkp-propertize-bookmark-names-flag)) (pp btags))
      (goto-char (point-min)))
    (pop-to-buffer edbuf)
    (buffer-enable-undo)
    (with-current-buffer (get-buffer edbuf) (bmkp-edit-tags-mode))))

(defun bmkp-edit-tags-send (&optional batchp)
  "Use buffer contents as the internal form of a bookmark's tags.
DO NOT MODIFY the header comment lines, which begin with `;;'."
  (interactive)
  (unless (eq major-mode 'bmkp-edit-tags-mode) (error "Not in `bmkp-edit-tags-mode'"))
  (let (bname)
    (unwind-protect
         (let (tags bmk)
           (goto-char (point-min))
           (unless (search-forward ";; Edit tags for bookmark\n;;\n;; ")
             (error "Missing header in edit buffer"))
           (unless (stringp (setq bname  (read (current-buffer))))
             (error "Bad bookmark name in edit-buffer header"))
           (unless (setq bmk  (bmkp-get-bookmark-in-alist bname 'NOERROR))
             (error "No such bookmark: `%s'" bname))
           (unless (bmkp-bookmark-type bmk) (error "Invalid bookmark"))
           (goto-char (point-min))
           (setq tags  (read (current-buffer)))
           (unless (listp tags) (error "Tags sexp is not a list of strings or an alist with string keys"))
           (bookmark-prop-set bmk 'tags tags)
           (setq bname  (bmkp-bookmark-name-from-record bmk))
           (bmkp-record-visit bmk batchp)
           (bmkp-refresh/rebuild-menu-list bname batchp)
           (bmkp-maybe-save-bookmarks)
           (unless batchp (message "Updated bookmark file with edited tags")))
      (kill-buffer (current-buffer)))
    (when bmkp-return-buffer
      (pop-to-buffer bmkp-return-buffer)
      (when (equal (buffer-name (current-buffer)) "*Bookmark List*")
        (bmkp-bmenu-goto-bookmark-named bname)))))

The most relevant bits are these:

  1. Define a command to initiate editing and a command to end it and save the changes.

  2. Provide an edit buffer using bmkp-with-output-to-plain-temp-buffer (essentially with-output-to-temp-buffer, but that macro in some Emacs versions also adds Help mode stuff not needed here).

  3. Put the edit buffer in a simple minor mode that binds C-c C-c to the save-and-exit command.

  4. Fill the edit buffer with the text to be edited. Pop to the buffer, for editing.

  5. In the save-and-exit command (bmkp-edit-tags-send), update the original data, replacing the relevant field contents with the edit-buffer contents. Save the updated data. Return to the original buffer.