branch: externals/shift-number
commit a71f5c3c77affc27a3d0bf16886a8a3b03c05044
Author: Campbell Barton <[email protected]>
Commit: Campbell Barton <[email protected]>
Re-write: replace the existing logic with evil-numbers
- Support hex/binary/octal numbers, subscript and superscript numbers.
- Add incremental shifting (within region).
- Add option to increment directional (based on the region).
- Add tests.
- Bump minimum supported emacs version to 28.1
---
readme.rst | 95 +++-
shift-number.el | 1082 +++++++++++++++++++++++++++++++++----------
tests/shift-number-tests.el | 776 ++++++++++++++++++++++++++++++-
tests/shift-number-tests.sh | 2 +-
4 files changed, 1682 insertions(+), 273 deletions(-)
diff --git a/readme.rst b/readme.rst
index ae952992e0..d5f3832c56 100644
--- a/readme.rst
+++ b/readme.rst
@@ -9,6 +9,15 @@ About
This Emacs package provides commands to increase and decrease the number
at point (or the next number on the current line).
+Supported number formats:
+
+- Decimal: ``42``, ``-17``, ``+5``, ``007``
+- Binary: ``0b101``, ``0B1010``
+- Octal: ``0o42``, ``0O755``
+- Hexadecimal: ``0xBEEF``, ``0xcafe``
+- Superscript: ``⁴²``, ``⁻¹``
+- Subscript: ``₄₂``, ``₋₁``
+
Installation
============
@@ -32,6 +41,8 @@ For the manual installation, clone the repository, add the
directory to
(add-to-list 'load-path "/path/to/shift-number-dir")
(autoload 'shift-number-up "shift-number" nil t)
(autoload 'shift-number-down "shift-number" nil t)
+ (autoload 'shift-number-up-incremental "shift-number" nil t)
+ (autoload 'shift-number-down-incremental "shift-number" nil t)
Usage
@@ -42,7 +53,7 @@ As you can see in the gif demonstration:
- ``M-x shift-number-up`` increases the current number.
If there is no number at point, the first number between the current
position and the end of line is increased.
- With a numeric prefix ARG, the number is increased for this ARG.
+ With a numeric prefix ARG, the number is increased by ARG.
- ``M-x shift-number-down`` decreases the current number.
@@ -54,38 +65,96 @@ You may bind some keys to these commands in a usual manner,
for example:
(global-set-key (kbd "M-_") 'shift-number-down)
+Region and Rectangle Support
+----------------------------
+
+When a region is active, all numbers in the region are modified.
+Rectangle selections (via ``rectangle-mark-mode``) are also supported.
+
+
+Incremental Mode
+----------------
+
+For incrementing multiple numbers with progressively increasing values,
+use the incremental commands:
+
+- ``M-x shift-number-up-incremental`` increases each number progressively
+ (first by 1, second by 2, etc.).
+
+- ``M-x shift-number-down-incremental`` decreases each number progressively.
+
+These commands operate on all numbers between point and mark.
+The mark must be set, but the region does not need to be active.
+
+This is useful for creating sequences. For example, with ``0 0 0`` between
+point and mark, running ``shift-number-up-incremental`` produces ``1 2 3``.
+
+The incremental count continues across lines and rectangle cells, so with
+three lines of ``0 0 0`` between point and mark, incrementing produces::
+
+ 1 2 3
+ 4 5 6
+ 7 8 9
+
+
Custom Variables
----------------
-``shift-number-motion``: ``nil``
- When non-nil, move the cursor to the end of the number,
- set the ``mark`` to the beginning.
``shift-number-negative``: ``t``
When non-nil, support negative numbers.
+``shift-number-motion``: ``nil``
+ Control cursor movement after modifying a number. Options are:
+
+ - ``nil``: cursor stays at beginning of number, mark unchanged.
+ - ``t``: cursor moves to end of number, mark unchanged.
+ - ``mark``: cursor moves to end of number, mark set to beginning.
+
+``shift-number-pad-default``: ``nil``
+ When non-nil, preserve the number's width when it shrinks
+ (e.g., ``10`` becomes ``09`` when decremented).
-Similar packages
+``shift-number-separator-chars``: ``nil``
+ A string of separator characters allowed in numeric literals for visual
+ grouping. For example, set to ``"_"`` to support numbers like ``1_000_000``.
+
+``shift-number-case``: ``nil``
+ Case to use for hexadecimal numbers. Options are:
+
+ - ``nil``: preserve current case
+ - ``upcase``: use upper case (A-F)
+ - ``downcase``: use lower case (a-f)
+
+``shift-number-incremental-direction-from-region``: ``t``
+ When non-nil, reverse incremental direction when point is before mark.
+ With point before mark, ``shift-number-up-incremental`` on ``0 0 0``
+ produces ``3 2 1`` instead of ``1 2 3``.
+
+
+Related packages
================
-There are other packages for the same task (modifying the number at
-point):
+Other packages for a similar task (modifying the number at point):
- `operate-on-number <https://github.com/knu/operate-on-number.el>`__
- `number <https://github.com/chrisdone/number>`__
-- `evil-numbers <https://github.com/cofi/evil-numbers>`__
Comparing with them, ``shift-number`` has the following distinctions:
- If there is no number at point, it operates on the next number on the
current line.
-- The point does not move anywhere when a number is modified.
+- The point does not move anywhere when a number is modified
+ (unless ``shift-number-motion`` is enabled).
- If a number has leading zeros (for example ``007``), they are preserved
during shifting.
-- It is simple: only shifting up/down is available, no multiplication or
- other more complex stuff.
+- Supports multiple number formats: decimal, binary, octal, hexadecimal,
+ superscript, and subscript.
+
+- Supports region and rectangle selections.
-- It does not prompt for any additional input: you just press a key
- bound to ``shift-number-{up/down}`` command and the number is changing.
+`evil-numbers <https://github.com/juliapath/evil-numbers>`__
+ Uses ``shift-number`` as its backend, providing Evil (Vim emulation)
+ integration with operators and visual state support.
diff --git a/shift-number.el b/shift-number.el
index b20d83c872..2de5f36c04 100644
--- a/shift-number.el
+++ b/shift-number.el
@@ -8,8 +8,8 @@
;; URL: https://codeberg.org/ideasman42/emacs-shift-number
;; Keywords: convenience
;; Created: 12 Apr 2016
-;; Version: 0.1
-;; Package-Requires: ((emacs "24.1"))
+;; Version: 0.2
+;; Package-Requires: ((emacs "28.1"))
;;; Commentary:
@@ -23,10 +23,13 @@
;; (autoload 'shift-number-down "shift-number" nil t)
;; For more verbose description and a gif demonstration, see
-;; <https://github.com/alezost/shift-number.el>.
+;; <https://codeberg.org/ideasman42/emacs-shift-number>.
;;; Code:
+(eval-when-compile
+ ;; For `pcase-dolist'.
+ (require 'pcase))
;; ---------------------------------------------------------------------------
;; Compatibility
@@ -44,6 +47,16 @@
(gv-letplace (getter setter) place
(funcall setter `(- ,getter ,(or delta 1)))))))
+(when (version< emacs-version "29.1")
+ (defsubst pos-bol (&optional n)
+ "Return the position at the line beginning.
+N specifies which line (default 1, the current line)."
+ (line-beginning-position n))
+ (defsubst pos-eol (&optional n)
+ "Return the position at the line end.
+N specifies which line (default 1, the current line)."
+ (line-end-position n)))
+
;; ---------------------------------------------------------------------------
;; Custom Variables
@@ -57,219 +70,643 @@
:type 'boolean)
(defcustom shift-number-motion nil
- "If non-nil, move the point to the end of the number.
-The `mark' is set the the beginning of the number."
+ "Control cursor movement after modifying a number.
+- nil: cursor stays at beginning of number, mark unchanged.
+- t: cursor moves to end of number, mark unchanged.
+- `mark': cursor moves to end of number, mark set to beginning."
+ :type
+ '(choice
+ (const :tag "No motion" nil) (const :tag "Motion" t) (const :tag "Motion
with mark" mark)))
+
+(defcustom shift-number-pad-default nil
+ "Whether to preserve number width when it shrinks.
+When non-nil, decrementing 10 gives 09 instead of 9."
+ :type 'boolean)
+
+(defcustom shift-number-separator-chars nil
+ "Separator characters allowed in numeric literals for visual grouping.
+
+This value is a string containing separator characters,
+typically \"_\" or \",\" which are allowed in numeric literals in some systems.
+
+Set to nil to disable this functionality."
+ :type '(choice (const nil) string))
+
+(defcustom shift-number-case nil
+ "Case to use for hexadecimal numbers."
+ :type
+ '(choice
+ (const :tag "Current Case" nil)
+ (const :tag "Upper Case" upcase)
+ (const :tag "Lower Case" downcase)))
+
+(defcustom shift-number-incremental-direction-from-region t
+ "When non-nil, reverse incremental direction when point is before mark.
+With point before mark, `shift-number-up-incremental' on \"0 0 0\"
+produces \"3 2 1\" instead of \"1 2 3\"."
:type 'boolean)
(declare-function apply-on-rectangle "rect")
+
;; ---------------------------------------------------------------------------
-;; Private Variables/Constants
+;; Internal Constants
+
+(defconst shift-number--chars-superscript "⁰¹²³⁴⁵⁶⁷⁸⁹"
+ "String containing superscript digit characters 0-9.")
+(defconst shift-number--chars-subscript "₀₁₂₃₄₅₆₇₈₉"
+ "String containing subscript digit characters 0-9.")
+
+;; Helper for building script alists below.
+(defun shift-number--build-script-alist (minus-char plus-char digit-chars)
+ "Build an alist mapping ASCII characters to script equivalents.
+MINUS-CHAR is the script minus sign.
+PLUS-CHAR is the script plus sign.
+DIGIT-CHARS is a 10-character string of script digits 0-9."
+ (cons
+ (cons ?- minus-char)
+ (cons
+ (cons ?+ plus-char)
+ (mapcar
+ (lambda (i)
+ (cons (string-to-char (number-to-string i)) (aref digit-chars i)))
+ (number-sequence 0 9)))))
+
+(defconst shift-number--superscript-alist
+ (shift-number--build-script-alist ?⁻ ?⁺ shift-number--chars-superscript)
+ "Alist mapping regular characters to superscript equivalents.")
+
+(defconst shift-number--subscript-alist
+ (shift-number--build-script-alist ?₋ ?₊ shift-number--chars-subscript)
+ "Alist mapping regular characters to subscript equivalents.")
+
+(defconst shift-number--superscript-alist-decode
+ (mapcar (lambda (x) (cons (cdr x) (car x))) shift-number--superscript-alist)
+ "Alist mapping superscript characters to regular equivalents.")
+
+(defconst shift-number--subscript-alist-decode
+ (mapcar (lambda (x) (cons (cdr x) (car x))) shift-number--subscript-alist)
+ "Alist mapping subscript characters to regular equivalents.")
-;; Regexp for `shift-number' function.
-;; The first parenthesized expression must match the number.
-(defconst shift-number--regexp "\\([[:digit:]]+\\)")
;; ---------------------------------------------------------------------------
-;; Private Functions
+;; Internal String Separator Utilities
+
+(defun shift-number--strip-chars (str sep-chars)
+ "Remove SEP-CHARS from STR."
+ (dotimes (i (length sep-chars))
+ (let ((ch (char-to-string (aref sep-chars i))))
+ (setq str (replace-regexp-in-string (regexp-quote ch) "" str t t))))
+ str)
+
+(defun shift-number--strip-chars-apply (str-src str-dst sep-chars)
+ "Restore SEP-CHARS positions from STR-SRC into STR-DST."
+ (let ((sep-chars-list (append sep-chars nil))
+ ;; Convert strings to lists.
+ (str-src-rev (nreverse (append str-src nil)))
+ (str-dst-rev (nreverse (append str-dst nil)))
+ (result (list)))
+ (while str-dst-rev
+ (let ((ch-src (pop str-src-rev)))
+ (cond
+ ((and ch-src (memq ch-src sep-chars-list))
+ (push ch-src result))
+ (t
+ (push (pop str-dst-rev) result)))))
+ (apply #'string result)))
-(defmacro shift-number--swap-vars (i j)
- "Swap the value of I & J."
- `(setq ,i
- (prog1 ,j
- (setq ,j ,i))))
+;; ---------------------------------------------------------------------------
+;; Internal Utilities
+
+(defun shift-number--case-category (str default)
+ "Categorize the case of STR.
+Return DEFAULT if STR has no case (e.g., digits only), otherwise:
+- 1: Upper case.
+- -1: Lower case.
+- nil: Mixed case."
+ (let ((str-dn (downcase str))
+ (str-up (upcase str)))
+ (cond
+ ((string-equal str str-dn)
+ (cond
+ ((string-equal str str-up)
+ default)
+ (t
+ -1)))
+ (t
+ (cond
+ ((string-equal str str-up)
+ 1)
+ (t
+ nil))))))
+
+(defun shift-number--format-binary (number &optional width fillchar)
+ "Format NUMBER as binary.
+Fill up to WIDTH with FILLCHAR (defaults to ?0) if binary
+representation of NUMBER is smaller than WIDTH."
+ (let ((nums (list))
+ (fillchar (or fillchar ?0)))
+ (while (> number 0)
+ (push (number-to-string (% number 2)) nums)
+ (setq number (truncate number 2)))
+ (let ((len (length nums)))
+ (apply #'concat
+ (cond
+ ((and width (< len width))
+ (make-string (- width len) fillchar))
+ (t
+ ""))
+ nums))))
+
+(defun shift-number--format (num width base)
+ "Format NUM with at least WIDTH digits in BASE."
+ (cond
+ ((= base 2)
+ (shift-number--format-binary num width))
+ ((= base 8)
+ (format (format "%%0%do" width) num))
+ ((= base 16)
+ (format (format "%%0%dX" width) num))
+ ((= base 10)
+ (format (format "%%0%dd" width) num))
+ (t
+ "")))
+
+(defun shift-number--skip-chars-impl (ch-skip ch-sep-optional dir ch-num limit)
+ "Wrapper for `skip-chars-forward' and `skip-chars-backward'.
+
+CH-SKIP: Characters to skip.
+CH-SEP-OPTIONAL: Separator characters (single instances are stepped over).
+DIR: Direction to step in (1 or -1).
+CH-NUM: Number of characters to step.
+LIMIT: Point which will not be stepped past."
+ (let* ((is-forward (< 0 dir))
+ (skip-chars-fn
+ (cond
+ (is-forward
+ #'skip-chars-forward)
+ (t
+ #'skip-chars-backward)))
+ (clamp-fn
+ (cond
+ (is-forward
+ #'min)
+ (t
+ #'max)))
+ (skipped
+ (abs
+ (funcall skip-chars-fn
+ ch-skip
+ ;; Limit.
+ (funcall clamp-fn (+ (point) (* ch-num dir)) limit)))))
+
+ ;; Step over single separators, as long as there is a number after them.
+ ;; Allow '100,123' and '16_777_216' to be handled as single numbers.
+ (when ch-sep-optional
+ (let ((point-next nil)
+ (skipped-next 0))
+ (setq ch-num (- ch-num skipped))
+ (while (and (not (zerop ch-num))
+ (save-excursion
+ (and (eq 1 (shift-number--skip-chars-impl
ch-sep-optional nil dir 1 limit))
+ (progn
+ ;; Not counted towards 'skipped'
+ ;; as this character is to be ignored entirely.
+ (setq skipped-next
+ (shift-number--skip-chars-impl ch-skip nil
dir ch-num limit))
+ (unless (zerop skipped-next)
+ (setq point-next (point))
+ ;; Found (apply `point-next').
+ t)))))
+ ;; Step over the separator and contents found afterwards.
+ (when point-next
+ (goto-char point-next)
+ (setq skipped (+ skipped skipped-next))
+ (setq ch-num (- ch-num skipped-next))
+ t))))
+
+ skipped))
+
+(defun shift-number--match-from-skip-chars (match-chars dir limit do-check
do-match)
+ "Match MATCH-CHARS in DIR (-1 or 1), until LIMIT.
+
+When DO-CHECK is non-nil, any failure to match returns nil.
+When DO-MATCH is non-nil, match data is set.
+
+Each item in MATCH-CHARS is a list of (CH-SKIP CH-NUM CH-SEP-OPTIONAL).
+- CH-SKIP is the argument to pass to
+ `skip-chars-forward' or `skip-chars-backward'.
+- CH-NUM specifies how many characters to match.
+ Valid values:
+ - Symbol `+' one or more.
+ - Symbol `*' zero or more.
+ - An integer: match exactly this many.
+- CH-SEP-OPTIONAL specifies optional separator characters."
+ (catch 'result
+ (let* ((is-forward (< 0 dir))
+ (point-init (point))
+ ;; Fill when `do-match' is set.
+ (match-list (list)))
+
+ ;; Sanity check.
+ (when (cond
+ (is-forward
+ (> (point) limit))
+ (t
+ (< (point) limit)))
+ (error "Limit is on wrong side of point (internal error)"))
+
+ (unless is-forward
+ (setq match-chars (reverse match-chars)))
+
+ (pcase-dolist (`(,ch-skip ,ch-num ,ch-sep-optional) match-chars)
+ ;; Beginning of the match.
+ (when do-match
+ (push (point) match-list))
-(defun shift-number--replace-in-region (str beg end)
- "Utility to replace region from BEG to END with STR.
-Return the region replaced."
- (declare (important-return-value nil))
+ (cond
+ ((integerp ch-num)
+ (let ((skipped (shift-number--skip-chars-impl ch-skip
ch-sep-optional dir ch-num limit)))
+ (when do-check
+ (unless (eq skipped ch-num)
+ (throw 'result nil)))))
+ ((eq ch-num '+)
+ (let ((skipped
+ (shift-number--skip-chars-impl
+ ch-skip ch-sep-optional dir most-positive-fixnum limit)))
+ (when do-check
+ (unless (>= skipped 1)
+ (throw 'result nil)))))
+
+ ;; No length checking needed as zero is acceptable.
+ ;; Skip these characters if they exist.
+ ((eq ch-num '*)
+ (shift-number--skip-chars-impl ch-skip ch-sep-optional dir
most-positive-fixnum limit))
+ ((eq ch-num '\?)
+ (shift-number--skip-chars-impl ch-skip ch-sep-optional dir 1 limit))
+ (t
+ (error "Unknown type %S (internal error)" ch-num)))
+
+ ;; End of the match.
+ (when do-match
+ (push (point) match-list)))
+
+ ;; Add match group 0 (full match) at the beginning of the list.
+ (when do-match
+ (setq match-list
+ (cond ; `point-init' `point' `match-list'.
+ (is-forward
+ (cons point-init (cons (point) (nreverse match-list))))
+ (t ; `point' `point-init' `match-list'.
+ (cons (point) (cons point-init match-list)))))
+
+ (set-match-data match-list)))
+ t))
- (let ((len (length str))
- (i-beg nil)
- (i-end nil)
- (i-end-ofs nil))
- ;; Check for skip end.
- (let ((i 0))
- (let ((len-test (min (- end beg) len)))
- (while (< i len-test)
- (let ((i-next (1+ i)))
- (cond
- ((eq (aref str (- len i-next)) (char-after (- end i-next)))
- (setq i i-next))
- (t ; Break.
- (setq len-test i))))))
- (unless (zerop i)
- (setq i-end (- len i))
- (decf len i)
- (decf end i)
- (setq i-end-ofs i)))
-
- ;; Check for skip start.
- (let ((i 0))
- (let ((len-test (min (- end beg) len)))
- (while (< i len-test)
- (cond
- ((eq (aref str i) (char-after (+ beg i)))
- (incf i))
- (t ; Break.
- (setq len-test i)))))
- (unless (zerop i)
- (setq i-beg i)
- (incf beg i)))
-
- (when (or i-beg i-end)
- (setq str (substring str (or i-beg 0) (or i-end len))))
-
- (goto-char beg)
-
- (unless (eq beg end)
- (delete-region beg end))
- (unless (string-empty-p str)
- (insert str))
-
- (when i-end-ofs
- ;; Leave the cursor where it would be if the end wasn't clipped.
- (goto-char (+ (point) i-end-ofs)))
- (cons beg (+ beg (length str)))))
-
-(defun shift-number--in-regexp-p (regexp pos limit-beg limit-end)
- "Return non-nil, if POS is inside REGEXP on the current line."
- ;; The code originates from `org-at-regexp-p'.
- (save-excursion
- (let ((found nil)
- (exit nil))
- (goto-char limit-beg)
- (while (and (null (or exit found)) (re-search-forward regexp limit-end
t))
- (cond
- ((> (match-beginning 0) pos)
- (setq exit t))
- ((>= (match-end 0) pos)
- (setq found t))))
- found)))
+;; ---------------------------------------------------------------------------
+;; Internal Script Translation
+
+(defun shift-number--translate-with-alist (alist string)
+ "Translate every character in STRING using ALIST."
+ (funcall (cond
+ ((stringp string)
+ #'concat)
+ (t
+ #'identity))
+ (mapcar (lambda (c) (cdr (assoc c alist))) string)))
+
+(defun shift-number--encode-super (x)
+ "Convert string X to superscript."
+ (shift-number--translate-with-alist shift-number--superscript-alist x))
+(defun shift-number--decode-super (x)
+ "Convert string X from superscript to regular characters."
+ (shift-number--translate-with-alist shift-number--superscript-alist-decode
x))
+
+(defun shift-number--encode-sub (x)
+ "Convert string X to subscript."
+ (shift-number--translate-with-alist shift-number--subscript-alist x))
+(defun shift-number--decode-sub (x)
+ "Convert string X from subscript to regular characters."
+ (shift-number--translate-with-alist shift-number--subscript-alist-decode x))
-(defun shift-number--impl (n pos limit-beg limit-end)
- "Change the number at point by N.
-If there is no number at point, search forward till the end of
-the current line and change it.
-
-Search backwards from LIMIT-BEG for a number overlapping POS.
-Otherwise search forward limited by LIMIT-END."
- ;; The whole number is removed and a new number is inserted in its
- ;; place, so `save-excursion' is not used, as it will put the point at
- ;; the beginning of the number. Instead, the point is saved and
- ;; restored later.
- (let ((num-bounds nil)
- (has-sign nil)
- ;; Allow numbers to become negative.
- (use-sign shift-number-negative))
- (save-match-data
- (when (or (and (< limit-beg pos)
- (shift-number--in-regexp-p shift-number--regexp pos
limit-beg limit-end))
- (re-search-forward shift-number--regexp limit-end t))
- (let ((beg (match-beginning 1))
- (end (match-end 1)))
- (setq num-bounds (cons beg end))
-
- ;; Only detect a sign when negative numbers are supported.
- (when (and use-sign (< limit-beg beg))
- (let ((ch (char-before beg)))
+;; ---------------------------------------------------------------------------
+;; Internal Implementation
+
+(defun shift-number--inc-at-pt-impl-with-match-chars
+ (match-chars
+ ;; Numeric & other options.
+ sign-group num-group base beg end padded do-case
+ ;; Callbacks.
+ range-check-fn number-xform-fn decode-fn encode-fn)
+ "Perform the increment/decrement within BEG and END.
+
+For MATCH-CHARS docs see `shift-number--match-from-skip-chars'.
+NUM-GROUP is the match group used to evaluate the number.
+SIGN-GROUP is the match group used for the sign ('-' or '+').
+
+When PADDED is non-nil,
+the number keeps its current width (with leading zeroes).
+When PADDED is the symbol `auto', detect leading zeros and preserve width.
+
+When RANGE-CHECK-FN is non-nil, it's called with the match beginning & end.
+A nil result causes this function to skip this match.
+
+When DO-CASE is non-nil, apply case handling for hexadecimal numbers.
+
+DECODE-FN converts the matched string to regular characters for parsing.
+ENCODE-FN converts the result back for replacement.
+
+When all characters are found in sequence, evaluate the number in BASE,
+replacing it by the result of NUMBER-XFORM-FN.
+Return (OLD-BEG . OLD-END) on success, nil on failure."
+ (save-match-data
+ (when (and (save-excursion
+ ;; Skip backwards (as needed), there may be no
+ ;; characters to skip back, so don't check the result.
+ (shift-number--match-from-skip-chars match-chars -1 beg nil
nil)
+ ;; Skip forwards from the beginning, setting match data.
+ (shift-number--match-from-skip-chars match-chars 1 end t t))
+
+ ;; Either there is no range checking or the range must
+ ;; be accepted by the caller.
+ (or (null range-check-fn)
+ (funcall range-check-fn (match-beginning 0) (match-end 0))))
+
+ ;; Capture old bounds before any modification.
+ (let* ((old-beg (match-beginning 0))
+ (old-end (match-end 0))
+ (sep-char (nth 2 (nth (1- num-group) match-chars)))
+ (num-str-raw (match-string num-group))
+ (sign-str (match-string sign-group))
+ ;; Check if sign is preceded by a decimal digit (e.g., "123-456").
+ ;; If so, ignore the sign to avoid treating it as a negative
number.
+ ;; Only check for 0-9 to allow "1e-10" scientific notation to
work.
+ (sign-preceded-by-digit
+ (and sign-str
+ (not (string-empty-p sign-str)) (> (match-beginning
sign-group) beg)
+ (let ((ch-before (char-before (match-beginning
sign-group))))
+ (and ch-before (>= ch-before ?0) (<= ch-before ?9)))))
+ (use-sign (and shift-number-negative (not
sign-preceded-by-digit)))
+ (str-prev
+ (funcall decode-fn
+ (concat
+ (cond
+ (use-sign
+ sign-str)
+ (t
+ ""))
+ num-str-raw)))
+
+ (str-prev-strip
(cond
- ((eq ?- ch)
- (setq has-sign t))
- ((eq ?+ ch)
- (setq has-sign t)))
-
- ;; Ignore the sign when immediately preceded by a number, e.g.
`123-456'.
- (when (and has-sign (< limit-beg (1- beg)))
- (save-excursion
- (goto-char (1- beg))
- (unless (zerop (skip-chars-backward "0-9" (- beg 2)))
- ;; Don't allow negative numbers otherwise
- ;; `1-0' would subtract zero to make `1--0'.
- (setq use-sign nil)
- (setq has-sign nil)))))))))
+ (sep-char
+ (shift-number--strip-chars str-prev sep-char))
+ (t
+ str-prev)))
- (cond
- (num-bounds
- (let* ((beg (car num-bounds))
- (end (cdr num-bounds))
- ;; Take care, nil when negative unsupported.
- (old-sign
- (and use-sign
- (cond
- ((and has-sign (eq ?- (char-before beg)))
- -1)
- (t
- 1))))
- (old-bounds
- (cons
+ ;; Auto-detect leading zeros: if number starts with 0 and has
more digits.
+ (has-leading-zeros (and (> (length num-str-raw) 1) (eq (aref
num-str-raw 0) ?0)))
+ (use-padding
+ (cond
+ ((eq padded 'auto)
+ has-leading-zeros)
+ (t
+ padded)))
+
+ (num-prev (string-to-number str-prev-strip base))
+ (num-next
+ (let ((result (funcall number-xform-fn num-prev)))
+ ;; When negative numbers are disabled, clamp to 0.
+ (cond
+ (shift-number-negative
+ result)
+ (t
+ (max 0 result)))))
+ (str-next
+ (shift-number--format
+ (abs num-next)
(cond
- (has-sign
- (1- beg))
+ (use-padding
+ (- (match-end num-group) (match-beginning num-group)))
(t
- beg))
- end))
+ 1))
+ base)))
- (old-num-str (buffer-substring-no-properties beg end))
- (old-num (string-to-number old-num-str))
- (new-num
+ ;; Maintain case.
+ (when do-case
+ ;; Upper case (already set), no need to handle here.
+ (cond
+ ;; Keep current case.
+ ((null shift-number-case)
+ (when (eq -1 (or (shift-number--case-category str-prev -1) -1))
+ (setq str-next (downcase str-next))))
+ ((eq shift-number-case 'downcase)
+ (setq str-next (downcase str-next)))))
+
+ (when sep-char
+ ;; This is a relatively expensive operation,
+ ;; only apply separators back if any were found to begin with.
+ (unless (string-equal str-prev str-prev-strip)
+ (setq str-next (shift-number--strip-chars-apply str-prev str-next
sep-char))))
+
+ ;; Replace number then sign to work around Emacs bug #74666.
+ ;; Order doesn't affect correctness, but this order avoids the bug.
+
+ ;; Replace the number.
+ (replace-match (funcall encode-fn str-next) t t nil num-group)
+
+ ;; Replace the sign (as needed).
+ (when use-sign
+ (cond
+ ;; From negative to positive.
+ ((and (< num-prev 0) (not (< num-next 0)))
+ (replace-match "" t t nil sign-group))
+ ;; From positive to negative.
+ ((and (not (< num-prev 0)) (< num-next 0))
+ (replace-match (funcall encode-fn "-") t t nil sign-group))))
+
+ (goto-char (match-end num-group))
+
+ ;; Return old bounds for cursor positioning.
+ (cons old-beg old-end)))))
+
+(defun shift-number--inc-at-pt-impl (beg end padded range-check-fn
number-xform-fn)
+ "Increment/decrement the number at point, limited by BEG and END.
+
+Keep padding when PADDED is non-nil. Use `auto' for automatic detection.
+
+See `shift-number--inc-at-pt-impl-with-match-chars' for details on
+RANGE-CHECK-FN and NUMBER-XFORM-FN.
+
+Return (OLD-BEG . OLD-END) on success, nil on failure.
+Point is left at the end of the modified number."
+ (or
+ ;; Find binary literals:
+ ;; 0[bB][01]+, e.g. 0b101 or 0B0.
+ (shift-number--inc-at-pt-impl-with-match-chars
+ `(("+-" \?) ("0" 1) ("bB" 1) ("01" + ,shift-number-separator-chars))
+ ;; Sign, number groups & base.
+ 1 4 2
+ ;; Other arguments.
+ beg end padded nil range-check-fn number-xform-fn
+ ;; Decode & encode callbacks.
+ #'identity #'identity)
+
+ ;; Find octal literals:
+ ;; 0[oO][0-7]+, e.g. 0o42 or 0O5.
+ (shift-number--inc-at-pt-impl-with-match-chars
+ `(("+-" \?) ("0" 1) ("oO" 1) ("0-7" + ,shift-number-separator-chars))
+ ;; Sign, number groups & base.
+ 1 4 8
+ ;; Other arguments.
+ beg end padded nil range-check-fn number-xform-fn
+ ;; Decode & encode callbacks.
+ #'identity #'identity)
+
+ ;; Find hex literals:
+ ;; 0[xX][0-9a-fA-F]+, e.g. 0xBEEF or 0Xcafe.
+ (shift-number--inc-at-pt-impl-with-match-chars
+ `(("+-" \?) ("0" 1) ("xX" 1) ("[:xdigit:]" +
,shift-number-separator-chars))
+ ;; Sign, number groups & base.
+ 1 4 16
+ ;; Other arguments.
+ beg end padded t range-check-fn number-xform-fn
+ ;; Decode & encode callbacks.
+ #'identity #'identity)
+
+ ;; Find decimal literals:
+ ;; [0-9]+, e.g. 42 or 23.
+ (shift-number--inc-at-pt-impl-with-match-chars
+ `(("+-" \?) ("0123456789" + ,shift-number-separator-chars))
+ ;; Sign, number groups & base.
+ 1 2 10
+ ;; Other arguments.
+ beg end padded nil range-check-fn number-xform-fn
+ ;; Decode & encode callbacks.
+ #'identity #'identity)
+
+ ;; Find decimal literals (superscript).
+ (shift-number--inc-at-pt-impl-with-match-chars
+ `(("⁺⁻" \?) (,shift-number--chars-superscript + nil))
+ ;; Sign, number groups & base.
+ 1 2 10
+ ;; Other arguments.
+ beg end padded nil range-check-fn number-xform-fn
+ ;; Decode & encode callbacks.
+ #'shift-number--decode-super #'shift-number--encode-super)
+
+ ;; Find decimal literals (subscript).
+ (shift-number--inc-at-pt-impl-with-match-chars
+ `(("₊₋" \?) (,shift-number--chars-subscript + nil))
+ ;; Sign, number groups & base.
+ 1 2 10
+ ;; Other arguments.
+ beg end padded nil range-check-fn number-xform-fn
+ ;; Decode & encode callbacks.
+ #'shift-number--decode-sub #'shift-number--encode-sub)))
+
+(defconst shift-number--number-chars-regexp
+ (concat "[" "[:xdigit:]" shift-number--chars-superscript
shift-number--chars-subscript "]")
+ "Regexp matching characters that may be part of a number.")
+
+(defun shift-number--inc-at-pt-with-search (amount beg end padded
range-check-fn dir)
+ "Change the number at point by AMOUNT, limited by BEG and END.
+
+Keep padding when PADDED is non-nil. Use `auto' for automatic detection.
+
+DIR specifies search direction: 1 for forward, -1 for backward.
+
+See `shift-number--inc-at-pt-impl-with-match-chars' for details on
+RANGE-CHECK-FN.
+
+Return (OLD-BEG . OLD-END) on success, nil on failure.
+Point is left at the end of the modified number."
+ (let ((result nil))
+ (save-match-data
+ ;; Search for any text that might be part of a number,
+ ;; if `shift-number--inc-at-pt-impl' cannot parse it - that's fine,
+ ;; keep searching until the limit.
+ ;; This avoids doubling up on number parsing logic.
+ ;;
+ ;; Note that the while body is empty.
+ (while (and
+ ;; Found item, exit the loop.
+ (null
+ (when (setq result
+ (shift-number--inc-at-pt-impl
+ ;; Clamp limits to line bounds.
+ ;; The caller may use a range that spans lines to
+ ;; allow searching and finding items across
+ ;; multiple lines (currently used for selection).
+ (max beg (pos-bol))
+ (min end (pos-eol))
+ padded
+ range-check-fn
+ (lambda (n) (+ n amount))))
+ t))
+
+ ;; No more matches found, exit the loop.
(cond
- (use-sign
- (+ old-num (* old-sign n)))
+ ((< dir 0)
+ (re-search-backward shift-number--number-chars-regexp beg t))
(t
- ;; It doesn't make sense to add a "sign" if further increments
ignore it.
- (max 0 (+ old-num n)))))
+ (re-search-forward shift-number--number-chars-regexp end
t))))))
+ result))
- (new-sign old-sign)
- (new-num-sign-str "")
- (new-num-leading-str "")
- (new-num-str (number-to-string (abs new-num))))
- ;; Handle sign flipping & negative numbers.
- (when use-sign
- (cond
- ((< new-num 0)
- (setq new-sign (- old-sign)))
- ;; Without this check -1 would increase to -0.
- ;; While technically correct, it's not desirable.
- ((zerop new-num)
- (when (eq old-sign -1)
- (setq new-sign 1))))
+;; ---------------------------------------------------------------------------
+;; Private Functions
- (cond
- ((eq new-sign -1)
- (setq new-num-sign-str "-"))
- ((and has-sign (eq old-sign 1))
- ;; If a literal `+' was present, don't remove it.
- (setq new-num-sign-str "+"))))
-
- ;; If there are leading zeros, preserve them keeping the same
- ;; length of the original number.
- (when (string-match-p "\\`0" old-num-str)
- (let ((len-diff (- (length old-num-str) (length new-num-str))))
- (when (> len-diff 0)
- (setq new-num-leading-str (make-string len-diff ?0)))))
-
- ;; Prefer this over delete+insert so as to reduce the undo overhead
- ;; when numbers are mostly the same.
- (let* ((new-num-str-full (concat new-num-sign-str new-num-leading-str
new-num-str))
- (new-bounds (cons (car old-bounds) (+ (car old-bounds) (length
new-num-str-full)))))
-
- (shift-number--replace-in-region new-num-str-full (car old-bounds)
(cdr old-bounds))
-
- ;; Result.
- (cons old-bounds new-bounds))))
- (t
- nil))))
+(defmacro shift-number--swap-vars (i j)
+ "Swap the value of I & J."
+ `(setq ,i
+ (prog1 ,j
+ (setq ,j ,i))))
+
+(defun shift-number--impl (n pos limit-beg limit-end dir)
+ "Change the number at point by N.
+If there is no number at point, search in the direction DIR
+and change the first number found.
+
+DIR specifies direction: 1 for forward, -1 for backward.
+Search is limited by LIMIT-BEG and LIMIT-END.
+
+Return (OLD-BOUNDS . NEW-BOUNDS) on success, nil on failure."
+ (save-excursion
+ (goto-char pos)
+ (let ((old-bounds
+ (shift-number--inc-at-pt-with-search
+ n limit-beg limit-end (or shift-number-pad-default 'auto) nil
dir)))
+ (when old-bounds
+ (let* ((num-chars
+ (concat
+ "[:xdigit:]oOxXbB" shift-number--chars-superscript
shift-number--chars-subscript))
+ (old-beg (car old-bounds))
+ (old-end (cdr old-bounds))
+ (new-beg
+ (save-excursion
+ (skip-chars-backward num-chars limit-beg)
+ (when (and shift-number-negative (memq (char-before) '(?- ?+
?⁻ ?⁺ ?₋ ?₊)))
+ (backward-char))
+ (point)))
+ (new-end
+ (cond
+ ((< dir 0)
+ (save-excursion
+ (skip-chars-forward num-chars limit-end)
+ (point)))
+ (t
+ ;; Forward search leaves point at end of number.
+ (point)))))
+ (cons old-bounds (cons new-beg new-end)))))))
(defun shift-number--on-line (n)
"Adjust the number N on the current line."
(let* ((old-pos (point))
- (bounds-pair
- (shift-number--impl n old-pos (line-beginning-position)
(line-end-position))))
+ (bounds-pair (shift-number--impl n old-pos (pos-bol) (pos-eol) 1)))
(unless bounds-pair
(error "No number on the current line"))
@@ -292,19 +729,26 @@ Otherwise search forward limited by LIMIT-END."
(goto-char old-pos)
(when shift-number-motion
- (set-mark new-beg)
+ (when (eq shift-number-motion 'mark)
+ (set-mark new-beg))
(goto-char new-end))
new-end)))
-(defun shift-number--on-region-impl (n region-beg region-end)
+(defun shift-number--on-region-impl (n region-beg region-end incremental dir
start-count)
"Shift the numbers N in the region defined.
-REGION-BEG & REGION-END define the region."
+REGION-BEG & REGION-END define the region.
+When INCREMENTAL is non-nil, each successive number is changed by an
+additional N (first by 1*N, second by 2*N, etc.).
+DIR specifies the direction: 1 for forward, -1 for backward.
+START-COUNT specifies the initial count for incremental mode.
+Returns the final count value."
(let* ((pos-beg-old (mark))
(pos-beg-new nil)
(pos-end-old (point))
(pos-end-new nil)
- (point-is-first nil))
+ (point-is-first nil)
+ (count start-count))
;; Ensure order, mark then point.
(when (< pos-end-old pos-beg-old)
@@ -312,9 +756,16 @@ REGION-BEG & REGION-END define the region."
(shift-number--swap-vars pos-end-old pos-beg-old))
(save-excursion
- (let ((bounds-pair nil))
- (goto-char region-beg)
- (while (and (setq bounds-pair (shift-number--impl n region-beg
region-beg region-end)))
+ (let ((bounds-pair nil)
+ (search-pos
+ (cond
+ ((< dir 0)
+ region-end)
+ (t
+ region-beg))))
+ (goto-char search-pos)
+ (while (and (setq bounds-pair
+ (shift-number--impl (* n count) search-pos
region-beg region-end dir)))
(let* ((old-bounds (car bounds-pair))
(new-bounds (cdr bounds-pair))
(old-beg (car old-bounds))
@@ -323,66 +774,150 @@ REGION-BEG & REGION-END define the region."
(new-end (cdr new-bounds))
(delta (- new-end old-end)))
- (when shift-number-motion
- ;; Clamp the mark to the number beginning.
- (cond
- ((<= pos-beg-old old-beg)) ; NOP.
- ((> pos-beg-old old-end)
- (incf pos-beg-old delta))
- (t
- (setq pos-beg-new new-beg)))
+ ;; Always update pos-end-new to track the last modified number.
+ (setq pos-end-new
+ (cond
+ (shift-number-motion
+ new-end)
+ (t
+ new-beg)))
- ;; Clamp the point to the number end.
+ (cond
+ ((< dir 0)
+ ;; For backward mode, we process from end to beginning.
+ ;; Positions before the modified number are unaffected.
+ ;; Continue searching from before the modified number.
+ (setq search-pos new-beg)
+ (setq region-end new-beg))
+ (t
+ ;; For forward mode, adjust cursor if after the modified number.
(cond
- ((<= pos-end-old old-beg)) ; NOP.
+ ((< pos-end-old old-beg)) ; NOP - cursor is before the number.
((> pos-end-old old-end)
- (incf pos-end-old delta))
- (t
- (setq pos-end-new new-end))))
-
- ;; Keep contracting the region forward & updating it's end-points.
- (setq region-beg new-end)
- (incf region-end delta)))))
-
- (when point-is-first
- (shift-number--swap-vars pos-end-new pos-beg-new))
+ (incf pos-end-old delta)))
+
+ ;; Track mark position when motion with mark is enabled.
+ (when (eq shift-number-motion 'mark)
+ (cond
+ ((< pos-beg-old old-beg)) ; NOP - mark is before the number.
+ ((> pos-beg-old old-end)
+ (incf pos-beg-old delta))
+ (t
+ (setq pos-beg-new new-beg))))
+
+ ;; Continue from after the modified number.
+ (setq search-pos new-end)
+ (setq region-beg new-end)
+ (incf region-end delta)))
+
+ ;; Increment count for incremental mode.
+ (when incremental
+ (incf count))))))
+
+ ;; Always position cursor at the last modified number.
+ ;; Only swap mark position (for motion mode) based on selection direction.
(when pos-end-new
(goto-char pos-end-new))
(when pos-beg-new
- (set-mark pos-beg-new)))
-
- region-end)
-
-(defun shift-number--on-region (n)
- "Shift the numbers N on the current region."
- (shift-number--on-region-impl n (region-beginning) (region-end)))
-
-(defun shift-number--on-rectangle (n)
- "Shift the numbers N on the current region."
- (let ((shift-fn `(lambda (beg end) (save-excursion
(shift-number--on-region-impl ,n beg end)))))
- (apply-on-rectangle
- ;; Make the values global.
- `(lambda (col-beg col-end)
- (let ((beg nil)
- (end nil))
- (save-excursion
- (move-to-column col-beg)
- (setq beg (point))
- (move-to-column col-end)
- (setq end (point)))
- (funcall ,shift-fn beg end)))
- (region-beginning) (region-end))))
+ (when point-is-first
+ (shift-number--swap-vars pos-end-new pos-beg-new))
+ (set-mark pos-beg-new))
+
+ ;; Return final count value (for chaining in rectangle mode).
+ count))
+
+(defun shift-number--on-region (n incremental)
+ "Shift the numbers N on the current region.
+When INCREMENTAL is non-nil, use progressive amounts."
+ (shift-number--on-region-impl n (region-beginning) (region-end) incremental
1 1))
+
+(defun shift-number--on-rectangle (n incremental dir)
+ "Shift the numbers N in the current rectangle selection.
+When INCREMENTAL is non-nil, use progressive amounts across all cells.
+DIR specifies the direction: 1 for forward, -1 for backward."
+ (let ((final-point nil)
+ (final-mark nil)
+ (count 1))
+ ;; For backward mode, we need to process lines in reverse order.
+ ;; Collect line info first, then process.
+ (cond
+ ((< dir 0)
+ (let ((line-info nil))
+ ;; Collect line positions and column info.
+ (apply-on-rectangle
+ (lambda (col-beg col-end)
+ (let ((beg nil)
+ (end nil))
+ (save-excursion
+ (move-to-column col-beg)
+ (setq beg (point))
+ (move-to-column col-end)
+ (setq end (point)))
+ (push (list beg end) line-info)))
+ (region-beginning) (region-end))
+ ;; Process in reverse order (line-info is already reversed from push).
+ (dolist (info line-info)
+ (let ((beg (car info))
+ (end (cadr info)))
+ (goto-char end)
+ (setq count (shift-number--on-region-impl n beg end incremental
dir count))
+ (setq final-point (point))
+ (when (mark)
+ (setq final-mark (mark)))))))
+ (t
+ (apply-on-rectangle
+ (lambda (col-beg col-end)
+ (let ((beg nil)
+ (end nil))
+ (save-excursion
+ (move-to-column col-beg)
+ (setq beg (point))
+ (move-to-column col-end)
+ (setq end (point)))
+ ;; Move point to region start so cursor tracking works correctly.
+ (goto-char beg)
+ ;; Don't use save-excursion here - we want to capture final
position.
+ ;; Chain count across lines for incremental mode.
+ (setq count (shift-number--on-region-impl n beg end incremental dir
count))
+ ;; Capture cursor/mark from last line processed.
+ (setq final-point (point))
+ (when (mark)
+ (setq final-mark (mark)))))
+ (region-beginning) (region-end))))
+ ;; Apply final cursor position.
+ (when final-point
+ (goto-char final-point))
+ (when final-mark
+ (set-mark final-mark))))
(defun shift-number--on-context (n)
"Manipulate numbers in the current region or line by N."
(cond
((bound-and-true-p rectangle-mark-mode)
- (shift-number--on-rectangle n))
+ (shift-number--on-rectangle n nil 1))
((region-active-p)
- (shift-number--on-region n))
+ (shift-number--on-region n nil))
(t
(shift-number--on-line n))))
+(defun shift-number--on-context-incremental (n)
+ "Incrementally change numbers between point and mark by N.
+The first number is changed by N, the second by 2*N, etc.
+Works with rectangle selection when `rectangle-mark-mode' is active."
+ (unless (mark)
+ (user-error "The mark is not set"))
+ (let ((dir
+ (cond
+ ((and shift-number-incremental-direction-from-region (< (point)
(mark)))
+ -1)
+ (t
+ 1))))
+ (cond
+ ((bound-and-true-p rectangle-mark-mode)
+ (shift-number--on-rectangle n t dir))
+ (t
+ (shift-number--on-region-impl n (region-beginning) (region-end) t dir
1)))))
+
;; ---------------------------------------------------------------------------
;; Public Functions
@@ -399,6 +934,75 @@ REGION-BEG & REGION-END define the region."
(interactive "*p")
(shift-number--on-context (- arg)))
+;;;###autoload
+(defun shift-number-up-incremental (&optional arg)
+ "Incrementally increase numbers between point and mark by ARG.
+The first number is increased by ARG, the second by 2*ARG, etc.
+Works with rectangle selection when `rectangle-mark-mode' is active."
+ (interactive "*p")
+ (shift-number--on-context-incremental arg))
+
+;;;###autoload
+(defun shift-number-down-incremental (&optional arg)
+ "Incrementally decrease numbers between point and mark by ARG.
+The first number is decreased by ARG, the second by 2*ARG, etc.
+Works with rectangle selection when `rectangle-mark-mode' is active."
+ (interactive "*p")
+ (shift-number--on-context-incremental (- arg)))
+
+;;;###autoload
+(defun shift-number-increment-at-point-with-search (&rest args)
+ "Change the number at point, with keyword arguments in ARGS.
+
+Required keywords:
+ :amount - The amount to increment.
+ :range - A cons cell (BEG . END) specifying search bounds.
+
+Optional keywords:
+ :dir - Search direction: 1 for forward, -1 for backward (default 1).
+ :range-check-fn - See `shift-number--inc-at-pt-impl-with-match-chars'.
+
+Optional keywords to override customization variables:
+ :pad-default - Override `shift-number-pad-default'.
+ :negative - Override `shift-number-negative'.
+ :separator-chars - Override `shift-number-separator-chars'.
+ :case - Override `shift-number-case'.
+ :motion - Override `shift-number-motion'.
+
+Return (OLD-BEG . OLD-END) on success, nil on failure.
+Point is left at the end of the modified number.
+
+This function is only exposed for use by `evil-numbers'."
+ (let ((amount nil)
+ (range nil)
+ (dir 1)
+ (range-check-fn nil)
+ (shift-number-pad-default shift-number-pad-default)
+ (shift-number-negative shift-number-negative)
+ (shift-number-separator-chars shift-number-separator-chars)
+ (shift-number-case shift-number-case)
+ (shift-number-motion shift-number-motion))
+ (while args
+ (let ((key (pop args))
+ (value (pop args)))
+ (pcase key
+ (:amount (setq amount value))
+ (:range (setq range value))
+ (:dir (setq dir value))
+ (:range-check-fn (setq range-check-fn value))
+ (:pad-default (setq shift-number-pad-default value))
+ (:negative (setq shift-number-negative value))
+ (:separator-chars (setq shift-number-separator-chars value))
+ (:case (setq shift-number-case value))
+ (:motion (setq shift-number-motion value))
+ (_ (error "Unknown keyword: %S" key)))))
+ (unless amount
+ (error "Missing required keyword: :amount"))
+ (unless range
+ (error "Missing required keyword: :range"))
+ (shift-number--inc-at-pt-with-search
+ amount (car range) (cdr range) (or shift-number-pad-default 'auto)
range-check-fn dir)))
+
(provide 'shift-number)
;; Local Variables:
;; fill-column: 99
diff --git a/tests/shift-number-tests.el b/tests/shift-number-tests.el
index b8ffd98527..2980e24cd6 100644
--- a/tests/shift-number-tests.el
+++ b/tests/shift-number-tests.el
@@ -568,7 +568,7 @@ The minus sign is ignored, so -5 is treated as 5,
decrementing gives 4, result i
"Check that only numbers within the region are modified.
The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4 and 5 unchanged."
(let ((text-initial "1 2 3 4 5")
- (text-expected "1 3 4 |4 5"))
+ (text-expected "1 3 |4 4 5"))
(with-shift-number-test text-initial
(transient-mark-mode 1)
(goto-char 3) ; Position before '2'.
@@ -594,7 +594,7 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4
and 5 unchanged."
(ert-deftest region-mixed-signs ()
"Check that region with mixed positive and negative numbers works."
(let ((text-initial "-5 0 +5")
- (text-expected "-4 1 +|6"))
+ (text-expected "-4 1 |+6"))
(with-shift-number-test text-initial
(transient-mark-mode 1)
(goto-char (point-min))
@@ -626,9 +626,10 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4
and 5 unchanged."
(should (equal text-expected (buffer-string))))))
(ert-deftest region-reversed ()
- "Check that region works when mark is after point."
+ "Check that region works when mark is after point.
+Cursor ends on last modified number regardless of selection direction."
(let ((text-initial "1 2 3")
- (text-expected "|2 3 4"))
+ (text-expected "2 3 |4"))
(with-shift-number-test text-initial
(transient-mark-mode 1)
(goto-char (point-max))
@@ -676,7 +677,7 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4
and 5 unchanged."
;; format-next-line: off
(concat "-4 0\n"
"-4 0\n"
- "-|4 0")))
+ "|-4 0")))
(with-shift-number-test text-initial
(transient-mark-mode 1)
(goto-char (point-min))
@@ -762,15 +763,257 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4
and 5 unchanged."
(cursor-marker)
(should (equal text-expected (buffer-string))))))
+(ert-deftest rectangle-with-motion ()
+ "Check rectangle mode works with shift-number-motion enabled."
+ (let ((text-initial
+ ;; format-next-line: off
+ (concat "1 0 0\n"
+ "1 0 0\n"
+ "1 0 0"))
+ (text-expected
+ ;; format-next-line: off
+ (concat "2 0 0\n"
+ "2 0 0\n"
+ "2| 0 0"))
+ (shift-number-motion t))
+ (with-shift-number-test text-initial
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (backward-char 4) ; Position at column 0 of last line.
+ (rectangle-mark-mode 1)
+ (shift-number-up 1)
+ (rectangle-mark-mode 0)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Incremental Mode Tests
+
+(ert-deftest incremental-no-mark-error ()
+ "Check that incremental mode signals error when mark is not set."
+ (with-shift-number-test "0 0 0"
+ (should-error-with-message
+ (shift-number-up-incremental 1)
+ 'user-error
+ "The mark is not set")))
+
+(ert-deftest incremental-without-active-region ()
+ "Check that incremental mode works without an active region.
+Only the mark needs to be set."
+ (let ((text-initial "0 0 0")
+ (text-expected "1 2 |3"))
+ (with-shift-number-test text-initial
+ ;; Set mark but don't activate region.
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ ;; Region is not active, but incremental should still work.
+ (shift-number-up-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-region-by-amount ()
+ "Check that incremental mode respects the amount argument."
+ (let ((text-initial "0 0 0")
+ (text-expected "2 4 |6"))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (shift-number-up-incremental 2)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-region-down ()
+ "Check that incremental decrement works."
+ (let ((text-initial "10 10 10")
+ (text-expected "9 8 |7"))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (shift-number-down-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-region-multiline ()
+ "Check that incremental mode works across multiple lines."
+ (let ((text-initial
+ ;; format-next-line: off
+ (concat "0 0 0\n"
+ "0 0 0\n"
+ "0 0 0"))
+ (text-expected
+ ;; format-next-line: off
+ (concat "1 2 3\n"
+ "4 5 6\n"
+ "7 8 |9")))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (shift-number-up-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-rectangle ()
+ "Check that incremental mode works with rectangle selection."
+ (let ((text-initial
+ ;; format-next-line: off
+ (concat "0 0 0\n"
+ "0 0 0\n"
+ "0 0 0"))
+ (text-expected
+ ;; format-next-line: off
+ (concat "1 0 0\n"
+ "2 0 0\n"
+ "|3 0 0")))
+ (with-shift-number-test text-initial
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (backward-char 4) ; Position at column 0 of last line.
+ (rectangle-mark-mode 1)
+ (shift-number-up-incremental 1)
+ (rectangle-mark-mode 0)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-rectangle-multicolumn ()
+ "Check that incremental count continues across rectangle cells.
+Cursor ends on the last number modified (highest increment)."
+ (let ((text-initial
+ ;; format-next-line: off
+ (concat "0 0\n"
+ "0 0\n"
+ "0 0"))
+ (text-expected
+ ;; format-next-line: off
+ (concat "1 2\n"
+ "3 4\n"
+ "5 |6")))
+ (with-shift-number-test text-initial
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (rectangle-mark-mode 1)
+ (shift-number-up-incremental 1)
+ (rectangle-mark-mode 0)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-direction-reversed ()
+ "Check that incremental direction reverses when point is before mark.
+With point before mark, 0 0 0 becomes 3 2 1 instead of 1 2 3."
+ (let ((text-initial "0 0 0")
+ (text-expected "|3 2 1")
+ (shift-number-incremental-direction-from-region t))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point-max))
+ (shift-number-up-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-direction-forward ()
+ "Check that incremental direction is forward when point is after mark.
+With point after mark, 0 0 0 becomes 1 2 3 (standard behavior)."
+ (let ((text-initial "0 0 0")
+ (text-expected "1 2 |3")
+ (shift-number-incremental-direction-from-region t))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (shift-number-up-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-direction-disabled ()
+ "Check that direction is always forward when option is disabled."
+ (let ((text-initial "0 0 0")
+ (text-expected "1 2 |3")
+ (shift-number-incremental-direction-from-region nil))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point-max))
+ ;; Point is before mark, but option is disabled so direction is forward.
+ (shift-number-up-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-direction-reversed-down ()
+ "Check that reversed direction works with shift-number-down-incremental."
+ (let ((text-initial "10 10 10")
+ (text-expected "|7 8 9")
+ (shift-number-incremental-direction-from-region t))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point-max))
+ (shift-number-down-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-direction-reversed-multiline ()
+ "Check that reversed direction works across multiple lines."
+ (let ((text-initial
+ ;; format-next-line: off
+ (concat "0 0 0\n"
+ "0 0 0\n"
+ "0 0 0"))
+ (text-expected
+ ;; format-next-line: off
+ (concat "|9 8 7\n"
+ "6 5 4\n"
+ "3 2 1"))
+ (shift-number-incremental-direction-from-region t))
+ (with-shift-number-test text-initial
+ (goto-char (point-min))
+ (set-mark (point-max))
+ (shift-number-up-incremental 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest incremental-direction-reversed-rectangle ()
+ "Check that reversed direction works with rectangle selection."
+ (let ((text-initial
+ ;; format-next-line: off
+ (concat "0 0 0\n"
+ "0 0 0\n"
+ "0 0 0"))
+ (text-expected
+ ;; format-next-line: off
+ (concat "|3 0 0\n"
+ "2 0 0\n"
+ "1 0 0"))
+ (shift-number-incremental-direction-from-region t))
+ (with-shift-number-test text-initial
+ (transient-mark-mode 1)
+ ;; Set mark at end of last line (column 0), point at start (column 0).
+ ;; Point is before mark, so direction is reversed.
+ (goto-char (point-max))
+ (backward-char 4) ; Position at column 0 of last line.
+ (set-mark (point))
+ (goto-char (point-min))
+ (rectangle-mark-mode 1)
+ (shift-number-up-incremental 1)
+ (rectangle-mark-mode 0)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
;; ---------------------------------------------------------------------------
;; Motion Tests
(ert-deftest motion-enabled ()
- "Check that shift-number-motion moves cursor and sets mark at number start."
+ "Check that shift-number-motion mark moves cursor and sets mark at number
start."
(let ((text-initial "abc 123 def")
(text-expected "abc 124| def")
(mark-expected 5) ; Position of "124".
- (shift-number-motion t))
+ (shift-number-motion 'mark))
(with-shift-number-test text-initial
(shift-number-up 1)
(cursor-marker)
@@ -778,11 +1021,11 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4
and 5 unchanged."
(should (equal mark-expected (mark))))))
(ert-deftest motion-enabled-down ()
- "Check that shift-number-motion works with shift-number-down."
+ "Check that shift-number-motion mark works with shift-number-down."
(let ((text-initial "abc 123 def")
(text-expected "abc 122| def")
(mark-expected 5) ; Position of "122".
- (shift-number-motion t))
+ (shift-number-motion 'mark))
(with-shift-number-test text-initial
(shift-number-down 1)
(cursor-marker)
@@ -790,11 +1033,11 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4
and 5 unchanged."
(should (equal mark-expected (mark))))))
(ert-deftest motion-enabled-region ()
- "Check that shift-number-motion works with region operations."
+ "Check that shift-number-motion mark works with region operations."
(let ((text-initial "1 2 3")
(text-expected "2 3 4|")
(mark-expected 1) ; Position of "2" (first number in region).
- (shift-number-motion t))
+ (shift-number-motion 'mark))
(with-shift-number-test text-initial
(transient-mark-mode 1)
(goto-char (point-min))
@@ -1007,15 +1250,6 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original 4
and 5 unchanged."
(cursor-marker)
(should (equal text-expected (buffer-string))))))
-(ert-deftest hexadecimal-prefix ()
- "Check that hexadecimal prefix is treated as separate number."
- (let ((text-initial "0x10")
- (text-expected "|1x10"))
- (with-shift-number-test text-initial
- (shift-number-up 1)
- (cursor-marker)
- (should (equal text-expected (buffer-string))))))
-
(ert-deftest multiple-decimal-points ()
"Check behavior with multiple decimal points like version numbers."
(let ((text-initial "1.2.3")
@@ -1092,5 +1326,507 @@ The 1 is unchanged, 2 becomes 3, 3 becomes 4, original
4 and 5 unchanged."
(cursor-marker)
(should (equal text-expected (buffer-string))))))
+;; ---------------------------------------------------------------------------
+;; Binary Literal Tests
+
+(ert-deftest binary-increment ()
+ "Check that binary literal increments correctly."
+ (let ((text-initial "0b101")
+ (text-expected "|0b110"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest binary-decrement ()
+ "Check that binary literal decrements correctly."
+ (let ((text-initial "0b110")
+ (text-expected "|0b101"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest binary-uppercase-prefix ()
+ "Check that uppercase 0B prefix is preserved."
+ (let ((text-initial "0B101")
+ (text-expected "|0B110"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest binary-padding-preserved ()
+ "Check that binary padding is preserved."
+ (let ((text-initial "0b0001")
+ (text-expected "|0b0010"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest binary-to-zero ()
+ "Check that binary can decrement to zero."
+ (let ((text-initial "0b1")
+ (text-expected "|0b0"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest binary-negative ()
+ "Check that negative binary literal works."
+ (let ((text-initial "-0b101")
+ (text-expected "|-0b110"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest binary-to-negative ()
+ "Check that binary can decrement below zero."
+ (let ((text-initial "0b0")
+ (text-expected "|-0b1"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Octal Literal Tests
+
+(ert-deftest octal-increment ()
+ "Check that octal literal increments correctly."
+ (let ((text-initial "0o42")
+ (text-expected "|0o43"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest octal-decrement ()
+ "Check that octal literal decrements correctly."
+ (let ((text-initial "0o43")
+ (text-expected "|0o42"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest octal-uppercase-prefix ()
+ "Check that uppercase 0O prefix is preserved."
+ (let ((text-initial "0O755")
+ (text-expected "|0O756"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest octal-wrap-digit ()
+ "Check that octal wraps from 7 to 10."
+ (let ((text-initial "0o7")
+ (text-expected "|0o10"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest octal-padding-preserved ()
+ "Check that octal padding is preserved."
+ (let ((text-initial "0o007")
+ (text-expected "|0o010"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest octal-negative ()
+ "Check that negative octal literal works."
+ (let ((text-initial "-0o77")
+ (text-expected "|-0o100"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest octal-to-negative ()
+ "Check that octal can decrement below zero."
+ (let ((text-initial "0o0")
+ (text-expected "|-0o1"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Hexadecimal Literal Tests
+
+(ert-deftest hex-increment ()
+ "Check that hexadecimal literal increments correctly."
+ (let ((text-initial "0xBEEF")
+ (text-expected "|0xBEF0"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-decrement ()
+ "Check that hexadecimal literal decrements correctly."
+ (let ((text-initial "0xBEF0")
+ (text-expected "|0xBEEF"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-lowercase ()
+ "Check that lowercase hex is preserved."
+ (let ((text-initial "0xcafe")
+ (text-expected "|0xcaff"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-uppercase-prefix ()
+ "Check that uppercase 0X prefix is preserved."
+ (let ((text-initial "0X10")
+ (text-expected "|0X11"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-padding-preserved ()
+ "Check that hex padding is preserved."
+ (let ((text-initial "0x00FF")
+ (text-expected "|0x0100"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-wrap-f-to-10 ()
+ "Check that hex F wraps to 10."
+ (let ((text-initial "0xF")
+ (text-expected "|0x10"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-case-upcase ()
+ "Check that shift-number-case upcase forces uppercase."
+ (let ((text-initial "0xcafe")
+ (text-expected "|0xCAFF")
+ (shift-number-case 'upcase))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-case-downcase ()
+ "Check that shift-number-case downcase forces lowercase."
+ (let ((text-initial "0xCAFE")
+ (text-expected "|0xcaff")
+ (shift-number-case 'downcase))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-case-nil-preserves ()
+ "Check that shift-number-case nil preserves original case."
+ (let ((text-initial "0xCaFe")
+ (text-expected "|0xcaff")
+ (shift-number-case nil))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-negative ()
+ "Check that negative hex literal works."
+ (let ((text-initial "-0xFF")
+ (text-expected "|-0x100"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest hex-to-negative ()
+ "Check that hex can decrement below zero."
+ (let ((text-initial "0x0")
+ (text-expected "|-0x1"))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Superscript Number Tests
+
+(ert-deftest superscript-increment ()
+ "Check that superscript number increments correctly."
+ (let ((text-initial "x⁴²")
+ (text-expected "x|⁴³"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest superscript-decrement ()
+ "Check that superscript number decrements correctly."
+ (let ((text-initial "x⁴³")
+ (text-expected "x|⁴²"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest superscript-negative ()
+ "Check that negative superscript works."
+ (let ((text-initial "x⁻¹")
+ (text-expected "x|⁻²"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest superscript-to-negative ()
+ "Check that superscript can become negative."
+ (let ((text-initial "x⁰")
+ (text-expected "x|⁻¹"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest superscript-single-digit ()
+ "Check that single digit superscript works."
+ (let ((text-initial "²")
+ (text-expected "|³"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest superscript-positive-sign ()
+ "Check that positive superscript sign works."
+ (let ((text-initial "x⁺¹")
+ (text-expected "x|⁺²"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Subscript Number Tests
+
+(ert-deftest subscript-increment ()
+ "Check that subscript number increments correctly."
+ (let ((text-initial "H₂O")
+ (text-expected "H|₃O"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest subscript-decrement ()
+ "Check that subscript number decrements correctly."
+ (let ((text-initial "H₃O")
+ (text-expected "H|₂O"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest subscript-negative ()
+ "Check that negative subscript works."
+ (let ((text-initial "x₋₁")
+ (text-expected "x|₋₂"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest subscript-multi-digit ()
+ "Check that multi-digit subscript works."
+ (let ((text-initial "C₁₂H₂₂O₁₁")
+ (text-expected "C|₁₃H₂₂O₁₁"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest subscript-positive-sign ()
+ "Check that positive subscript sign works."
+ (let ((text-initial "x₊₁")
+ (text-expected "x|₊₂"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest subscript-to-negative ()
+ "Check that subscript can decrement below zero."
+ (let ((text-initial "x₀")
+ (text-expected "x|₋₁"))
+ (with-shift-number-test text-initial
+ (forward-char 1)
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Separator Characters Tests
+
+(ert-deftest separator-underscore-enabled ()
+ "Check that underscore separator is treated as part of number when enabled."
+ (let ((text-initial "1_000_000")
+ (text-expected "|1_000_001")
+ (shift-number-separator-chars "_"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest separator-underscore-large-increment ()
+ "Check that underscore-separated number increments by large amount."
+ (let ((text-initial "1_000_000")
+ (text-expected "|2_000_000")
+ (shift-number-separator-chars "_"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1000000)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest separator-comma-enabled ()
+ "Check that comma separator is treated as part of number when enabled."
+ (let ((text-initial "1,000,000")
+ (text-expected "|1,000,001")
+ (shift-number-separator-chars ","))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest separator-disabled ()
+ "Check that separator is not recognized when disabled."
+ (let ((text-initial "1_000")
+ (text-expected "|2_000")
+ (shift-number-separator-chars nil))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest separator-hex-underscore ()
+ "Check that underscore separator works with hex literals.
+Separator position is preserved relative to the end of the number."
+ (let ((text-initial "0xFF_FF")
+ (text-expected "|0x100_00")
+ (shift-number-separator-chars "_"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest separator-binary-underscore ()
+ "Check that underscore separator works with binary literals."
+ (let ((text-initial "0b1111_1111")
+ (text-expected "|0b10000_0000")
+ (shift-number-separator-chars "_"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest separator-octal-underscore ()
+ "Check that underscore separator works with octal literals."
+ (let ((text-initial "0o77_77")
+ (text-expected "|0o100_00")
+ (shift-number-separator-chars "_"))
+ (with-shift-number-test text-initial
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Padding Default Option Tests
+
+(ert-deftest pad-default-enabled ()
+ "Check that shift-number-pad-default preserves width when number shrinks."
+ (let ((text-initial "10")
+ (text-expected "|09")
+ (shift-number-pad-default t))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest pad-default-disabled ()
+ "Check that shift-number-pad-default disabled doesn't pad."
+ (let ((text-initial "9")
+ (text-expected "|8")
+ (shift-number-pad-default nil))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+(ert-deftest pad-default-leading-zeros-always-pad ()
+ "Check that leading zeros are preserved regardless of pad-default."
+ (let ((text-initial "09")
+ (text-expected "|08")
+ (shift-number-pad-default nil))
+ (with-shift-number-test text-initial
+ (shift-number-down 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
+;; ---------------------------------------------------------------------------
+;; Region Cursor Position Tests
+
+(ert-deftest region-cursor-with-motion ()
+ "Check that shift-number-motion t positions cursor at end without mark."
+ (let ((text-initial "1 2 3")
+ (text-expected "2 3 4|")
+ (shift-number-motion t))
+ (with-shift-number-test text-initial
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string)))
+ ;; Mark should not be changed to number position.
+ (should (equal 1 (mark))))))
+
+(ert-deftest region-cursor-without-motion ()
+ "Check that cursor is at beginning of last number when motion is nil."
+ (let ((text-initial "1 2 3")
+ (text-expected "2 3 |4")
+ (shift-number-motion nil))
+ (with-shift-number-test text-initial
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (set-mark (point))
+ (goto-char (point-max))
+ (shift-number-up 1)
+ (cursor-marker)
+ (should (equal text-expected (buffer-string))))))
+
(provide 'shift-number-tests)
;;; shift-number-tests.el ends here
diff --git a/tests/shift-number-tests.sh b/tests/shift-number-tests.sh
index fc0617e2ab..b487ba330e 100755
--- a/tests/shift-number-tests.sh
+++ b/tests/shift-number-tests.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
${EMACS:-emacs} \