twlz0ne / separedit.el

Edit comment or string/docstring or code block inside them in separate buffer with your favorite mode
GNU General Public License v3.0
146 stars 14 forks source link

Editing Clojure's multi-line blocks wrapped in `(str)` blocks #38

Open agzam opened 1 year ago

agzam commented 1 year ago

Clojure doesn't have support for multilines that preserve indentation, i.e., if you want to break some text into multiple lines you typically have to wrap it in (str) block like this:

(def some-var 
  {:description
   (str "Lorem ipsum dolor sit amet, "
        "consectetuer adipiscing elit. Donec hendrerit tempor tellus.  "
        "Donec pretium posuere tellus. Proin quam nisl, tincidunt et, "
        "mattis eget, convallis nec, purus. Cum sociis natoque penatibus et "
        "magnis dis parturient montes, nascetur ridiculus mus. "
        "Nulla posuere.  Donec vitae dolor. Nullam tristique diam non turpis. "
        "Cras placerat accumsan nulla. Nullam rutrum.  Nam vestibulum accumsan nisl.")})

I think it's possible to teach separedit to understand this structure so you can edit the text while being able to use fill-paragraph, etc.

Can someone please help me figuring it out?

agzam commented 1 year ago

I think I'm very close to figuring it out. Here's what I got:

(defun separedit--remove-clj-str-delimeters (_ &optional _)
  (let ((result-string ""))
    (save-excursion
      (goto-char (point-min))
      (while (search-forward-regexp "\"\\([^\"]*\\)\"" nil t)
        (setq result-string (concat result-string (match-string-no-properties 1) "\n"))))
    (delete-region (point-min) (point-max))
    (insert (substring result-string 0 -1))))

(setq
 separedit-block-regexp-plists
 '((:header "(str\s+"
    :footer ".*\"\s?)"
    :body ""
    :straight t
    :keep-header t
    :keep-footer t
    :modes (clojure-mode clojurec-mode)
    :delimiter-remove-fn separedit--remove-clj-str-delimeters
    :edit-mode markdown-mode)

Now, if I run the separadit-dwim on the example structure I posted, while the cursor is inside any of the lines (within the quotes), separadit pick up the text and opens a buffer for a single line (that's fine and quite expected).

But, if I move the cursor outside of quotes it does pick up the structure and even delimiter-remove-fn works as expected.

However, there's a problem. If I place (print (buffer-string)) at the beginning of my custom delimiter-remove-fn, it prints the following:

"   (str \"Lorem ipsum dolor sit amet, \"
        \"consectetuer adipiscing elit. Donec hendrerit tempor tellus.  \"
        \"Donec pretium posuere tellus. Proin quam nisl, tincidunt et, \"
        \"mattis eget, convallis nec, purus. Cum sociis natoque penatibus et \"
        \"magnis dis parturient montes, nascetur ridiculus mus. \"
        \"Nulla posuere.  Donec vitae dolor. Nullam tristique diam non turpis. \"
        \"Cras placerat accumsan nulla. Nullam rutrum.  Nam vestibulum accumsan nisl.\")})"

That's what gets sent into the indirect separedit buffer. I'm removing all the crap leaving only the text (with delimiter-remove-fn). Now, I can restore the quotes around the lines (with :delimiter-restore-fn), but how do I get back the outer wrapping? I can always blindly wrap it into (str) block, but the problem is, that won't work for when it's like this:

{:foo (str "Lorem ipsum dolor sit amet" "Cras placerat accumsan nulla")}

So how do I make it, so it marks only "Lorem ipsum dolor sit amet" "Cras placerat accumsan nulla" and doesn't overwrite other things, coming out of separedit buffer?

I think I just need to tweak regexes in :header, :footer and/or :body

agzam commented 1 year ago

Ahem, I solved it by using advice.

Here's the full working solution:

;; What this does is that it removes all the quotes surrounding 
;; each line that gets sent to separedit buffer
(defun separedit--remove-clj-str-delimeters (_ &optional _)
  (save-excursion
    (replace-regexp-in-region "^\s*\"" "" (point-min))
    (replace-regexp-in-region "\"\s*$" "" (point-min))))

;; After editing the lines in the separadit buffer, 
;; we need to wrap each line into quotes,
;; replace every empty line with explicit \n, maybe do some other text manipulations
(defun separedit--restore-clj-str-delimeters (&optional _)
  (save-excursion
    (replace-regexp-in-region "^\\s-*$" "\\\\n" (point-min))
    (replace-regexp-in-region "^" "\"" (point-min))
    (replace-regexp-in-region ".$" "\\& \"" (point-min))))

;; This is the 'magic' that teaches separedit to understand Clojure (str) blocks
;; basically saying that whenever it encounters something between `(str "` and `")`
;; that it should be treated differently
(add-to-list
 'separedit-block-regexp-plists
 '(:header "(str\s+\""
   :footer ".*\"\s?)"
   :body ""

   ;; We're keeping the footer and the header (because they contain relevant content), 
   ;; but we need only to send the relevant text (and not the entire clojure form) into
   ;; the separadit buffer, 
   ;; that's why the following trick with advice is important for this to work
   :keep-header t
   :keep-footer t

   ;; The modes where separedit looks for these blocks
   ;; i.e., if you try to edit `(str "foo" "bar")` in Elisp,
   ;; it won't get triggered
   :modes (clojure-mode clojurec-mode clojurescript-mode)

   ;; Some pre and post-processing of the existing text required
   :delimiter-remove-fn separedit--remove-clj-str-delimeters
   :delimiter-restore-fn separedit--restore-clj-str-delimeters

   ;; Optionally, we can set the mode of separedit buffer
   :edit-mode markdown-mode))

;; Since we're telling separedit to keep the header and the footer, it will try to send
;; everything within the lines of `(str "` and `")`, inclusively.
;; It will mark the entire region from the top line of (str
;; So, we need to trick it to think that it's editing only the content within quotes
;; We do that by slightly modifying the marked region

(defun fix-separadit-region-for-clj-a (block-info-fn &optional)
  "Fix separadit block for Clojure (str) multi-line."
  (let ((block-info (funcall block-info-fn)))
    (when-let* ((_ (member 'clojure-mode
                           (plist-get
                            (plist-get block-info :regexps)
                            :modes)))
                (beg (plist-get block-info :beginning))
                (end (plist-get block-info :end)))
      (goto-char beg)
      (search-forward-regexp "(str\s+")
      (plist-put block-info :beginning (point))
      (goto-char end)
      (search-backward-regexp "\")")
      (forward-char)
      (plist-put block-info :end (point)))
    block-info))

(advice-add 'separedit--block-info :around #'fix-separadit-region-for-clj-a)
twlz0ne commented 1 year ago

@agzam Thank you for providing a full working solution.

That's a great idea. I thought maybe should add support for other lisp-like (or even c-like) languages .

I just pushed my implementation to this branch: https://github.com/twlz0ne/separedit.el/tree/feat-multi-line-string-block, hope it works as you expected.

agzam commented 1 year ago

I thought maybe should add support for other lisp-like (or even c-like) languages

I'm not sure what you mean by adding support for other languages. This is specifically a Clojure thing, i.e., even though these two snippets look similar, they are not the same.

(def some-var 
  {:description "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Donec hendrerit tempor tellus.
  Donec pretium posuere tellus. Proin quam nisl, tincidunt et, mattis eget, convallis nec, purus. Cum sociis natoque
  penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nulla posuere. Donec vitae dolor. Nullam tristique
  diam non turpis. Cras placerat accumsan nulla. Nullam rutrum. Nam vestibulum accumsan nisl. "})

(def some-var
  {:description
   (str "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. "
        "Donec hendrerit tempor tellus.  Donec pretium posuere tellus. "
        "Proin quam nisl, tincidunt et, mattis eget, convallis nec, purus. "
        "Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. "
        "Nulla posuere.  Donec vitae dolor.  Nullam tristique diam non turpis. "
        "Cras placerat accumsan nulla.  Nullam rutrum.  Nam vestibulum accumsan nisl.")})

It's some known inconvenience Clojurists sometimes have to deal with. How does your improvement makes the difference for this use-case?

twlz0ne commented 1 year ago

I'm not sure what you mean by adding support for other languages.

For example in c:

char* my_str =
  "Here is the first line.\n"
  "Here is the second line.";

But I haven't implement it yet.

(def some-var 
  {:description "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Donec hendrerit tempor tellus.
  Donec pretium posuere tellus. Proin quam nisl, tincidunt et, mattis eget, convallis nec, purus. Cum sociis natoque
  penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nulla posuere. Donec vitae dolor. Nullam tristique
  diam non turpis. Cras placerat accumsan nulla. Nullam rutrum. Nam vestibulum accumsan nisl. "})

Does the indentation below 1st line in the string will show up in the final doc?

agzam commented 1 year ago

Does the indentation below 1st line...

No, that's a bad example. Typically, the second and subsequent lines are not indented in that case; they would start at the margin.

twlz0ne commented 1 year ago

No, that's a bad example. Typically, the second and subsequent lines are not indented in that case; they would start at the margin.

That means we just treat it like a normal string block?

I made some changes and now supports multiple strings whether they are arranged horizontally or vertically:

Screenshot_2023-07-01_at_2 51 06_PM

Maybe it shouldn't only works in {:description} and (str)?

It is now also support other languages, for example c:

Screenshot_2023-07-01_at_3 02 52_PM

My goal is implement a generic multple line string editing, no need to add rules to separedit-block-regexp-plists.

But the new problem is that I think it is more convenient to edit multiple strings together in situations like following (point in the string "bar"):

["foo"      C-c '   foo
  "b|ar"    ---->   b|ar
  "quxx"]           quux

But sometimes I just want to edit the string "bar", there is only one C-c ' shortcut key, how should I resolve the conflict? I haven't figured it out yet.

The latest code: https://github.com/twlz0ne/separedit.el/tree/feat-multi-string-block

agzam commented 1 year ago

My goal is implement a generic multple line string editing, no need to add rules to separedit-block-regexp-plists.

Yah, that would be awesome. I just realized that even in elisp I sometimes do stuff like:

(concat "Integer placerat tristique nisl"
        "Sed bibendum")

how should I resolve the conflict? I haven't figured it out yet.

region-active-p seems to be the most straightforward solution here, in my opinion. Perhaps, If cursor is within the quotes, then edit the line. If cursor is outside of the quotes but within the block - the whole block. Additionally, if region is active, then edit the block within the region, or the string, if only a single line selected.