branch: elpa/adoc-mode
commit 3fc5674064f6d79926e01162395c00b06199c460
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>

    Add list item move and renumber commands
    
    Add adoc-move-list-item-up (M-up) and adoc-move-list-item-down (M-down),
    which transpose the list item at point (together with its nested
    sub-items and continuation lines) past its previous or next sibling.
    Siblings must share the same list type, level and indentation, so two
    distinct adjacent lists are never merged.
    
    Add adoc-renumber-list, which renumbers a contiguous arabic or
    alphabetic explicitly-numbered list starting from its first item's
    value.
---
 CHANGELOG.md           |   1 +
 README.adoc            |   3 +-
 adoc-mode.el           | 198 +++++++++++++++++++++++++++++++++++++++++++++++++
 test/adoc-mode-test.el |  72 ++++++++++++++++++
 4 files changed, 272 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf0f3eecea..5343d29810 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 ### New features
 
 - Add list editing: `adoc-promote` (`M-left`) and `adoc-demote` (`M-right`) 
now nest the list item at point one level deeper or shallower (for unordered 
`*`/`-` and implicitly-numbered `.` lists) in addition to acting on section 
titles, and the new `adoc-insert-list-item` (`M-RET`) inserts a sibling item 
below the current one, keeping its indentation and marker and incrementing the 
number/letter of explicitly-numbered items.
+- Add `adoc-move-list-item-up` (`M-up`) and `adoc-move-list-item-down` 
(`M-down`), which move the list item at point (together with its nested 
sub-items) past its previous/next sibling, and `adoc-renumber-list`, which 
renumbers a contiguous arabic (`1.`) or alphabetic (`a.`/`A.`) 
explicitly-numbered list starting from its first item's value.
 - Add heading navigation commands modelled on `markdown-mode` and `org-mode`: 
`adoc-next-visible-heading` (`C-c C-n`), `adoc-previous-visible-heading` (`C-c 
C-p`), `adoc-forward-same-level` (`C-c C-f`), `adoc-backward-same-level` (`C-c 
C-b`), and `adoc-up-heading` (`C-c C-u`). They understand both one-line (`== 
Title`) and two-line (underlined) titles and skip headings hidden by folding. 
`outline-minor-mode` is now enabled by default so the folding commands are 
available out of the box.
 - New `adoc-title-scaling` defcustom (default `t`) and 
`adoc-title-scaling-values` list let users disable the variable-height title 
faces or pick their own scale factors. Set the boolean to nil for 
uniformly-sized headings, or customise the list to control the level-0..5 
heights. Mirrors `markdown-header-scaling`.
 - New `adoc-blockquote-face` for the body of `[quote]` and `[verse]` delimited 
blocks (inherits `font-lock-doc-face`); previously the body was left 
unfontified.
diff --git a/README.adoc b/README.adoc
index 0cf8053a5a..dfb469fb05 100644
--- a/README.adoc
+++ b/README.adoc
@@ -41,7 +41,7 @@ Here are some of the main features of `adoc-mode`:
 - ~50 tempo templates for inserting AsciiDoc markup (formatting, titles, 
blocks, lists, macros, etc.)
 - heading navigation modelled on `markdown-mode` / `org-mode`: next / previous 
heading (`C-c C-n` / `C-c C-p`), forward / backward at the same level (`C-c 
C-f` / `C-c C-b`), and up to the parent heading (`C-c C-u`)
 - title management: promote / demote (`M-left` / `M-right`), toggle between 
one-line and two-line styles, adjust underline length
-- list editing: `M-left` / `M-right` also nest the list item at point deeper 
or shallower, and `M-RET` inserts a sibling item (incrementing the number for 
explicitly-numbered lists)
+- list editing: `M-left` / `M-right` nest the list item at point deeper or 
shallower, `M-RET` inserts a sibling item (incrementing the number for 
explicitly-numbered lists), `M-up` / `M-down` move an item (with its sub-items) 
past its siblings, and `M-x adoc-renumber-list` renumbers an 
explicitly-numbered list
 - navigate to anchors (`C-c C-a`) and follow URLs, `include::` macros, and 
xrefs at point (`C-c C-o` / `M-.`)
 - nested `imenu` index with hierarchical heading structure
 - `outline-minor-mode` enabled out of the box for folding (one-line title 
style only)
@@ -233,7 +233,6 @@ These days all upstream packages (e.g. on MELPA) are built 
from Bozhidar's fork.
 
 Here are some features that we're considering to add in the future:
 
-- Moving list items up / down and renumbering explicitly-numbered lists
 - Correctly highlighting backslash escapes
 
 Check out the issue tracker for more details.
diff --git a/adoc-mode.el b/adoc-mode.el
index 584ced5929..e7084b0578 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -2987,6 +2987,199 @@ incremented number or letter."
       (end-of-line)
       (insert "\n" indent new-marker " "))))
 
+(defun adoc--line-list-level ()
+  "Return the nesting level of the list item on the current line, or nil."
+  (let ((item (adoc--list-item-at-point)))
+    (and item (plist-get item :level))))
+
+(defun adoc--list-item-block-end (level)
+  "Return the position at the end of the list item block on the current line.
+Point must be at the beginning of the marker line of an item at
+nesting LEVEL.  The block runs through any immediately following
+continuation lines and more deeply nested items, stopping before
+the first blank line, sibling, or shallower item.  The returned
+position is at the beginning of a line (or `point-max')."
+  (save-excursion
+    (forward-line 1)
+    (let ((end (point)))
+      (while (and (not (eobp))
+                  (not (looking-at-p "[ \t]*$"))
+                  (let ((il (adoc--line-list-level)))
+                    (or (null il) (> il level))))
+        (forward-line 1)
+        (setq end (point)))
+      end)))
+
+(defun adoc--same-list-p (item)
+  "Return non-nil if the item on the current line is a sibling of ITEM.
+Siblings share the same list type, nesting level and indentation
+\(and, for explicitly-numbered lists, the same marker kind), so
+two distinct adjacent lists are not treated as one."
+  (let ((other (adoc--list-item-at-point)))
+    (and other
+         (eq (plist-get other :type) (plist-get item :type))
+         (= (plist-get other :level) (plist-get item :level))
+         (string= (plist-get other :indent) (plist-get item :indent))
+         (or (not (eq (plist-get item :type) 'explicit-numbered))
+             (eq (adoc--explicit-marker-kind (plist-get other :marker))
+                 (adoc--explicit-marker-kind (plist-get item :marker)))))))
+
+(defun adoc--next-sibling-start (from item)
+  "Return the start of the next sibling of ITEM at or after FROM, or nil.
+Blank lines, continuation lines and more deeply nested items are
+skipped.  A shallower item, or a same-level item belonging to a
+different list, stops the search and yields nil."
+  (let ((level (plist-get item :level)))
+    (save-excursion
+      (goto-char from)
+      (catch 'found
+        (while (not (eobp))
+          (unless (looking-at-p "[ \t]*$")
+            (let ((il (adoc--line-list-level)))
+              (cond
+               ((null il))
+               ((and (= il level) (adoc--same-list-p item)) (throw 'found 
(point)))
+               ((<= il level) (throw 'found nil)))))
+          (forward-line 1))
+        nil))))
+
+(defun adoc--prev-sibling-start (before item)
+  "Return the start of the previous sibling of ITEM before BEFORE, or nil.
+Blank lines, continuation lines and more deeply nested items are
+skipped.  A shallower item, or a same-level item belonging to a
+different list, stops the search and yields nil."
+  (let ((level (plist-get item :level)))
+    (save-excursion
+      (goto-char before)
+      (catch 'found
+        (while (not (bobp))
+          (forward-line -1)
+          (unless (looking-at-p "[ \t]*$")
+            (let ((il (adoc--line-list-level)))
+              (cond
+               ((null il))
+               ((and (= il level) (adoc--same-list-p item)) (throw 'found 
(point)))
+               ((<= il level) (throw 'found nil))))))
+        nil))))
+
+(defun adoc--move-list-item (down)
+  "Move the list item at point past its sibling, DOWN when non-nil else up.
+The item's nested sub-items and continuation lines move with it."
+  (let ((item (adoc--list-item-at-point)))
+    (unless item
+      (user-error "Not on a list item"))
+    (let ((level (plist-get item :level))
+          (col (current-column)))
+      (beginning-of-line)
+      (let* ((s1 (point))
+             (e1 (adoc--list-item-block-end level)))
+        (if down
+            (let ((s2 (adoc--next-sibling-start e1 item)))
+              (unless s2 (user-error "No next item at this level"))
+              (let ((e2 (save-excursion
+                          (goto-char s2)
+                          (adoc--list-item-block-end level))))
+                ;; Avoid merging lines when the buffer lacks a final newline.
+                (when (and (= e2 (point-max)) (/= (char-before e2) ?\n))
+                  (save-excursion (goto-char (point-max)) (insert "\n"))
+                  (setq e2 (point-max)))
+                (transpose-regions s1 e1 s2 e2)
+                (goto-char (+ s1 (- e2 e1)))
+                (move-to-column col)))
+          (let ((ps (adoc--prev-sibling-start s1 item)))
+            (unless ps (user-error "No previous item at this level"))
+            ;; Avoid merging lines when the buffer lacks a final newline.
+            (when (and (= e1 (point-max)) (/= (char-before e1) ?\n))
+              (save-excursion (goto-char (point-max)) (insert "\n"))
+              (setq e1 (point-max)))
+            (transpose-regions ps s1 s1 e1)
+            (goto-char ps)
+            (move-to-column col)))))))
+
+(defun adoc-move-list-item-down (&optional arg)
+  "Move the list item at point down past the next sibling ARG times.
+The item's nested sub-items and continuation lines move with it."
+  (interactive "p")
+  (dotimes (_ (or arg 1))
+    (adoc--move-list-item t)))
+
+(defun adoc-move-list-item-up (&optional arg)
+  "Move the list item at point up past the previous sibling ARG times.
+The item's nested sub-items and continuation lines move with it."
+  (interactive "p")
+  (dotimes (_ (or arg 1))
+    (adoc--move-list-item nil)))
+
+(defun adoc--explicit-marker-kind (marker)
+  "Return the kind of an explicitly-numbered list MARKER.
+One of `arabic', `lower-alpha', `upper-alpha', `lower-roman',
+`upper-roman', or nil."
+  (cond
+   ((string-match-p "\\`[0-9]+\\.\\'" marker) 'arabic)
+   ((string-match-p "\\`[a-z]\\.\\'" marker) 'lower-alpha)
+   ((string-match-p "\\`[A-Z]\\.\\'" marker) 'upper-alpha)
+   ((string-match-p "\\`[ivxlcdm]+)\\'" marker) 'lower-roman)
+   ((string-match-p "\\`[IVXLCDM]+)\\'" marker) 'upper-roman)))
+
+(defun adoc--explicit-marker-value (kind marker)
+  "Return the 1-based ordinal that MARKER represents for list KIND."
+  (pcase kind
+    ('arabic (string-to-number marker))
+    ('lower-alpha (1+ (- (aref marker 0) ?a)))
+    ('upper-alpha (1+ (- (aref marker 0) ?A)))))
+
+(defun adoc--explicit-marker (kind n)
+  "Return the marker string for the Nth (1-based) item of list KIND."
+  (pcase kind
+    ('arabic (format "%d." n))
+    ('lower-alpha (format "%c." (+ ?a (1- n))))
+    ('upper-alpha (format "%c." (+ ?A (1- n))))))
+
+(defun adoc--explicit-item-on-line-p (kind indent)
+  "Non-nil if the current line holds an explicit item of KIND and INDENT."
+  (let ((item (adoc--list-item-at-point)))
+    (and item
+         (eq (plist-get item :type) 'explicit-numbered)
+         (string= (plist-get item :indent) indent)
+         (eq (adoc--explicit-marker-kind (plist-get item :marker)) kind))))
+
+(defun adoc-renumber-list ()
+  "Renumber the explicitly-numbered list at point.
+Only flat arabic (\"1.\") and alphabetic (\"a.\"/\"A.\") lists are
+renumbered; the sequence starts from the first item's current
+value.  Roman-numeral lists are left untouched."
+  (interactive)
+  (let ((item (adoc--list-item-at-point)))
+    (unless (and item (eq (plist-get item :type) 'explicit-numbered))
+      (user-error "Not on an explicitly-numbered list item"))
+    (let* ((indent (plist-get item :indent))
+           (kind (adoc--explicit-marker-kind (plist-get item :marker))))
+      (unless (memq kind '(arabic lower-alpha upper-alpha))
+        (user-error "Can only renumber arabic or alphabetic lists"))
+      (save-excursion
+        (beginning-of-line)
+        ;; Walk back to the first item of the run.
+        (while (save-excursion (and (zerop (forward-line -1))
+                                    (adoc--explicit-item-on-line-p kind 
indent)))
+          (forward-line -1))
+        (let ((n (adoc--explicit-marker-value
+                  kind (plist-get (adoc--list-item-at-point) :marker))))
+          (while (and (not (eobp))
+                      (adoc--explicit-item-on-line-p kind indent))
+            (when (and (memq kind '(lower-alpha upper-alpha)) (> n 26))
+              (user-error "Alphabetic lists cannot have more than 26 items"))
+            (let* ((it (adoc--list-item-at-point))
+                   (beg (plist-get it :marker-beg))
+                   (old (plist-get it :marker))
+                   (new (adoc--explicit-marker kind n)))
+              (unless (string= old new)
+                (save-excursion
+                  (goto-char beg)
+                  (delete-region beg (+ beg (length old)))
+                  (insert new))))
+            (setq n (1+ n))
+            (forward-line 1)))))))
+
 (defun adoc-calc ()
   "(Re-)calculates variables used in adoc-mode.
 Needs to be called after changes to certain (customization)
@@ -3551,6 +3744,8 @@ ITEMS is a list of (name pos . level)."
     (define-key map (kbd "M-<left>") 'adoc-promote)
     (define-key map (kbd "M-<right>") 'adoc-demote)
     (define-key map (kbd "M-RET") 'adoc-insert-list-item)
+    (define-key map (kbd "M-<up>") 'adoc-move-list-item-up)
+    (define-key map (kbd "M-<down>") 'adoc-move-list-item-down)
     (define-key map "\C-c\C-t" 'adoc-toggle-title-type)
     (define-key map "\C-c\C-a" 'adoc-goto-ref-label)
     (define-key map "\C-c\C-o" 'adoc-follow-thing-at-point)
@@ -3566,6 +3761,9 @@ ITEMS is a list of (name pos . level)."
         ["Promote (title / list item)" adoc-promote]
         ["Demote (title / list item)" adoc-demote]
         ["Insert list item" adoc-insert-list-item]
+        ["Move list item up" adoc-move-list-item-up]
+        ["Move list item down" adoc-move-list-item-down]
+        ["Renumber list" adoc-renumber-list]
         ["Toggle title type" adoc-toggle-title-type]
         ["Adjust title underline" adoc-adjust-title-del]
         ["Follow thing at point" adoc-follow-thing-at-point]
diff --git a/test/adoc-mode-test.el b/test/adoc-mode-test.el
index 8a544f113a..565e3ca528 100644
--- a/test/adoc-mode-test.el
+++ b/test/adoc-mode-test.el
@@ -1066,6 +1066,78 @@ on top of the `<mark>' default)."
     (should-error (adoc-insert-list-item) :type 'user-error)
     (should (string-equal (buffer-string) "just some prose"))))
 
+(ert-deftest adoctest-test-move-list-item-down ()
+  (adoctest-trans "* a!\n* b\n" "* b\n* a\n" '(adoc-move-list-item-down))
+  ;; a missing final newline must not merge the two items into one line
+  (adoctest-trans "* a!\n* b" "* b\n* a\n" '(adoc-move-list-item-down))
+  ;; the item carries its nested sub-items with it
+  (adoctest-trans "* one!\n** sub\n* two\n" "* two\n* one\n** sub\n"
+                  '(adoc-move-list-item-down)))
+
+(ert-deftest adoctest-test-move-list-item-up ()
+  (adoctest-trans "* a\n* b!\n" "* b\n* a\n" '(adoc-move-list-item-up))
+  ;; a missing final newline must not merge the two items into one line
+  (adoctest-trans ". one\n. two!" ". two\n. one\n" '(adoc-move-list-item-up))
+  (adoctest-trans "* one\n** sub\n* two!\n" "* two\n* one\n** sub\n"
+                  '(adoc-move-list-item-up)))
+
+(ert-deftest adoctest-test-move-list-item-distinct-lists ()
+  ;; a same-level item of a different list type is not a sibling
+  (with-temp-buffer
+    (adoc-mode)
+    (insert "1. alpha\n- bullet\n2. beta\n")
+    (goto-char (point-min))
+    (should-error (adoc-move-list-item-down) :type 'user-error)
+    (should (string-equal (buffer-string) "1. alpha\n- bullet\n2. beta\n"))))
+
+(ert-deftest adoctest-test-move-list-item-no-sibling ()
+  (with-temp-buffer
+    (adoc-mode)
+    (insert "* only\n")
+    (goto-char (point-min))
+    (should-error (adoc-move-list-item-down) :type 'user-error)
+    (should (string-equal (buffer-string) "* only\n")))
+  ;; a shallower neighbour is not a sibling
+  (with-temp-buffer
+    (adoc-mode)
+    (insert "** a\n* b\n")
+    (goto-char (point-min))
+    (should-error (adoc-move-list-item-down) :type 'user-error)
+    (should (string-equal (buffer-string) "** a\n* b\n"))))
+
+(ert-deftest adoctest-test-renumber-list ()
+  (adoctest-trans "1. a!\n1. b\n1. c\n" "1. a\n2. b\n3. c\n" 
'(adoc-renumber-list))
+  ;; works from any item in the run, and preserves the first item's start value
+  (adoctest-trans "1. a\n5. b!\n9. c\n" "1. a\n2. b\n3. c\n" 
'(adoc-renumber-list))
+  (adoctest-trans "3. a!\n1. b\n" "3. a\n4. b\n" '(adoc-renumber-list))
+  ;; alphabetic lists
+  (adoctest-trans "a. x!\na. y\n" "a. x\nb. y\n" '(adoc-renumber-list))
+  ;; a missing final newline must terminate (not loop forever)
+  (adoctest-trans "1. a!\n9. b" "1. a\n2. b" '(adoc-renumber-list)))
+
+(ert-deftest adoctest-test-renumber-list-alpha-overflow ()
+  (with-temp-buffer
+    (adoc-mode)
+    (dotimes (_ 27) (insert "a. item\n"))
+    (goto-char (point-min))
+    (should-error (adoc-renumber-list) :type 'user-error)))
+
+(ert-deftest adoctest-test-renumber-list-errors ()
+  ;; roman lists are left untouched
+  (with-temp-buffer
+    (adoc-mode)
+    (insert "i) a\nv) b\n")
+    (goto-char (point-min))
+    (should-error (adoc-renumber-list) :type 'user-error)
+    (should (string-equal (buffer-string) "i) a\nv) b\n")))
+  ;; not on an explicitly-numbered list
+  (with-temp-buffer
+    (adoc-mode)
+    (insert "* a\n* b\n")
+    (goto-char (point-min))
+    (should-error (adoc-renumber-list) :type 'user-error)
+    (should (string-equal (buffer-string) "* a\n* b\n"))))
+
 (ert-deftest adoctest-test-xref-at-point-1 ()
   (unwind-protect
       (progn

Reply via email to